Files
test/stormdata_SERVERMAP.html
2025-12-09 00:20:32 +00:00

1059 lines
67 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>NWS Warning Summarizer</title>
<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.6.0/jquery.min.js"></script>
<script src="https://code.jquery.com/ui/1.13.2/jquery-ui.js"></script>
<link rel="stylesheet" href="https://code.jquery.com/ui/1.13.2/themes/smoothness/jquery-ui.css">
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.8.0/dist/leaflet.css" integrity="sha512-hoalWLoI8r4UszCkZ5kL8vayOGVae1oxXe/2A4AO6J9+580uKHDO3JdHb7NzwwzK5xr/Fs0W40kiNHxM9vyTtQ==" crossorigin="" />
<script src="https://unpkg.com/leaflet@1.8.0/dist/leaflet.js" integrity="sha512-BB3hKbKWOc9Ez/TAwyWxNXeoV9c1v6FIeYiBieIWkpLjauysF18NzgR1MBNBXf8/KABdlkX68nAhlwcDFLGPCQ==" crossorigin=""></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js@3.9.1/dist/chart.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chartjs-adapter-date-fns@^2.0.0/dist/chartjs-adapter-date-fns.bundle.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@turf/turf@6.5.0/turf.min.js"></script>
<!-- CSS -->
<style type="text/css">
body, html {
height: 100%; width: 100%; margin: 0; padding: 0; font-family: sans-serif;
}
#container {
display: flex; height: 100%; width: 100%;
}
#controls {
width: 350px; padding: 10px; background: #f0f0f0; overflow-y: auto;
border-right: 1px solid #ccc; z-index: 1000; display: flex; flex-direction: column;
box-sizing: border-box;
}
#map-container {
flex-grow: 1; height: 100%; display: flex; flex-direction: column;
}
#mapid {
flex-grow: 1; width: 60%; z-index: 1;
}
#summary-container {
height: 55%; /* Adjust map/summary ratio (higher % = smaller map) */
overflow-y: auto; padding: 15px; background: #f9f9f9;
border-top: 1px solid #ddd; line-height: 1.4; box-sizing: border-box;
}
#summary-container img {
max-width: 100%; height: auto; border: 1px solid #ccc; margin-top: 10px;
margin-bottom: 10px; display: block;
}
#summary-container h2 {
margin-top: 0; margin-bottom: 10px; padding-bottom: 5px; border-bottom: 2px solid #ccc;
}
#summary-container h3 {
margin-top: 18px; margin-bottom: 8px; padding-bottom: 3px; border-bottom: 1px solid #eee;
}
#summary-container h4 { margin-top: 10px; margin-bottom: 5px; }
#summary-container p { margin-top: 0.5em; margin-bottom: 0.5em; }
#summary-container ul { margin-top: 0.5em; padding-left: 25px; }
#summary-container li { margin-bottom: 0.4em; }
#summary-container .gauge-summary { margin-bottom: 20px; border-top: 1px solid #eee; padding-top: 10px; }
#summary-container .event-summary-item { border-left-width: 5px; border-left-style: solid; padding-left: 10px; margin-bottom: 15px; }
#copySummaryBtn {
float: right; margin-bottom: 10px; padding: 5px 10px; cursor: pointer; border: 1px solid #ccc;
background-color: #f0f0f0; border-radius: 3px; font-size: 0.9em;
}
#copySummaryBtn:hover { background-color: #e0e0e0; }
#controls {
width: 400px; /* Or your desired initial width */
padding: 10px;
background: #f0f0f0;
overflow-y: auto;
border-right: 1px solid #ccc;
z-index: 1000;
display: flex;
flex-direction: column;
box-sizing: border-box;
}
#controls label, #controls select, #controls input[type=number], #controls input[type=text] {
display: block;
margin-bottom: 8px;
width: 100%; /* Change to 100% to fit the #controls */
box-sizing: border-box;
}
#controls select[multiple] {
height: 250px;
width: 100%;
box-sizing: border-box;
}
/* Leaflet Layer Styling */
.my-label { /* County Labels */
font-size: 8px; font-weight: bold; color: #333; background-color: rgba(255, 255, 255, 0.7);
border: 0px; border-radius: 3px; padding: 1px 3px; white-space: nowrap; pointer-events: none;
}
.leaflet-tooltip { /* Optional overrides for base tooltip */ }
.lsr-label-tooltip {
font-size: 9px !important; font-weight: bold !important; color: black !important; white-space: nowrap;
background: transparent !important; border: none !important; box-shadow: none !important;
padding: 0 !important; margin: 0 !important; display: inline-block; text-align: center;
vertical-align: middle; pointer-events: none;
}
/* Hide the template canvas */
#hydrograph-canvas-template { display: none; }
#summary-container ul.analysis-list {
margin-top: 0; /* Remove extra space above list */
padding-left: 25px; /* Keep indentation */
list-style-type: disc; /* Or desired bullet style */
}
/* Style for the per-zone verification list */
#summary-container .zone-verification-list {
margin-top: 0; /* Remove extra space above list */
padding-left: 25px; /* Keep indentation */
list-style-type: disc; /* Or desired bullet style */
font-size: 0.9em; /* Match other sub-details */
}
#summary-container .zone-verification-list li {
margin-bottom: 0.2em; /* Smaller spacing between zones */
}
</style>
</head>
<body>
<div id="container">
<div id="controls">
<h3>Select Warnings</h3>
<label for="yeartowork">Choose a year:</label>
<!-- Event selector added by JS -->
<!-- NEW: Filter Input Box -->
<label for="eventFilterInput" style="margin-top: 15px;">Filter Events:</label>
<input type="text" id="eventFilterInput" placeholder="Type to filter list..." />
<!-- End NEW -->
<h3>LSR Options</h3>
<label for="lsrbuffer">LSR Time Buffer (hrs after end):</label>
<input id="lsrbuffer" name="lsrbuffer" type="number" value="0">
<input id="generateSummaryBtn" type="button" value="Generate Summary" onclick="generateSummaryForSelectedEvents();" />
</div>
<div id="map-container">
<div id="mapid"></div>
<div id="summary-container">
<!-- Copy button will be added here by JS in buildHtmlSummary -->
<p>Select one or more warnings and click "Generate Summary".</p>
<!-- Summary content will be generated here -->
</div>
</div>
</div>
<script>
let mymap;
let geoJSONwwas; // Layer for selected warning polygons
let geoJSONcounties; // Layer for county/zone outlines & zone lookup
let markersLayer; // Layer for LSR markers
let currentSelectedEvents = [];
let currentSelectedEventDetails = [];
let currentLSRs = { type: "FeatureCollection", features: [] };
let hydrographCharts = {}; // Keep track of Chart.js instances if needed (though now generating images directly)
let gaugeMarkersLayer; // Layer for NWS Gauge markers
// --- Lookups ---
const sbwprods = ["TO", "SV", "EW", "SQ", "FA", "FL", "FF", "DS", "SS"];
const hydroprods = ["FA", "FF", "FL", "HY"];
const phenomenonCodes = { /* Populated from original code */
"AF":"Ashfall (land)","AS":"Air Stagnation","BH":"Beach Hazard","BW":"Brisk Wind","BZ":"Blizzard","CF":"Coastal Flood","CW":"Cold Weather","DF":"Debris Flow","DS":"Dust Storm","DU":"Blowing Dust","EC":"Extreme Cold","EH":"Excessive Heat","XH":"Extreme Heat","EW":"Extreme Wind","FA":"Flood","FF":"Flash Flood","FG":"Dense Fog (land)","FL":"Flood (Forecast Points)","FR":"Frost","FW":"Fire Weather","FZ":"Freeze","GL":"Gale","HF":"Hurricane Force Wind","HT":"Heat","HU":"Hurricane","HW":"High Wind","HY":"Hydrologic","HZ":"Hard Freeze","IS":"Ice Storm","LE":"Lake Effect Snow","LO":"Low Water","LS":"Lakeshore Flood","LW":"Lake Wind","MA":"Marine","MF":"Dense Fog (marine)","MH":"Ashfall (marine)","MS":"Dense Smoke (marine)","RP":"Rip Current Risk","SC":"Small Craft","SE":"Hazardous Seas","SM":"Dense Smoke (land)","SR":"Storm","SS":"Storm Surge","SQ":"Snow Squall","SU":"High Surf","SV":"Severe Thunderstorm","TO":"Tornado","TR":"Tropical Storm","TS":"Tsunami","TY":"Typhoon","UP":"Heavy Freezing Spray","WC":"Wind Chill","WI":"Wind","WS":"Winter Storm","WW":"Winter Weather","ZF":"Freezing Fog","ZR":"Freezing Rain","ZY":"Freezing Spray"
};
const sigCodes = { "W": "Warning", "A": "Watch", "Y": "Advisory", "S": "Statement" };
const warningsColorMap = [ /* Populated/Simplified from original code */
{"warningType":"Tornado Warning","color":"#FF0000","code":["T","H","D","N","G"]},
{"warningType":"Extreme Wind Warning","color":"#FF8C00","code":["G","O","N"]},
{"warningType":"Severe Thunderstorm Warning","color":"#FFA500","code":["T","H","D","N","G"]},
{"warningType":"Flash Flood Warning","color":"#8B0000","code":["F","E","R","0","x"]},
{"warningType":"Flood Warning","color":"#00FF00","code":["F","E","R","0","x"]},
{"warningType":"Flood (Forecast Points) Warning","color":"#00FF00","code":["E","R"]},
{"warningType":"Snow Squall Warning","color":"#C71585","code":["S","O","5","Z","s","q"]},
{"warningType":"Blizzard Warning","color":"#FF4500","code":["S","O","5","Z","s","q"]},
{"warningType":"Ice Storm Warning","color":"#8B008B","code":["q","O","5"]},
{"warningType":"Winter Storm Warning","color":"#FF69B4","code":["S","O","5","Z","s","q"]},
{"warningType":"High Wind Warning","color":"#DAA520","code":["G","O","N"]},
{"warningType":"Excessive Heat Warning","color":"#C71585","code":["6","7","I"]},
{"warningType":"Extreme Cold Warning","color":"#0000FF","code":["6","7","I"]},
{"warningType":"Freeze Warning","color":"#483D8B","code":["6","7","I"]},
{"warningType":"Red Flag Warning","color":"#FF1493","code":[]},
{"warningType":"Winter Weather Advisory","color":"#7B68EE","code":["S","O","5","Z","s","q"]},
{"warningType":"Heat Advisory","color":"#FF7F50","code":["6","7","I"]},
{"warningType":"Wind Advisory","color":"#D2B48C","code":["G","O","N"]},
{"warningType":"Frost Advisory","color":"#6495ED","code":["6","7","I"]},
{"warningType":"Default","color":"#808080","code":[]} // Default
];
const criteria = [ /* Populated from original code */
{"state_zone":"WV020","shortname":"Doddridge","state":"WV","advisory":3,"warning":5,"warncold":-15,"advcold":-5},{"state_zone":"WV027","shortname":"Clay","state":"WV","advisory":3,"warning":5,"warncold":-15,"advcold":-5},{"state_zone":"WV018","shortname":"Calhoun","state":"WV","advisory":2,"warning":4,"warncold":-15,"advcold":-5},{"state_zone":"WV006","shortname":"Cabell","state":"WV","advisory":2,"warning":4,"warncold":-15,"advcold":-5},{"state_zone":"WV028","shortname":"Braxton","state":"WV","advisory":3,"warning":5,"warncold":-15,"advcold":-5},{"state_zone":"WV026","shortname":"Boone","state":"WV","advisory":2,"warning":4,"warncold":-15,"advcold":-5},{"state_zone":"WV017","shortname":"Wirt","state":"WV","advisory":2,"warning":4,"warncold":-15,"advcold":-5},{"state_zone":"WV032","shortname":"Taylor","state":"WV","advisory":3,"warning":5,"warncold":-15,"advcold":-5},{"state_zone":"WV009","shortname":"Wood","state":"WV","advisory":2,"warning":4,"warncold":-15,"advcold":-5},{"state_zone":"WV034","shortname":"Wyoming","state":"WV","advisory":2,"warning":4,"warncold":-15,"advcold":-5},{"state_zone":"WV040","shortname":"Barbour","state":"WV","advisory":3,"warning":5,"warncold":-15,"advcold":-5},{"state_zone":"WV005","shortname":"Wayne","state":"WV","advisory":2,"warning":4,"warncold":-15,"advcold":-5},{"state_zone":"WV039","shortname":"Upshur","state":"WV","advisory":3,"warning":5,"warncold":-15,"advcold":-5},{"state_zone":"WV011","shortname":"Tyler","state":"WV","advisory":3,"warning":5,"warncold":-15,"advcold":-5},{"state_zone":"WV016","shortname":"Roane","state":"WV","advisory":2,"warning":4,"warncold":-15,"advcold":-5},{"state_zone":"WV019","shortname":"Ritchie","state":"WV","advisory":2,"warning":4,"warncold":-15,"advcold":-5},
{"state_zone":"KY105","shortname":"Lawrence","state":"KY","advisory":2,"warning":4,"warncold":-15,"advcold":-5},{"state_zone":"KY103","shortname":"Boyd","state":"KY","advisory":2,"warning":4,"warncold":-15,"advcold":-5},{"state_zone":"KY102","shortname":"Carter","state":"KY","advisory":2,"warning":4,"warncold":-15,"advcold":-5},{"state_zone":"KY101","shortname":"Greenup","state":"KY","advisory":2,"warning":4,"warncold":-15,"advcold":-5},
{"state_zone":"OH075","shortname":"Athens","state":"OH","advisory":2,"warning":4,"warncold":-15,"advcold":-5},{"state_zone":"OH086","shortname":"Gallia","state":"OH","advisory":2,"warning":4,"warncold":-15,"advcold":-5},{"state_zone":"OH083","shortname":"Jackson","state":"OH","advisory":2,"warning":4,"warncold":-15,"advcold":-5},{"state_zone":"OH087","shortname":"Lawrence","state":"OH","advisory":2,"warning":4,"warncold":-15,"advcold":-5},{"state_zone":"OH067","shortname":"Morgan","state":"OH","advisory":2,"warning":4,"warncold":-15,"advcold":-5},{"state_zone":"OH085","shortname":"Meigs","state":"OH","advisory":2,"warning":4,"warncold":-15,"advcold":-5},{"state_zone":"OH066","shortname":"Perry","state":"OH","advisory":2,"warning":4,"warncold":-15,"advcold":-5},{"state_zone":"OH084","shortname":"Vinton","state":"OH","advisory":2,"warning":4,"warncold":-15,"advcold":-5},{"state_zone":"OH076","shortname":"Washington","state":"OH","advisory":2,"warning":4,"warncold":-15,"advcold":-5},
{"state_zone":"VA003","shortname":"Dickenson","state":"VA","advisory":2,"warning":4,"warncold":-15,"advcold":-5},{"state_zone":"VA004","shortname":"Buchanan","state":"VA","advisory":2,"warning":4,"warncold":-15,"advcold":-5},
{"state_zone":"WV014","shortname":"Putnam","state":"WV","advisory":2,"warning":4,"warncold":-15,"advcold":-5},{"state_zone":"WV010","shortname":"Pleasants","state":"WV","advisory":2,"warning":4,"warncold":-15,"advcold":-5},{"state_zone":"WV007","shortname":"Mason","state":"WV","advisory":2,"warning":4,"warncold":-15,"advcold":-5},{"state_zone":"WV024","shortname":"Mingo","state":"WV","advisory":2,"warning":4,"warncold":-15,"advcold":-5},{"state_zone":"WV025","shortname":"Logan","state":"WV","advisory":2,"warning":4,"warncold":-15,"advcold":-5},{"state_zone":"WV033","shortname":"McDowell","state":"WV","advisory":2,"warning":4,"warncold":-15,"advcold":-5},{"state_zone":"WV013","shortname":"Lincoln","state":"WV","advisory":2,"warning":4,"warncold":-15,"advcold":-5},{"state_zone":"WV030","shortname":"Lewis","state":"WV","advisory":3,"warning":5,"warncold":-15,"advcold":-5},{"state_zone":"WV015","shortname":"Kanawha","state":"WV","advisory":2,"warning":4,"warncold":-15,"advcold":-5},{"state_zone":"WV008","shortname":"Jackson","state":"WV","advisory":2,"warning":4,"warncold":-15,"advcold":-5},{"state_zone":"WV031","shortname":"Harrison","state":"WV","advisory":3,"warning":5,"warncold":-15,"advcold":-5},{"state_zone":"WV029","shortname":"Gilmer","state":"WV","advisory":3,"warning":5,"warncold":-15,"advcold":-5},
{"state_zone":"WV522","shortname":"Southeast Webster","state":"WV","advisory":4,"warning":6,"warncold":-20,"advcold":-10},{"state_zone":"WV524","shortname":"Southeast Pocahontas","state":"WV","advisory":3,"warning":5,"warncold":-15,"advcold":-5},{"state_zone":"WV515","shortname":"Northwest Raleigh","state":"WV","advisory":3,"warning":5,"warncold":-15,"advcold":-5},{"state_zone":"WV523","shortname":"Northwest Pocahontas","state":"WV","advisory":4,"warning":6,"warncold":-20,"advcold":-10},{"state_zone":"WV519","shortname":"Northwest Nicholas","state":"WV","advisory":3,"warning":5,"warncold":-15,"advcold":-5},{"state_zone":"WV526","shortname":"Southeast Randolph","state":"WV","advisory":4,"warning":6,"warncold":-20,"advcold":-10},{"state_zone":"WV517","shortname":"Northwest Fayette","state":"WV","advisory":3,"warning":5,"warncold":-15,"advcold":-5},{"state_zone":"WV521","shortname":"Northwest Webster","state":"WV","advisory":4,"warning":6,"warncold":-15,"advcold":-5},{"state_zone":"WV525","shortname":"Northwest Randolph","state":"WV","advisory":4,"warning":6,"warncold":-15,"advcold":-5},{"state_zone":"WV520","shortname":"Southeast Nicholas","state":"WV","advisory":4,"warning":6,"warncold":-20,"advcold":-10},{"state_zone":"WV516","shortname":"Southeast Raleigh","state":"WV","advisory":3,"warning":5,"warncold":-15,"advcold":-5},{"state_zone":"WV518","shortname":"Southeast Fayette","state":"WV","advisory":4,"warning":6,"warncold":-15,"advcold":-5}
];
const lsrtype = [
{phenomena:"Flash Flood",abv:"F",style:"FF",color:'darkred',fillColor:'darkred',passvalue:false,verifies:"warning"},
{phenomena:"Flood",abv:"E",style:"F",color:'green',fillColor:'green',passvalue:false,verifies:"warning"},
{phenomena:"Sleet",abv:"s",style:"Sl",color:'magenta',fillColor:'pink',passvalue:true,resolvelsr:sleet,advbottom:0.01,advtop:0.49,warnbottom:0.5,warntop:999},
{phenomena:"Extreme Cold",abv:"6",style:"EC",color:'blue',fillColor:'lightblue',passvalue:true,resolvelsr:cold,advbottom:-999,advtop:-5,warnbottom:-999,warntop:-15},
{phenomena:"Wind Chill",abv:"7",style:"WC",color:'blue',fillColor:'lightblue',passvalue:true,resolvelsr:cold,advbottom:-999,advtop:-5,warnbottom:-999,warntop:-15},
{phenomena:"Extreme Heat",abv:"I",style:"EH",color:'red',fillColor:'coral',passvalue:true,resolvelsr:heat,advbottom:100,advtop:104,warnbottom:105,warntop:999},
{phenomena:"Snow",abv:"S",style:"Sn",color:'blue',fillColor:'lightblue',passvalue:true,resolvelsr:snow},
{phenomena:"Thunderstorm Wind Damage",abv:"D",style:"DMG",color:'orange',fillColor:'orange',passvalue:false,verifies:"warning"},
{phenomena:"Hail",abv:"H",style:"Hail",color:'green',fillColor:'lightgreen',passvalue:true,resolvelsr:hail,advbottom:0.25,advtop:0.99,warnbottom:1.00,warntop:999},
{phenomena:"Non Thunderstorm Wind Gust",abv:"N",style:"WndG",color:'brown',fillColor:'tan',passvalue:true,resolvelsr:wind,advbottom:45,advtop:57,warnbottom:58,warntop:999},
{phenomena:"Rain",abv:"R",style:"Rain",color:'green',fillColor:'lightgreen',passvalue:true,resolvelsr:rain,advbottom:1.5,advtop:1.99,warnbottom:2.0,warntop:999},
{phenomena:"Tornado",abv:"T",style:"TOR",color:'red',fillColor:'red',passvalue:false,verifies:"warning"},
{phenomena:"Thunderstorm Wind Gust",abv:"G",style:"TS G",color:'orange',fillColor:'yellow',passvalue:true,resolvelsr:wind,advbottom:30,advtop:45,warnbottom:46,warntop:999},
{phenomena:"Debris Flow",abv:"x",style:"DB",color:'brown',fillColor:'brown',passvalue:false,verifies:"warning"},
{phenomena:"Landslide",abv:"0",style:"LS",color:'brown',fillColor:'saddlebrown',passvalue:false,verifies:"warning"},
{phenomena:"Freezing Rain", abv:"5", style:"FZRA", color:'purple', fillColor:'violet', passvalue:true, resolvelsr:fzra, advbottom:0.01, advtop:0.24, warnbottom:0.25, warntop:999, typetextMatch: "FREEZING RAIN"}, // Match via typetext
{phenomena:"Ice/Snow Damage", abv:"5", style:"DMG", color:'darkmagenta', fillColor:'magenta', passvalue:false, verifies:"warning", typetextMatch: "SNOW/ICE DMG"}, // Match via typetext
{phenomena:"Snow Squall",abv:"q",style:"SQ",color:'darkblue',fillColor:'lightblue',passvalue:false,verifies:"warning"},
{phenomena:"Blizzard",abv:"Z",style:"BZ",color:'red',fillColor:'orange',passvalue:false,verifies:"warning"},
{phenomena:"Funnel Cloud",abv:"C",style:"FC",color:'red',fillColor:'pink',passvalue:false,verifies:"advisory"}
];
// --- Map Setup ---
function initializeMap() {
mymap = L.map('mapid', { zoomDelta: 0.25, zoomSnap: 0, zoomControl: false, attributionControl: false }).setView([38.508, -82.652480], 7.5);
// --- Base Layers ---
const CartoDB_Positron = L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', {
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors © <a href="https://carto.com/attributions">CARTO</a>',
subdomains: 'abcd', maxZoom: 20
}).addTo(mymap);
const Esri_WorldImagery = L.tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', {
attribution: 'Tiles © Esri'
});
// --- Overlay Layers ---
geoJSONwwas = L.geoJSON(null, { style: styleWarningPolygon }).addTo(mymap);
markersLayer = L.layerGroup().addTo(mymap);
gaugeMarkersLayer = L.layerGroup().addTo(mymap);
// Initialize geoJSONcounties WITH TOOLTIPS
geoJSONcounties = L.geoJSON(null, {
style: { // Style for faint outlines
fillColor: 'none', fillOpacity: 0, color: '#ccc', weight: 0.8, opacity: 0.5, interactive: false
},
onEachFeature: function(feature, layer) {
// Pre-process properties if needed
if (feature.properties && feature.properties.state && feature.properties.zone && !feature.properties.state_zone) {
feature.properties.state_zone = feature.properties.state + feature.properties.zone;
}
// <<< ADD/ENABLE TOOLTIP BINDING HERE >>>
const labelPropertyName = 'shortname'; // Use 'shortname' from criteria if available, else fallback needed
if (feature.properties && feature.properties[labelPropertyName]) {
layer.bindTooltip(
feature.properties[labelPropertyName], // The text to display
{
permanent: true, // Make it always visible
direction: 'center', // Position in the center of the polygon
className: 'my-label', // Apply the existing label style
opacity: 0.7 // Make it slightly transparent
}
);
} else if (feature.properties && feature.properties.state_zone) {
// Fallback to display state_zone if shortname missing
layer.bindTooltip(
feature.properties.state_zone,
{ permanent: true, direction: 'center', className: 'my-label', opacity: 0.7 }
);
}
}
}).addTo(mymap); // Add to map
geoJSONcounties.bringToBack(); // Keep it behind warnings/LSRs
// --- Layer Control ---
const baseLayers = { "Grayscale": CartoDB_Positron, "Satellite": Esri_WorldImagery };
const overlayMaps = { "Warnings": geoJSONwwas, "LSRs": markersLayer, "Counties": geoJSONcounties, "Gauges": gaugeMarkersLayer};
L.control.layers(baseLayers, overlayMaps, { collapsed: true }).addTo(mymap);
L.control.scale().addTo(mymap);
$('.leaflet-control-layers').hide();
}
// Existing function - modify it
function setupMapForScreenshot(data) {
console.log("JS: setupMapForScreenshot called with data:", data); // Keep this log
// --- Basic Checks ---
if (!mymap) {
console.error("JS setupMapForScreenshot: Leaflet map 'mymap' not initialized!");
return; // Cannot proceed
}
// Ensure layers exist
if (!geoJSONwwas || !markersLayer || !gaugeMarkersLayer) {
console.error("JS setupMapForScreenshot: One or more required layers (geoJSONwwas, markersLayer, gaugeMarkersLayer) are missing!");
return;
}
// Ensure styling function exists
if (typeof styleGaugeMarker !== 'function') {
console.error("JS setupMapForScreenshot: styleGaugeMarker function is not defined!");
return; // Cannot plot gauges
}
// --- End Basic Checks ---
try {
// --- Clear existing layers ---
geoJSONwwas.clearLayers();
markersLayer.clearLayers();
gaugeMarkersLayer.clearLayers(); // Clear previous gauge markers
console.log("JS: Layers cleared.");
// --- Set Map View ---
if (data.bounds && Array.isArray(data.bounds) && data.bounds.length === 2) {
try {
// Leaflet bounds are [[south, west], [north, east]]
// Ensure data.bounds is in the correct format or convert if needed
// Assuming data.bounds comes as [[south, west], [north, east]]
const leafletBounds = L.latLngBounds(data.bounds[0], data.bounds[1]);
if (leafletBounds.isValid()) {
mymap.fitBounds(leafletBounds, { padding: [10, 10], animate: false, maxZoom: 14 }); // Added maxZoom
console.log("JS: Map bounds set.");
} else {
console.error("JS: Invalid Leaflet bounds object created from data:", data.bounds);
}
} catch (boundsError) {
console.error("JS: Error setting map bounds:", boundsError, data.bounds);
}
} else {
console.error("JS: Missing or invalid bounds data in input.");
// Set a default view maybe?
// mymap.setView([38.508, -82.652480], 7.5);
}
// --- Add Warning Polygons ---
if (data.warningsGeoJsonFeatures && Array.isArray(data.warningsGeoJsonFeatures)) {
const warningFc = { type: "FeatureCollection", features: data.warningsGeoJsonFeatures };
geoJSONwwas.addData(warningFc);
console.log(`JS: Added ${data.warningsGeoJsonFeatures.length} warning features.`);
} else {
console.warn("JS: No warning features to add.");
}
// --- Add LSR Markers ---
if (data.lsrFeatures && Array.isArray(data.lsrFeatures)) {
console.log(`JS: Adding ${data.lsrFeatures.length} LSR markers...`);
data.lsrFeatures.forEach(lsrFeature => {
if (lsrFeature?.geometry?.coordinates && lsrFeature.geometry.coordinates.length === 2) {
const lat = lsrFeature.geometry.coordinates[1];
const lon = lsrFeature.geometry.coordinates[0];
if (typeof lat === 'number' && typeof lon === 'number') {
const props = lsrFeature.properties || {};
// Pass necessary properties, including timeColor and verification if available
const marker = styleLSRMarker([lat, lon], props, props.verification || null, props.timeColor || 'grey');
if (marker) markersLayer.addLayer(marker);
}
}
});
console.log("JS: Finished adding LSR markers.");
} else {
console.warn("JS: No LSR features to add.");
}
// --- *** NEW: Add Gauge Markers *** ---
if (data.gaugeLocations && Array.isArray(data.gaugeLocations)) {
console.log(`JS: Adding ${data.gaugeLocations.length} gauge markers...`);
data.gaugeLocations.forEach(gauge => {
if (gauge && typeof gauge.lat === 'number' && typeof gauge.lon === 'number' && gauge.lid) {
const latLng = L.latLng(gauge.lat, gauge.lon);
const gaugeName = gauge.name || gauge.lid;
const marker = styleGaugeMarker(latLng, gauge.lid, gaugeName); // Use existing style function
if (marker) gaugeMarkersLayer.addLayer(marker); // Add to the gauge layer
} else {
console.warn("JS: Skipping invalid gauge location data:", gauge);
}
});
console.log("JS: Finished adding gauge markers.");
} else {
console.warn("JS: No gaugeLocations data found in input.");
}
// --- *** END Add Gauge Markers *** ---
// --- Signal Readiness (Optional but helpful for Playwright timing) ---
// This variable is sometimes used in Playwright examples, not strictly needed here
// window.mapIsReady = true;
console.log("JS: setupMapForScreenshot completed.");
} catch (error) {
console.error("JS: Error inside setupMapForScreenshot:", error);
// window.mapIsReady = false; // Indicate failure if using this flag
}
}
function loadCountyLayer(url) {
console.log("Loading county/zone layer from:", url);
$.getJSON(url, function(data) {
console.log("Successfully fetched county/zone layer data.");
if (geoJSONcounties) {
// Attempt to merge 'shortname' from 'criteria' into the GeoJSON properties
if (data && data.features) {
data.features.forEach(feature => {
if (feature.properties && feature.properties.state_zone) {
const match = criteria.find(c => c.state_zone === feature.properties.state_zone);
if (match && match.shortname) {
feature.properties.shortname = match.shortname;
}
} else if (feature.properties && feature.properties.state && feature.properties.zone) {
// Handle case where state_zone might be missing but state/zone exist
const sz = feature.properties.state + feature.properties.zone;
feature.properties.state_zone = sz; // Create it first
const match = criteria.find(c => c.state_zone === sz);
if (match && match.shortname) {
feature.properties.shortname = match.shortname;
}
}
});
}
geoJSONcounties.addData(data);
geoJSONcounties.bringToBack();
console.log("County/zone layer data added (with potential shortname merge).");
} else { console.error("Cannot add data: geoJSONcounties layer not initialized."); }
}).fail(function(jqXHR, textStatus, errorThrown) {
console.error(`Failed to load county/zone layer from ${url}. Status: ${textStatus}, Error: ${errorThrown}`);
alert(`Failed to load essential County/Zone layer from ${url}. LSR zone assignment may fail.`);
});
}
function clearMapAndSelection() {
currentSelectedEvents = []; currentSelectedEventDetails = [];
currentLSRs = { type: "FeatureCollection", features: [] };
if (geoJSONwwas) geoJSONwwas.clearLayers();
if (markersLayer) markersLayer.clearLayers();
if (gaugeMarkersLayer) gaugeMarkersLayer.clearLayers(); // Clear gauge markers
$("#summary-container").html("<p>Select one or more warnings and click 'Generate Summary'.</p>");
if ($("#eventSelector").length > 0) $("#eventSelector").val([]); // Deselect
}
// --- Plotting and Styling ---
function styleWarningPolygon(feature) { /* Styles warning polygons */
const phen = feature.properties.phenomena; const sig = feature.properties.significance; const phenSigText = getPhenSig(phen, sig);
let color = warningsColorMap.find(w => w.warningType === phenSigText)?.color || warningsColorMap.find(w => phenSigText.includes(w.warningType))?.color || warningsColorMap.find(w => w.warningType === "Default").color;
return { fillColor: color, fillOpacity: 0.25, color: color, weight: 2, opacity: 0.8 };
}
// --- Helper to get LSR type info, handling ambiguous codes ---
function getLsrTypeInfo(lsrProperties) {
if (!lsrProperties || !lsrProperties.type) {
console.warn("getLsrTypeInfo: Missing lsrProperties or type property.");
return null;
}
const typeCode = lsrProperties.type;
// Use uppercase for reliable comparison, handle potential null/undefined typetext
const typeText = typeof lsrProperties.typetext === 'string' ? lsrProperties.typetext.toUpperCase() : null;
// Find all entries matching the type code
const matchingEntries = lsrtype.filter(w => w.abv === typeCode);
if (matchingEntries.length === 0) {
// console.log(`DEBUG getLsrTypeInfo: No match found for type code "${typeCode}"`);
return null; // No match for the code
} else if (matchingEntries.length === 1) {
// Unambiguous type code
// console.log(`DEBUG getLsrTypeInfo: Found unique match for type code "${typeCode}"`);
return matchingEntries[0];
} else {
// Ambiguous type code (like "5") - need to use typetext
// console.log(`DEBUG getLsrTypeInfo: Ambiguous type code "${typeCode}". Checking typetext "${typeText}"...`);
if (!typeText) {
console.warn(`Ambiguous LSR type code "${typeCode}" but no typetext provided in LSR data. Cannot differentiate.`);
// Fallback: Return the first match? Or null? Let's return null for safety.
return null;
}
// Find the entry that also matches the typetextMatch property (case-insensitive)
const specificEntry = matchingEntries.find(entry =>
entry.typetextMatch && entry.typetextMatch.toUpperCase() === typeText
);
if (specificEntry) {
// console.log(`DEBUG getLsrTypeInfo: Found specific match for "${typeCode}" using typetext "${typeText}"`);
return specificEntry;
} else {
console.warn(`Ambiguous LSR type code "${typeCode}" with typetext "${typeText}" did not match any specific typetextMatch entry.`);
// Fallback: Return null if no specific text match found
return null;
}
}
}
function styleLSRMarker(latlng, properties, verificationStatus, timeColor) {
// 1. Get LSR Type Info and Determine Colors/Text
const lsrTypeInfo = getLsrTypeInfo(properties); // Use the new helper function
const defaultColor = '#CCCCCC'; let baseFillColor = lsrTypeInfo ? lsrTypeInfo.fillColor : defaultColor;
const defaultOutline = 'blue'; const outlineColor = timeColor || (lsrTypeInfo ? lsrTypeInfo.color : defaultOutline);
let labelValue; if (lsrTypeInfo) { if (lsrTypeInfo.passvalue) { labelValue = (typeof properties.magf === 'number' && isFinite(properties.magf)) ? properties.magf : '?'; } else { labelValue = lsrTypeInfo.style ?? '?'; } } else { labelValue = '?'; } const labelText = String(labelValue);
const fullText = properties.typetext || 'Unknown LSR'; const magUnit = `${properties.magnitude || ''} ${properties.unit || ''}`.trim();
// Change background color based on verification status
if (verificationStatus === 'warning') baseFillColor = 'gold'; else if (verificationStatus === 'advisory') baseFillColor = 'lightgreen';
// Basic color validation
if (!baseFillColor.startsWith('#') && !['red', 'blue', 'green', 'yellow', 'orange', 'purple', 'pink', 'brown', 'black', 'white', 'grey', 'gray', 'gold', 'lightgreen', 'lightblue', 'tan', 'coral', 'violet', 'magenta', 'darkred', 'saddlebrown'].includes(baseFillColor.toLowerCase())) { baseFillColor = defaultColor; } if (!outlineColor.startsWith('#') && !['red', 'blue', 'green', 'yellow', 'orange', 'purple', 'pink', 'brown', 'black', 'white', 'grey', 'gray', 'gold', 'lightgreen', 'lightblue', 'tan', 'coral', 'violet', 'magenta', 'darkred', 'saddlebrown'].includes(outlineColor.toLowerCase())) { outlineColor = defaultOutline; }
// 2. Define SVG Properties
const radius = 8; const strokeWidth = 2; const padding = 2;
const svgSize = (radius + strokeWidth + padding) * 2; const svgCenter = svgSize / 2;
const fontSize = 9; const fontWeight = 'bold'; const fontColor = 'black';
// 3. Create the SVG String
const escapedLabelText = labelText.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, '\\\'');
const svgString = `
<svg width="${svgSize}" height="${svgSize}" viewBox="0 0 ${svgSize} ${svgSize}"
xmlns="http://www.w3.org/2000/svg">
<circle cx="${svgCenter}" cy="${svgCenter}" r="${radius}"
stroke="${outlineColor}" stroke-width="${strokeWidth}"
fill="${baseFillColor}" opacity="0.8" />
<text x="${svgCenter}" y="${svgCenter}"
font-family="sans-serif" font-size="${fontSize}" font-weight="${fontWeight}"
fill="${fontColor}" text-anchor="middle" dominant-baseline="central" dy="0.05em">
${escapedLabelText}
</text>
</svg>
`.trim();
// 4. Create the Data URI for the SVG
const svgDataUri = 'data:image/svg+xml;base64,' + btoa(svgString);
// 5. Create an L.Icon using the Data URI
const markerIcon = L.icon({
iconUrl: svgDataUri,
iconSize: [svgSize, svgSize], // Size of the icon image
iconAnchor: [svgCenter, svgCenter], // Anchor point relative to icon (center)
popupAnchor: [0, -svgCenter] // Position popup slightly above the center anchor
});
// 6. Create the L.Marker using the L.Icon
const marker = L.marker(latlng, {
icon: markerIcon,
keyboard: false // Optional
});
// Add shortname from criteria to popup if available
const zoneInfo = criteria.find(c => c.state_zone === properties.state_zone);
const zoneDisplay = zoneInfo ? `${properties.state_zone} (${zoneInfo.shortname})` : properties.state_zone || 'N/A';
const popupContent = `<div><strong>${fullText}</strong><br>Time: ${formatDateReadable(properties.valid)}<br>Magnitude: ${magUnit}<br>Location: ${properties.city || 'N/A'} (${latlng[0].toFixed(3)}, ${latlng[1].toFixed(3)})<br>Zone: ${zoneDisplay}<br>Verification: ${verificationStatus || 'None'}<br><span style="font-style: italic;">${properties.remark || ''}</span></div>`;
marker.bindPopup(popupContent);
return marker;
}
/**
* Creates a Leaflet marker for an NWS gauge location.
* Uses an SVG icon for a green triangle with a black text label below.
* @param {L.LatLng} latlng - The latitude/longitude of the gauge.
* @param {string} gaugeLid - The NWS Location ID (LID).
* @param {string} gaugeName - The name of the gauge.
* @returns {L.Marker} A Leaflet marker object.
*/
function styleGaugeMarker(latlng, gaugeLid, gaugeName) {
const iconSize = 18; // Size of the triangle base/height
const labelOffsetY = iconSize + 2; // Position label below triangle
const fontSize = 5;
const svgWidth = iconSize + 4; // Add padding
// Calculate required height dynamically based on label existence
const labelHeight = fontSize + 2; // Approximate height needed for label
const svgHeight = labelOffsetY + labelHeight; // Total height includes triangle and label space
// SVG for an upward-pointing equilateral triangle
const svgString = `
<svg width="${svgWidth}" height="${svgHeight}" viewBox="0 0 ${svgWidth} ${svgHeight}"
xmlns="http://www.w3.org/2000/svg">
<polygon points="${svgWidth/2},2 ${svgWidth-2},${iconSize+2} 2,${iconSize+2}"
fill="green" stroke="darkgreen" stroke-width="1" opacity="0.8"/>
<text x="${svgWidth/2}" y="${labelOffsetY}"
font-family="sans-serif" font-size="${fontSize}px" font-weight="bold"
fill="black" text-anchor="middle" dominant-baseline="hanging">
${gaugeLid || ''}
</text>
</svg>
`.trim();
const svgDataUri = 'data:image/svg+xml;base64,' + btoa(svgString);
const gaugeIcon = L.icon({
iconUrl: svgDataUri,
iconSize: [svgWidth, svgHeight],
// Anchor at the bottom center of the whole SVG
iconAnchor: [svgWidth / 2, svgHeight],
// Popup anchor slightly above the bottom center
popupAnchor: [0, -svgHeight + 5]
});
const marker = L.marker(latlng, {
icon: gaugeIcon,
keyboard: false, // Optional: disable keyboard interaction
title: `${gaugeName || 'N/A'} (${gaugeLid})` // Tooltip on hover
});
// Add popup info
const nl = `https://water.noaa.gov/gauges/${gaugeLid}`;
marker.bindPopup(`<div><strong>Gauge: ${gaugeName || gaugeLid}</strong><br>LID: ${gaugeLid}<br><a href="${nl}" target="_blank">NWS Hydrograph</a></div>`);
return marker;
}
// --- Chart.js Hydrograph Generation ---
function generateHydrograph(canvasId, data, floodStages, gaugeId, gaugeName) {
// console.log(`DEBUG: generateHydrograph called for ${gaugeId}`);
const canvas = document.getElementById(canvasId);
if (!canvas) {
console.error(`Canvas not found: ${canvasId} for ${gaugeId}`);
return Promise.resolve(null);
}
const ctx = canvas.getContext("2d");
const validData = data?.filter(d=>d?.timestamp && d.gaugeHeight !== null && !isNaN(parseFloat(d.gaugeHeight))) || []; // Ensure data is an array
if (validData.length === 0) {
console.warn(`No valid hydro data for ${gaugeId}.`);
return Promise.resolve(null); // Resolve with null, not an error
}
// Ensure data is sorted by time
validData.sort((a, b) => new Date(a.timestamp) - new Date(b.timestamp));
let chart = Chart.getChart(ctx); if (chart) chart.destroy();
// --- Data Prep ---
const labels = validData.map(d => new Date(d.timestamp));
const heights = validData.map(d => parseFloat(d.gaugeHeight));
const datasets = [{
label: "Gauge Height (ft)",
data: validData.map(d => ({ x: new Date(d.timestamp), y: parseFloat(d.gaugeHeight) })),
borderColor: "blue", backgroundColor: "rgba(0,0,255,0.1)",
borderWidth: 2, pointRadius: 0, tension: 0.1, fill: true, order: 2
}];
const stageColors = { action:'darkgoldenrod', minor:'orange', moderate:'red', major:'purple' };
const stageLabels = { action:'Action', minor:'Minor', moderate:'Moderate', major:'Major' };
let minY = Math.min(...heights); let maxY = Math.max(...heights);
// Add stage lines (only if floodStages is valid)
if (floodStages && typeof floodStages === 'object') {
Object.keys(floodStages).reverse().forEach(level => {
const stageValue = floodStages[level];
if (stageValue !== null && stageValue !== undefined && !isNaN(parseFloat(stageValue))) {
const numericValue = parseFloat(stageValue);
datasets.push({
label: `${stageLabels[level]} (${numericValue.toFixed(2)} ft)`,
data: labels.map(t => ({ x: t, y: numericValue })),
borderColor: stageColors[level], borderWidth: 1.5, pointRadius: 0,
fill: false, borderDash: [5, 5], order: 1
});
minY = Math.min(minY, numericValue);
maxY = Math.max(maxY, numericValue);
}
});
}
minY = isFinite(minY) ? minY : 0;
maxY = isFinite(maxY) ? maxY : (minY + 10);
const range = maxY - minY;
const padding = (isFinite(range) && range > 0) ? range * 0.1 : 2;
const finalMinY = Math.floor(Math.max(0, minY - padding));
const finalMaxY = Math.ceil(maxY + padding);
// --- End Data Prep ---
// --- Determine Timezone Label ---
let xAxisLabel = "Time (Eastern Time)"; // Default
try {
if (validData.length > 0) {
const startDate = new Date(validData[0].timestamp);
const endDate = new Date(validData[validData.length - 1].timestamp);
const tzFormatter = new Intl.DateTimeFormat('en-US', { timeZone: 'America/New_York', timeZoneName: 'short' });
const startTZ = tzFormatter.formatToParts(startDate).find(part => part.type === 'timeZoneName')?.value;
const endTZ = tzFormatter.formatToParts(endDate).find(part => part.type === 'timeZoneName')?.value;
if (startTZ && endTZ) {
xAxisLabel = (startTZ === endTZ) ? `Time (${startTZ})` : "Time (Eastern Time)";
// console.log(`DEBUG: Determined timezone label for ${gaugeId}: ${xAxisLabel}`);
}
}
} catch (tzError) { console.error(`ERROR determining timezone label for ${gaugeId}:`, tzError); }
// --- End Timezone Label ---
return new Promise((resolve) => {
let chartInstance=null, imageDataUrl=null;
try {
// console.log(`DEBUG: Creating Chart instance for ${gaugeId}`);
chartInstance = new Chart(ctx, {
type: "line", data: { datasets },
options: {
responsive: true, // Make chart responsive to container changes
maintainAspectRatio: false, // Allow chart to adjust height
animation: { duration: 0 },
scales: {
x: {
type: 'time',
time: {
tooltipFormat: 'MMM d, yyyy HH:mm zzzz',
displayFormats: { hour: 'MMM d, HH:mm', day: 'MMM d', month: 'MMM yyyy', year: 'yyyy' }
},
adapters: { date: { zone: 'America/New_York' } },
ticks: { source: 'auto', maxRotation: 0, autoSkip: true, autoSkipPadding: 20 },
title: { display: true, text: xAxisLabel }
},
y: {
title: { display: true, text: "Gauge Height (ft)" },
min: finalMinY, max: finalMaxY
}
},
plugins: {
title: { display: true, text: `Hydrograph: ${gaugeName || gaugeId} (${gaugeId})` }, // Use gaugeId if name missing
legend: { position: "top", labels: { boxWidth: 15, padding: 10 } },
tooltip: {
mode: 'index', intersect: false,
callbacks: {
label: function(context) {
let label = context.dataset.label || '';
if (label) { label += ': '; }
if (context.parsed.y !== null) {
if (context.dataset.label === "Gauge Height (ft)") { label += context.parsed.y.toFixed(2) + ' ft'; }
else { return null; } // Hide stage lines in tooltip body
}
return label;
}
}
}
},
events: [] // Disable events for static image
}
});
// console.log(`DEBUG: Chart instance created for ${gaugeId}`);
setTimeout(() => { // Ensure render cycle complete
try {
if (chartInstance && canvas.height > 0 && canvas.width > 0) {
// console.log(`DEBUG: Attempting canvas.toDataURL for ${gaugeId}`);
imageDataUrl = canvas.toDataURL('image/png');
if (!imageDataUrl || imageDataUrl.length < 100) {
console.error(`>>> Failed to get valid imageDataUrl from canvas for ${gaugeId}. Length: ${imageDataUrl?.length}`);
imageDataUrl = null;
} // else { console.log(`DEBUG: Successfully captured imageDataUrl for ${gaugeId}`); }
} else { console.error(`>>> Canvas invalid or chart instance missing before toDataURL for ${gaugeId}.`); imageDataUrl = null; }
} catch (e) { console.error(`>>> Error during canvas.toDataURL ${gaugeId}:`, e); imageDataUrl = null; }
finally {
if (chartInstance) { chartInstance.destroy(); }
// console.log(`DEBUG: Resolving generateHydrograph for ${gaugeId}`);
resolve(imageDataUrl);
}
}, 50); // Short delay
} catch (e) {
console.error(`>>> Error CREATING Chart ${gaugeId}:`, e);
if (chartInstance) chartInstance.destroy();
resolve(null); // Resolve with null on creation error
}
});
}
/**
* Copies the generated summary HTML (including the map image) to the clipboard.
* Uses the modern Clipboard API which supports rich content.
* Requires HTTPS or localhost and user permission.
*/
async function copySummaryToClipboard() {
console.log("Copy function started.");
const btn = document.getElementById('copySummaryBtn');
const originalBtnText = btn.textContent; // Store original text
const div = document.getElementById('summary-container');
if (!div) {
console.error("Summary container element not found.");
alert("Error: Could not find the summary content to copy.");
return;
}
// Check for Clipboard API support and secure context
if (!navigator.clipboard?.write) {
console.warn("Clipboard API (navigator.clipboard.write) not supported or not available in this context (requires HTTPS or localhost).");
alert("Clipboard write functionality is not available. This feature requires a secure connection (HTTPS) or localhost, and browser support.");
return;
}
console.log("Clipboard API supported.");
let htmlContent = '';
try {
// 1. Clone the summary container to avoid modifying the original
const clone = div.cloneNode(true);
// 2. Remove the copy button itself from the cloned content
clone.querySelector('#copySummaryBtn')?.remove();
// Remove the spacer div if it exists
clone.querySelector('div[style*="clear: both"]')?.remove();
// 3. Get the inner HTML of the cleaned clone
htmlContent = clone.innerHTML.trim();
if (!htmlContent) {
alert("Cannot copy empty summary.");
return;
}
console.log("Prepared HTML content length:", htmlContent.length);
} catch (e) {
console.error("Error preparing HTML content for clipboard:", e);
alert("An error occurred while preparing the summary content for copying.");
return;
}
// 4. Find the generated map image within the original summary
// We target the image generated by the CGI script specifically.
const mapImageElement = div.querySelector('img[alt="Map of Warnings and LSRs"]');
let clipboardItems = [];
// 5. Try to fetch the map image data if it exists
if (mapImageElement && mapImageElement.src && mapImageElement.src.startsWith('blob:')) {
console.log("Map image element found. Attempting to fetch blob:", mapImageElement.src);
try {
btn.textContent = 'Fetching Image...'; // Update button text
btn.disabled = true; // Disable button during fetch
const response = await fetch(mapImageElement.src);
if (!response.ok) {
throw new Error(`Failed to fetch map image blob: ${response.status} ${response.statusText}`);
}
const imageBlob = await response.blob();
console.log("Map image blob fetched successfully. Type:", imageBlob.type, "Size:", imageBlob.size);
// Ensure blob type is reasonable (e.g., image/png)
if (!imageBlob.type.startsWith('image/')) {
console.warn(`Fetched blob type '${imageBlob.type}' might not be suitable for clipboard image. Proceeding anyway.`);
}
// Create ClipboardItem with both HTML and Image data
clipboardItems.push(new ClipboardItem({
'text/html': new Blob([htmlContent], { type: 'text/html' }),
// Use the fetched blob directly
[imageBlob.type || 'image/png']: imageBlob // Use fetched type or default to png
}));
console.log("ClipboardItem created with HTML and image data.");
} catch (fetchError) {
console.error("Error fetching or processing map image blob for clipboard:", fetchError);
alert("Warning: Could not copy the map image. The text summary will still be copied. Check console for details.");
// Fallback: Create ClipboardItem with only HTML data
clipboardItems.push(new ClipboardItem({
'text/html': new Blob([htmlContent], { type: 'text/html' })
}));
console.log("ClipboardItem created with HTML data only (image fetch failed).");
} finally {
btn.disabled = false; // Re-enable button
btn.textContent = originalBtnText; // Restore original text or proceed to copy status
}
} else {
console.log("Map image element not found or src is not a blob URL. Copying HTML only.");
// Create ClipboardItem with only HTML data
clipboardItems.push(new ClipboardItem({
'text/html': new Blob([htmlContent], { type: 'text/html' })
}));
console.log("ClipboardItem created with HTML data only.");
}
// 6. Write the ClipboardItem(s) to the clipboard
try {
btn.textContent = 'Copying...'; // Update button text
btn.disabled = true; // Disable button during write
await navigator.clipboard.write(clipboardItems);
console.log("Content successfully written to clipboard.");
// Success feedback
btn.textContent = 'Copied!';
btn.style.backgroundColor = '#90ee90'; // Light green background
setTimeout(() => {
btn.textContent = originalBtnText;
btn.style.backgroundColor = ''; // Reset background
}, 2000); // Reset after 2 seconds
} catch (error) {
console.error('Failed to write content to clipboard:', error);
alert(`Failed to copy summary to clipboard: ${error.name} - ${error.message}. This often requires HTTPS/localhost and user permission.`);
// Failure feedback
btn.textContent = 'Copy Failed';
btn.style.backgroundColor = '#ffcccb'; // Light red background
setTimeout(() => {
btn.textContent = originalBtnText;
btn.style.backgroundColor = ''; // Reset background
}, 3000); // Reset after 3 seconds
} finally {
btn.disabled = false; // Ensure button is re-enabled
}
}
// --- Utility Functions ---
function getEarliestAndLatestTimes(events) { if (!events?.length) { const n=new Date(); return { earliestIssue: n.toISOString(), latestExpire: n.toISOString() }; } let ei = events[0].issue; let le = events[0].expire; for (let i=1; i<events.length; i++) { try { const issueDate = new Date(events[i]?.issue); if (!isNaN(issueDate) && issueDate < new Date(ei)) ei = events[i].issue; } catch(e){} try { const expireDate = new Date(events[i]?.expire); if(!isNaN(expireDate) && expireDate > new Date(le)) le = events[i].expire; } catch(e){} } return { earliestIssue: ei, latestExpire: le }; }
function getPhenSig(code, sig) { code = code?.toUpperCase()||"??"; sig = sig?.toUpperCase()||"?"; const p = phenomenonCodes[code]||`Unknown (${code})`; const s = sigCodes[sig]||`Unknown (${sig})`; return `${p} ${s}`; }
function getColorForWarning(phenSigText) { return warningsColorMap.find(w=>w.warningType===phenSigText)?.color || warningsColorMap.find(w=>phenSigText.includes(w.warningType))?.color || warningsColorMap.find(w=>w.warningType==="Default").color; }
function yearfromissue(issueTimestamp) { try { return new Date(issueTimestamp).getUTCFullYear(); } catch (e) { /*console.warn("Bad year:", issueTimestamp);*/ return new Date().getUTCFullYear(); } }
function addHoursToDate(dateInput, hours) { try { const date = new Date(dateInput); if (isNaN(date.getTime())) throw new Error("Invalid input date"); date.setTime(date.getTime() + hours * 60 * 60 * 1000); return date; } catch (e) { console.error("Error in addHoursToDate:", e, "Input:", dateInput); return new Date(); } }
function sleep(ms) { return new Promise(resolve => setTimeout(resolve, ms)); }
function getZoneCodeFromName(locationStringPart) { if (!locationStringPart || typeof locationStringPart !== 'string') return null; const trimmedPart = locationStringPart.trim(); const zoneRegex = /^([A-Z]{2})([CZ])(\d{3})$/; const zoneMatch = trimmedPart.match(zoneRegex); if (zoneMatch) { return trimmedPart.toUpperCase(); } const nameStateRegex = /(.+)\s+\[([A-Z]{2})\]$/; const nameMatch = trimmedPart.match(nameStateRegex); if (nameMatch?.length === 3) { const searchName = nameMatch[1].trim().toLowerCase(); const searchState = nameMatch[2].toUpperCase(); const found = criteria.find(item => item.state === searchState && item.shortname.toLowerCase() === searchName); return found?.state_zone || null; } console.warn(`Could not parse zone code from location part: "${locationStringPart}"`); return null; }
function normalizeZoneCode(zoneCode) { if (!zoneCode || typeof zoneCode !== 'string') return null; const match = zoneCode.toUpperCase().match(/^([A-Z]{2})[CZ](\d{3})$/); if (match) { return match[1] + match[2]; } if (/^[A-Z]{2}\d{3}$/.test(zoneCode.toUpperCase())) { return zoneCode.toUpperCase(); } console.warn(`Could not normalize potentially non-standard zone code: ${zoneCode}`); return zoneCode.toUpperCase(); }
// --- Date Formatting Helpers ---
function formatDateUTC_Compact(dateInput) { try { const d = (dateInput instanceof Date) ? dateInput : new Date(dateInput); if (isNaN(d.getTime())) { return 'Invalid Date Obj'; } const y = d.getUTCFullYear(); const m = (d.getUTCMonth() + 1).toString().padStart(2, '0'); const day = d.getUTCDate().toString().padStart(2, '0'); const h = d.getUTCHours().toString().padStart(2, '0'); const min = d.getUTCMinutes().toString().padStart(2, '0'); const yearStr = y.toString(); return `${yearStr}${m}${day}/${h}${min}Z`; } catch (e) { console.error("Error in formatDateUTC_Compact:", e, "Input:", dateInput); return 'Error UTC'; } }
function formatDate_IEM(date) { try { if (!(date instanceof Date) || isNaN(date.getTime())) { date = new Date(); console.warn("formatDate_IEM received invalid date, using 'now'"); } const y = date.getUTCFullYear(); const m = (date.getUTCMonth() + 1).toString().padStart(2, '0'); const d = date.getUTCDate().toString().padStart(2, '0'); const h = date.getUTCHours().toString().padStart(2, '0'); const i = date.getUTCMinutes().toString().padStart(2, '0'); return `${y}${m}${d}${h}${i}`; } catch (e) { console.error("formatDate_IEM error:", e, "Input:", date); return "InvalidDate"; } }
function formatDate_YYMMDD_HHMMZ(dateString) { try { const d = new Date(dateString); if (isNaN(d.getTime())) { return "Invalid Date"; } const y = d.getUTCFullYear().toString().slice(-2); const m = (d.getUTCMonth() + 1).toString().padStart(2, '0'); const day = d.getUTCDate().toString().padStart(2, '0'); const h = d.getUTCHours().toString().padStart(2, '0'); const min = d.getUTCMinutes().toString().padStart(2, '0'); return `${y}${m}${day}/${h}${min}Z`; } catch (e) { console.error("Error in formatDate_YYMMDD_HHMMZ:", e, "Input:", dateString); return "Format Error"; } }
function formatDateReadable(dateString) { try { const d = new Date(dateString); if (isNaN(d.getTime())) { return "Invalid Date"; } const opts = { year: 'numeric', month: 'numeric', day: 'numeric', hour: 'numeric', minute: '2-digit', timeZone: 'America/New_York', timeZoneName: 'short' }; const localFormatted = new Intl.DateTimeFormat('en-US', opts).format(d); const utcCompact = formatDateUTC_Compact(d); return `${localFormatted} (${utcCompact})`; } catch (e) { return "Invalid Date (Format Error)"; } }
function formatHvtecTime(timeZ_YY) { if (!timeZ_YY || typeof timeZ_YY !== 'string' || timeZ_YY.length !== 12) { return 'N/A'; } try { const yearYY = timeZ_YY.substring(0, 2); const month = timeZ_YY.substring(2, 4); const day = timeZ_YY.substring(4, 6); const hour = timeZ_YY.substring(7, 9); const minute = timeZ_YY.substring(9, 11); const yearYYYY = `20${yearYY}`; const isoString = `${yearYYYY}-${month}-${day}T${hour}:${minute}:00Z`; const dateObj = new Date(isoString); if (isNaN(dateObj.getTime())) { console.error(`DEBUG formatHvtecTime: Constructed INVALID date object from ISO String: ${isoString} (from input ${timeZ_YY})`); return 'Invalid Date'; } const opts = { year: 'numeric', month: 'numeric', day: 'numeric', hour: 'numeric', minute: '2-digit', timeZone: 'America/New_York', timeZoneName: 'short' }; const localFormatted = new Intl.DateTimeFormat('en-US', opts).format(dateObj); const utcCompact = `${yearYYYY}${month}${day}/${hour}${minute}Z`; return `${localFormatted} (${utcCompact})`; } catch (e) { console.error("CRITICAL Error inside formatHvtecTime:", timeZ_YY, e); return 'Format Error'; } }
function formatDate_URLParam(isoDateString) { if (!isoDateString) return ''; try { const d = new Date(isoDateString); if (isNaN(d.getTime())) { return ''; } return d.toISOString().slice(0, 19); } catch (e) { console.error('Error formatting date for URL:', isoDateString, e); return ''; } }
function showLoadingIndicator(show) { const btn = $("#generateSummaryBtn"); if (show) { btn.val("Processing...").prop('disabled', true); } else { btn.val("Generate Summary").prop('disabled', false); } }
function formatDate_MMDD_HHMM(dateString) {
try {
const d = new Date(dateString);
if (isNaN(d.getTime())) {
console.warn("formatDate_MMDD_HHMM received invalid date string:", dateString);
return "Invalid Time";
}
// Options to get MM/DD, HH:MM and the timezone abbreviation (EST/EDT)
const options = {
month: 'numeric',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
timeZone: 'America/New_York', // Let Intl handle EST/EDT
hour12: false, // Use 24-hour format
timeZoneName: 'short' // Explicitly include the timezone abbreviation
};
// localFormatted will be like "4/6, 10:30 EST" or "7/1, 14:00 EDT"
const localFormatted = new Intl.DateTimeFormat('en-US', options).format(d);
// Get the compact UTC representation (assuming this function exists)
const utcCompact = formatDateUTC_Compact(d); // e.g., "20250406/1430Z"
// Combine the local time (with its timezone) and the UTC time
return `${localFormatted} (${utcCompact})`;
} catch (e) {
console.error("Error in formatDate_MMDD_HHMM:", e, "Input:", dateString);
return "Invalid Time (Format Error)";
}
}
/**
* Formats date as HH:MM TZN (UTC_Compact) using specified timezone.
* TZN will be EST or EDT depending on the date.
* Used for LSR timestamps in the summary. Uses 24-hour format.
* @param {string | Date} dateString - Input date string or object.
* @returns {string} Formatted time string or error message.
*/
function formatDate_HHMM(dateString) {
try {
const d = new Date(dateString);
if (isNaN(d.getTime())) {
console.warn("formatDate_HHMM received invalid date string:", dateString);
return "Invalid Time";
}
const opts = {
hour: 'numeric', minute: '2-digit',
timeZone: 'America/New_York', // Let Intl handle EST/EDT
hour12: false // Use 24-hour format
};
// localFormatted will be like "10:30 EST" or "14:00 EDT"
const localFormatted = new Intl.DateTimeFormat('en-US', opts).format(d);
const utcCompact = formatDateUTC_Compact(d); // e.g., "20250406/1430Z"
// Combine the local time (with its correct timezone) and the UTC time
return `${localFormatted} (${utcCompact})`; // REMOVED the extra " EST"
} catch (e) {
console.error("Error in formatDate_HHMM:", e, "Input:", dateString);
return "Invalid Time (Format Error)";
}
}
// --- Event Listener for Filtering the Event List ---
$(document).ready(function() {
// Ensure map initialization and initial data load happen first
initializeMap();
loadCountyLayer('https://wx.stoat.org/main.php?service=ver');
}); // End $(document).ready()
function cold(zone, value) { const z = criteria.find(c=>c.state_zone===zone); if(!z||typeof value !=='number') return {level:null}; if(value<=z.warncold) return {level:'warning'}; if(value<=z.advcold) return {level:'advisory'}; return {level:null}; }
function heat(zone, value) { const z = criteria.find(c=>c.state_zone===zone); if(!z||typeof value !=='number') return {level:null}; if(value>=105) return {level:'warning'}; if(value>=100) return {level:'advisory'}; return {level:null}; } // Added zone lookup - although criteria doesn't have heat thresholds yet
function snow(zone, value) { const z = criteria.find(c=>c.state_zone===zone); if(!z||typeof value !=='number') return {level:null}; if(value>=z.warning) return {level:'warning'}; if(value>=z.advisory) return {level:'advisory'}; return {level:null}; }
function hail(zone, value) { /* zone not needed for hail */ if(typeof value !=='number') return {level:null}; if(value>=1.00) return {level:'warning'}; if(value>=0.25) return {level:'advisory'}; return {level:null}; }
function wind(zone, value) { /* zone not needed for wind */ if(typeof value !=='number') return {level:null}; if(value>=58) return {level:'warning'}; if(value>=40) return {level:'advisory'}; return {level:null}; }
function sleet(zone, value){ /* zone not needed for sleet */ if(typeof value !=='number') return {level:null}; if(value>=0.50) return {level:'warning'}; return {level:null}; } // No std advisory
function fzra(zone, value) { /* zone not needed for fzra */ if(typeof value !=='number') return {level:null}; if(value>=0.25) return {level:'warning'}; if(value>=0.01) return {level:'advisory'}; return {level:null}; }
function rain(zone, value) { /* zone not needed for rain */ if(typeof value !=='number') return {level:null}; if(value>=2.0) return {level:'warning'}; if(value>=0.5) return {level:'advisory'}; return {level:null}; }
</script>
<canvas id="hydrograph-canvas-template" width="800" height="450" style="display: none;"></canvas>
</body>
</html>