Files
test/stormdata_backup.html
2025-11-27 22:25:36 +00:00

3082 lines
223 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 VTEC Browser</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%;
}
#map-container {
flex-grow: 1; height: 100%; display: flex; flex-direction: column;
}
#mapid {
flex-grow: 1; width: 100%; z-index: 1;
}
#summary-container {
height: 60%; /* 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: calc(100% - 450px); 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; /* Set the desired width */
flex-shrink: 0; /* Prevent the item from shrinking */
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], #controls input[type=button] {
display: block;
margin-bottom: 8px;
width: 100%; /* Make controls fill width */
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 for export buttons */
#export-controls {
margin-top: 15px;
border-top: 1px solid #ccc;
padding-top: 10px;
}
#export-controls h3 {
margin-bottom: 5px;
}
#export-controls input[type=button] {
background-color: #e7e7e7;
border: 1px solid #ccc;
padding: 6px 12px;
cursor: pointer;
margin-top: 5px; /* Space between buttons */
}
#export-controls input[type=button]:hover {
background-color: #d7d7d7;
}
#export-controls input[type=button]:disabled {
background-color: #f5f5f5;
color: #aaa;
cursor: not-allowed;
}
</style>
</head>
<body>
<div id="container">
<div id="controls">
<h3>Select Products</h3>
<label for="yeartowork">Choose a year:</label>
<select name="yeartowork" id="yeartowork" onchange="getwwas()">
<option value="2025">2025</option>
<option value="2024">2024</option>
<option value="2023">2023</option>
<option value="2022">2022</option>
</select>
<!-- Event selector added by JS -->
<label for="eventFilterInput" style="margin-top: 15px;">Filter Events:</label>
<input type="text" id="eventFilterInput" placeholder="Type to filter list..." />
<h3>Options</h3>
<label for="lsrbuffer">Time Buffer for Reports (hrs):</label>
<input id="lsrbuffer" name="lsrbuffer" type="number" value="1">
<label for="powerthresh">Power Outage Threshold for Reporting:</label>
<input id="powerthresh" name="powerthresh" type="number" value="50">
<input id="generateSummaryBtn" type="button" value="Generate Summary" onclick="generateSummaryForSelectedEvents();" style="margin-top: 15px; font-weight: bold; background-color: #add8e6;" />
<!-- Export Controls Section -->
<div id="export-controls">
<h3>Export Options</h3>
<input type="button" id="exportLsrCsvBtn" value="Export All Reports (CSV)" onclick="generateLsrCsv();" disabled>
<input type="button" id="exportKmlBtn" value="Export Products/Reports (KML)" onclick="generateKml();" disabled>
Note: Reports not from LSRs (power outage, DOT, 911, etc) will be filtered if an LSR has already been issued within 1 mile of them.
</div>
</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 (Filtered by selected product polygons)
let gaugeMarkersLayer; // Layer for NWS Gauge markers
let currentSelectedEvents = []; // Raw event objects selected by the user
let currentSelectedEventDetails = []; // Detailed info { event, data, fullDetails } for selected events
let unfilteredLSRs = { type: "FeatureCollection", features: [] }; // Stores ALL LSRs fetched for the time range + buffer
let hydrographCharts = {}; // Keep track of Chart.js instances if needed (though now generating images directly)
let prePlotLayer; // Layer for pre-plotting selected warnings
let eventGeometryCache = {}; // Simple cache for event geometry { 'phen|sig|etn|year': geoJsonData }
async function fetchDetailedEventData(event) {
const year = yearfromissue(event.issue);
const wfo = "KRLX"; // Assuming KRLX is the WFO, you might need to generalize this later if needed
const url = `https://mesonet.agron.iastate.edu/json/vtec_event.py?wfo=${wfo}&phenomena=${event.phenomena}&significance=${event.significance}&etn=${event.eventid}&year=${year}`;
try {
const data = await $.getJSON(url);
return data; // Return the detailed event data
} catch (error) {
console.error(`Failed to fetch detailed event data for ${event.phenomena}.${event.significance}.${event.eventid}:`, error);
return null; // Return null in case of error
}
}
/**
* Parses NWS product text for H-VTEC line times AND narrative forecast details.
* Extracts each piece of info (Rise, Crest Value, Crest Time, Fall Time)
* independently from the EARLIEST product (Report or SVS) where it's found.
* Uses specific patterns for crest value, falling back to 'rise to'.
* Attempts to enhance narrative time descriptions with nearby modifiers.
* @param {object} fullDetails - The full event details object containing 'report' and 'svs' array.
* @param {string} targetLid - The 5-character NWS Location ID (e.g., "GNUK2").
* @returns {object|null} An object containing structured times and narrative descriptions, or null if no info found.
* { riseTimeZ, crestTimeZ, fallTimeZ, isValidStructure } // isValidStructure reflects if ANY valid H-VTEC line was found
* { riseTimeDescNarrative, crestValueNarrative, crestTimeDescNarrative, fallTimeDescNarrative }
* { riseSource, crestValSource, crestTimeSource, fallSource } // Indicates source product index ('Report', 'SVS 0', 'SVS 1', etc.)
*/
function parseHvtecLine(fullDetails, targetLid) {
if (!fullDetails || !targetLid || targetLid.length !== 5) {
return null;
}
if (!fullDetails.report && (!fullDetails.svs || fullDetails.svs.length === 0)) {
// console.warn(`parseHvtecLine: No report or SVS text provided for ${targetLid}`);
return null; // No text to parse
}
const normalizedLid = targetLid.toUpperCase();
// --- Initialize Results & Sources ---
const results = {
riseTimeZ: null, crestTimeZ: null, fallTimeZ: null, isValidStructure: false,
riseTimeDescNarrative: null, crestValueNarrative: null, crestTimeDescNarrative: null, fallTimeDescNarrative: null,
riseSource: null, crestValSource: null, crestTimeSource: null, fallSource: null // Track source
};
// --- List of Products to Check (Report first, then SVS) ---
const productsToCheck = [];
if (fullDetails.report?.text) {
productsToCheck.push({ text: fullDetails.report.text, sourceName: 'Report' });
}
if (fullDetails.svs && Array.isArray(fullDetails.svs)) {
fullDetails.svs.forEach((svs, index) => {
if (svs?.text) {
productsToCheck.push({ text: svs.text, sourceName: `SVS ${index}` });
}
});
}
// --- Iterate through products, populating results ---
for (const product of productsToCheck) {
const productText = product.text;
const sourceName = product.sourceName;
// --- Text Cleaning ---
let cleanedText = productText.replace(/\r\n|\r/g, '\n');
cleanedText = cleanedText.replace(/\u200B/g, '');
cleanedText = cleanedText.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F]/g, '');
// --- 1. Attempt to Parse Structured H-VTEC Line from THIS product ---
let currentProductVtecLineValid = false;
let currentRiseZ = null, currentCrestZ = null, currentFallZ = null;
const lines = cleanedText.split('\n');
let vtecLineFound = null;
const lineStartPattern = `/${normalizedLid}.`;
for (const line of lines) {
let trimmedLine = line.trim();
if (trimmedLine.startsWith(lineStartPattern) && trimmedLine.endsWith('/') && trimmedLine.split('.').length >= 7) {
const partsCheck = trimmedLine.slice(1, -1).split('.');
if (partsCheck.length === 7 && partsCheck[3]?.length === 12 && partsCheck[3].includes('T') && partsCheck[3].endsWith('Z') && partsCheck[4]?.length === 12 && partsCheck[4].includes('T') && partsCheck[4].endsWith('Z') && partsCheck[5]?.length === 12 && partsCheck[5].includes('T') && partsCheck[5].endsWith('Z'))
{ vtecLineFound = trimmedLine; break; }
}
}
if (vtecLineFound) {
try {
const parts = vtecLineFound.slice(1, -1).split('.');
if (parts.length === 7) {
const riseZ = parts[3]; const crestZ = parts[4]; const fallZ = parts[5];
const timeFormatRegex = /^(\d{6}T\d{4}Z)$/;
if (timeFormatRegex.test(riseZ) && timeFormatRegex.test(crestZ) && timeFormatRegex.test(fallZ)) {
const zeroTime = "000000T0000Z";
const isRiseZero = riseZ === zeroTime; const isCrestZero = crestZ === zeroTime; const isFallZero = fallZ === zeroTime;
if (!isRiseZero) currentRiseZ = riseZ;
if (!isCrestZero) currentCrestZ = crestZ;
if (!isFallZero) currentFallZ = fallZ;
currentProductVtecLineValid = (!isRiseZero || !isCrestZero || !isFallZero);
if (currentProductVtecLineValid) results.isValidStructure = true; // Mark if ANY valid line found
}
}
} catch (splitError) { console.error(`ERROR parsing VTEC line in ${sourceName}: ${vtecLineFound}`, splitError); }
}
// --- Fill Results from H-VTEC (if not already filled) ---
if (results.riseTimeZ === null && currentRiseZ !== null) { results.riseTimeZ = currentRiseZ; results.riseSource = sourceName + ' (H-VTEC)'; }
if (results.crestTimeZ === null && currentCrestZ !== null) { results.crestTimeZ = currentCrestZ; results.crestTimeSource = sourceName + ' (H-VTEC)'; }
if (results.fallTimeZ === null && currentFallZ !== null) { results.fallTimeZ = currentFallZ; results.fallSource = sourceName + ' (H-VTEC)'; }
// --- 2. Attempt to Parse Narrative from THIS product ---
let narrativeSectionText = null;
const sections = cleanedText.split(/\n(?:&&|##)\n/);
for (const section of sections) {
// Simple check: does the section contain the LID or /LID.?
if (section.toUpperCase().includes(`/${normalizedLid}.`) || section.toUpperCase().includes(normalizedLid)) {
narrativeSectionText = section.trim();
break;
}
}
if (narrativeSectionText) {
// Define Regex patterns
// Crest Value Patterns (Option 2)
const crestValPatternPrimary = /(?:crest(?: of| at)?)\s+([\d.]+)(?:\s+f(?:ee)?t)?/i; // Only for "crest" keyword
const crestValPatternFallback = /(?:rise|expected to rise)\s+(?:above.+?)?\s*to\s+([\d.]+)(?:\s+f(?:ee)?t)?/i; // Specific "rise to" pattern
// Other Narrative Patterns
const riseDescPattern = /rise (?:above |to )?flood stage\s+(.*?)(?:to a crest|\.|and continue|$)/i;
const crestTimeDescPattern = /(?:crest of|crest at|rise to|reach)\s+[\d.]+(?:\s+f(?:ee)?t)?\s+(.*?)(?:\. It will then|\. It is expected|\. Fall|\.$|$)/i;
const fallDescPattern = /fall below flood stage\s+(.*?)(?:\.|$|and continue)/i;
// --- Crest Value Parsing (Using Option 2 logic) ---
let currentCrestVal = null;
const crestValMatchPrimary = narrativeSectionText.match(crestValPatternPrimary);
if (crestValMatchPrimary && crestValMatchPrimary[1]) {
currentCrestVal = crestValMatchPrimary[1].trim();
} else {
const crestValMatchFallback = narrativeSectionText.match(crestValPatternFallback);
if (crestValMatchFallback && crestValMatchFallback[1]) {
currentCrestVal = crestValMatchFallback[1].trim();
}
}
// Fill result if found and not already filled
if (results.crestValueNarrative === null && currentCrestVal !== null) {
results.crestValueNarrative = currentCrestVal;
results.crestValSource = sourceName + ' (Narrative)';
}
// --- End Crest Value Parsing ---
// --- Parse Other Narrative Details ---
const riseDescMatch = narrativeSectionText.match(riseDescPattern);
const crestTimeDescMatch = narrativeSectionText.match(crestTimeDescPattern);
const fallDescMatch = narrativeSectionText.match(fallDescPattern);
let currentRiseDesc = null, currentCrestDesc = null, currentFallDesc = null;
if (riseDescMatch && riseDescMatch[1]) { currentRiseDesc = riseDescMatch[1].trim(); }
// Corrected index for crestTimeDescPattern group
if (crestTimeDescMatch && crestTimeDescMatch[1]) { currentCrestDesc = crestTimeDescMatch[1].trim(); }
if (fallDescMatch && fallDescMatch[1]) { currentFallDesc = fallDescMatch[1].trim(); }
// --- End Parse Other Details ---
// --- Enhance Descriptions (Helper Function) ---
const enhanceTimeDesc = (desc, fullText, matchStartIndex, matchLength) => {
if (!desc || !fullText || matchStartIndex < 0) return desc;
let baseDesc = desc.trim().replace(/[.,;:]$/, '').trim(); let finalDesc = baseDesc; const searchWindow = 35;
const descStartIndex = matchStartIndex; const precedingText = fullText.substring(Math.max(0, descStartIndex - searchWindow), descStartIndex).toLowerCase(); const modifierMatch = /\b(early|late)\s*$/i.exec(precedingText); if (modifierMatch && !/^(early|late)\b/i.test(baseDesc)) { const modifier = modifierMatch[1]; finalDesc = modifier.charAt(0).toUpperCase() + modifier.slice(1) + " " + baseDesc; }
const descEndIndex = descStartIndex + matchLength; const followingText = fullText.substring(descEndIndex, Math.min(fullText.length, descEndIndex + searchWindow)).toLowerCase(); const timeOfDayMatch = /^\s*\b(morning|afternoon|evening|tonight)\b/i.exec(followingText); if (timeOfDayMatch && !/\b(morning|afternoon|evening|tonight)\b/i.test(baseDesc)) { finalDesc += " " + timeOfDayMatch[1]; }
finalDesc = finalDesc.trim(); const lowerFinal = finalDesc.toLowerCase(); if (lowerFinal.endsWith(" tonight") && lowerFinal.startsWith("tonight")) { finalDesc = "Tonight"; } if (lowerFinal.endsWith(" morning") && lowerFinal.includes("morning") && lowerFinal.indexOf(" morning") !== lowerFinal.lastIndexOf(" morning")) { finalDesc = finalDesc.replace(/ morning$/, ''); } if (lowerFinal.endsWith(" afternoon") && lowerFinal.includes("afternoon") && lowerFinal.indexOf(" afternoon") !== lowerFinal.lastIndexOf(" afternoon")) { finalDesc = finalDesc.replace(/ afternoon$/, ''); } if (lowerFinal.endsWith(" evening") && lowerFinal.includes("evening") && lowerFinal.indexOf(" evening") !== lowerFinal.lastIndexOf(" evening")) { finalDesc = finalDesc.replace(/ evening$/, ''); }
return finalDesc || desc;
};
// --- End Helper Function ---
// Apply enhancer to CURRENT values
if (currentRiseDesc && riseDescMatch) { const matchString = riseDescMatch[1]; const fullMatchString = riseDescMatch[0]; const matchIndex = riseDescMatch.index; const groupStartIndex = fullMatchString.indexOf(matchString); if (groupStartIndex !== -1) { currentRiseDesc = enhanceTimeDesc(currentRiseDesc, narrativeSectionText, matchIndex + groupStartIndex, matchString.length); } }
// Corrected index for crestTimeDescMatch group application
if (currentCrestDesc && crestTimeDescMatch) { const matchString = crestTimeDescMatch[1]; const fullMatchString = crestTimeDescMatch[0]; const matchIndex = crestTimeDescMatch.index; const groupStartIndex = fullMatchString.indexOf(matchString); if (groupStartIndex !== -1) { currentCrestDesc = enhanceTimeDesc(currentCrestDesc, narrativeSectionText, matchIndex + groupStartIndex, matchString.length); } }
if (currentFallDesc && fallDescMatch) { const matchString = fallDescMatch[1]; const fullMatchString = fallDescMatch[0]; const matchIndex = fallDescMatch.index; const groupStartIndex = fullMatchString.indexOf(matchString); if (groupStartIndex !== -1) { currentFallDesc = enhanceTimeDesc(currentFallDesc, narrativeSectionText, matchIndex + groupStartIndex, matchString.length); } }
// --- Fill Results from Narrative (if not already filled by H-VTEC or previous product) ---
// Prefer H-VTEC time if available from ANY product, otherwise use narrative
if (results.riseTimeZ === null && results.riseTimeDescNarrative === null && currentRiseDesc !== null) { results.riseTimeDescNarrative = currentRiseDesc; results.riseSource = sourceName + ' (Narrative)'; }
if (results.crestTimeZ === null && results.crestTimeDescNarrative === null && currentCrestDesc !== null) { results.crestTimeDescNarrative = currentCrestDesc; results.crestTimeSource = sourceName + ' (Narrative)'; }
if (results.fallTimeZ === null && results.fallTimeDescNarrative === null && currentFallDesc !== null) { results.fallTimeDescNarrative = currentFallDesc; results.fallSource = sourceName + ' (Narrative)'; }
} // End if (narrativeSectionText)
} // End loop through products
// --- Final Return ---
if (results.isValidStructure || results.crestValueNarrative || results.riseTimeZ || results.crestTimeZ || results.fallTimeZ || results.riseTimeDescNarrative || results.crestTimeDescNarrative || results.fallTimeDescNarrative) {
return results;
} else {
return null; // Nothing useful found
}
}
/**
* Analyzes hydrograph data to find crest and stage crossings.
* @param {Array<object>} hydroData Array of {timestamp, gaugeHeight} objects.
* @param {object} floodStages Object with {action, minor, moderate, major} stage values.
* @returns {object} Analysis results object.
*/
function analyzeHydrograph(hydroData, floodStages) {
const analysisResult = { firstRiseAboveAction: null, lastFallBelowAction: null, remainsAboveAction: false, firstRiseAboveMinor: null, lastFallBelowMinor: null, remainsAboveMinor: false, firstRiseAboveModerate: null, lastFallBelowModerate: null, remainsAboveModerate: false, firstRiseAboveMajor: null, lastFallBelowMajor: null, remainsAboveMajor: false, crestTime: null, crestHeight: null, crestCategory: "N/A" };
if (!hydroData || !Array.isArray(hydroData) || hydroData.length === 0) { return analysisResult; }
let crestH = -Infinity; let validPointsProcessed = 0; let wasAboveAction = false, wasAboveMinor = false, wasAboveModerate = false, wasAboveMajor = false;
try { const sortedData = [...hydroData].sort((a, b) => { const dateA = new Date(a?.timestamp); const dateB = new Date(b?.timestamp); if (isNaN(dateA.getTime()) || isNaN(dateB.getTime())) { return 0; } return dateA - dateB; }); hydroData = sortedData; } catch (sortError) { console.error("ERROR sorting hydroData:", sortError); }
for (const point of hydroData) { if (!point || typeof point.timestamp === 'undefined' || typeof point.gaugeHeight === 'undefined') { continue; } const currentTime = new Date(point.timestamp); const currentHeight = parseFloat(point.gaugeHeight); if (isNaN(currentHeight) || isNaN(currentTime.getTime())) { continue; } validPointsProcessed++; if (currentHeight > crestH) { crestH = currentHeight; analysisResult.crestTime = currentTime.toISOString(); } const actionStage = floodStages?.action; if (actionStage !== null && !isNaN(actionStage)) { const isAbove = currentHeight >= actionStage; if (isAbove && !analysisResult.firstRiseAboveAction) analysisResult.firstRiseAboveAction = currentTime.toISOString(); if (wasAboveAction && !isAbove) analysisResult.lastFallBelowAction = currentTime.toISOString(); wasAboveAction = isAbove; } const minorStage = floodStages?.minor; if (minorStage !== null && !isNaN(minorStage)) { const isAbove = currentHeight >= minorStage; if (isAbove && !analysisResult.firstRiseAboveMinor) analysisResult.firstRiseAboveMinor = currentTime.toISOString(); if (wasAboveMinor && !isAbove) analysisResult.lastFallBelowMinor = currentTime.toISOString(); wasAboveMinor = isAbove; } const moderateStage = floodStages?.moderate; if (moderateStage !== null && !isNaN(moderateStage)) { const isAbove = currentHeight >= moderateStage; if (isAbove && !analysisResult.firstRiseAboveModerate) analysisResult.firstRiseAboveModerate = currentTime.toISOString(); if (wasAboveModerate && !isAbove) analysisResult.lastFallBelowModerate = currentTime.toISOString(); wasAboveModerate = isAbove; } const majorStage = floodStages?.major; if (majorStage !== null && !isNaN(majorStage)) { const isAbove = currentHeight >= majorStage; if (isAbove && !analysisResult.firstRiseAboveMajor) analysisResult.firstRiseAboveMajor = currentTime.toISOString(); if (wasAboveMajor && !isAbove) analysisResult.lastFallBelowMajor = currentTime.toISOString(); wasAboveMajor = isAbove; } }
if (validPointsProcessed > 0 && crestH > -Infinity) { analysisResult.crestHeight = crestH; let crestCat = "Below Action"; if (floodStages) { if (floodStages.action !== null && !isNaN(floodStages.action) && crestH >= floodStages.action) crestCat = "Action Stage"; if (floodStages.minor !== null && !isNaN(floodStages.minor) && crestH >= floodStages.minor) crestCat = "Minor Flood"; if (floodStages.moderate !== null && !isNaN(floodStages.moderate) && crestH >= floodStages.moderate) crestCat = "Moderate Flood"; if (floodStages.major !== null && !isNaN(floodStages.major) && crestH >= floodStages.major) crestCat = "Major Flood"; } analysisResult.crestCategory = crestCat; } analysisResult.remainsAboveAction = wasAboveAction; analysisResult.remainsAboveMinor = wasAboveMinor; analysisResult.remainsAboveModerate = wasAboveModerate; analysisResult.remainsAboveMajor = wasAboveMajor; return analysisResult;
}
/**
* Helper to safely format H-VTEC time strings (YYMMDDTHHMMZ)
* using formatDateReadable. Prepends "20" for the century.
* @param {string|null} timeZ_YY The YYMMDDTHHMMZ UTC time string or null.
* @returns {string} Formatted date/time string or 'N/A'.
*/
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); // Correct index for T at position 6
const minute = timeZ_YY.substring(9, 11); // Correct index for T at position 6
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}`);
return 'Invalid Date';
}
const formattedDate = formatDateReadable(isoString); // Use the standard readable formatter
return formattedDate;
} catch (e) {
console.error("CRITICAL Error inside formatHvtecTime:", timeZ_YY, e);
return 'Format Error';
}
}
/**
* Parses NWS product text to extract H-VTEC forecast narrative details for a specific gauge LID.
* Focuses on common narrative phrases rather than the structured VTEC line.
* @param {string} productText The raw text of the NWS product (FLW or FLS).
* @param {string} targetLid The 5-character NWS Location ID (e.g., "PHIW2").
* @returns {object|null} An object { riseTimeDesc, crestValue, crestTimeDesc, fallTimeDesc } or null if LID not found.
* Returns descriptions directly from text. Returns null for properties if not found.
*/
function parseHvtecForecastFromText(productText, targetLid) {
if (!productText || !targetLid || targetLid.length !== 5) {
return null;
}
const normalizedLid = targetLid.toUpperCase();
const sections = productText.split(/\n(?:&&|##)\n/); // Split by && or ## dividers
let relevantSectionText = null;
// Find the section related to the targetLid
for (const section of sections) {
const vtecLidRegex = new RegExp(`/${normalizedLid}\\.\\d\\.(?:ER|EW|ET)\\.`); // Matches /LID.S.RT.
if (section.includes(`/${normalizedLid}`)) { // Check if VTEC line exists for this LID
relevantSectionText = section;
break; // Found the likely section
}
}
// Even simpler check if the above fails: Check section containing '* WHERE...' line.
if (!relevantSectionText) {
for (const section of sections) {
if (section.includes(`/${normalizedLid}`)) { // Re-check specifically for VTEC line if needed
relevantSectionText = section;
break;
}
if (section.includes('* WHERE...')) {
if (section.toUpperCase().includes(normalizedLid)) {
relevantSectionText = section;
// Don't break immediately, VTEC line check is better
}
}
}
}
if (!relevantSectionText) {
return null; // LID section not found
}
const results = { riseTimeDesc: null, crestValue: null, crestTimeDesc: null, fallTimeDesc: null };
// Regex patterns for narrative forecast details (case-insensitive)
const risePattern = /rise (?:above |to )?flood stage\s+(.*?)(?:to a crest|\.|$)/i;
const crestPattern = /(?:crest of|crest at|rise to|reach)\s+([\d\.]+)\s+feet\s+(.*?)(?:\. It will then|\. It is expected|\. Fall|\.$)/i;
const fallPattern = /fall below flood stage\s+(.*?)(?:\.|$|and continue falling)/i;
const riseMatch = relevantSectionText.match(risePattern);
if (riseMatch && riseMatch[1]) { results.riseTimeDesc = riseMatch[1].trim().replace(/\.$/, ''); }
const crestMatch = relevantSectionText.match(crestPattern);
if (crestMatch) { if (crestMatch[1]) results.crestValue = crestMatch[1].trim(); if (crestMatch[2]) results.crestTimeDesc = crestMatch[2].trim().replace(/\.$/, ''); }
const fallMatch = relevantSectionText.match(fallPattern);
if (fallMatch && fallMatch[1]) { results.fallTimeDesc = fallMatch[1].trim().replace(/\.$/, ''); }
const isGeneric = (desc) => !desc || /unknown time|not available/i.test(desc);
results.isValid = !(isGeneric(results.riseTimeDesc) && isGeneric(results.crestTimeDesc) && isGeneric(results.fallTimeDesc) && !results.crestValue);
return results;
}
function groupUGCsByTimes(ugcArray) {
const groupedUGCs = {};
if (!ugcArray || !Array.isArray(ugcArray)) {
return groupedUGCs; // Return empty object if ugcArray is invalid
}
ugcArray.forEach(ugc => {
const issueTime = ugc.utc_issue;
const initExpireTime = ugc.utc_init_expire;
const expireTime = ugc.utc_expire;
const timeKey = `${issueTime}-${initExpireTime}-${expireTime}`; // Create a unique key based on times
if (!groupedUGCs[timeKey]) {
groupedUGCs[timeKey] = {
issue: issueTime,
initExpire: initExpireTime,
expire: expireTime,
ugcList: []
};
}
groupedUGCs[timeKey].ugcList.push(ugc);
});
return groupedUGCs;
}
function normalizeZoneCode(zoneCode) {
if (!zoneCode || typeof zoneCode !== 'string') return null;
// Matches SSZNNN or SSCNNN, captures SS and NNN
const match = zoneCode.toUpperCase().match(/^([A-Z]{2})[CZ](\d{3})$/);
if (match) {
return match[1] + match[2]; // Return SSNNN (e.g., KY102)
}
// Basic check for SSNNN format
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(); // Return uppercase original as a fallback
}
// --- Lookups ---
const sbwprods = ["TO", "SV", "EW", "SQ", "FA", "FL", "FF", "DS", "SS"]; // Note: FL moved from hydroprods for polygon check
const hydroprods = ["FF", "HY", "FA", "FL"]; // Keep FF/HY here if they have specific hydro processing needs
// If FL.W (Forecast Points) needs hydro processing, it should be handled specially
const phenomenonCodes = {
"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 = [
{"warningType":"Tornado Warning","color":"#FF0000"}, // Red
{"warningType":"Extreme Wind Warning","color":"#FF8C00"}, // Dark Orange
{"warningType":"Severe Thunderstorm Warning","color":"#FFA500"}, // Orange
{"warningType":"Flash Flood Warning","color":"#8B0000"}, // Dark Red
{"warningType":"Flood Warning","color":"#00FF00"}, // Green
{"warningType":"Flood Advisory","color":"#90EE90"}, // Light Green (Added)
{"warningType":"Flood (Forecast Points) Warning","color":"#008000"}, // Dark Green (Differentiate from areal flood)
{"warningType":"Flood (Forecast Points) Advisory","color":"#98FB98"}, // Pale Green (Differentiate from areal flood)
{"warningType":"Hydrologic Advisory","color":"#98FB98"}, // Pale Green
{"warningType":"Hydrologic Warning","color":"#008000"}, // Dark Green
{"warningType":"Snow Squall Warning","color":"#C71585"}, // Medium Violet Red
{"warningType":"Blizzard Warning","color":"#FF4500"}, // Orange Red
{"warningType":"Ice Storm Warning","color":"#8B008B"}, // Dark Magenta
{"warningType":"Winter Storm Warning","color":"#FF69B4"}, // Hot Pink
{"warningType":"High Wind Warning","color":"#DAA520"}, // Goldenrod
{"warningType":"Excessive Heat Warning","color":"#C71585"}, // Medium Violet Red (Maybe change?)
{"warningType":"Extreme Cold Warning","color":"#0000FF"}, // Blue
{"warningType":"Freeze Warning","color":"#483D8B"}, // Dark Slate Blue
{"warningType":"Red Flag Warning","color":"#FF1493"}, // Deep Pink
{"warningType":"Winter Weather Advisory","color":"#7B68EE"}, // Medium Slate Blue
{"warningType":"Heat Advisory","color":"#FF7F50"}, // Coral
{"warningType":"Wind Advisory","color":"#D2B48C"}, // Tan
{"warningType":"Frost Advisory","color":"#6495ED"}, // Cornflower Blue
{"warningType":"Default","color":"#808080"} // Gray
];
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 = [
// Verified by presence only
{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:"Thunderstorm Wind Damage",abv:"D",style:"DMG",color:'orange',fillColor:'orange',passvalue:false,verifies:"warning"},
{phenomena:"Tornado",abv:"T",style:"TOR",color:'red',fillColor:'red',passvalue:false,verifies:"warning"},
{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:"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"},
// Verified by value and criteria function
{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:"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:40,advtop:57,warnbottom:58,warntop:999}, // Adjusted thresholds
{phenomena:"Rain",abv:"R",style:"Rain",color:'green',fillColor:'lightgreen',passvalue:true,resolvelsr:rain,advbottom:0.5,advtop:1.99,warnbottom:2.0,warntop:999}, // Adjusted thresholds
{phenomena:"Thunderstorm Wind Gust",abv:"G",style:"TS G",color:'orange',fillColor:'yellow',passvalue:true,resolvelsr:wind,advbottom:40,advtop:57,warnbottom:58,warntop:999}, // Adjusted thresholds
{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
// Add Power Outage - treated separately in fetching, but define basic style if needed
{phenomena:"Power Outage", abv:"P", style:"PO", color:'black', fillColor:'grey', passvalue:false, verifies:null, typetextMatch: "POWER OUTAGE"}
];
// --- Map Setup ---
function initializeMap() {
mymap = L.map('mapid', { zoomDelta: 0.25, zoomSnap: 0, 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); // For LSRs filtered by product polygons
gaugeMarkersLayer = L.layerGroup().addTo(mymap);
prePlotLayer = L.layerGroup().addTo(mymap);
// Initialize geoJSONcounties WITH TOOLTIPS
geoJSONcounties = L.geoJSON(null, {
style: { fillColor: 'none', fillOpacity: 0, color: '#ccc', weight: 0.8, opacity: 0.5, interactive: false },
onEachFeature: function(feature, layer) {
if (feature.properties && feature.properties.state && feature.properties.zone && !feature.properties.state_zone) {
feature.properties.state_zone = feature.properties.state + feature.properties.zone;
}
const labelPropertyName = 'shortname';
if (feature.properties && feature.properties[labelPropertyName]) {
layer.bindTooltip(feature.properties[labelPropertyName], { permanent: true, direction: 'center', className: 'my-label', opacity: 0.7 });
} else if (feature.properties && feature.properties.state_zone) {
layer.bindTooltip(feature.properties.state_zone, { permanent: true, direction: 'center', className: 'my-label', opacity: 0.7 });
}
}
}).addTo(mymap);
geoJSONcounties.bringToBack();
// --- Layer Control ---
const baseLayers = { "Grayscale": CartoDB_Positron, "Satellite": Esri_WorldImagery };
const overlayMaps = {
"Warnings (Final)": geoJSONwwas, // Is geoJSONwwas defined?
"LSRs (in Warn)": markersLayer, // Is markersLayer defined?
"Counties": geoJSONcounties, // Is geoJSONcounties defined?
"Gauges": gaugeMarkersLayer, // Is gaugeMarkersLayer defined?
"Selected (Pre-plot)": prePlotLayer // Is prePlotLayer defined?
};
L.control.layers(baseLayers, overlayMaps, { collapsed: true }).addTo(mymap);
L.control.scale().addTo(mymap);
}
function setupMapForScreenshot(data) {
if (!mymap || !geoJSONwwas || !markersLayer || !gaugeMarkersLayer || typeof styleGaugeMarker !== 'function') {
console.error("JS setupMapForScreenshot: Map or required layers/functions missing!");
return;
}
try {
geoJSONwwas.clearLayers();
markersLayer.clearLayers();
gaugeMarkersLayer.clearLayers();
console.log("JS: Layers cleared for screenshot.");
if (data.bounds && Array.isArray(data.bounds) && data.bounds.length === 2) {
const leafletBounds = L.latLngBounds(data.bounds[0], data.bounds[1]);
if (leafletBounds.isValid()) {
mymap.fitBounds(leafletBounds, { padding: [10, 10], animate: false, maxZoom: 14 });
} else { console.error("JS: Invalid Leaflet bounds for screenshot:", data.bounds); }
} else { console.error("JS: Missing/invalid bounds data for screenshot."); }
if (data.warningsGeoJsonFeatures?.length) {
geoJSONwwas.addData({ type: "FeatureCollection", features: data.warningsGeoJsonFeatures });
console.log(`JS: Added ${data.warningsGeoJsonFeatures.length} warning features for screenshot.`);
}
if (data.lsrFeatures?.length) {
console.log(`JS: Adding ${data.lsrFeatures.length} LSR markers for screenshot...`);
data.lsrFeatures.forEach(lsrFeature => {
if (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 || {};
const marker = styleLSRMarker([lat, lon], props, props.verification || null, props.timeColor || 'grey');
if (marker) markersLayer.addLayer(marker);
}
}
});
}
if (data.gaugeLocations?.length) {
data.gaugeLocations.forEach(gauge => {
if (gauge && typeof gauge.lat === 'number' && typeof gauge.lon === 'number' && gauge.lid) {
const marker = styleGaugeMarker(L.latLng(gauge.lat, gauge.lon), gauge.lid, gauge.name || gauge.lid);
if (marker) gaugeMarkersLayer.addLayer(marker);
}
});
}
console.log("JS: setupMapForScreenshot completed.");
} catch (error) {
console.error("JS: Error inside setupMapForScreenshot:", error);
}
}
function loadCountyLayer(url) {
$.getJSON(url, function(data) {
if (geoJSONcounties) {
if (data && data.features) {
data.features.forEach(feature => {
if (feature.properties) {
// Ensure state_zone exists
if (!feature.properties.state_zone && feature.properties.state && feature.properties.zone) {
feature.properties.state_zone = feature.properties.state + feature.properties.zone;
}
// Add shortname if found in criteria
if (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;
}
}
}
});
}
geoJSONcounties.addData(data);
geoJSONcounties.bringToBack();
} 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.`);
});
}
// --- VTEC Event Handling ---
function getwwas() {
const year = $("#yeartowork").val();
const url = `https://mesonet.agron.iastate.edu/json/vtec_events.py?wfo=RLX&year=${year}`;
clearMapAndSelection();
$("#summary-container").html("<p>Loading events for " + year + "...</p>");
$.getJSON(url, function(data) {
const events = data.events ? data.events.filter(e => e.significance !== "A") : []; // Filter watches
events.sort((a, b) => { // Sort by Significance, then newest Product Issue Time
const sigWeight = {'W': 1, 'Y': 2, 'S': 3, 'O': 4};
const sigA = sigWeight[a.significance] || 99;
const sigB = sigWeight[b.significance] || 99;
if (sigA !== sigB) { return sigA - sigB; }
try {
const productDateB = new Date(b.product_issue);
const productDateA = new Date(a.product_issue);
const isValidA = !isNaN(productDateA.getTime());
const isValidB = !isNaN(productDateB.getTime());
if (isValidA && isValidB) { return productDateB - productDateA; }
else if (isValidB) { return -1; } else if (isValidA) { return 1; }
else { return new Date(b.issue) - new Date(a.issue); } // Fallback
} catch (e) { return new Date(b.issue) - new Date(a.issue); } // Fallback
});
createEventSelector(events);
$("#summary-container").html("<p>Select one or more warnings and click 'Generate Summary'.</p>");
}).fail(function() {
console.error("Failed to fetch VTEC events for year " + year);
$("#summary-container").html("<p class='error'>Failed to load events for " + year + ". Please try again.</p>");
disableExportButtons(); // Disable exports if event load fails
});
}
function createEventSelector(events) {
$("#eventSelector").remove(); // Clear previous selector
const select = $('<select id="eventSelector" multiple="multiple"></select>');
select.attr('size', Math.min(events.length, 15));
if (events.length === 0) {
select.append('<option disabled>No non-watch events found for this year.</option>');
} else {
events.forEach(event => {
const phenSigText = getPhenSig(event.phenomena, event.significance);
const optionText = `${phenSigText} (${event.eventid}) | ${formatDate_YYMMDD_HHMMZ(event.issue)} - ${formatDate_YYMMDD_HHMMZ(event.expire)}`;
const optionValue = `${event.phenomena}|${event.significance}|${event.eventid}|${yearfromissue(event.issue)}`;
select.append(`<option value="${optionValue}">${optionText}</option>`);
});
}
select.data('events', events); // Store full list for filtering
select.insertAfter("#eventFilterInput"); // Place after filter input
}
function getSelectedEventData() {
const selectedOptions = $("#eventSelector").val();
const allEvents = $("#eventSelector").data('events') || [];
currentSelectedEvents = []; // Reset global list
if (!selectedOptions || selectedOptions.length === 0) return [];
selectedOptions.forEach(valueString => {
const [phen, sig, etn, year] = valueString.split('|');
const foundEvent = allEvents.find(e => e.phenomena === phen && e.significance === sig && e.eventid == etn && yearfromissue(e.issue) == year);
if (foundEvent) currentSelectedEvents.push(foundEvent);
});
// Invert the order to match original selection order if needed (usually not necessary)
// currentSelectedEvents.reverse();
return currentSelectedEvents;
}
function clearMapAndSelection() {
currentSelectedEvents = []; currentSelectedEventDetails = [];
unfilteredLSRs = { type: "FeatureCollection", features: [] };
eventGeometryCache = {}; // <<< Clear the geometry cache
if (geoJSONwwas) geoJSONwwas.clearLayers();
if (markersLayer) markersLayer.clearLayers();
if (gaugeMarkersLayer) gaugeMarkersLayer.clearLayers();
if (prePlotLayer) prePlotLayer.clearLayers(); // <<< Clear the pre-plot layer
if (gaugeMarkersLayer) gaugeMarkersLayer.clearLayers();
hydrographCharts = {};
$("#summary-container").html("<p>Select one or more warnings and click 'Generate Summary'.</p>");
if ($("#eventSelector").length > 0) $("#eventSelector").val([]);
disableExportButtons();
}
// --- Main Summary Generation ---
async function generateSummaryForSelectedEvents() {
const selectedEventsRaw = getSelectedEventData(); // Gets raw event objects
if (selectedEventsRaw.length === 0) {
alert("Please select at least one warning event.");
return;
}
showLoadingIndicator(true);
if (prePlotLayer) prePlotLayer.clearLayers(); // <<< Clear pre-plot layer HERE
$("#summary-container").html("<p>Processing selected events... Fetching details...</p>");
currentSelectedEventDetails = []; // Reset detailed list
unfilteredLSRs = { type: "FeatureCollection", features: [] }; // Reset unfiltered LSRs
geoJSONwwas.clearLayers(); markersLayer.clearLayers();
if (gaugeMarkersLayer) gaugeMarkersLayer.clearLayers();
hydrographCharts = {};
disableExportButtons(); // Disable exports during processing
try {
// Fetch event details (geometry + full text)
const eventPromises = selectedEventsRaw.map(event => fetchEventDetails(event, true)); // fetchFullDetails: true
currentSelectedEventDetails = (await Promise.all(eventPromises)).filter(detail => detail !== null);
if (currentSelectedEventDetails.length === 0) { throw new Error("Could not load details for any selected event."); }
// Add polygons to map & zoom
let allBounds = [];
currentSelectedEventDetails.forEach(detail => {
if (detail.data?.features) {
geoJSONwwas.addData(detail.data);
try {
let layerBounds = L.geoJSON(detail.data).getBounds();
if (layerBounds.isValid()) allBounds.push(layerBounds);
}
catch (e) { console.warn("Could not get bounds for event:", detail.event.eventid, e)}
}
});
if (allBounds.length > 0) {
let combinedBounds = allBounds.reduce((bounds, current) => bounds.extend(current));
if (combinedBounds.isValid()) mymap.fitBounds(combinedBounds.pad(0.05)); // Use padding
} else { console.warn("No valid bounds found for selected events. Map view not adjusted."); }
$("#summary-container").append("<p>Fetching Local Storm Reports (LSRs)...</p>");
// --- Fetch ALL LSRs for the combined time range + buffer ---
const { earliestIssue, latestExpire } = getEarliestAndLatestTimes(currentSelectedEventDetails.map(d => d.event));
const lsrBufferHours = parseInt($("#lsrbuffer").val()) || 0;
const lsrEndTime = addHoursToDate(new Date(latestExpire), lsrBufferHours);
//const lsrStartTime = new Date(earliestIssue);
const lsrStartTime = addHoursToDate(new Date(earliestIssue), -1*lsrBufferHours);
const lsrStartStr = formatDate_IEM(lsrStartTime);
const lsrEndStr = formatDate_IEM(lsrEndTime);
// Fetch main IEM LSRs
const tempLSRs = await fetchLSRs(lsrStartStr, lsrEndStr);
// Determine if power outage fetch is needed
let powerOutageNeeded = false;
let tempInfoNeeded = false;
let windInfoNeeded = false;
const powerPhenomena = new Set(['WI', 'EW', 'SV', 'TO', 'HW', 'IS', 'WS', 'ZR']);
const tempPhenomena = new Set(['WI', 'EW', 'SV', 'TO', 'HW', 'IS', 'WS', 'ZR']);
const windPhenomena = new Set(['WI', 'EW', 'SV', 'TO', 'HW', 'IS', 'WS', 'ZR']);
currentSelectedEventDetails.forEach(eventItem => {
if (powerPhenomena.has(eventItem.event?.phenomena)) { powerOutageNeeded = true; }
//Add unified wind and temp grab from wuobs
if (tempPhenomena.has(eventItem.event?.phenomena)) { tempInfoNeeded = true; }
if (windPhenomena.has(eventItem.event?.phenomena)) { windInfoNeeded = true; }
});
const powerThreshNumber = parseInt(document.getElementById('powerthresh')?.value) || 50;
const lsrBuffer = parseInt(document.getElementById('lsrbuffer')?.value) || 1;
let powerLSR = null;
if (powerOutageNeeded) {
powerLSR = await fetchGeoJsonDataNoPoly(earliestIssue, lsrEndTime, 'powernopoly',lsrBuffer, powerThreshNumber); // Fetch power outage LSRs
}
// Fetch other specific LSR sources (e.g., Ohio Flood)
const floodLSRs = await fetchGeoJsonDataNoPoly(earliestIssue, lsrEndTime, 'ohgonopoly');
// --- Combine and Deduplicate LSRs ---
const allFetchedFeatures = [...tempLSRs.features];
// Function to check proximity
const isNear = (feature1, feature2, maxDistanceMiles = 1) => {
if (feature1.geometry.type !== 'Point' || !feature1.geometry.coordinates ||
feature2.geometry.type !== 'Point' || !feature2.geometry.coordinates) {
return false; // Skip non-points
}
try {
return turf.distance(feature1, feature2, { units: 'miles' }) <= maxDistanceMiles;
} catch (e) {
console.error("Turf distance error:", e);
return false;
}
};
// Filter flood LSRs (remove if near any tempLSR)
const filteredFloodFeatures = floodLSRs.features.filter(floodF => {
return !tempLSRs.features.some(tempF => isNear(floodF, tempF));
});
allFetchedFeatures.push(...filteredFloodFeatures); // Add non-duplicate flood LSRs
// Filter power LSRs (if fetched) (remove if near any temp or filtered flood LSR)
if (powerLSR && powerLSR.features) {
const filteredPowerFeatures = powerLSR.features.filter(powerF => {
return !allFetchedFeatures.some(existingF => isNear(powerF, existingF));
});
allFetchedFeatures.push(...filteredPowerFeatures); // Add non-duplicate power LSRs
}
console.log(allFetchedFeatures);
// Deduplicate the combined list against itself (within 1 mile)
const finalUniqueFeatures = [];
const usedIndices = new Set();
for (let i = 0; i < allFetchedFeatures.length; i++) {
if (usedIndices.has(i)) continue;
const currentFeature = allFetchedFeatures[i];
finalUniqueFeatures.push(currentFeature);
usedIndices.add(i);
// Check subsequent features
for (let j = i + 1; j < allFetchedFeatures.length; j++) {
if (usedIndices.has(j)) continue;
//if (isNear(currentFeature, allFetchedFeatures[j])) {
//Don't proximity check real reports
//usedIndices.add(j); // Mark as duplicate
//}
}
}
// --- Store the final UNFILTERED list globally ---
unfilteredLSRs = { type: "FeatureCollection", features: finalUniqueFeatures };
// --- End Combine/Deduplicate ---
// --->>> NEW: Assign Zones to ALL LSRs before summary and plotting <<<---
const pipLayerForZoneLookup = geoJSONcounties; // Use the global county layer
if (pipLayerForZoneLookup && pipLayerForZoneLookup.getLayers().length > 0) {
unfilteredLSRs.features.forEach((lsrFeature, index) => {
// Attempt assignment only if state_zone isn't already present or is clearly invalid
let needsAssignment = !lsrFeature.properties.state_zone ||
['UNKNOWN', 'ERROR', 'LAYER_MISSING'].includes(lsrFeature.properties.state_zone);
if (needsAssignment && lsrFeature.geometry?.coordinates && lsrFeature.geometry.coordinates.length >= 2) {
const lon = parseFloat(lsrFeature.geometry.coordinates[0]);
const lat = parseFloat(lsrFeature.geometry.coordinates[1]);
if (!isNaN(lon) && !isNaN(lat)) {
const point = turf.point([lon, lat]);
let zoneId = 'UNKNOWN';
let zoneLayer = null;
try {
// Find the containing zone layer using Point-in-Polygon
pipLayerForZoneLookup.eachLayer(layer => {
if (!zoneLayer && layer.feature?.geometry) {
// Added check for valid polygon structure before PIP
if ((layer.feature.geometry.type === "Polygon" && layer.feature.geometry.coordinates?.[0]?.length >= 4) ||
(layer.feature.geometry.type === "MultiPolygon" && layer.feature.geometry.coordinates?.[0]?.[0]?.length >= 4)) {
if (turf.booleanPointInPolygon(point, layer.feature)) {
zoneLayer = layer;
}
}
}
});
// Extract zone info if found
if (zoneLayer?.feature?.properties) {
const props = zoneLayer.feature.properties;
// Prioritize state_zone if exists, otherwise construct from state/zone
zoneId = props.state_zone || (props.state && props.zone ? `${props.state}${props.zone}` : null) || props.zone_id || props.ZONENAME || 'UNKNOWN';
if (zoneId !== 'UNKNOWN') {
lsrFeature.properties.state_zone = zoneId;
if (props.shortname) { // Add shortname if available
lsrFeature.properties.shortname = props.shortname;
}
} else {
lsrFeature.properties.state_zone = 'UNKNOWN'; // Explicitly mark if lookup failed
}
} else {
lsrFeature.properties.state_zone = 'UNKNOWN'; // Explicitly mark if no layer found
}
} catch (zonePipError) {
console.error(`ERROR during global Zone PIP check for LSR #${index}: ${zonePipError.message}`);
lsrFeature.properties.state_zone = 'ERROR';
}
} else {
if (!lsrFeature.properties.state_zone) lsrFeature.properties.state_zone = 'INVALID_COORDS';
}
} else if (!lsrFeature.properties.state_zone) {
lsrFeature.properties.state_zone = 'NO_GEOMETRY'; // Mark if geometry was missing
}
// Ensure magf exists if magnitude does (useful for verification later)
if (lsrFeature.properties.magnitude && typeof lsrFeature.properties.magf === 'undefined') {
lsrFeature.properties.magf = parseFloat(lsrFeature.properties.magnitude) || null;
} else if (typeof lsrFeature.properties.magf === 'undefined') {
lsrFeature.properties.magf = null;
}
});
} else {
console.error("CRITICAL - Cannot assign zones to all LSRs: County/Zone layer (geoJSONcounties) missing or empty.");
// Optionally alert the user
alert("Warning: County/Zone layer failed to load. LSR zone assignment might be incomplete in the summary.");
}
// --->>> END NEW ZONE ASSIGNMENT <<<---
$("#summary-container").append("<p>Processing LSRs for map display...</p>");
// --- Process & Plot LSRs for the MAP (Filtered by Polygon Intersection) ---
// This now uses the unfilteredLSRs list which *should* have state_zone assigned
const { plottedLSRs, verifiedLsrCounts } = processAndPlotLSRs(
unfilteredLSRs, // Pass the list potentially enriched with state_zone
geoJSONwwas, // Pass the warning layers for intersection check
earliestIssue, latestExpire // Pass times for marker styling
);
// --- Build HTML Summary ---
// Pass the UNFILTERED LSR list for the unified section
await buildHtmlSummary(currentSelectedEventDetails, unfilteredLSRs.features, verifiedLsrCounts);
// Enable export buttons now that data is ready
enableExportButtons();
} catch (error) {
console.error("Error during summary generation:", error);
$("#summary-container").html(`<p style='color:red;'>An error occurred: ${error.message}. Check console for details.</p>`);
disableExportButtons(); // Ensure exports are disabled on error
} finally {
showLoadingIndicator(false);
}
}
// --- Data Fetching ---
async function fetchEventDetails(event, fetchFullDetails = false) {
const year = yearfromissue(event.issue);
const isSbw = sbwprods.includes(event.phenomena);
const geometryUrl = `https://mesonet.agron.iastate.edu/geojson/vtec_event.py?wfo=RLX&phenomena=${event.phenomena}&significance=${event.significance}&etn=${event.eventid}&year=${year}${isSbw ? '&sbw=1' : ''}`;
let geometryData = null;
let fullDetailsData = null;
try {
geometryData = await $.getJSON(geometryUrl);
if (!geometryData || !geometryData.features || geometryData.features.length === 0) {
console.warn(`No geometry data found for event: ${event.phenomena}.${event.significance}.${event.eventid}`);
event.load_error = "No geometry data from IEM.";
geometryData = null;
} else {
// Ensure state_zone property exists
geometryData.features.forEach(f => {
if (f.properties && f.properties.state && f.properties.zone && !f.properties.state_zone) {
f.properties.state_zone = f.properties.state + f.properties.zone;
}
});
}
} catch (error) {
console.error(`Failed to fetch geometry details for event ${event.phenomena}.${event.significance}.${event.eventid}:`, error);
event.load_error = `Failed to fetch geometry: ${error.message}`;
geometryData = null;
}
if (fetchFullDetails) {
fullDetailsData = await fetchDetailedEventData(event);
}
return { event, data: geometryData, fullDetails: fullDetailsData };
}
/*
// ===================================================================
// 1. PRIMARY DATA SOURCE: The GeoJSON endpoint (UPDATED)
// ===================================================================
async function fetchLSRs_primary(startTimeStr, endTimeStr) {
// Helper to convert the original 'YYYYMMDDHHMM' string to the new
// required 'YYYY-MM-DDTHH:MM:SS.sssZ' format.
const convertToPrimaryApiFormat = (dateTimeString) => {
const year = dateTimeString.substring(0, 4);
const month = dateTimeString.substring(4, 6);
const day = dateTimeString.substring(6, 8);
const hours = dateTimeString.substring(8, 10);
const minutes = dateTimeString.substring(10, 12);
// Assemble the full ISO 8601 UTC string with seconds and milliseconds.
return `${year}-${month}-${day}T${hours}:${minutes}:00.000Z`;
};
// Convert the input strings to the new required format.
const primaryStartTime = convertToPrimaryApiFormat(startTimeStr);
const primaryEndTime = convertToPrimaryApiFormat(endTimeStr);
const url = `https://mesonet.agron.iastate.edu/geojson/lsr.geojson?sts=${primaryStartTime}&ets=${primaryEndTime}&wfos=KRLX`;
try {
const data = await $.getJSON(url);
return data || { type: "FeatureCollection", features: [] };
} catch (error) {
console.error("Primary LSR fetch failed:", error);
return { type: "FeatureCollection", features: [] };
}
}
// ===================================================================
// 2. BACKUP DATA SOURCE: The CSV endpoint (UNCHANGED)
// ===================================================================
async function fetchLSRs_backup(startTimeStr, endTimeStr) {
const convertToApiFormat = (dateTimeString) => {
const year = dateTimeString.substring(0, 4);
const month = dateTimeString.substring(4, 6);
const day = dateTimeString.substring(6, 8);
const hours = dateTimeString.substring(8, 10);
const minutes = dateTimeString.substring(10, 12);
return `${year}-${month}-${day}T${hours}:${minutes}Z`;
};
const backupStartTime = convertToApiFormat(startTimeStr);
const backupEndTime = convertToApiFormat(endTimeStr);
const url = `https://mesonet.agron.iastate.edu/cgi-bin/request/gis/lsr.py?wfos=KRLX&sts=${backupStartTime}&ets=${backupEndTime}&fmt=csv`;
try {
const csvData = await $.get(url);
if (!csvData || csvData.trim() === "") return { type: "FeatureCollection", features: [] };
const lines = csvData.trim().split('\n');
if (lines.length <= 1) return { type: "FeatureCollection", features: [] };
const headers = lines.shift().split(',');
const features = lines.map(line => {
if (line.trim() === "") return null;
const values = line.split(',', headers.length);
const csvProps = {};
headers.forEach((header, index) => {
const key = header.trim();
csvProps[key] = values[index] ? values[index].trim() : "";
});
const lat = parseFloat(csvProps.LAT);
const lon = parseFloat(csvProps.LON);
if (isNaN(lat) || isNaN(lon)) return null;
const validString = csvProps.VALID;
let year, month, day, hours, minutes;
if (validString.includes('-')) {
const parts = validString.split(' ');
const dateParts = parts[0].split('-');
const timeParts = parts[1].split(':');
[year, month, day] = dateParts;
[hours, minutes] = timeParts;
} else if (validString.length === 12 && /^\d+$/.test(validString)) {
year = validString.substring(0, 4);
month = validString.substring(4, 6);
day = validString.substring(6, 8);
hours = validString.substring(8, 10);
minutes = validString.substring(10, 12);
} else {
console.error('BACKUP: Skipping LSR due to unrecognized VALID format:', validString);
return null;
}
const validTime = `${year}-${month}-${day}T${hours}:${minutes}:00Z`;
const finalProps = {
valid: validTime, wfo: csvProps.WFO, typetext: csvProps.TYPETEXT, type: csvProps.TYPECODE,
magnitude: parseFloat(csvProps.MAG) || null, city: csvProps.CITY, county: csvProps.COUNTY,
st: csvProps.STATE, source: csvProps.SOURCE, remark: csvProps.REMARK, lat: lat, lon: lon,
ugc: csvProps.UGC, ugcname: csvProps.UGCNAME, qualifier: csvProps.QUALIFIER,
};
return { type: "Feature", geometry: { type: "Point", coordinates: [lon, lat] }, properties: finalProps };
}).filter(feature => feature !== null);
return { type: "FeatureCollection", features: features };
} catch (error) {
console.error("Backup LSR fetch failed:", error);
return { type: "FeatureCollection", features: [] };
}
}
// ===================================================================
// 3. MAIN WRAPPER FUNCTION with Fallback Logic (UNCHANGED)
// ===================================================================
async function fetchLSRs(startTimeStr, endTimeStr) {
console.log("Attempting to fetch LSRs from primary source...");
const primaryData = await fetchLSRs_primary(startTimeStr, endTimeStr);
if (primaryData?.features?.length > 0) {
console.log(`Primary source successful. Found ${primaryData.features.length} reports.`);
return primaryData;
}
console.warn("Primary source failed or returned empty. Attempting fallback to backup source...");
const backupData = await fetchLSRs_backup(startTimeStr, endTimeStr);
if (backupData?.features?.length > 0) {
console.log(`Backup source successful. Found ${backupData.features.length} reports.`);
} else {
console.error("Both primary and backup sources failed or returned no data.");
}
return backupData;
}
*/
/* OLD SIMPLE STUFF */
async function fetchLSRs(startTimeStr, endTimeStr) {
const url = `https://mesonet.agron.iastate.edu/geojson/lsr.geojson?sts=${startTimeStr}&ets=${endTimeStr}&wfos=RLX`;
try {
const data = await $.getJSON(url);
return data || { type: "FeatureCollection", features: [] };
} catch (error) {
console.error("Failed to fetch LSRs:", error);
return { type: "FeatureCollection", features: [] };
}
}
async function getNWSGaugesByBoundingBox(bounds) {
if (!bounds || !bounds.isValid()) {
console.error("Invalid bounds provided to getNWSGaugesByBoundingBox.");
return [];
}
const xmin = bounds.getWest(); const ymin = bounds.getSouth(); const xmax = bounds.getEast(); const ymax = bounds.getNorth();
const url = `https://api.water.noaa.gov/nwps/v1/gauges?bbox.xmin=${xmin}&bbox.ymin=${ymin}&bbox.xmax=${xmax}&bbox.ymax=${ymax}&srid=EPSG_4326&catfim=false`;
console.log("Fetching NWS gauges for bbox:", bounds.toBBoxString());
try {
const response = await fetch(url, { headers: { 'Accept': 'application/json', 'User-Agent': 'NWSWarningSummarizerTool/1.0' } });
if (!response.ok) throw new Error(`NWS Gauge API error! Status: ${response.status}`);
const data = await response.json();
console.log("Found NWS Gauges in BBox:", data.gauges?.length || 0);
return data.gauges || [];
} catch (error) {
console.error('Error fetching NWS gauge data:', error);
return [];
}
}
// --- Plotting and Styling ---
function styleWarningPolygon(feature) {
const phen = feature.properties.phenomena;
const sig = feature.properties.significance;
const phenSigText = getPhenSig(phen, sig);
let color = getColorForWarning(phenSigText);
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) { return null; }
const typeCode = lsrProperties.type;
const typeText = typeof lsrProperties.typetext === 'string' ? lsrProperties.typetext.toUpperCase() : null;
const matchingEntries = lsrtype.filter(w => w.abv === typeCode);
if (matchingEntries.length === 0) { return null; }
else if (matchingEntries.length === 1) { return matchingEntries[0]; }
else { // Ambiguous code
if (!typeText) {
console.warn(`Ambiguous LSR type code "${typeCode}" but no typetext provided.`);
return null; // Or return first match? Null seems safer.
}
const specificEntry = matchingEntries.find(entry =>
entry.typetextMatch && entry.typetextMatch.toUpperCase() === typeText
);
if (specificEntry) { return specificEntry; }
else {
console.warn(`Ambiguous LSR type code "${typeCode}" with typetext "${typeText}" did not match any specific typetextMatch entry.`);
return null; // Or return first match? Null seems safer.
}
}
}
/**
* Creates a Leaflet marker for an LSR point.
* Styles the marker based on LSR type, verification, and time relative to warning.
* Attaches the original GeoJSON feature to the marker object.
* (DEBUG: Removed single quote replacement)
* @param {L.LatLngTuple} latlng - [lat, lon] for the marker.
* @param {object} lsrFeature - The full GeoJSON Feature object for the LSR.
* @param {string|null} verificationStatus - 'warning', 'advisory', or null.
* @param {string} timeColor - Color name ('green', 'yellow', 'orange', 'red', 'grey') based on timing.
* @returns {L.Marker|null} A Leaflet marker object, or null if invalid input.
*/
function styleLSRMarker(latlng, lsrFeature, verificationStatus, timeColor) {
// --- Input Validation ---
if (!latlng || !Array.isArray(latlng) || latlng.length !== 2 ||
!lsrFeature || !lsrFeature.properties || !lsrFeature.geometry) {
console.error("Invalid input to styleLSRMarker", { latlng, lsrFeature });
return null;
}
const properties = lsrFeature.properties;
// --- End Input Validation ---
const lsrTypeInfo = getLsrTypeInfo(properties);
const defaultColor = '#CCCCCC'; let baseFillColor = lsrTypeInfo ? lsrTypeInfo.fillColor : defaultColor;
const defaultOutline = 'blue';
//const validTimeColors = ['green', 'yellow', 'orange', 'red', 'grey'];
const validTimeColors = ['green', 'yellow', 'orange', 'salmon', 'darkred', 'grey'];
const finalOutlineColor = validTimeColors.includes(timeColor) ? 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();
if (verificationStatus === 'warning') baseFillColor = 'gold';
else if (verificationStatus === 'advisory') baseFillColor = 'lightgreen';
const validColors = ['red', 'blue', 'green', 'yellow', 'orange', 'purple', 'pink', 'brown', 'black', 'white', 'grey', 'gray', 'gold', 'lightgreen', 'lightblue', 'tan', 'coral', 'violet', 'magenta', 'darkred', 'saddlebrown', 'darkblue', 'darkmagenta', 'salmon'];
if (!baseFillColor.startsWith('#') && !validColors.includes(baseFillColor.toLowerCase())) { baseFillColor = defaultColor; }
if (!finalOutlineColor.startsWith('#') && !validColors.includes(finalOutlineColor.toLowerCase())) { finalOutlineColor = defaultOutline; }
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';
// --- *** DEBUG: REMOVED SINGLE QUOTE REPLACEMENT *** ---
let escapedLabelText = labelText; // Start with the original text
escapedLabelText = escapedLabelText.replace(/&/g, '&'); // Replace & first
escapedLabelText = escapedLabelText.replace(/</g, '<');
escapedLabelText = escapedLabelText.replace(/>/g, '>');
escapedLabelText = escapedLabelText.replace(/"/g, '"');
// The .replace(/'/g, ...) line has been completely removed.
// --- *********************************************** ---
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="${finalOutlineColor}" 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();
const svgDataUri = 'data:image/svg+xml;base64,' + btoa(svgString);
const markerIcon = L.icon({
iconUrl: svgDataUri,
iconSize: [svgSize, svgSize],
iconAnchor: [svgCenter, svgCenter],
popupAnchor: [0, -svgCenter]
});
const marker = L.marker(latlng, {
icon: markerIcon,
keyboard: false
});
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);
marker.feature = lsrFeature; // Attach original feature data
return marker;
}
/**
* Processes LSR GeoJSON data specifically for MAP DISPLAY:
* 1. Filters LSRs to only those falling within the geometry of the provided warning layers.
* 2. Assigns 'state_zone', 'shortname', and 'source_event_etn' properties to plotted LSRs.
* 3. Verifies the LSR against criteria (if applicable).
* 4. Styles and plots verified LSRs on the map's markersLayer, coloring the marker
* border based on its "best case" time relationship to *all* selected warning polygons.
* 5. Calculates verification counts per zone for the PLOTTED LSRs.
* @param {object} allLsrGeoJson - GeoJSON FeatureCollection of ALL LSRs for the time period.
* @param {L.LayerGroup | L.GeoJSON} warningLayers - Leaflet layer(s) containing the warning features (polygons/zones).
* @param {string} overallStartTime - ISO string for the overall start time of selected events (not directly used in new logic).
* @param {string} overallEndTime - ISO string for the overall end time of selected events (not directly used in new logic).
* @returns {object} { plottedLSRs: Array<object>, verifiedLsrCounts: object } - plottedLSRs contains only features added to the map.
*/
function processAndPlotLSRs(allLsrGeoJson, warningLayers, overallStartTime, overallEndTime) {
if (markersLayer) markersLayer.clearLayers(); // Clear previous LSR markers from map
const plottedLSRs = []; // Array to hold features of LSRs that are actually PLOTTED
const verifiedLsrCounts = {}; // Counts based on PLOTTED LSRs
// [Input Validation code...]
if (!allLsrGeoJson?.features) { console.warn("P&P LSRs: No LSR features provided."); return { plottedLSRs, verifiedLsrCounts }; }
if (!warningLayers) { console.warn("P&P LSRs: No warning layers provided."); return { plottedLSRs, verifiedLsrCounts }; }
const pipLayerForZoneLookup = geoJSONcounties;
if (typeof pipLayerForZoneLookup === 'undefined' || !pipLayerForZoneLookup || pipLayerForZoneLookup.getLayers().length === 0) {
console.error("P&P LSRs: Critical - Global County/Zone layer (geoJSONcounties) missing or empty. Zone assignment will fail.");
}
// [Prepare Warning Features code...]
const warningPolygonsForFiltering = [];
warningLayers.eachLayer(layer => {
if (layer instanceof L.FeatureGroup || layer instanceof L.LayerGroup) {
layer.eachLayer(subLayer => { if (subLayer.feature?.geometry && subLayer.feature?.properties?.eventid) { warningPolygonsForFiltering.push(subLayer.feature); } });
} else if (layer.feature?.geometry && layer.feature?.properties?.eventid) { warningPolygonsForFiltering.push(layer.feature); }
});
console.log(`DEBUG P&P LSRs (Map): Found ${warningPolygonsForFiltering.length} warning features for filtering.`);
let pipChecksAttempted = 0, pipChecksPassedInitialFilter = 0, zoneLookupsAttempted = 0, zoneLookupsSuccessful = 0;
// --- Iterate through each LSR from the FULL list ---
allLsrGeoJson.features.forEach((lsrFeature, index) => { // lsrFeature is the full feature object
if (!lsrFeature.geometry?.coordinates || lsrFeature.geometry.coordinates.length < 2) { return; }
const lon = parseFloat(lsrFeature.geometry.coordinates[0]);
const lat = parseFloat(lsrFeature.geometry.coordinates[1]);
if (isNaN(lon) || isNaN(lat)) { return; }
const lsrCoords = [lon, lat]; const latLng = [lat, lon]; const point = turf.point(lsrCoords);
let insideAnyWarning = false;
let containingWarningFeature = null; // Store the *first* warning feature it's inside
// [PIP check code - Find the FIRST containing warning]
if (warningPolygonsForFiltering.length > 0) {
pipChecksAttempted++;
for (const warningFeature of warningPolygonsForFiltering) {
if (!warningFeature?.geometry || !warningFeature?.properties?.eventid) continue;
try { if (turf.booleanPointInPolygon(point, warningFeature)) {
insideAnyWarning = true;
containingWarningFeature = warningFeature; // Store the feature it's inside
pipChecksPassedInitialFilter++;
break; // Stop checking once found inside one relevant polygon
}
}
catch (turfError) { console.error(`DEBUG P&P LSRs (Map): Turf.js ERROR checking LSR #${index} in WARNING ETN ${warningFeature.properties.eventid}: ${turfError.message}`); }
}
}
// Process ONLY if inside a warning (for map display)
if (insideAnyWarning && containingWarningFeature) { // Keep this outer check - only plot markers if inside *any* polygon
// [Zone Lookup code...]
let zoneId = 'UNKNOWN'; let zoneLayer = null;
zoneLookupsAttempted++;
if (pipLayerForZoneLookup && pipLayerForZoneLookup.getLayers().length > 0) {
try {
pipLayerForZoneLookup.eachLayer(layer => { if (!zoneLayer && layer.feature?.geometry) { if(turf.booleanPointInPolygon(point, layer.feature)) { zoneLayer = layer; } } });
if (zoneLayer?.feature?.properties) {
const props = zoneLayer.feature.properties;
zoneId = props.state_zone || (props.state && props.zone ? `${props.state}${props.zone}` : null) || props.zone_id || props.ZONENAME || 'UNKNOWN';
if (zoneId !== 'UNKNOWN') {
zoneLookupsSuccessful++;
if (props.shortname) { lsrFeature.properties.shortname = props.shortname; }
}
} else { zoneId = 'UNKNOWN'; }
} catch(zonePipError) { console.error(`DEBUG P&P LSRs (Map): ERROR during Zone PIP check for LSR #${index}: ${zonePipError.message}`); zoneId = 'ERROR'; }
} else { zoneId = 'LAYER_MISSING'; }
// --- Assign properties, Verify, Determine Time Color, Plot, Count ---
const lsrProps = lsrFeature.properties;
lsrProps.state_zone = zoneId;
if (lsrProps.magnitude && typeof lsrProps.magf === 'undefined') { lsrProps.magf = parseFloat(lsrProps.magnitude) || null; } else if (typeof lsrProps.magf === 'undefined') { lsrProps.magf = null; }
// Assign ETN from the FIRST containing polygon found (for informational purposes in popup/CSV, though time color uses all)
lsrProps.source_event_etn = containingWarningFeature.properties.eventid || null;
const verification = checkLSRVerification(lsrProps); // Verify
lsrProps.verification = verification; // Store verification status
// === !!! REPLACEMENT START !!! ===
// Calculate the BEST time color by checking against ALL selected events
const lsrBufferHours = parseInt($("#lsrbuffer").val()) || 1; // Get buffer value
const [bestTimeColor, productHighlightsHtml] = getBestLsrStatus(lsrFeature, currentSelectedEventDetails, lsrBufferHours);
// Store the calculated bestTimeColor on the feature properties
lsrProps.timeColor = bestTimeColor;
// === !!! REPLACEMENT END !!! ===
// Style and create the Leaflet marker, passing the calculated bestTimeColor
const marker = styleLSRMarker(latLng, lsrFeature, verification, bestTimeColor); // Use bestTimeColor here
if (markersLayer && marker) {
markersLayer.addLayer(marker); // Add marker to the map layer
}
// Add the *processed* LSR feature (with added properties) to our PLOTTED results list
plottedLSRs.push(lsrFeature);
// [Update verification counts code...]
if (verification && zoneId && !['UNKNOWN', 'ERROR', 'LAYER_MISSING'].includes(zoneId)) {
const normalizedZoneForCount = normalizeZoneCode(zoneId);
if (normalizedZoneForCount) {
if (!verifiedLsrCounts[normalizedZoneForCount]) { verifiedLsrCounts[normalizedZoneForCount] = { advisory: 0, warning: 0 }; }
verifiedLsrCounts[normalizedZoneForCount][verification]++;
}
}
} // End of 'if (insideAnyWarning && containingWarningFeature)' check
}); // --- End forEach LSR feature ---
console.log(`DEBUG P&P LSRs (Map): Finished processing for map. Filter Checks: ${pipChecksAttempted}/${pipChecksPassedInitialFilter} passed filter. Zone Lookups: ${zoneLookupsAttempted}/${zoneLookupsSuccessful} successful.`);
console.log("DEBUG P&P LSRs (Map): Final verifiedLsrCounts (from plotted LSRs):", verifiedLsrCounts);
return { plottedLSRs, verifiedLsrCounts }; // Return only the LSRs plotted on the map
}
// LSR Verification Check (accepts properties object)
function checkLSRVerification(lsrProperties) {
const lsrTypeInfo = getLsrTypeInfo(lsrProperties);
if (!lsrTypeInfo) { return null; }
const zone = lsrProperties.state_zone;
const value = lsrProperties.magf; // Already parsed to float or null
if (!zone || ['UNKNOWN', 'ERROR', 'LAYER_MISSING'].includes(zone)) {
if (lsrTypeInfo.verifies) { return lsrTypeInfo.verifies; } // Types not needing zone criteria
return null; // Cannot verify zone-dependent types
}
// 1. Direct verification (e.g., DMG)
if (lsrTypeInfo.verifies) { return lsrTypeInfo.verifies; }
// 2. Verification via resolver function (Snow, Wind, etc.)
if (lsrTypeInfo.resolvelsr) {
const requiresValue = lsrTypeInfo.passvalue === true;
if (requiresValue && (value === null || typeof value !== 'number' || isNaN(value))) {
console.warn(`WARN checkLSRVerification: Cannot verify LSR type ${lsrProperties.type} for zone ${zone}. Requires valid number, got: ${value}.`);
return null;
}
const result = lsrTypeInfo.resolvelsr(zone, value);
return result?.level || null;
}
// 3. Fallback: Generic thresholds (less common)
if (lsrTypeInfo.passvalue === true && value !== null && typeof value === 'number' && !isNaN(value)) {
if (lsrTypeInfo.warnbottom !== undefined && value >= lsrTypeInfo.warnbottom && (lsrTypeInfo.warntop === undefined || value <= lsrTypeInfo.warntop)) return 'warning';
if (lsrTypeInfo.advbottom !== undefined && value >= lsrTypeInfo.advbottom && (lsrTypeInfo.advtop === undefined || value <= lsrTypeInfo.advtop)) return 'advisory';
}
return null; // Did not verify
}
// --- LSR Verification Helper Functions ---
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 */ 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 */ if(typeof value !=='number') return {level:null}; if(value>=58) return {level:'warning'}; if(value>=40) return {level:'advisory'}; return {level:null}; } // Using 40 for advisory based on common criteria
function sleet(zone, value){ /* zone not needed */ 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 */ 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 */ if(typeof value !=='number') return {level:null}; if(value>=2.0) return {level:'warning'}; if(value>=0.5) return {level:'advisory'}; return {level:null}; }
/**
* Finds NWS river gauges within warning polygon(s), plots their locations,
* determines relevant gauges inside polygons, fetches data, AND returns gauges found in bounds.
* @param {object} event - The VTEC event object.
* @param {object} geometryData - GeoJSON FeatureCollection for the event's geometry.
* @param {string} eventStartTimeStr - ISO string of event.issue.
* @param {string} eventEndTimeStr - ISO string of event.expire.
* @returns {Promise<object>} A promise resolving to an object:
* { gaugeDataResults: Array<object>, gaugesInBoundsList: Array<object> }
*/
async function processHydroWarning(event, geometryData, eventStartTimeStr, eventEndTimeStr) {
console.log(`DEBUG: processHydroWarning started for event ${event?.phenomena}.${event?.significance}.${event?.eventid}.`);
const warningPolygonFeatures = geometryData?.features;
const returnObject = { gaugeDataResults: [], gaugesInBoundsList: [] }; // Initialize return structure
if (!warningPolygonFeatures?.length) {
console.warn(`WARN processHydroWarning (${event?.eventid}): No features in geometryData. Skipping hydro.`);
return returnObject;
}
// --- Calculate Bounds ---
let bounds = null;
try {
const validFeatures = warningPolygonFeatures.filter(f => f?.geometry);
if (!validFeatures.length) throw new Error("No valid geometry features found.");
const featureCollection = turf.featureCollection(validFeatures);
const bbox = turf.bbox(featureCollection);
if (!bbox || !Array.isArray(bbox) || bbox.length !== 4 || !bbox.every(coord => typeof coord === 'number' && isFinite(coord)) || bbox[0] > bbox[2] || bbox[1] > bbox[3]) {
console.warn(`WARN processHydroWarning (${event?.eventid}): Invalid Turf BBox: ${bbox}. Using Leaflet fallback.`);
try {
bounds = L.geoJSON(featureCollection).getBounds();
if (!bounds || !bounds.isValid()) throw new Error("Leaflet bounds also invalid.");
} catch (leafletBoundsError) { throw new Error(`Invalid Turf BBox AND Leaflet bounds failed: ${leafletBoundsError.message}`); }
} else { bounds = L.latLngBounds([[bbox[1], bbox[0]], [bbox[3], bbox[2]]]); }
if (!bounds || !bounds.isValid()) { throw new Error(`Calculated bounds are invalid.`); }
// console.log(`DEBUG processHydroWarning (${event?.eventid}): Calculated bounds: ${bounds.toBBoxString()}`);
} catch (e) {
console.error(`CRITICAL ERROR during bounds calculation for hydro warning ${event?.eventid}:`, e);
return returnObject;
}
// --- End Calculate Bounds ---
// --- Find Gauges in Bounds ---
const gaugesInBounds = await getNWSGaugesByBoundingBox(bounds);
if (gaugesInBounds.length === 0) { console.log(`DEBUG processHydroWarning (${event?.eventid}): No NWS gauges found within bounds.`); return returnObject; }
// --- End Find Gauges ---
// --- Plot ALL Gauges Found in Bounds & Prepare List for Return ---
gaugesInBounds.forEach(gauge => {
if (gauge && gauge.longitude != null && gauge.latitude != null && gauge.lid) {
const gaugeLon = parseFloat(gauge.longitude); const gaugeLat = parseFloat(gauge.latitude);
if (!isNaN(gaugeLon) && !isNaN(gaugeLat)) {
const latLng = L.latLng(gaugeLat, gaugeLon);
const gaugeName = gauge.name || gauge.lid;
const marker = styleGaugeMarker(latLng, gauge.lid, gaugeName);
if (gaugeMarkersLayer && marker) { gaugeMarkersLayer.addLayer(marker); }
// ADD gauge info to the list to be returned
returnObject.gaugesInBoundsList.push({ lid: gauge.lid, name: gaugeName, lat: gaugeLat, lon: gaugeLon });
}
}
});
console.log(`DEBUG processHydroWarning (${event?.eventid}): Plotted ${returnObject.gaugesInBoundsList.length} gauge markers.`);
// --- END Plot Gauges & Prepare List ---
// --- Filter Gauges to those inside Polygon(s) ---
const relevantGauges = [];
gaugesInBounds.forEach((gauge, gaugeIndex) => {
if (gauge && gauge.longitude != null && gauge.latitude != null) {
const gaugeLon = parseFloat(gauge.longitude); const gaugeLat = parseFloat(gauge.latitude);
if (isNaN(gaugeLon) || isNaN(gaugeLat)) return;
const gaugePoint = turf.point([gaugeLon, gaugeLat]);
for (const feature of warningPolygonFeatures) {
if (!feature?.geometry) continue;
if ((feature.geometry.type === "Polygon" && feature.geometry.coordinates?.[0]?.length < 4) || (feature.geometry.type === "MultiPolygon" && feature.geometry.coordinates?.[0]?.[0]?.length < 4)) { continue; }
try { if (turf.booleanPointInPolygon(gaugePoint, feature)) { relevantGauges.push(gauge); break; } }
catch(turfError) { console.error(`DEBUG processHydroWarning (${event?.eventid}): Turf.js ERROR checking Gauge ${gauge.lid || `Index ${gaugeIndex}`} / Feature: ${turfError.message}`); }
}
}
});
if (relevantGauges.length === 0) { console.log(`DEBUG processHydroWarning (${event?.eventid}): No relevant gauges inside polygon(s).`); return returnObject; }
console.log(`DEBUG processHydroWarning (${event?.eventid}): Filtered to ${relevantGauges.length} relevant gauges inside polygon(s).`);
// --- End Filter Gauges ---
// --- Determine Hydrograph Fetch Time Window ---
const bufferHours = 24; let fetchStartTime, fetchEndTime; let issueDate, initExpireDate, finalExpireDate; let dateErrorOccurred = false; let originalErrorNote = null;
try {
issueDate = new Date(event.issue); initExpireDate = event.init_expire ? new Date(event.init_expire) : null; finalExpireDate = new Date(event.expire);
if (isNaN(issueDate.getTime()) || isNaN(finalExpireDate.getTime())) throw new Error(`Invalid event issue or final expire date`);
if (initExpireDate && isNaN(initExpireDate.getTime())) { initExpireDate = null; }
let targetEndTimeDate;
if (initExpireDate && finalExpireDate) { targetEndTimeDate = initExpireDate > finalExpireDate ? initExpireDate : finalExpireDate; }
else if (initExpireDate) { targetEndTimeDate = initExpireDate; } else { targetEndTimeDate = finalExpireDate; }
if (finalExpireDate <= issueDate) { originalErrorNote = "DATES_INVALID_ORDER"; }
fetchStartTime = addHoursToDate(issueDate, -bufferHours).toISOString();
fetchEndTime = addHoursToDate(targetEndTimeDate, bufferHours).toISOString();
console.log(`DEBUG processHydroWarning (${event?.eventid}): Fetch window: ${fetchStartTime} to ${fetchEndTime}`);
} catch (dateError) { console.error(`CRITICAL ERROR processing dates for hydro warning ${event?.eventid}: ${dateError.message}.`); return returnObject; }
// --- End Determine Time Window ---
// --- Fetch Data for Relevant Gauges ---
const gaugePromises = relevantGauges.map(async (gauge) => {
let gaugeResult = await getUsgsIdFromNwsGauge(gauge.lid, fetchStartTime, fetchEndTime, false);
if (originalErrorNote && gaugeResult) { gaugeResult.originalError = originalErrorNote; }
return gaugeResult;
});
try {
const results = await Promise.all(gaugePromises);
returnObject.gaugeDataResults = results.filter(res => res != null);
} catch (fetchError) { console.error(`ERROR processHydroWarning (${event?.eventid}): Error during gauge data fetching:`, fetchError); }
console.log(`DEBUG processHydroWarning (${event?.eventid}): Returning ${returnObject.gaugeDataResults.length} data results.`);
return returnObject;
// --- End Fetch Data ---
}
/**
* Finds NWS river gauge LIDs located within the boundaries of the provided GeoJSON geometry.
* @param {object} geometryData - GeoJSON FeatureCollection for the area of interest.
* @returns {Promise<Set<string>>} A promise resolving to a Set containing unique NWS gauge LIDs found within the geometry.
*/
async function findGaugesInGeometry(geometryData) {
const uniqueLids = new Set();
const warningPolygonFeatures = geometryData?.features;
if (!warningPolygonFeatures?.length) {
// console.log("DEBUG findGaugesInGeometry: No features in geometryData.");
return uniqueLids;
}
// --- Calculate Bounds ---
let bounds = null;
try {
const validFeatures = warningPolygonFeatures.filter(f => f?.geometry);
if (!validFeatures.length) throw new Error("No valid geometry features found.");
const featureCollection = turf.featureCollection(validFeatures);
const bbox = turf.bbox(featureCollection);
// Added more robust bbox validation
if (!bbox || !Array.isArray(bbox) || bbox.length !== 4 || !bbox.every(coord => typeof coord === 'number' && isFinite(coord)) || bbox[0] > bbox[2] || bbox[1] > bbox[3]) {
console.warn(`WARN findGaugesInGeometry: Invalid Turf BBox: ${bbox}. Using Leaflet fallback.`);
try {
bounds = L.geoJSON(featureCollection).getBounds();
if (!bounds || !bounds.isValid()) throw new Error("Leaflet bounds also invalid.");
} catch (leafletBoundsError) { throw new Error(`Invalid Turf BBox AND Leaflet bounds failed: ${leafletBoundsError.message}`); }
} else { bounds = L.latLngBounds([[bbox[1], bbox[0]], [bbox[3], bbox[2]]]); }
if (!bounds || !bounds.isValid()) { throw new Error(`Calculated bounds are invalid.`); }
// console.log(`DEBUG findGaugesInGeometry: Calculated bounds: ${bounds.toBBoxString()}`);
} catch (e) {
console.error(`CRITICAL ERROR during bounds calculation in findGaugesInGeometry:`, e);
return uniqueLids; // Return empty set on error
}
// --- End Calculate Bounds ---
// --- Find Gauges in Bounds ---
const gaugesInBounds = await getNWSGaugesByBoundingBox(bounds);
if (gaugesInBounds.length === 0) {
// console.log(`DEBUG findGaugesInGeometry: No NWS gauges found within bounds.`);
return uniqueLids;
}
// --- End Find Gauges ---
// --- Filter Gauges to those inside Polygon(s) ---
gaugesInBounds.forEach((gauge, gaugeIndex) => {
if (gauge && gauge.longitude != null && gauge.latitude != null && gauge.lid) { // Ensure LID exists
const gaugeLon = parseFloat(gauge.longitude);
const gaugeLat = parseFloat(gauge.latitude);
if (isNaN(gaugeLon) || isNaN(gaugeLat)) return;
const gaugePoint = turf.point([gaugeLon, gaugeLat]);
for (const feature of warningPolygonFeatures) {
if (!feature?.geometry) continue;
// Basic validity check for polygon coordinates
if ((feature.geometry.type === "Polygon" && feature.geometry.coordinates?.[0]?.length < 4) ||
(feature.geometry.type === "MultiPolygon" && feature.geometry.coordinates?.[0]?.[0]?.length < 4)) {
// console.warn(`DEBUG findGaugesInGeometry: Skipping feature with potentially invalid geometry for gauge ${gauge.lid || `Index ${gaugeIndex}`}`);
continue;
}
try {
if (turf.booleanPointInPolygon(gaugePoint, feature)) {
uniqueLids.add(gauge.lid); // Add the LID to the set
break; // No need to check other features for this gauge once found
}
} catch(turfError) {
// Log error but continue trying other features/gauges
console.error(`DEBUG findGaugesInGeometry: Turf.js ERROR checking Gauge ${gauge.lid || `Index ${gaugeIndex}`} / Feature: ${turfError.message}`);
}
}
}
});
// console.log(`DEBUG findGaugesInGeometry: Found ${uniqueLids.size} unique LIDs inside geometry.`);
return uniqueLids;
// --- End Filter Gauges ---
}
/**
* Builds the complete HTML summary content for the selected events.
* Includes event details, segment breakdowns (with per-segment LSRs),
* hydrograph info (consolidated), map image, and a final unified list of ALL LSRs fetched.
* @param {Array<object>} eventDetails - Array of processed event detail objects { event, data, fullDetails }.
* @param {Array<object>} allLSRsInRange - Array of ALL LSR GeoJSON features fetched for the event time range + buffer (UNFILTERED by polygon).
* @param {object} verifiedLsrCounts - Object containing verification counts per zone { ZONEID: { advisory: X, warning: Y }, ... } - derived from *plotted* LSRs.
*/
async function buildHtmlSummary(eventDetails, allLSRsInRange, verifiedLsrCounts) {
// --- Initial HTML Structure ---
let html = `<button id="copySummaryBtn" onclick="copySummaryToClipboard()" style="float: right; margin-bottom: 10px; padding: 5px 10px; cursor: pointer; display: block; border: 1px solid #ccc; background-color: #f0f0f0; border-radius: 3px;">Copy Summary</button>`;
if (eventDetails.length > 1) { const { earliestIssue, latestExpire } = getEarliestAndLatestTimes(eventDetails.map(d => d.event)); html += `<p><strong>Overall Period Selected:</strong> ${formatDateReadable(earliestIssue)} to ${formatDateReadable(latestExpire)}</p>`; }
const hasHydro = eventDetails.some(detail => detail?.event?.phenomena && hydroprods.includes(detail.event.phenomena));
html += `<h3>Event Map</h3>`;
html += `<div id="summary-map-image-container" style="margin-bottom: 15px;">Generating map image...</div>`;
html += `<h3>Selected Warning(s)</h3>`;
// --- Hydro Data Collection Setup ---
const uniqueGaugeLids = new Set();
const gaugeToEventsMap = {}; // <<< NEW: Map to store { lid: Set<string> }
let allGaugeLocationsToPlot = [];
const processedGaugeLidsForMap = new Set();
let hydroDataFetchPromises = [];
let bufferedStartTimeISO, bufferedEndTimeISO;
// --- Determine Overall Time Window for Hydro ---
if (hasHydro) {
// ... (existing time window calculation) ...
const { earliestIssue, latestExpire } = getEarliestAndLatestTimes(eventDetails.map(d => d.event));
try {
const start = new Date(earliestIssue);
const end = new Date(latestExpire);
if (isNaN(start.getTime()) || isNaN(end.getTime())) {
throw new Error("Invalid overall start or end date for hydro window.");
}
bufferedStartTimeISO = addHoursToDate(start, -24).toISOString();
bufferedEndTimeISO = addHoursToDate(end, +24).toISOString();
console.log(`DEBUG buildHtmlSummary: Hydro fetch window: ${bufferedStartTimeISO} to ${bufferedEndTimeISO}`);
} catch (timeError) {
console.error("CRITICAL ERROR calculating overall hydro time window:", timeError);
bufferedStartTimeISO = null;
bufferedEndTimeISO = null;
}
}
// --- Iterate through each selected event ---
const findGaugePromises = [];
eventDetails.forEach(({ event, data, fullDetails }) => {
// ... (existing setup code within the loop: phenSigText, isFloodWarningFLW, etc.) ...
const phenSigText = getPhenSig(event?.phenomena, event?.significance);
const isFloodWarningFLW = event?.phenomena === 'FL' && event?.significance === 'W';
const isSbwEvent = event?.data?.features?.[0]?.properties?.is_sbw || sbwprods.includes(event?.phenomena);
const isHydroEvent = event?.phenomena && hydroprods.includes(event.phenomena);
const isZoneBasedProduct = !isSbwEvent && !isFloodWarningFLW && !isHydroEvent;
const processedHvtecLids = new Set();
// --- Event Header & Overall Timing ---
// ... (Existing code) ...
html += `<div class="event-summary-item" style="border-left: 5px solid ${getColorForWarning(phenSigText)}; padding-left: 10px; margin-bottom: 15px;">`;
html += `<p><strong>${phenSigText} (ETN: ${event?.eventid || 'N/A'})</strong></p>`;
html += `<div style="font-size: 0.9em; margin-bottom: 10px; padding-bottom: 5px; border-bottom: 1px solid #eee;">`;
html += `<p style="margin:0 0 0.3em 0;"><strong>Overall Event Span (from VTEC Event List):</strong></p>`;
html += `<p style="margin:0 0 0.3em 15px;">Initial Issue: ${formatDateReadable(event?.issue)}</p>`;
if (event?.init_expire && event.init_expire !== event.expire && event.init_expire !== event.issue) { html += `<p style="margin:0 0 0.3em 15px;">Initial Expire (Overall): ${formatDateReadable(event.init_expire)}</p>`; }
html += `<p style="margin:0 0 0.3em 15px;">Final Expiration: ${formatDateReadable(event?.expire)}</p>`;
if (event?.locations) { const sortedLocations = event.locations.split(', ').sort().join(', '); html += `<p style="margin:0.5em 0 0.3em 0;"><strong>Counties/Zones Involved (Overall):</strong> ${sortedLocations}</p>`; }
html += `</div>`;
// --- Segment Details Container ---
// ... (Existing code) ...
html += `<div style="font-size: 0.9em; margin-bottom: 8px; border-top: 1px solid #eee; padding-top: 8px;">`;
html += `<p style="margin-bottom: 5px; border-bottom: 1px dashed #eee; padding-bottom: 3px;"><strong>Product/Segment Details:</strong></p>`;
let hasSegmentedInfo = fullDetails && fullDetails.ugcs && fullDetails.ugcs.length > 0;
if (hasSegmentedInfo && (isZoneBasedProduct || isFloodWarningFLW)) {
// ... (Existing Segmented Display Code) ...
const groupedUGCData = groupUGCsByTimes(fullDetails.ugcs);
const timeKeys = Object.keys(groupedUGCData).sort((a, b) => new Date(groupedUGCData[a].issue) - new Date(groupedUGCData[b].issue) || new Date(groupedUGCData[a].expire) - new Date(groupedUGCData[b].expire));
if (timeKeys.length === 0 && fullDetails.ugcs.length > 0) {
html += `<p style="font-style: italic; color: orange;">Could not group detailed segment timing information.</p>`;
hasSegmentedInfo = false; // Fallback to non-segmented display below
} else {
timeKeys.forEach((timeKey, segmentIndex) => { // Iterate through each time segment group
const timeGroup = groupedUGCData[timeKey];
const groupStartTime = new Date(timeGroup.issue);
const groupEndTime = new Date(timeGroup.expire);
const segmentUgcCodes = timeGroup.ugcList.map(u => u.ugc);
let segmentWarningFeatures = [];
if(data?.features) {
segmentWarningFeatures = data.features.filter(f => f.properties?.ugc && segmentUgcCodes.includes(f.properties.ugc));
if(segmentWarningFeatures.length === 0) {
segmentWarningFeatures = data.features || [];
}
}
html += `<div style="margin-left: 15px; margin-bottom: 15px; border-left: 2px solid #ddd; padding-left: 8px;">`; // Start Segment Div
const ugcProdIssue = timeGroup.ugcList[0]?.utc_prodissue; const eventProdIssue = event?.product_issue; const prodIssueTimeToUse = ugcProdIssue || eventProdIssue; const formattedProdIssue = prodIssueTimeToUse ? formatDateReadable(prodIssueTimeToUse) : 'N/A';
html += `<p style="margin-top:0; margin-bottom: 0.2em;">Segment Product Issue: ${formattedProdIssue}</p>`;
html += `<p style="margin-top:0; margin-bottom: 0.2em;">Segment Valid Time: ${formatDateReadable(groupStartTime)}</p>`;
if (timeGroup.initExpire && timeGroup.initExpire !== timeGroup.expire && timeGroup.initExpire !== timeGroup.issue) { html += `<p style="margin-top:0; margin-bottom: 0.2em;">Segment Initial Expiration: ${formatDateReadable(timeGroup.initExpire)}</p>`; }
html += `<p style="margin-top:0; margin-bottom: 0.2em;">Segment Cancel/Expire Time: ${formatDateReadable(groupEndTime)}</p>`;
if (isZoneBasedProduct) {
html += `<h5 style="margin-top: 10px; margin-bottom: 3px; border-top: 1px dashed #ccc; padding-top: 5px;">Zones in this Segment:</h5>`;
const sortedUgcList = timeGroup.ugcList.sort((a, b) => (a.ugc || '').localeCompare(b.ugc || ''));
// --->>> MODIFICATION START <<<---
// Get normalized zone codes for the current segment (use a Set for efficiency)
const segmentZoneCodesNormalized = new Set(
sortedUgcList.map(ugc => normalizeZoneCode(ugc.ugc)).filter(Boolean) // Normalize and remove nulls/empty strings
);
const zoneStrings = sortedUgcList.map(ugc => formatIndividualZoneDisplay(ugc.ugc, ugc.name)).join(', ');
// --->>> MODIFICATION END <<<---
html += `<p style="margin-left: 10px; margin-bottom: 8px;">${zoneStrings || 'None listed'}</p>`;
// --->>> MODIFICATION START: Update the filter for segmentLSRs <<<---
const segmentLSRs = allLSRsInRange.filter(lsr => {
// Basic LSR validity checks
if (!lsr?.properties?.valid || !lsr?.geometry?.coordinates) return false;
try {
const lsrTime = new Date(lsr.properties.valid);
// 1. Time Check: Must be within segment start/end time
if (isNaN(lsrTime.getTime()) || lsrTime < groupStartTime || lsrTime > groupEndTime) {
return false;
}
// 2. Zone Check: LSR's zone must be one of the zones active in *this* segment
const lsrZoneNormalized = normalizeZoneCode(lsr.properties.state_zone);
if (!lsrZoneNormalized || !segmentZoneCodesNormalized.has(lsrZoneNormalized)) {
// Uncomment for debugging:
// if (lsrZoneNormalized) { console.log(`Filtering out LSR zone ${lsrZoneNormalized} because it's not in segment zones: ${Array.from(segmentZoneCodesNormalized).join(', ')}`); }
// else { console.log(`Filtering out LSR because its zone is null/invalid: ${lsr.properties.state_zone}`); }
return false; // Filter out if zone doesn't match or is invalid
}
// 3. Spatial Check (Optional but good safety): LSR location must be inside *a* polygon of this segment
// (This helps catch edge cases or polygon errors)
const lsrPoint = turf.point(lsr.geometry.coordinates);
const isInsideSegmentGeometry = segmentWarningFeatures.some(wf => {
try {
// Add geometry validity check before PIP
if (wf.geometry &&
((wf.geometry.type === "Polygon" && wf.geometry.coordinates?.[0]?.length >= 4) ||
(wf.geometry.type === "MultiPolygon" && wf.geometry.coordinates?.[0]?.[0]?.length >= 4))
) {
return turf.booleanPointInPolygon(lsrPoint, wf);
}
return false; // Skip invalid warning features
} catch (e) {
// console.warn("Turf error during segment LSR spatial check:", e); // Reduce console noise
return false;
}
});
if (!isInsideSegmentGeometry) {
// Uncomment for debugging:
// console.log(`Filtering out LSR in zone ${lsrZoneNormalized} because it's not spatially within segment polygons.`);
return false; // Filter out if not spatially contained (even if zone matches)
}
// If all checks pass, include the LSR
return true;
} catch (e) {
// console.error("Error filtering segment LSRs:", e, lsr); // Reduce console noise
return false; // Filter out on error
}
});
segmentLSRs.sort((a, b) => new Date(a.properties.valid) - new Date(b.properties.valid));
// --->>> MODIFICATION END <<<---
if (segmentLSRs.length > 0) {
// ... (rest of the existing code to display the filtered segmentLSRs) ...
html += `<p style="font-size: 0.9em; margin-top: 5px; margin-bottom: 2px; font-style: italic;">Reports During Segment Period (${formatDate_HHMM(groupStartTime)} - ${formatDate_HHMM(groupEndTime)}) (Filtered by Zone):</p>`; // Added note
html += `<ul style="font-size: 0.9em; padding-left: 20px; margin-top: 0; margin-bottom: 5px; list-style-type: disc;">`;
segmentLSRs.forEach(lsr => { /* ... LSR display ... */ const props = lsr.properties; const magUnit = `${props.magnitude || ''} ${props.unit || ''}`.trim(); const verificationText = props.verification ? ` (${props.verification.toUpperCase()})` : ''; const zoneCode = props.state_zone; const shortName = props.shortname; let zoneDisplay = ''; if (zoneCode && !['UNKNOWN', 'ERROR', 'LAYER_MISSING', 'NO_GEOMETRY', 'INVALID_COORDS'].includes(zoneCode)) { zoneDisplay = shortName ? ` [${zoneCode} (${shortName})]` : ` [${zoneCode}]`; } else if (zoneCode) { zoneDisplay = ` [${zoneCode}]`; } html += `<li style="margin-bottom: 0.2em;">${formatDate_HHMM(props.valid)} - <strong>${props.typetext || 'LSR'}</strong> ${magUnit}${verificationText} - ${props.city || 'N/A'}${zoneDisplay}<i style="font-size:0.9em;">${props.remark ? ' - ' + props.remark : ''}</i></li>`; });
html += `</ul>`;
} else {
html += `<p style="font-size: 0.9em; margin-top: 5px; margin-bottom: 5px; font-style: italic;">(No reports found for the specific zones active during this segment's time/area)</p>`;
}
} else if (isFloodWarningFLW) { timeGroup.ugcList.forEach(ugc => { /* ... H-VTEC parsing/display ... */ const currentLid = ugc.hvtec_nwsli; if (currentLid && currentLid !== "00000" && !processedHvtecLids.has(currentLid)) { processedHvtecLids.add(currentLid); let forecastInfo = parseHvtecLine(fullDetails, currentLid); if (forecastInfo) { let sources = []; if(forecastInfo.riseSource) sources.push(`Rise: ${forecastInfo.riseSource}`); if(forecastInfo.crestValSource) sources.push(`CrestVal: ${forecastInfo.crestValSource}`); if(forecastInfo.crestTimeSource) sources.push(`CrestTime: ${forecastInfo.crestTimeSource}`); if(forecastInfo.fallSource) sources.push(`Fall: ${forecastInfo.fallSource}`); const sourceText = sources.length > 0 ? sources.join(', ') : 'Unknown Source'; html += `<div style="margin-top: 8px; padding-top: 5px; border-top: 1px dotted #ccc;">`;
console.log('full details:', fullDetails)
html += `<p style="margin-bottom: 2px;"><strong>Forecast for ${currentLid} <span style='font-size:0.85em; font-style:italic;'> (Sources: ${sourceText})</span>:</strong></p>`; html += `<ul style="margin-top:0; padding-left: 25px; list-style-type: square;">`; const riseDisplay = forecastInfo.riseTimeZ ? formatHvtecTime(forecastInfo.riseTimeZ) : forecastInfo.riseTimeDescNarrative ? `${forecastInfo.riseTimeDescNarrative} (narrative)` : 'N/A'; html += `<li>Rise Above Flood: ${riseDisplay}</li>`; const crestValDisplay = forecastInfo.crestValueNarrative ? `<strong>${forecastInfo.crestValueNarrative} ft</strong> (narrative)` : 'N/A'; html += `<li>Crest Value: ${crestValDisplay}</li>`; const crestTimeDisplay = forecastInfo.crestTimeZ ? formatHvtecTime(forecastInfo.crestTimeZ) : forecastInfo.crestTimeDescNarrative ? `${forecastInfo.crestTimeDescNarrative} (narrative)` : 'N/A'; html += `<li>Crest Time: ${crestTimeDisplay}</li>`; const fallDisplay = forecastInfo.fallTimeZ ? formatHvtecTime(forecastInfo.fallTimeZ) : forecastInfo.fallTimeDescNarrative ? `${forecastInfo.fallTimeDescNarrative} (narrative)` : 'N/A'; html += `<li>Fall Below Flood: ${fallDisplay}</li>`; html += `</ul></div>`; } } });
html += `<p style="margin-top:8px; margin-bottom: 5px; border-top: 1px dashed #ccc; padding-top: 5px;"><strong>Zones Affected During Segment:</strong> `; const zoneStringsZone = timeGroup.ugcList.sort((a, b) => (a.ugc || '').localeCompare(b.ugc || '')) .map(ugc => formatIndividualZoneDisplay(ugc.ugc, ugc.name)).join(', '); html += zoneStringsZone || 'None listed'; html += `</p>`;
const segmentLSRs = allLSRsInRange.filter(lsr => { /* ... LSR filtering ... */ if (!lsr?.properties?.valid || !lsr?.geometry?.coordinates) return false; try { const lsrTime = new Date(lsr.properties.valid); if (isNaN(lsrTime.getTime()) || lsrTime < groupStartTime || lsrTime > groupEndTime) return false; const lsrPoint = turf.point(lsr.geometry.coordinates); return segmentWarningFeatures.some(wf => { try { return wf.geometry && turf.booleanPointInPolygon(lsrPoint, wf); } catch(e) { return false; } }); } catch (e) { return false; } }); segmentLSRs.sort((a, b) => new Date(a.properties.valid) - new Date(b.properties.valid));
if (segmentLSRs.length > 0) {
html += `<p style="font-size: 0.9em; margin-top: 5px; margin-bottom: 2px; font-style: italic;">Reports During Segment Period (${formatDate_HHMM(groupStartTime)} - ${formatDate_HHMM(groupEndTime)}):</p>`;
html += `<ul style="font-size: 0.9em; padding-left: 20px; margin-top: 0; margin-bottom: 5px; list-style-type: disc;">`;
segmentLSRs.forEach(lsr => { /* ... LSR display ... */ const props = lsr.properties; const magUnit = `${props.magnitude || ''} ${props.unit || ''}`.trim(); const verificationText = props.verification ? ` (${props.verification.toUpperCase()})` : ''; const zoneCode = props.state_zone; const shortName = props.shortname; let zoneDisplay = ''; if (zoneCode && !['UNKNOWN', 'ERROR', 'LAYER_MISSING'].includes(zoneCode)) { zoneDisplay = shortName ? ` [${zoneCode} (${shortName})]` : ` [${zoneCode}]`; } else if (zoneCode) { zoneDisplay = ` [${zoneCode}]`; } html += `<li style="margin-bottom: 0.2em;">${formatDate_HHMM(props.valid)} - <strong>${props.typetext || 'LSR'}</strong> ${magUnit}${verificationText} - ${props.city || 'N/A'}${zoneDisplay}<i style="font-size:0.9em;">${props.remark ? ' - ' + props.remark : ''}</i></li>`; });
html += `</ul>`;
} else { html += `<p style="font-size: 0.9em; margin-top: 5px; margin-bottom: 5px; font-style: italic;">(No reports found intersecting this specific segment's time/area)</p>`; }
}
html += `</div>`; // End Segment Div
}); // End timeKeys.forEach
} // End else (timeKeys found)
} else {
// --- Process for SBW/Hydro (Non-Segmented Display) ---
// ... (Existing Non-Segmented Display Code) ...
const eventStartTime = new Date(event.issue);
const eventEndTime = new Date(event.expire);
const eventWarningFeatures = data?.features || [];
html += `<div style="margin-left: 15px; margin-bottom: 15px; border-left: 2px solid #ddd; padding-left: 8px;">`; // Start Details Div
html += `<p style="margin-top:0; margin-bottom: 0.2em;">Event Product Issue: ${formatDateReadable(event?.product_issue || event.issue)}</p>`;
html += `<p style="margin-top:0; margin-bottom: 0.2em;">Event Valid Time: ${formatDateReadable(eventStartTime)}</p>`;
if (event?.init_expire && event.init_expire !== event.expire && event.init_expire !== event.issue) { html += `<p style="margin-top:0; margin-bottom: 0.2em;">Event Initial Expiration: ${formatDateReadable(event.init_expire)}</p>`; }
html += `<p style="margin-top:0; margin-bottom: 0.2em;">Event Final Cancel/Expire Time: ${formatDateReadable(eventEndTime)}</p>`;
let zoneDataSource = event.locations ? event.locations.split(',').map(loc => ({ name: loc.trim(), ugc: 'N/A' })) : [];
if(fullDetails?.ugcs?.length > 0) { zoneDataSource = fullDetails.ugcs; }
if (zoneDataSource.length > 0) {
html += `<p style="margin-top:8px; margin-bottom: 5px; border-top: 1px dashed #ccc; padding-top: 5px;"><strong>Zones/Areas Affected:</strong> `;
const zoneStrings = zoneDataSource.sort((a, b) => (a.name || a.ugc || '').localeCompare(b.name || b.ugc || '')) .map(item => formatIndividualZoneDisplay(item.ugc, item.name)) .join(', ');
html += zoneStrings || 'None listed';
html += `</p>`;
}
const eventLSRs = allLSRsInRange.filter(lsr => { /* ... LSR filtering ... */ if (!lsr?.properties?.valid || !lsr?.geometry?.coordinates) return false; try { const lsrTime = new Date(lsr.properties.valid); if (isNaN(lsrTime.getTime()) || lsrTime < eventStartTime || lsrTime > eventEndTime) return false; const lsrPoint = turf.point(lsr.geometry.coordinates); return eventWarningFeatures.some(wf => { try { return wf.geometry && turf.booleanPointInPolygon(lsrPoint, wf); } catch(e) { return false; } }); } catch (e) { return false; } }); eventLSRs.sort((a, b) => new Date(a.properties.valid) - new Date(b.properties.valid));
if (eventLSRs.length > 0) {
html += `<p style="font-size: 0.9em; margin-top: 5px; margin-bottom: 2px; font-style: italic;">Reports During Event Period (${formatDate_HHMM(eventStartTime)} - ${formatDate_HHMM(eventEndTime)}):</p>`;
html += `<ul style="font-size: 0.9em; padding-left: 20px; margin-top: 0; margin-bottom: 5px; list-style-type: disc;">`;
eventLSRs.forEach(lsr => { /* ... LSR display ... */ const props = lsr.properties; const magUnit = `${props.magnitude || ''} ${props.unit || ''}`.trim(); const verificationText = props.verification ? ` (${props.verification.toUpperCase()})` : ''; const zoneCode = props.state_zone; const shortName = props.shortname; let zoneDisplay = ''; if (zoneCode && !['UNKNOWN', 'ERROR', 'LAYER_MISSING'].includes(zoneCode)) { zoneDisplay = shortName ? ` [${zoneCode} (${shortName})]` : ` [${zoneCode}]`; } else if (zoneCode) { zoneDisplay = ` [${zoneCode}]`; } html += `<li style="margin-bottom: 0.2em;">${formatDate_HHMM(props.valid)} - <strong>${props.typetext || 'LSR'}</strong> ${magUnit}${verificationText} - ${props.city || 'N/A'}${zoneDisplay}<i style="font-size:0.9em;">${props.remark ? ' - ' + props.remark : ''}</i></li>`; });
html += `</ul>`;
} else { html += `<p style="font-size: 0.9em; margin-top: 5px; margin-bottom: 5px; font-style: italic;">(No reports found intersecting this event's time/area)</p>`; }
html += `</div>`; // End Details Div
}
html += `</div>`; // End Segment Details Container
// --- Collect Gauge LIDs and Associate with Event --- <<< MODIFIED
if (isHydroEvent && data && bufferedStartTimeISO && bufferedEndTimeISO) {
const vtecString = `${event.phenomena}.${event.significance} ${event.eventid}`; // Format VTEC string
const promise = findGaugesInGeometry(data)
.then(lidsFound => {
lidsFound.forEach(lid => {
uniqueGaugeLids.add(lid); // Add to overall set of unique LIDs
// Add event association to the map
if (!gaugeToEventsMap[lid]) {
gaugeToEventsMap[lid] = new Set();
}
gaugeToEventsMap[lid].add(vtecString);
});
}).catch(err => {
console.error(`Error finding gauges for ETN ${event?.eventid}:`, err);
});
findGaugePromises.push(promise);
// Plot markers (existing logic)
processHydroWarning(event, data, event.issue, event.expire)
.then(plotResult => { /* ... existing marker plotting logic ... */ (plotResult?.gaugesInBoundsList || []).forEach(gaugeLoc => { if (gaugeLoc && gaugeLoc.lid && !processedGaugeLidsForMap.has(gaugeLoc.lid)) { allGaugeLocationsToPlot.push(gaugeLoc); processedGaugeLidsForMap.add(gaugeLoc.lid); } }); })
.catch(plotErr => console.error(`Error during gauge plotting for ETN ${event?.eventid}:`, plotErr));
}
// --- End Gauge Collection ---
// --- Event Footer Links ---
html += `<p style="font-size: 0.9em;"><a href="${event?.url}" target="_blank">IEM VTEC Link</a></p>`;
let startTimeUTC = '', endTimeUTC = ''; try { if (event?.issue) startTimeUTC = new Date(event.issue).toISOString().slice(0, 19); if (event?.expire) endTimeUTC = new Date(event.expire).toISOString().slice(0, 19); } catch (e) { /* ignore */ } if (startTimeUTC && endTimeUTC) { const outageUrl = `outage.html?start=${startTimeUTC}Z&end=${endTimeUTC}Z`; html += `<p style="font-size: 0.9em;"><a href="${outageUrl}" target="_blank">Power Outages During Event</a></p>`; const timingGraphUrl = `metar.html?start=${startTimeUTC}&end=${endTimeUTC}`; html += `<p style="font-size: 0.9em;"><a href="${timingGraphUrl}" target="_blank">Timing Graph (METARs)</a></p>`; } if (event?.load_error) { html += `<p style="font-size: 0.9em; color: red;">Note: ${event.load_error}</p>`; }
html += `</div>`; // End event-summary-item Div
}); // --- End eventDetails.forEach ---
// --- Unified LSR List Section ---
html += `<h3>Local Storm Reports (LSRs) - Full Time Range</h3>`;
if (allLSRsInRange && allLSRsInRange.length > 0) {
html += `<p>Found ${allLSRsInRange.length} unique LSRs within the overall time range (+ buffer) of selected warning(s).</p>`;
html += `<p style="font-size: 0.85em; font-style: italic;">Highlighting: ` + `<span style="background-color:lightgreen; padding: 1px 3px;">Green</span> = Inside polygon & time. ` + `<span style="background-color:lightyellow; padding: 1px 3px;">Yellow</span> = Inside polygon, after time (within buffer) ` + `<span style="background-color:orange; padding: 1px 3px;">Orange</span> = Inside polygon, before time (within buffer) ` + `<span style="background-color:#ffdddd; padding: 1px 3px;">Salmon</span> = In a polygon but outside buffer ` +`<span style="background-color:#D3D3D3; padding: 1px 3px;">Gray</span> = Outside all polygons ` + `<span style="background-color:#8B0000; padding: 1px 3px;">Dark Red</span> = Some kind of error</p>` ;
html += `<ul style="margin-top: 0.5em; padding-left: 20px; list-style-type: disc;">`;
const warningChecks = currentSelectedEventDetails.map(detail => { /* ... prepare warning data ... */ let startTime = null; let endTime = null; try { startTime = detail.event?.issue ? new Date(detail.event.issue) : null; endTime = detail.event?.expire ? new Date(detail.event.expire) : null; if (isNaN(startTime?.getTime()) || isNaN(endTime?.getTime())) { throw new Error('Invalid date'); } } catch(e) { console.warn(`Could not parse valid start/end time for ETN ${detail.event?.eventid}, skipping for highlighting.`); startTime = null; endTime = null; } return { features: detail.data?.features || [], startTime: startTime, endTime: endTime }; }).filter(check => check.features.length > 0 && check.startTime && check.endTime);
const sortedAllLSRs = [...allLSRsInRange].sort((a, b) => new Date(a.properties.valid) - new Date(b.properties.valid));
sortedAllLSRs.forEach(lsr => {
const props = lsr.properties;
const geom = lsr.geometry;
// === !!! REPLACEMENT START !!! ===
// Calculate the best highlight status using the helper function
const lsrBufferHours = parseInt($("#lsrbuffer").val()) || 1; // Get buffer value
const [bestHighlightStatus, productHighlightsHtml] = getBestLsrStatus(lsr, eventDetails, lsrBufferHours); // Pass eventDetails (already available)
let highlightStyle = '';
switch (bestHighlightStatus) {
case 'green': highlightStyle = 'background-color: lightgreen;'; break;
case 'yellow': highlightStyle = 'background-color: lightyellow;'; break;
case 'orange': highlightStyle = 'background-color: orange;'; break; // Added orange case
case 'salmon': highlightStyle = 'background-color: #ffdddd;'; break; // Red for outside OR inside but out of buffer time
case 'darkred': highlightStyle = 'background-color: #D3D3D3;'; break; //
case 'grey':
default: highlightStyle = 'background-color: #8B0000;'; break; // Grey for errors/invalid
}
// === !!! REPLACEMENT END !!! ===
const magUnit = `${props.magnitude || ''} ${props.unit || ''}`.trim();
const verificationText = props.verification ? ` (${props.verification.toUpperCase()})` : '';
const zoneCode = props.state_zone;
const shortName = props.shortname;
let zoneDisplay = ''; if (zoneCode && !['UNKNOWN', 'ERROR', 'LAYER_MISSING'].includes(zoneCode)) { zoneDisplay = shortName ? ` [${zoneCode} (${shortName})]` : `[${zoneCode}]`; } else if (zoneCode) { zoneDisplay = ` [${zoneCode}]`; } else { zoneDisplay = props.county && props.state ? ` ${props.county} [${props.state}]` : ''; };
html += `<li style="${highlightStyle} margin-bottom: 0.3em; font-size: 0.95em; padding: 1px 3px;">`;
html += `${formatDate_MMDD_HHMM(props.valid)} - `;
html += `<strong>${props.typetext || 'LSR'}</strong> ${magUnit}${verificationText} - `;
html += `${props.city || 'N/A'}${zoneDisplay} `;
html += `<i style="font-size:0.9em;">${props.remark ? ' - ' + props.remark : ' '}</i>`;
html += productHighlightsHtml;
html += `</li>`;
});
html += `</ul>`;
} else {
html += `<p>No LSRs found within the overall time range (+ buffer) of selected warning(s).</p>`;
}
// --- Hydrological Section Placeholder ---
if (hasHydro) {
html += `<h3>Hydrological Information</h3>`;
html += `<div id="hydro-results-container">Collecting gauge information...</div>`;
}
// --- End Hydrological Section ---
// --- Render Initial HTML & Start Asynchronous Operations ---
console.log("DEBUG ASYNC START: Setting initial HTML structure.");
$("#summary-container").html(html);
try {
console.log("DEBUG ASYNC: Allowing initial DOM update...");
await sleep(50);
console.log("DEBUG ASYNC: DOM update likely complete. Starting async tasks...");
// --- Process Hydro Results Asynchronously --- <<< MODIFIED
if (hasHydro) {
const hydroContainer = $("#hydro-results-container");
if (!hydroContainer.length) { /* ... error handling ... */ console.error("DEBUG ASYNC HYDRO: Error - Container #hydro-results-container not found!"); }
else {
try {
console.log(`DEBUG ASYNC HYDRO: Waiting for ${findGaugePromises.length} gauge finding operations...`);
await Promise.all(findGaugePromises); // Wait for LIDs and map to be populated
console.log(`DEBUG ASYNC HYDRO: Found ${uniqueGaugeLids.size} unique gauge LIDs.`);
if (uniqueGaugeLids.size === 0) { /* ... no gauges found message ... */ hydroContainer.html("<p>No relevant NWS river gauges found within the selected hydro product polygon(s).</p>"); }
else if (!bufferedStartTimeISO || !bufferedEndTimeISO) { /* ... invalid time window message ... */ hydroContainer.html("<p style='color:red;'>Could not process hydrological data due to invalid time window.</p>"); }
else {
hydroContainer.html(`<p>Fetching data for ${uniqueGaugeLids.size} unique gauge(s)...</p>`);
hydroDataFetchPromises = Array.from(uniqueGaugeLids).map(lid =>
getUsgsIdFromNwsGauge(lid, bufferedStartTimeISO, bufferedEndTimeISO)
.catch(fetchErr => { /* ... fetch error handling ... */ console.error(`Error fetching data for gauge ${lid}:`, fetchErr); return { gaugeId: lid, gaugeName: lid, error: `Fetch failed: ${fetchErr.message}`, hydroData: [], floodStages: null }; })
);
console.log(`DEBUG ASYNC HYDRO: Awaiting ${hydroDataFetchPromises.length} gauge data fetches...`);
const allProcessedGaugeData = await Promise.all(hydroDataFetchPromises);
const validGaugeResults = allProcessedGaugeData.filter(res => res != null);
console.log(`DEBUG ASYNC HYDRO: Fetched data for ${validGaugeResults.length} gauges.`);
if (validGaugeResults.length > 0) {
console.log("DEBUG ASYNC HYDRO: Generating gauge summary HTML...");
let hydroOutputHtml = '';
validGaugeResults.sort((a, b) => (a.gaugeName || a.gaugeId).localeCompare(b.gaugeName || b.gaugeId));
validGaugeResults.forEach(gaugeData => {
// <<< Get associated events from the map
const associatedEvents = gaugeToEventsMap[gaugeData.gaugeId] || new Set();
// <<< Pass the set to the generator function
hydroOutputHtml += generateGaugeSummaryHtmlStructure(gaugeData, associatedEvents);
});
hydroContainer.html(hydroOutputHtml);
console.log("DEBUG ASYNC HYDRO: Gauge summary HTML injected.");
await sleep(50);
console.log(`DEBUG ASYNC HYDRO IMG: Starting sequential image generation...`);
for (const gaugeData of validGaugeResults) {
if (!gaugeData || !gaugeData.gaugeId) continue;
const placeholderId = `hydrograph-img-${gaugeData.gaugeId}`;
const targetElement = $(`#${placeholderId}`);
if (!targetElement.length) { console.warn(`DEBUG ASYNC HYDRO IMG: Placeholder ${placeholderId} not found.`); continue; }
targetElement.html(`Generating hydrograph for ${gaugeData.gaugeId}...`);
if (gaugeData.error && gaugeData.error !== "DATES_INVALID_ORDER") { targetElement.html(`<p style='color:red;'>Failed to fetch/process data: ${gaugeData.error}</p>`); continue; }
if (!gaugeData.hydroData || gaugeData.hydroData.length === 0) { targetElement.html(`<p style='font-style: italic;'>(No gauge height data available for this period)</p>`); if (gaugeData.originalError === "DATES_INVALID_ORDER") { targetElement.append(`<p style='font-style: italic; font-size: 0.9em;'>(Note: Warning was cancelled/expired before issue time)</p>`); } else if(gaugeData.error === "DATES_INVALID_ORDER") { targetElement.append(`<p style='font-style: italic; font-size: 0.9em;'>(Note: Warning issue/expire dates invalid)</p>`); } continue; }
try {
const imageDataUrl = await generateHydrograph('hydrograph-canvas-template', gaugeData.hydroData, gaugeData.floodStages, gaugeData.gaugeId, gaugeData.gaugeName);
if (imageDataUrl) { targetElement.html(`<img src="${imageDataUrl}" alt="Hydrograph for ${gaugeData.gaugeName || gaugeData.gaugeId}">`); }
else { targetElement.html(`<p style='color:red;'>Failed to generate hydrograph image for ${gaugeData.gaugeId}.</p>`); }
} catch (genError) { console.error(`DEBUG ASYNC HYDRO IMG: ERROR generating image for ${gaugeData.gaugeId}:`, genError); targetElement.html(`<p style='color:red;'>Error generating hydrograph image for ${gaugeData.gaugeId}.</p>`); }
}
console.log("DEBUG ASYNC HYDRO IMG: Finished sequential image generation.");
} else { /* ... no data retrieved message ... */ hydroContainer.html("<p>No hydrological data could be retrieved for the identified gauges.</p>"); }
}
} catch (hydroError) { /* ... error handling ... */ console.error("DEBUG ASYNC HYDRO: ERROR processing consolidated hydro warnings:", hydroError); hydroContainer.html(`<p style='color:red;'>Error processing hydrological data: ${hydroError.message}</p>`); }
}
} else if (hasHydro) { /* ... handle expected but failed hydro ... */ $("#hydro-results-container").html("<p>Hydrological information expected but could not be processed.</p>"); }
// --- End Process Hydro Results ---
// --- Generate Map Image ---
console.log("DEBUG ASYNC MAP: Starting map image generation...");
const mapContainer = document.getElementById('summary-map-image-container');
if (!mapContainer) { console.error("DEBUG ASYNC MAP: CRITICAL Error - Container #summary-map-image-container not found!"); }
else {
mapContainer.textContent = "Generating map image via server...";
try {
const boundsLeaflet = mymap.getBounds().isValid() ? [[mymap.getBounds().getSouth(), mymap.getBounds().getWest()], [mymap.getBounds().getNorth(), mymap.getBounds().getEast()]] : null; if (!boundsLeaflet) { throw new Error("Map bounds are invalid for map image generation."); }
const warningsData = currentSelectedEventDetails.flatMap(d => d.data?.features || []).filter(Boolean);
const lsrDataForMap = []; if (markersLayer) { markersLayer.eachLayer(layer => { if (layer.feature) { lsrDataForMap.push(layer.feature); } else { console.warn("Marker found on layer without a .feature property"); } }); }
const dataToSend = { bounds: boundsLeaflet, warningsGeoJsonFeatures: warningsData, lsrFeatures: lsrDataForMap, gaugeLocations: allGaugeLocationsToPlot };
console.log(`DEBUG ASYNC MAP: Sending fetch request with ${lsrDataForMap.length} plotted LSRs and ${allGaugeLocationsToPlot.length} unique gauge locations...`);
const response = await fetch('/cgi-bin/generate_map.py', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, body: JSON.stringify(dataToSend) });
console.log(`DEBUG ASYNC MAP: Fetch response status: ${response.status}`); let responseData = null; let responseError = null; try { responseData = await response.json(); } catch(jsonError) { responseError = jsonError; try { responseError = await response.text(); } catch(textError) { responseError = "Could not parse JSON or Text response."; } } if (!response.ok) { const errorMessage = responseData?.error || responseError || response.statusText || "Unknown server error"; throw new Error(`Server error (${response.status}): ${errorMessage}`); } if (!responseData || !responseData.imageData) { throw new Error("Server response missing 'imageData'."); } const base64ImageString = responseData.imageData; const imageUrl = `data:image/png;base64,${base64ImageString}`; const mapImage = document.createElement('img'); mapImage.src = imageUrl; mapImage.alt = "Map of Warnings and LSRs"; mapImage.crossOrigin = "anonymous"; const copyButton = document.getElementById('copySummaryBtn'); if (copyButton) copyButton.disabled = true; await new Promise((resolve, reject) => { mapImage.onload = () => { mapContainer.innerHTML = ''; mapContainer.appendChild(mapImage); if (copyButton) copyButton.disabled = false; resolve(); }; mapImage.onerror = (errorEvent) => { mapContainer.textContent = `Error displaying generated map image.`; if (copyButton) copyButton.disabled = false; reject(new Error("Failed to load map image")); }; }); console.log("DEBUG ASYNC MAP: Image displayed.");
} catch (mapError) { console.error("DEBUG ASYNC MAP: ERROR generating or displaying map image:", mapError); mapContainer.textContent = `Error generating map image: ${mapError.message}`; const copyButton = document.getElementById('copySummaryBtn'); if (copyButton) copyButton.disabled = false; }
}
console.log("DEBUG ASYNC END: buildHtmlSummary async block completed.");
} catch (overallAsyncError) { /* ... error handling ... */ console.error("CRITICAL ERROR during async summary build:", overallAsyncError); $("#summary-container").append(`<p style="color: red; font-weight: bold;">An error occurred during asynchronous content loading: ${overallAsyncError.message}</p>`); }
finally { /* ... final logging ... */ console.log("DEBUG ASYNC FINALLY: End of buildHtmlSummary."); }
} // --- End of buildHtmlSummary async function ---
/**
* Helper function to format a single zone's display string.
* @param {string|null} ugcCode - The UGC code (e.g., 'MIC049').
* @param {string|null} name - The zone/county name (e.g., 'Calhoun [MI]').
* @returns {string} Formatted display string.
*/
function formatIndividualZoneDisplay(ugcCode, name) {
ugcCode = ugcCode || 'UNKNOWN';
name = name || '';
let state = '';
if (typeof ugcCode === 'string' && ugcCode.length >= 3 && ugcCode !== 'UNKNOWN') {
state = ugcCode.substring(0, 2).toUpperCase();
if (!/^[A-Z]{2}$/.test(state)) state = ''; // Basic state code validation
}
let displayText = (ugcCode !== 'UNKNOWN') ? ugcCode : '';
if (name) {
if (displayText) displayText += " - ";
const stateInBracketsRegex = / \[([A-Z]{2})\]$/i;
const match = name.match(stateInBracketsRegex);
if (match) { // Name already includes state like "County [ST]"
const stateFromName = match[1].toUpperCase();
if (state && stateFromName !== state) console.warn(`State mismatch for ${ugcCode}: Name [${stateFromName}], UGC [${state}]. Using name.`);
displayText += name;
} else if (state) { // Append state if known and not already in name
displayText += `${name} [${state}]`;
} else { // Use name as is if state unknown
displayText += name;
}
} else if (!displayText) {
displayText = 'Unknown Zone'; // Fallback if no code or name
}
return displayText;
}
/**
* Helper function to create just the HTML structure for a gauge (without image).
* Includes the list of VTEC products associated with this gauge.
* @param {object} gaugeResult - The result object from getUsgsIdFromNwsGauge.
* @param {Set<string>} associatedEvents - A Set containing formatted VTEC strings (e.g., "FL.W 123").
* @returns {string} HTML structure for the gauge summary.
*/
function generateGaugeSummaryHtmlStructure(gaugeResult, associatedEvents = new Set()) { // Added associatedEvents parameter
if (!gaugeResult || !gaugeResult.gaugeId) { return '<div class="gauge-summary"><p style="color:red;">Error: Invalid gauge data received.</p></div>'; }
let gaugeHtml = `<div class="gauge-summary">`;
const nl = `https://water.noaa.gov/gauges/${gaugeResult.gaugeId}`;
gaugeHtml += `<h4>Gauge: ${gaugeResult.gaugeName || gaugeResult.gaugeId} (LID: ${gaugeResult.gaugeId}, USGS: ${gaugeResult.usgsId||'N/A'})`;
if (gaugeResult.gaugeId) gaugeHtml += ` - <a href="${nl}" target="_blank" style="font-size: 0.9em;">NWS Hydrograph</a>`;
if (gaugeResult.usgsId) { const usgsUrl = `https://waterdata.usgs.gov/monitoring-location/${gaugeResult.usgsId}/#dataTypeId=continuous-00065-0&period=P7D&showMedian=false`; gaugeHtml += ` - <a href="${usgsUrl}" target="_blank" style="font-size: 0.9em;">USGS Gauge</a>`; }
gaugeHtml += `</h4>`;
// <<< NEW: Display associated VTEC products >>>
if (associatedEvents.size > 0) {
// Sort the VTEC strings for consistent display order
const sortedEvents = Array.from(associatedEvents).sort();
gaugeHtml += `<p style="font-size: 0.9em; margin-top: 2px; margin-bottom: 5px; color: #555;">`;
gaugeHtml += `Associated Products: ${sortedEvents.join(', ')}`;
gaugeHtml += `</p>`;
}
// <<< END NEW >>>
const fs = gaugeResult.floodStages;
let stageInfo = ''; if (fs && typeof fs === 'object') { stageInfo = Object.entries(fs).map(([l, v]) => v !== null && !isNaN(v) ? `${l[0].toUpperCase()+l.slice(1)}: ${v.toFixed(2)} ft` : null).filter(Boolean).join(', '); }
if (stageInfo) { gaugeHtml += `<p><strong>Flood Stages:</strong> ${stageInfo}</p>`; } else { if (!gaugeResult.error || gaugeResult.error === "DATES_INVALID_ORDER" || gaugeResult.originalError === "DATES_INVALID_ORDER") { gaugeHtml += `<p>Flood stage info unavailable.</p>`; } }
gaugeHtml += `<p><strong>Analysis:</strong></p>`;
if (gaugeResult.originalError === "DATES_INVALID_ORDER") { gaugeHtml += `<p style="font-style: italic;">Note: The Warning was cancelled or expired before its valid time. Showing data until initial expiration.</p>`; }
if (gaugeResult.error && gaugeResult.error !== "DATES_INVALID_ORDER") { gaugeHtml += `<p style="color: red; font-style: italic;">Analysis unavailable due to error: ${gaugeResult.error}</p>`; }
else if (!gaugeResult.hydroData || gaugeResult.hydroData.length === 0) { if (gaugeResult.originalError !== "DATES_INVALID_ORDER") { gaugeHtml += `<p style="font-style: italic;">No gauge height data available for the requested period.</p>`; } else if (!gaugeResult.error) { gaugeHtml += `<p style="font-style: italic;">No gauge height data found for the initial period.</p>`; } }
else {
const analysis = analyzeHydrograph(gaugeResult.hydroData, fs);
if (analysis) {
// ... (existing analysis list generation) ...
gaugeHtml += `<ul class="analysis-list">`;
if (analysis.crestHeight !== null && !isNaN(analysis.crestHeight)) { gaugeHtml += `<li>Crest: <strong>${analysis.crestHeight.toFixed(2)} ft</strong> (${analysis.crestCategory||'N/A'}) at ${analysis.crestTime ? formatDate_MMDD_HHMM(analysis.crestTime) : 'N/A'}</li>`; } else { gaugeHtml += `<li>No distinct crest or invalid data.</li>`; }
const stages = ['Action', 'Minor', 'Moderate', 'Major'];
stages.forEach(stageName => {
const lowerStageName = stageName.toLowerCase(); const stageValue = fs?.[lowerStageName];
if (stageValue !== null && stageValue !== undefined && !isNaN(stageValue)) {
const firstRiseProp = `firstRiseAbove${stageName}`; const lastFallProp = `lastFallBelow${stageName}`; const remainsAboveProp = `remainsAbove${stageName}`;
if (analysis[firstRiseProp]) { let stageHtml = `<li>Exceeded ${stageName} (${stageValue.toFixed(2)} ft): ${formatDate_MMDD_HHMM(analysis[firstRiseProp])}`; if (analysis[lastFallProp]) { stageHtml += ` (Fell Below: ${formatDate_MMDD_HHMM(analysis[lastFallProp])})`; } else if (analysis[remainsAboveProp]) { stageHtml += ` (Remained Above)`; } stageHtml += `</li>`; gaugeHtml += stageHtml; }
}
});
gaugeHtml += `</ul>`;
} else { gaugeHtml += `<p style="font-style: italic;">Could not analyze available data.</p>`; }
}
const placeholderId = `hydrograph-img-${gaugeResult.gaugeId}`;
if (!gaugeResult.error || gaugeResult.originalError === "DATES_INVALID_ORDER") { gaugeHtml += `<div id="${placeholderId}">Waiting for hydrograph image generation...</div>`; } else { gaugeHtml += `<div id="${placeholderId}"><p style='color:red; font-size: 0.9em;'>Hydrograph not generated due to error.</p></div>`; }
gaugeHtml += `</div>`;
return gaugeHtml;
}
async function getUsgsIdFromNwsGauge(gaugeId, startDateStr, endDateStr, isRetry = false) {
const nwsUrl = `https://api.water.noaa.gov/nwps/v1/gauges/${gaugeId}`;
let usgsId = null;
let gaugeName = gaugeId;
let floodStages = { action: null, minor: null, moderate: null, major: null };
let hydroData = [];
let fetchError = null;
let usedFallback = false; // Will be true for hydrograph data if NWS is used
let nwsData = null;
try {
// --- 1. Fetch NWS Gauge Metadata ---
const nwsResponse = await fetch(nwsUrl, { headers: { 'Accept': 'application/json', 'User-Agent': 'NWSWarningSummarizerTool/1.0' } });
if (!nwsResponse.ok) {
throw new Error(`NWS Gauge Detail API error! Status: ${nwsResponse.status} for ${gaugeId}`);
}
nwsData = await nwsResponse.json();
gaugeName = nwsData.name || gaugeId;
usgsId = nwsData.usgsId || null;
if (!usgsId && nwsData.dataAttribution) {
const usgsEntry = nwsData.dataAttribution.find(e => e.abbrev === "USGS" && e.text?.match(/^\d{8,15}$/));
usgsId = usgsEntry?.text || null;
}
// --- 2. Populate Flood Stages directly from NWS Data (CORRECTED PARSING) ---
if (nwsData && nwsData.flood && nwsData.flood.categories) {
const categories = nwsData.flood.categories;
const parseStage = (stageValue) => {
const val = parseFloat(stageValue);
return (val === -9999 || isNaN(val)) ? null : val; // Treat -9999 as null
};
floodStages = {
action: parseStage(categories.action?.stage),
minor: parseStage(categories.minor?.stage),
moderate: parseStage(categories.moderate?.stage),
major: parseStage(categories.major?.stage)
};
// console.log(`Parsed NWS flood stages for ${gaugeId}:`, floodStages);
} else {
console.warn(`WARN: No nwsData.flood.categories object found in NWS details for ${gaugeId}. Flood stages will be null.`);
// floodStages remains as initialized (all null)
}
// Ensure all stages are null if they ended up as NaN for any other reason (though parseStage should handle it)
Object.keys(floodStages).forEach(key => { if (isNaN(floodStages[key])) floodStages[key] = null; });
// --- 3. Validate Dates & Fetch Hydrograph Data ---
// (This part attempts USGS NWIS then NWS StageFlow, which are generally CORS-friendly)
const startDT_obj = new Date(startDateStr);
const endDT_obj = new Date(endDateStr);
if (isNaN(startDT_obj.getTime()) || isNaN(endDT_obj.getTime())) {
throw new Error("Invalid start or end date for hydrograph.");
}
if (startDT_obj >= endDT_obj) {
fetchError = "DATES_INVALID_ORDER"; // Affects hydroData
}
const startDT_iso = startDT_obj.toISOString();
const endDT_iso = endDT_obj.toISOString();
// Attempt Primary: USGS NWIS for time-series hydrograph data
if (usgsId && fetchError !== "DATES_INVALID_ORDER") {
try {
const params = new URLSearchParams({ format: 'json', sites: usgsId, startDT: startDT_iso, endDT: endDT_iso, parameterCd: '00065', siteStatus: 'all' });
const usgsIVUrl = `https://nwis.waterservices.usgs.gov/nwis/iv/?${params.toString()}`;
const usgsIVResponse = await fetch(usgsIVUrl, { headers: { 'Accept': 'application/json', 'User-Agent': 'NWSWarningSummarizerTool/1.0' } });
if (!usgsIVResponse.ok) { throw new Error(`USGS IV API error! Status: ${usgsIVResponse.status}`); }
const usgsIVData = await usgsIVResponse.json();
if (usgsIVData.value?.timeSeries?.length > 0) {
const timeSeries = usgsIVData.value.timeSeries.find(ts => ts.variable?.variableCode?.[0]?.value === "00065");
if (timeSeries?.values?.[0]?.value) {
hydroData = timeSeries.values[0].value.map(v => ({ timestamp: v.dateTime, gaugeHeight: parseFloat(v.value) })).filter(v => v.timestamp && v.gaugeHeight !== null && !isNaN(v.gaugeHeight));
if (hydroData.length > 0) fetchError = null;
}
}
// if (hydroData.length === 0 && !fetchError) { // Removed this, as NWS fallback is desired if USGS IV is empty
// // console.log(`No data from USGS IV for ${usgsId}. Will attempt NWS StageFlow fallback.`);
// }
} catch (usgsIVError) {
const newError = `USGS IV Error: ${usgsIVError.message}`;
fetchError = fetchError ? `${fetchError}; ${newError}` : newError;
hydroData = [];
}
} else if (!usgsId && fetchError !== "DATES_INVALID_ORDER") {
const newError = "No USGS ID for hydrograph";
fetchError = fetchError ? `${fetchError}; ${newError}` : newError;
}
// Fallback: NWS API for hydrograph data if USGS IV failed, wasn't tried, or returned no data
if (hydroData.length === 0 && fetchError !== "DATES_INVALID_ORDER") {
const thirtyOneDaysAgo = new Date();
thirtyOneDaysAgo.setDate(thirtyOneDaysAgo.getDate() - 31);
if (endDT_obj >= thirtyOneDaysAgo) {
try {
const nwsStageFlowUrl = `https://api.water.noaa.gov/nwps/v1/gauges/${gaugeId}/stageflow`;
const nwsStageResponse = await fetch(nwsStageFlowUrl, { headers: { 'Accept': 'application/json', 'User-Agent': 'NWSWarningSummarizerTool/1.0' } });
if (!nwsStageResponse.ok) { throw new Error(`NWS StageFlow API error! Status: ${nwsStageResponse.status}`); }
const nwsStageData = await nwsStageResponse.json();
if (nwsStageData.observed?.data?.length > 0) {
hydroData = nwsStageData.observed.data
.map(item => ({ timestamp: item.validTime, gaugeHeight: (item.primary !== null && item.primary !== -999) ? parseFloat(item.primary) : NaN }))
.filter(item => {
if (!item.timestamp || isNaN(item.gaugeHeight)) return false;
try { const itemDate = new Date(item.timestamp); return !isNaN(itemDate.getTime()) && itemDate >= startDT_obj && itemDate <= endDT_obj; } catch (e) { return false; }
});
hydroData.sort((a, b) => new Date(a.timestamp) - new Date(b.timestamp));
if (hydroData.length > 0) {
fetchError = null;
usedFallback = true;
} else {
const newError = "No data from NWS StageFlow in range";
fetchError = fetchError ? `${fetchError}; ${newError}` : newError;
}
} else {
const newError = "No observed data in NWS StageFlow response";
fetchError = fetchError ? `${fetchError}; ${newError}` : newError;
}
} catch (nwsFallbackError) {
console.warn(`WARN Fallback NWS StageFlow (hydrograph): FAILED for ${gaugeId}. Error: ${nwsFallbackError.message}`);
const newError = `NWS StageFlow Error: ${nwsFallbackError.message}`;
fetchError = fetchError ? `${fetchError}; ${newError}` : newError;
}
} else {
const newError = "NWS StageFlow fallback (hydrograph) skipped (end date too old)";
fetchError = fetchError ? `${fetchError}; ${newError}` : newError;
}
}
if (hydroData.length === 0 && !fetchError) {
fetchError = "No hydrograph data found from any source for the specified period.";
}
} catch (error) {
console.error(`CRITICAL Error in getUsgsIdFromNwsGauge for ${gaugeId}: `, error);
fetchError = fetchError ? `${fetchError}; ${error.message}` : error.message;
hydroData = [];
floodStages = { action: null, minor: null, moderate: null, major: null };
}
// console.log(`Final for ${gaugeId} (USGS: ${usgsId}): Stages:`, floodStages, `Hydro Errors: ${fetchError}`);
return { gaugeId, gaugeName, usgsId, floodStages, hydroData, error: fetchError, usedFallback };
}
/**
* 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; const labelOffsetY = iconSize + 2; const fontSize = 5;
const svgWidth = iconSize + 4; const labelHeight = fontSize + 2; const svgHeight = labelOffsetY + labelHeight;
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], iconAnchor: [svgWidth / 2, svgHeight], popupAnchor: [0, -svgHeight + 5] });
const marker = L.marker(latlng, { icon: gaugeIcon, keyboard: false, title: `${gaugeName || 'N/A'} (${gaugeLid})` });
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>`);
marker.feature = { // Add feature-like structure for potential KML export needs
type: "Feature",
geometry: { type: "Point", coordinates: [latlng.lng, latlng.lat] },
properties: { name: gaugeName || gaugeLid, lid: gaugeLid, type: "Gauge" }
};
return marker;
}
// --- Chart.js Hydrograph Generation ---
function generateHydrograph(canvasId, data, floodStages, gaugeId, gaugeName) {
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))) || [];
if (validData.length === 0) { return Promise.resolve(null); }
validData.sort((a, b) => new Date(a.timestamp) - new Date(b.timestamp));
let chart = Chart.getChart(ctx); if (chart) chart.destroy();
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);
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);
let xAxisLabel = "Time (Eastern Time)";
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)"; } } } catch (tzError) { console.error(`ERROR determining timezone label for ${gaugeId}:`, tzError); }
return new Promise((resolve) => {
let chartInstance=null, imageDataUrl=null;
try {
chartInstance = new Chart(ctx, {
type: "line", data: { datasets },
options: { responsive: true, maintainAspectRatio: false, 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})` }, 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; } } return label; } } } },
events: []
}
});
setTimeout(() => { try { if (chartInstance && canvas.height > 0 && canvas.width > 0) { imageDataUrl = canvas.toDataURL('image/png'); if (!imageDataUrl || imageDataUrl.length < 100) { console.error(`>>> Failed to get valid imageDataUrl for ${gaugeId}.`); imageDataUrl = null; } } else { console.error(`>>> Canvas invalid before toDataURL for ${gaugeId}.`); imageDataUrl = null; } } catch (e) { console.error(`>>> Error during canvas.toDataURL ${gaugeId}:`, e); imageDataUrl = null; } finally { if (chartInstance) { chartInstance.destroy(); } resolve(imageDataUrl); } }, 50);
} catch (e) { console.error(`>>> Error CREATING Chart ${gaugeId}:`, e); if (chartInstance) chartInstance.destroy(); resolve(null); }
});
}
/**
* Copies the generated summary HTML (including the map image) to the clipboard.
*/
async function copySummaryToClipboard() {
const btn = document.getElementById('copySummaryBtn'); const originalBtnText = btn.textContent;
const div = document.getElementById('summary-container');
if (!div) { alert("Error: Could not find summary content."); return; }
if (!navigator.clipboard?.write) { alert("Clipboard write unavailable (requires HTTPS/localhost)."); return; }
let htmlContent = '';
try { const clone = div.cloneNode(true); clone.querySelector('#copySummaryBtn')?.remove(); htmlContent = clone.innerHTML.trim(); if (!htmlContent) { alert("Cannot copy empty summary."); return; } } catch (e) { alert("Error preparing summary content."); return; }
const mapImageElement = div.querySelector('img[alt="Map of Warnings and LSRs"]');
let clipboardItems = [];
if (mapImageElement && mapImageElement.src && (mapImageElement.src.startsWith('data:image/') || mapImageElement.src.startsWith('blob:'))) {
console.log("Map image found. Attempting to fetch:", mapImageElement.src.substring(0, 60) + "...");
try {
btn.textContent = 'Fetching Image...'; btn.disabled = true;
const response = await fetch(mapImageElement.src); // Works for data: and blob: URLs
if (!response.ok) { throw new Error(`Failed to fetch map image: ${response.status}`); }
const imageBlob = await response.blob();
console.log("Map image blob fetched. Type:", imageBlob.type, "Size:", imageBlob.size);
if (!imageBlob.type.startsWith('image/')) { console.warn(`Fetched blob type '${imageBlob.type}' may not be ideal.`); }
clipboardItems.push(new ClipboardItem({ 'text/html': new Blob([htmlContent], { type: 'text/html' }), [imageBlob.type || 'image/png']: imageBlob }));
console.log("ClipboardItem created with HTML and image.");
} catch (fetchError) { console.error("Error fetching map image for clipboard:", fetchError); alert("Warning: Could not copy map image. Text summary will be copied."); clipboardItems.push(new ClipboardItem({ 'text/html': new Blob([htmlContent], { type: 'text/html' }) })); } finally { btn.disabled = false; btn.textContent = originalBtnText; }
} else {
console.log("Map image not found or invalid src. Copying HTML only.");
clipboardItems.push(new ClipboardItem({ 'text/html': new Blob([htmlContent], { type: 'text/html' }) }));
}
try { btn.textContent = 'Copying...'; btn.disabled = true; await navigator.clipboard.write(clipboardItems); console.log("Content successfully written to clipboard."); btn.textContent = 'Copied!'; btn.style.backgroundColor = '#90ee90'; setTimeout(() => { btn.textContent = originalBtnText; btn.style.backgroundColor = ''; }, 2000); } catch (error) { console.error('Failed to write content to clipboard:', error); alert(`Failed to copy summary: ${error.name}. Requires HTTPS/localhost and permission.`); btn.textContent = 'Copy Failed'; btn.style.backgroundColor = '#ffcccb'; setTimeout(() => { btn.textContent = originalBtnText; btn.style.backgroundColor = ''; }, 3000); } finally { btn.disabled = false; }
}
// --- 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) { 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 showLoadingIndicator(show) { const btn = $("#generateSummaryBtn"); if (show) { btn.val("Processing...").prop('disabled', true); } else { btn.val("Generate Summary").prop('disabled', false); } }
function escapeXml(unsafe) { return unsafe.replace(/[<>&'"]/g, function (c) { switch (c) { case '<': return '&lt;'; case '>': return '&gt;'; case '&': return '&amp;'; case '\'': return '&apos;'; case '"': return '&quot;'; } }); }
// --- Date Formatting Helpers ---
function formatDateUTC_Compact(dateInput) { try { const d = (dateInput instanceof Date) ? dateInput : new Date(dateInput); if (isNaN(d.getTime())) { return 'Invalid Date'; } 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'); return `${y}${m}${day}/${h}${min}Z`; } catch (e) { 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) { 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) { 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 formatDate_MMDD_HHMM(dateString) { try { const d = new Date(dateString); if (isNaN(d.getTime())) { return "Invalid Time"; } const options = { month: 'numeric', day: 'numeric', hour: 'numeric', minute: '2-digit', timeZone: 'America/New_York', hour12: false, timeZoneName: 'short' }; const localFormatted = new Intl.DateTimeFormat('en-US', options).format(d); const utcCompact = formatDateUTC_Compact(d); return `${localFormatted} (${utcCompact})`; } catch (e) { return "Format Error"; } }
function formatDate_KML(dateString) { try { const d = new Date(dateString); if (isNaN(d.getTime())) { return ''; } return d.toISOString(); } catch (e) { return ''; } } // KML prefers ISO 8601
// --- NEW: Export Button Enabling/Disabling ---
function enableExportButtons() {
$("#exportLsrCsvBtn").prop('disabled', false);
$("#exportKmlBtn").prop('disabled', false);
}
function disableExportButtons() {
$("#exportLsrCsvBtn").prop('disabled', true);
$("#exportKmlBtn").prop('disabled', true);
}
// --- NEW: CSV Export Function (Revised) ---
// --- NEW: CSV Export Function (Revised based on user request) ---
// --- NEW: CSV Export Function (Revised based on user request + "None" default) ---
function generateLsrCsv() {
console.log("Starting CSV generation (User Modified + None default)...");
if (currentSelectedEventDetails.length === 0) {
alert("Please generate a summary first to select events.");
return;
}
if (!unfilteredLSRs || unfilteredLSRs.features.length === 0) {
alert("No LSR data available for export (unfiltered list is empty).");
return;
}
const featuresToSort = [...unfilteredLSRs.features];
console.log("Sorting LSRs by valid time...");
featuresToSort.sort((a, b) => {
const propsA = a?.properties;
const propsB = b?.properties;
const timeStrA = propsA?.valid;
const timeStrB = propsB?.valid;
const dateA = timeStrA ? new Date(timeStrA) : null;
const dateB = timeStrB ? new Date(timeStrB) : null;
const isValidA = dateA && !isNaN(dateA.getTime());
const isValidB = dateB && !isNaN(dateB.getTime());
if (!isValidA && !isValidB) return 0;
if (!isValidA) return 1;
if (!isValidB) return -1;
return dateA.getTime() - dateB.getTime();
});
console.log("Sorting complete.");
// --- CSV Header ---
let csvContent = "Products (In Time & Location),Phenomenon,LSR Time (L),Always EST,Lat,Lon,County,State,Magnitude,Units,Remarks,Products (Out of Time, In Location)\n";
let processedCount = 0;
// Helper function to format CSV fields
const formatCsvField = (value) => {
const strValue = String(value === null || value === undefined ? '' : value);
if (strValue.includes(',') || strValue.includes('\n') || strValue.includes('"')) {
return `"${strValue.replace(/"/g, '""')}"`;
}
return strValue;
};
// --- Iterate through each SORTED LSR ---
featuresToSort.forEach(lsrFeature => {
const props = lsrFeature.properties;
const geom = lsrFeature.geometry;
if (!geom || geom.type !== 'Point' || !geom.coordinates || geom.coordinates.length < 2 || !props || !props.valid) {
console.warn("Skipping LSR due to invalid geometry, missing properties, or invalid valid time:", lsrFeature);
return;
}
const lon = geom.coordinates[0];
const lat = geom.coordinates[1];
const lsrPoint = turf.point([lon, lat]);
let lsrTime;
try {
lsrTime = new Date(props.valid);
if (isNaN(lsrTime.getTime())) {
console.warn("Skipping LSR due to invalid date in props.valid:", props.valid, lsrFeature);
return; // Skip if LSR time is invalid
}
} catch (e) {
console.warn("Skipping LSR due to error parsing props.valid:", props.valid, lsrFeature, e);
return; // Skip on error
}
const productsInTimeAndLocation = new Set();
const productsOutOfTimeInLocation = new Set();
// Iterate through each selected VTEC event (product)
currentSelectedEventDetails.forEach(detail => {
if (!detail || !detail.event || !detail.event.phenomena || !detail.event.significance || !detail.event.eventid || !detail.event.issue || !detail.event.expire) {
// console.warn("Skipping a VTEC detail due to missing critical event properties", detail.event);
return;
}
let eventStartTime, eventEndTime;
try {
eventStartTime = new Date(detail.event.issue);
eventEndTime = new Date(detail.event.expire);
if (isNaN(eventStartTime.getTime()) || isNaN(eventEndTime.getTime())) {
// console.warn(`Invalid VTEC event dates for ETN ${detail.event.eventid}. Skipping this product check for current LSR.`);
return; // Skip if event times are invalid
}
} catch (e) {
// console.warn(`Error parsing VTEC event dates for ETN ${detail.event.eventid}. Skipping product check.`, e);
return; // Skip on error
}
const vtecString = `${detail.event.phenomena}.${detail.event.significance} ${detail.event.eventid}`;
let lsrFoundInThisEventGeometry = false;
if (detail.data && detail.data.features) {
for (const warningFeature of detail.data.features) {
if (!warningFeature || !warningFeature.geometry) continue;
try {
// Basic geometry check for Turf.js
if ((warningFeature.geometry.type === "Polygon" && warningFeature.geometry.coordinates?.[0]?.length >= 4) ||
(warningFeature.geometry.type === "MultiPolygon" && warningFeature.geometry.coordinates?.[0]?.[0]?.length >= 4)) {
if (turf.booleanPointInPolygon(lsrPoint, warningFeature)) {
lsrFoundInThisEventGeometry = true;
break; // Found in at least one polygon of this event
}
} else {
// console.warn(`Skipping potentially invalid warning geometry for ETN ${vtecString} during Turf check.`);
}
} catch (turfError) {
// console.warn(`Turf error checking LSR against ETN ${vtecString}: ${turfError.message}`);
}
}
}
if (lsrFoundInThisEventGeometry) {
const isInsideStrictTime = (lsrTime >= eventStartTime && lsrTime <= eventEndTime);
if (isInsideStrictTime) {
productsInTimeAndLocation.add(vtecString);
} else {
productsOutOfTimeInLocation.add(vtecString);
}
}
});
// Prepare data for the row
const productsInTimeAndLocationCsv = formatCsvField(
productsInTimeAndLocation.size > 0 ? Array.from(productsInTimeAndLocation).join('; ') : 'None'
);
const productsOutOfTimeInLocationCsv = formatCsvField(
productsOutOfTimeInLocation.size > 0 ? Array.from(productsOutOfTimeInLocation).join('; ') : 'None'
);
const lsrPhenomenon = formatCsvField(props.typetext || 'Unknown');
const timeLocal = formatCsvField(formatDateReadable(props.valid));
const timeEST = formatCsvField(formatDateAlwaysEST_HHMM(props.valid));
const mag = formatCsvField(props.magnitude === null || props.magnitude === undefined ? '' : props.magnitude);
const unit = formatCsvField(props.unit || '');
const remark = formatCsvField(props.remark || '');
const county = formatCsvField(props.county || '');
const state = formatCsvField(props.state || '');
csvContent += `${productsInTimeAndLocationCsv},${lsrPhenomenon},${timeLocal},${timeEST},${lat},${lon},${county},${state},${mag},${unit},${remark},${productsOutOfTimeInLocationCsv}\n`;
processedCount++;
});
console.log(`Processed ${processedCount} LSRs for CSV.`);
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
const link = document.createElement("a");
const url = URL.createObjectURL(blob);
link.setAttribute("href", url);
let filename = "lsr_export_classified_products_sorted.csv";
if (currentSelectedEvents.length > 0) {
try {
if (currentSelectedEvents[0]?.eventid && currentSelectedEvents[0]?.issue) {
const firstEtn = currentSelectedEvents[0].eventid;
const lastEvent = currentSelectedEvents[currentSelectedEvents.length - 1];
const lastEtn = lastEvent?.eventid || firstEtn;
const year = yearfromissue(currentSelectedEvents[0].issue);
if (year) {
filename = `lsrs_${year}_${firstEtn}_to_${lastEtn}_classified_products_sorted.csv`;
}
}
} catch (error) {
console.error("Error generating dynamic filename:", error);
}
}
link.setAttribute("download", filename);
link.style.visibility = 'hidden';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
console.log("CSV download triggered.");
}
// --- NEW: KML Export Function ---
function generateKml() {
console.log("Starting KML generation...");
if (currentSelectedEventDetails.length === 0) {
alert("Please generate a summary first to select events.");
return;
}
if (!unfilteredLSRs || unfilteredLSRs.features.length === 0) {
// Option: Allow exporting only warnings if no LSRs? For now, require LSRs.
// alert("No LSR data available for KML export.");
// return;
console.warn("No LSRs found for KML export, proceeding with warnings only.");
}
const allSelectedWarningFeatures = currentSelectedEventDetails.flatMap(detail => detail.data?.features || []).filter(Boolean);
const allLsrFeatures = unfilteredLSRs.features; // Use the unfiltered list
let kml = `<?xml version="1.0" encoding="UTF-8"?>
<kml xmlns="http://www.opengis.net/kml/2.2">
<Document>\n`;
// --- Define Styles ---
kml += ` <Style id="lsrDefaultStyle">
<IconStyle>
<color>ffcccccc</color> <!-- Light Gray Icon -->
<scale>0.8</scale>
<Icon><href>http://maps.google.com/mapfiles/kml/pushpin/ylw-pushpin.png</href></Icon>
</IconStyle>
<LabelStyle><scale>0.7</scale></LabelStyle>
</Style>\n`;
// Generate styles for each unique warning type present
const uniqueWarningStyles = {};
allSelectedWarningFeatures.forEach(feature => {
const props = feature.properties;
const phenSigText = getPhenSig(props.phenomena, props.significance);
const styleId = `warn_${props.phenomena}_${props.significance}`;
if (!uniqueWarningStyles[styleId]) {
const colorHex = getColorForWarning(phenSigText); // e.g., #FF0000
// Convert #RRGGBB to aabbggrr KML format (assuming 70% opacity for fill)
const kmlColorFill = `B3${colorHex.substring(5, 7)}${colorHex.substring(3, 5)}${colorHex.substring(1, 3)}`; // Opacity B3 = 70%
const kmlColorLine = `FF${colorHex.substring(5, 7)}${colorHex.substring(3, 5)}${colorHex.substring(1, 3)}`; // Opacity FF = 100%
kml += ` <Style id="${styleId}">
<LineStyle><color>${kmlColorLine}</color><width>2</width></LineStyle>
<PolyStyle><color>${kmlColorFill}</color><outline>1</outline></PolyStyle>
</Style>\n`;
uniqueWarningStyles[styleId] = true;
}
});
// Define styles for LSR types (optional, using default for now)
// You could loop through lsrtype and create styles based on lsrtype[i].color
// --- Folder for Selected Products ---
kml += ` <Folder><name>Selected Products</name>\n`;
allSelectedWarningFeatures.forEach((feature, index) => {
const props = feature.properties;
const geometry = feature.geometry;
if (!geometry) return;
const phenSigText = getPhenSig(props.phenomena, props.significance);
const styleId = `warn_${props.phenomena}_${props.significance}`;
const name = `${phenSigText} (ETN: ${props.eventid})`;
const description = `<![CDATA[
<b>${phenSigText}</b><br>
ETN: ${props.eventid}<br>
<hr>
<i>Additional properties could go here</i>
]]>`;
kml += ` <Placemark>\n`;
kml += ` <name>${escapeXml(name)}</name>\n`;
kml += ` <styleUrl>#${styleId}</styleUrl>\n`;
kml += ` <description>${description}</description>\n`;
// Handle Geometry (Polygon and MultiPolygon)
if (geometry.type === 'Polygon') {
kml += ` <Polygon><outerBoundaryIs><LinearRing><coordinates>\n`;
geometry.coordinates[0].forEach(coord => { kml += ` ${coord[0]},${coord[1]},0\n`; });
kml += ` </coordinates></LinearRing></outerBoundaryIs></Polygon>\n`;
} else if (geometry.type === 'MultiPolygon') {
kml += ` <MultiGeometry>\n`;
geometry.coordinates.forEach(polygon => {
kml += ` <Polygon><outerBoundaryIs><LinearRing><coordinates>\n`;
polygon[0].forEach(coord => { kml += ` ${coord[0]},${coord[1]},0\n`; });
kml += ` </coordinates></LinearRing></outerBoundaryIs></Polygon>\n`;
});
kml += ` </MultiGeometry>\n`;
}
kml += ` </Placemark>\n`;
});
kml += ` </Folder>\n`; // Close Selected Products Folder
// --- Folder for LSRs ---
if (allLsrFeatures.length > 0) {
kml += ` <Folder><name>LSRs (All in Time Range)</name>\n`;
allLsrFeatures.forEach(lsrFeature => {
const props = lsrFeature.properties;
const geom = lsrFeature.geometry;
if (!geom || geom.type !== 'Point' || !geom.coordinates || geom.coordinates.length < 2) return;
const lon = geom.coordinates[0];
const lat = geom.coordinates[1];
const name = `${props.typetext || 'LSR'} - ${props.city || 'Unknown Loc'}`;
const magUnit = `${props.magnitude || ''} ${props.unit || ''}`.trim();
const timeReadable = formatDateReadable(props.valid);
const kmlTime = formatDate_KML(props.valid); // ISO 8601 for KML time
const description = `<![CDATA[
<b>${props.typetext || 'LSR'}</b><br>
Time: ${timeReadable}<br>
Magnitude: ${magUnit}<br>
Location: ${props.city || 'N/A'} (${lat.toFixed(3)}, ${lon.toFixed(3)})<br>
Remarks: ${props.remark || ''}<br>
<hr>
Type Code: ${props.type || 'N/A'}<br>
Source ETN (if applicable): ${props.source_event_etn || 'N/A'}<br>
Verification (if applicable): ${props.verification || 'N/A'}
]]>`;
kml += ` <Placemark>\n`;
kml += ` <name>${escapeXml(name)}</name>\n`;
kml += ` <styleUrl>#lsrDefaultStyle</styleUrl>\n`; // Use default style for now
kml += ` <description>${description}</description>\n`;
if (kmlTime) {
kml += ` <TimeStamp><when>${kmlTime}</when></TimeStamp>\n`;
}
kml += ` <Point><coordinates>${lon},${lat},0</coordinates></Point>\n`;
kml += ` </Placemark>\n`;
});
kml += ` </Folder>\n`; // Close LSRs Folder
}
kml += `</Document>\n</kml>`;
// Create Blob and trigger download
const blob = new Blob([kml], { type: 'application/vnd.google-earth.kml+xml;charset=utf-8;' });
const link = document.createElement("a");
const url = URL.createObjectURL(blob);
link.setAttribute("href", url);
let filename = "vtec_export.kml";
if (currentSelectedEvents.length > 0) {
const firstEtn = currentSelectedEvents[0].eventid;
const lastEtn = currentSelectedEvents[currentSelectedEvents.length - 1].eventid;
const year = yearfromissue(currentSelectedEvents[0].issue);
filename = `vtec_${year}_${firstEtn}_to_${lastEtn}.kml`;
}
link.setAttribute("download", filename);
link.style.visibility = 'hidden';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
console.log("KML download triggered.");
}
// --- Event Listener for Filtering the Event List ---
$(document).ready(function() {
initializeMap();
loadCountyLayer('ver.php'); // URL providing county/zone GeoJSON
getwwas(); // Initial load for VTEC events
disableExportButtons(); // Start with export buttons disabled
// Attach the input event listener to the filter box
$('#eventFilterInput').on('input', function() {
const filterText = $(this).val().toLowerCase().trim();
const $eventSelector = $("#eventSelector");
const allEvents = $eventSelector.data('events') || [];
if (allEvents.length === 0) { return; }
const filteredEvents = allEvents.filter(event => {
const phenSigText = getPhenSig(event.phenomena, event.significance);
const optionText = `${phenSigText} (${event.eventid}) | ${formatDate_YYMMDD_HHMMZ(event.issue)} - ${formatDate_YYMMDD_HHMMZ(event.expire)}`;
return optionText.toLowerCase().includes(filterText);
});
$eventSelector.empty(); // Remove current options
if (filteredEvents.length === 0) {
$eventSelector.append('<option disabled>No events match filter.</option>');
} else {
filteredEvents.forEach(event => {
const phenSigText = getPhenSig(event.phenomena, event.significance);
const optionText = `${phenSigText} (${event.eventid}) | ${formatDate_YYMMDD_HHMMZ(event.issue)} - ${formatDate_YYMMDD_HHMMZ(event.expire)}`;
const optionValue = `${event.phenomena}|${event.significance}|${event.eventid}|${yearfromissue(event.issue)}`;
$eventSelector.append(`<option value="${optionValue}">${optionText}</option>`);
});
}
$eventSelector.attr('size', Math.max(2, Math.min(filteredEvents.length, 15)));
});
$(document).on('change', '#eventSelector', function() {
console.log("Event selector changed, triggering pre-plot...");
mymap.setView([38.508, -82.652480], 7.5);
prePlotSelectedWarnings(); // Call the new function
});
}); // End $(document).ready()
// --- PHP Data Fetching Helpers (Assuming stormdata.php exists) ---
async function fetchGeoJsonData(start, end, geojson, request_type,buffer_time=0) {
let url = 'stormdata.php'
let mapBoundsGeoJSONString;
try { mapBoundsGeoJSONString = JSON.stringify(geojson); } catch (stringifyError) { throw new Error(`Failed to stringify geojson: ${stringifyError.message}`); }
const dataToSend = { request_type: request_type, start_time: start, end_time: end, buffer: buffer_time, area_geojson: mapBoundsGeoJSONString, };
// console.log(`Fetching data from ${url} with payload:`, dataToSend);
try {
const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/geo+json, application/json' }, body: JSON.stringify(dataToSend) });
if (!response.ok) { let errorBody = 'Could not retrieve error body.'; try { errorBody = await response.text(); } catch (textError) {} throw new Error(`HTTP error! Status: ${response.status} ${response.statusText}. URL: ${url}. Body: ${errorBody}`); }
const contentType = response.headers.get("content-type");
if (contentType && (contentType.includes("application/geo+json") || contentType.includes("application/json"))) {
const data = await response.json(); console.log('Success: Received GeoJSON data from', url, 'Type:', request_type); return data;
} else { let responseBody = 'Could not retrieve response body.'; try { responseBody = await response.text(); } catch (textError) {} throw new Error(`Unexpected content type received: '${contentType}'. URL: ${url}. Body: ${responseBody}`); }
} catch (error) { console.error(`Error fetching data from ${url} (Type: ${request_type}):`, error); throw error; }
}
async function fetchGeoJsonDataNoPoly(start, end, request_type, buffer_time=0, outage_threshold=50) {
let url = 'stormdata.php';
const dataToSend = { request_type: request_type, start_time: start, end_time: end, buffer: buffer_time, outage_threshold: outage_threshold, };
// console.log(`Fetching data from ${url} with payload (no poly):`, dataToSend);
try {
const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/geo+json, application/json' }, body: JSON.stringify(dataToSend) });
if (!response.ok) { let errorBody = 'Could not retrieve error body.'; try { errorBody = await response.text(); } catch (textError) {} throw new Error(`HTTP error! Status: ${response.status} ${response.statusText}. URL: ${url}. Body: ${errorBody}`); }
const contentType = response.headers.get("content-type");
if (contentType && (contentType.includes("application/geo+json") || contentType.includes("application/json"))) {
const data = await response.json(); console.log('Success: Received GeoJSON data from', url, 'Type:', request_type); return data;
} else { let responseBody = 'Could not retrieve response body.'; try { responseBody = await response.text(); } catch (textError) {} throw new Error(`Unexpected content type received: '${contentType}'. URL: ${url}. Body: ${responseBody}`); }
} catch (error) { console.error(`Error fetching data from ${url} (Type: ${request_type}, no poly):`, error); throw 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)";
}
}
function formatDateAlwaysEST_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: 'Etc/GMT+5', // Fixed UTC-5, equivalent to EST without DST
hour12: false // Use 24-hour format
};
// Format local time in EST
let localFormatted = new Intl.DateTimeFormat('en-US', opts).format(d);
// Append "EST" explicitly since Etc/GMT+5 doesn't include timezone abbreviation
localFormatted = `${localFormatted} EST`;
const utcCompact = formatDateUTC_Compact(d); // e.g., "20250406/1430Z"
// Combine local time and UTC time
return `${localFormatted} / ${utcCompact}`;
} catch (e) {
console.error("Error in formatDate_HHMM:", e, "Input:", dateString);
return "Invalid Time (Format Error)";
}
}
/**
* Fetches (or uses cache) and plots the geometry of currently selected warnings
* onto the prePlotLayer for immediate visual feedback.
* Clears any previously generated "final" warnings from the map.
*/
async function prePlotSelectedWarnings() {
// Safety checks
if (!prePlotLayer) {
console.warn("Pre-plot layer not initialized.");
return;
}
// *** NEW: Clear the 'final' warning layer ***
if (geoJSONwwas) {
geoJSONwwas.clearLayers();
}
if (markersLayer) {
markersLayer.clearLayers();
}
// ********************************************
if (gaugeMarkersLayer) gaugeMarkersLayer.clearLayers();
const selectedOptions = $("#eventSelector").val() || [];
const allEventsData = $("#eventSelector").data('events') || []; // Get the basic event list
prePlotLayer.clearLayers(); // Clear previous pre-plots
if (selectedOptions.length === 0) {
// Also disable exports if selection becomes empty after a summary was generated
disableExportButtons();
return; // Nothing to plot
}
const promises = [];
const featuresToPlot = [];
const keysToFetch = [];
// [ Existing logic for checking cache and queuing fetches... ]
selectedOptions.forEach(valueString => {
const [phen, sig, etn, year] = valueString.split('|');
const cacheKey = `${phen}|${sig}|${etn}|${year}`;
if (eventGeometryCache[cacheKey]) {
if(eventGeometryCache[cacheKey]){ // Double check cache data is not null/undefined
featuresToPlot.push(eventGeometryCache[cacheKey]);
} else {
console.warn(`Pre-plot: Cached data for ${cacheKey} was null/undefined.`);
}
} else {
const eventData = allEventsData.find(e => e.phenomena === phen && e.significance === sig && e.eventid == etn && yearfromissue(e.issue) == year);
if (eventData) {
promises.push(
fetchEventDetails(eventData, false)
.then(detail => ({ key: cacheKey, detail: detail }))
.catch(error => {
console.error(`Pre-plot: Failed to fetch geometry for ${cacheKey}:`, error);
return { key: cacheKey, detail: null };
})
);
keysToFetch.push(cacheKey);
} else {
console.warn(`Pre-plot: Could not find event data in list for ${cacheKey}`);
}
}
});
// Fetch all non-cached items concurrently
if (promises.length > 0) {
try {
const results = await Promise.all(promises);
results.forEach(result => {
if (result && result.detail && result.detail.data) {
eventGeometryCache[result.key] = result.detail.data; // Cache the fetched data
featuresToPlot.push(result.detail.data); // Add fetched data to plot list
} else {
console.warn(`Pre-plot: Fetch successful but no data returned for ${result?.key}`);
}
});
} catch (error) {
// Should be handled by individual promise catches, but catch overall error too
console.error("Pre-plot: Error during Promise.all for geometry fetching:", error);
}
}
// Now plot all features (cached and newly fetched)
console.log(`Pre-plot: Plotting ${featuresToPlot.length} features.`);
if (featuresToPlot.length > 0) {
featuresToPlot.forEach(geoJsonData => {
if (geoJsonData && geoJsonData.features) { // Check if data is valid GeoJSON FeatureCollection
L.geoJSON(geoJsonData, {
style: styleWarningPolygon // Use the same styling function
}).addTo(prePlotLayer);
} else {
console.warn("Pre-plot: Skipping invalid GeoJSON data during plotting.", geoJsonData);
}
});
} else {
console.log("Pre-plot: No valid features found to plot.");
}
console.log("Pre-plot: Finished processing selection change.");
}
/**
* Helper function to determine a contrasting text color (black or white) for a given background color.
* @param {string} bgColor - The background color (CSS color name).
* @returns {string} 'white' or 'black'.
*/
function getContrastColor(bgColor) {
// Simple switch for known color names.
// For a more general solution (e.g., with hex codes), a luminance calculation would be more robust.
switch (bgColor.toLowerCase()) {
case 'green': // e.g., CSS 'green' is #008000
case 'darkred': // CSS 'darkred' is #8B0000
return 'white'; // Dark colors needing light text
case 'yellow': // CSS 'yellow' is #FFFF00
case 'orange': // CSS 'orange' is #FFA500
case 'salmon': // CSS 'salmon' is #FA8072
case 'grey': // CSS 'grey' is #808080 (though highlights won't typically be grey)
return 'black'; // Lighter/medium colors needing dark text
default:
return 'black'; // Default fallback
}
}
/**
* Calculates the "best case" status color for an LSR and generates an HTML string
* highlighting products associated with the LSR.
* Priority for best status: Green > Yellow > Orange > Salmon > Grey.
* Dark Red is used if the LSR is outside ALL selected polygons.
* Grey is used for invalid LSR data or errors during processing.
*
* @param {object} lsrFeature - The GeoJSON feature object for the LSR.
* @param {Array<object>} selectedEventDetails - Array of { event, data, fullDetails } for selected events.
* @param {number} bufferHours - The time buffer in hours to apply around warning times.
* @returns {Array<string>} An array containing two strings:
* [0] The best status color ('green', 'yellow', 'orange', 'salmon', 'darkred', 'grey').
* [1] An HTML string with product highlights, or an empty string if no products are associated or if the status is 'grey'.
*/
function getBestLsrStatus(lsrFeature, selectedEventDetails, bufferHours = 1) {
// --- LSR Data Validation ---
if (!lsrFeature || !lsrFeature.geometry || lsrFeature.geometry.type !== 'Point' ||
!lsrFeature.geometry.coordinates || lsrFeature.geometry.coordinates.length < 2 ||
!lsrFeature.properties || !lsrFeature.properties.valid) {
console.warn("getBestLsrStatus: Invalid LSR feature data.", lsrFeature);
return ['grey', ''];
}
const props = lsrFeature.properties;
const geom = lsrFeature.geometry;
const lon = geom.coordinates[0];
const lat = geom.coordinates[1];
let lsrTime;
try {
lsrTime = new Date(props.valid);
if (isNaN(lsrTime.getTime())) throw new Error("Invalid date");
} catch (e) {
console.warn("getBestLsrStatus: Invalid LSR time.", props.valid);
return ['grey', ''];
}
if (typeof lon !== 'number' || typeof lat !== 'number' || isNaN(lon) || isNaN(lat)) {
console.warn("getBestLsrStatus: Invalid LSR coordinates.", geom.coordinates);
return ['grey', ''];
}
const lsrPoint = turf.point([lon, lat]);
let bestOverallStatus = 'grey'; // Default to grey, will be updated by interactions.
let wasInsideAnyPolygon = false;
const colorPriority = { 'green': 1, 'yellow': 2, 'orange': 3, 'salmon': 4, 'grey': 5 }; // Lower number is higher priority
const productHighlights = []; // Stores {text, color} for HTML generation
// --- Iterate through each selected event's details ---
// Ensure selectedEventDetails is an array to prevent errors if it's null/undefined
if (Array.isArray(selectedEventDetails)) {
for (const detail of selectedEventDetails) {
if (!detail || !detail.event || !detail.data || !detail.data.features || detail.data.features.length === 0) {
continue; // Skip if event details or features are missing
}
// Construct product identifier string, handling potentially missing fields
const phenom = detail.event.phenomena || '';
const signif = detail.event.significance || '';
const etn = detail.event.eventid || '';
const productIdentifier = `${phenom}.${signif} ${etn}`//.replace(/\s+/g, ' ').trim(); // Consolidate spaces and trim
let eventStartTime, eventEndTime;
try {
eventStartTime = new Date(detail.event.issue);
eventEndTime = new Date(detail.event.expire);
if (isNaN(eventStartTime.getTime()) || isNaN(eventEndTime.getTime())) {
// console.warn(`getBestLsrStatus: Invalid start/end time for ETN ${detail.event.eventid}. Skipping this event for LSR check.`);
continue; // Skip this event if times are invalid
}
} catch (e) {
// console.warn(`getBestLsrStatus: Error parsing start/end time for ETN ${detail.event.eventid}. Skipping this event for LSR check.`);
continue;
}
const bufferMillis = bufferHours * 60 * 60 * 1000;
const warningStartBuffered = new Date(eventStartTime.getTime() - bufferMillis);
const warningEndBuffered = new Date(eventEndTime.getTime() + bufferMillis);
// --- Iterate through features (polygons) of the current event ---
for (const feature of detail.data.features) {
if (!feature || !feature.geometry) continue;
try {
// Check if LSR point is inside this warning polygon
if (turf.booleanPointInPolygon(lsrPoint, feature)) {
wasInsideAnyPolygon = true;
let currentProductStatus = 'grey'; // Status relative to *this* product interaction
// Determine time relationship
if (lsrTime >= eventStartTime && lsrTime <= eventEndTime) {
currentProductStatus = 'green'; // During warning
} else if (lsrTime > eventEndTime && lsrTime <= warningEndBuffered) {
currentProductStatus = 'yellow'; // After but within buffer
} else if (lsrTime >= warningStartBuffered && lsrTime < eventStartTime) {
currentProductStatus = 'orange'; // Before but within buffer
} else if (lsrTime < warningStartBuffered || lsrTime > warningEndBuffered) {
currentProductStatus = 'salmon'; // Inside polygon, but outside buffered time window
}
// Add to HTML highlights list if productIdentifier is not empty
if (productIdentifier) {
productHighlights.push({ text: productIdentifier, color: currentProductStatus });
}
// Update bestOverallStatus based on priority
if (colorPriority[currentProductStatus] < colorPriority[bestOverallStatus]) {
bestOverallStatus = currentProductStatus;
}
}
} catch (turfError) {
// console.warn(`getBestLsrStatus: Turf error checking LSR against ETN ${detail.event.eventid}: ${turfError.message}`);
// Continue checking other features/events even if one fails
}
} // End loop through features of one event
} // End loop through all selected events
} // End check for Array.isArray(selectedEventDetails)
// --- Final Determination of LSR Status ---
let finalLsrStatus;
if (!wasInsideAnyPolygon) {
// If it was never inside any selected polygon
finalLsrStatus = 'darkred';
} else {
// It was inside at least one polygon. bestOverallStatus reflects the best case.
// If wasInsideAnyPolygon is true, bestOverallStatus cannot be 'grey' at this point
// because any valid interaction would have upgraded it from the initial 'grey'.
finalLsrStatus = bestOverallStatus;
}
// --- Generate HTML String ---
let productHtmlString = "";
// Only generate HTML if there are product highlights to show.
// If finalLsrStatus is 'darkred', productHighlights will be empty.
// If finalLsrStatus is 'grey', it means an early error exit occurred, and we already returned ['grey', ''].
// This part of the code is effectively not reached for 'grey' final statuses.
if (productHighlights.length > 0) {
const htmlSnippets = productHighlights.map(highlight =>
`<span style="background-color: ${highlight.color}; color: ${getContrastColor(highlight.color)}; padding: 2px 4px; margin-right: 5px; border-radius: 3px; white-space: nowrap;">${highlight.text}</span>`
);
productHtmlString = htmlSnippets.join(' ');
}
// The TODO comment `// TODO: Add in which warnings are included...` is now addressed by this functionality.
return [finalLsrStatus, productHtmlString];
}
</script>
<canvas id="hydrograph-canvas-template" width="800" height="450" style="display: none;"></canvas>
</body>
</html>