From dc994be3294ff01aa8e31d1f249ebd927e98415c Mon Sep 17 00:00:00 2001 From: Satyam-Rastogi Date: Tue, 19 May 2026 01:10:17 +0530 Subject: [PATCH] feat(storm-intel): richer event data + NWS boundary label in drawer and map tooltip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit useStormEvents: - Extract windSpeed (windtag), expireTime, warningDurationMins, phenomenaCode/Label - Calculate polygonAreaSqMi via shoelace formula; estimate homes at 1800/sq mi StormDetailDrawer — new sections: - Weather Metrics: hail size with damage descriptor, wind speed with severity label - Impact Zone: warning area sq miles + estimated homes in zone - Warning Timeline: issued time, expired time, duration, issuing NWS office - NWS Boundary note: explains the shape is a meteorologist-drawn warning zone Map tooltip: - Shows phenomenaLabel, wind speed, area sq mi - Footer section labeled "NWS Warning Boundary" with shape explanation --- src/hooks/useStormEvents.js | 63 +++++++++++-- src/pages/StormIntelPage.jsx | 175 ++++++++++++++++++++++++++--------- 2 files changed, 186 insertions(+), 52 deletions(-) diff --git a/src/hooks/useStormEvents.js b/src/hooks/useStormEvents.js index e152257..9abc097 100644 --- a/src/hooks/useStormEvents.js +++ b/src/hooks/useStormEvents.js @@ -60,6 +60,32 @@ function derivedAreaName(rawCoords, ph) { return `${city} — ${label}`; } +// Shoelace formula — polygon is Leaflet [lat,lon] pairs +function polygonAreaSqMi(polygon) { + const n = polygon.length; + if (n < 3) return 0; + const avgLat = polygon.reduce((s, p) => s + p[0], 0) / n; + const mLat = 69.0; + const mLon = 69.0 * Math.cos(avgLat * Math.PI / 180); + let area = 0; + for (let i = 0; i < n; i++) { + const [lat1, lon1] = polygon[i]; + const [lat2, lon2] = polygon[(i + 1) % n]; + area += (lon1 * mLon) * (lat2 * mLat) - (lon2 * mLon) * (lat1 * mLat); + } + return Math.abs(area) / 2; +} + +const PH_LABEL = { + SV: 'Severe Thunderstorm Warning', + TO: 'Tornado Warning', + FF: 'Flash Flood Warning', + WS: 'Winter Storm Warning', + BZ: 'Blizzard Warning', + WW: 'Winter Weather Advisory', + IS: 'Ice Storm Warning', +}; + function featureToEvent(feature, idx) { const p = feature.properties || {}; const ph = p.phenomena || ''; @@ -70,7 +96,18 @@ function featureToEvent(feature, idx) { const polygon = rawCoords.map(([lon, lat]) => [lat, lon]); // → Leaflet [lat,lon] - const hailIn = p.hailtag ? parseFloat(p.hailtag) : null; + const hailIn = p.hailtag ? parseFloat(p.hailtag) : null; + const windMph = p.windtag ? parseInt(p.windtag, 10) : null; + const issueTime = p.issue || p.polygon_begin || null; + const expireTime = p.expire || p.polygon_end || null; + + const warningDurationMins = (issueTime && expireTime) + ? Math.round((new Date(expireTime) - new Date(issueTime)) / 60000) + : null; + + const areaSqMi = Math.round(polygonAreaSqMi(polygon)); + // Suburban DFW density ~1 800 homes/sq mi — conservative (polygons often include open land) + const estHomes = Math.round(areaSqMi * 1800); const type = ph === 'TO' ? 'tornado' : ph === 'FF' ? 'flood' @@ -78,6 +115,7 @@ function featureToEvent(feature, idx) { : ph === 'WW' || ph === 'IS' ? 'ice' : hailIn !== null ? 'hail' : 'wind'; + const severity = ph === 'TO' ? 'severe' : hailIn !== null ? (hailIn >= 2.0 ? 'severe' : hailIn >= 1.5 ? 'significant' : hailIn >= 1.0 ? 'moderate' : 'trace') @@ -85,16 +123,23 @@ function featureToEvent(feature, idx) { : 'significant'; return { - id: `IEM-${WFO}-${ph}-${p.year ?? ''}-${p.eventid ?? idx}`, - date: p.issue || p.polygon_begin || new Date().toISOString(), + id: `IEM-${WFO}-${ph}-${p.year ?? ''}-${p.eventid ?? idx}`, + date: issueTime || new Date().toISOString(), + expireTime, + warningDurationMins, type, severity, - maxHailSize: hailIn, - areaName: derivedAreaName(rawCoords, ph), - county: 'Collin', - estimatedHomes: null, - description: `${type === 'tornado' ? 'Tornado Warning' : type === 'flood' ? 'Flash Flood Warning' : 'Severe Thunderstorm Warning'} — NWS ${WFO}`, - source: 'IEM/NWS', + phenomenaCode: ph, + phenomenaLabel: PH_LABEL[ph] ?? ph, + maxHailSize: hailIn, + windSpeed: windMph, + areaName: derivedAreaName(rawCoords, ph), + county: 'Collin', + areaSqMi, + estimatedHomes: estHomes > 0 ? estHomes : null, + description: `${PH_LABEL[ph] ?? ph} — NWS ${WFO}`, + source: 'IEM/NWS', + wfo: WFO, polygon, }; } diff --git a/src/pages/StormIntelPage.jsx b/src/pages/StormIntelPage.jsx index 0f91c07..3293220 100644 --- a/src/pages/StormIntelPage.jsx +++ b/src/pages/StormIntelPage.jsx @@ -331,13 +331,13 @@ const StormMap = memo(({ }} > -
-
+
+
- {sty.label} Hail + {sty.label} · {storm.phenomenaLabel ?? storm.type} {storm.maxHailSize && ( - + {storm.maxHailSize}" )} @@ -345,9 +345,19 @@ const StormMap = memo(({

{storm.areaName}

-

- {Math.floor((Date.now() - new Date(storm.date).getTime()) / 86400000)}d ago · ~{(storm.estimatedHomes ?? '?').toLocaleString()} homes +

+ {Math.floor((Date.now() - new Date(storm.date).getTime()) / 86400000)}d ago + {storm.windSpeed ? ` · ${storm.windSpeed} mph` : ''} + {storm.areaSqMi ? ` · ${storm.areaSqMi} sq mi` : ''}

+
+

+ NWS Warning Boundary +

+

+ Official zone drawn by NWS meteorologist — covers the projected storm track, not the exact cloud outline. +

+
@@ -700,61 +710,140 @@ const StormDetailDrawer = ({ storm, open, onClose, onFlyTo, onAssignZone, canMan
-
+
+ + {/* Title */}
-

- {storm.areaName} -

+

{storm.areaName}

{storm.county} County, TX

-
- {conv.label === 'Hot Zone' && } - {conv.label === 'Warm' && } - {conv.label === 'Cold' && } - {conv.label === 'Too Fresh' && } + {/* Canvassing window */} +
+
+ {conv.label === 'Hot Zone' && } + {conv.label === 'Warm' && } + {conv.label === 'Cold' && } + {conv.label === 'Too Fresh' && } +

{conv.label}

-

- {conv.label === 'Hot Zone' && 'Peak canvassing window — highest conversion likelihood'} - {conv.label === 'Warm' && 'Still worth canvassing — moderate conversion rate'} - {conv.label === 'Cold' && 'Low conversion — storm damage assessment mostly done'} - {conv.label === 'Too Fresh' && 'Too recent — wait for claims process to begin'} +

+ {conv.label === 'Hot Zone' && 'Peak canvassing window. Insurance claims actively being filed — homeowners are receptive. Lead with free inspection.'} + {conv.label === 'Warm' && 'Claims in process. Many have had adjuster visits. Lead with supplement expertise and contractor credentials.'} + {conv.label === 'Cold' && 'Most claims settled. Final window before statute expires. Lead with "late claim" and supplemental services.'} + {conv.label === 'Too Fresh' && 'Adjuster process hasn\'t started. Build rapport, collect contacts, follow up in 2–3 weeks when claims open.'}

-
- {storm.maxHailSize && ( + {/* Weather metrics */} +
+

Weather Metrics

+
-

Max Hail

-

{storm.maxHailSize}"

-

{sty.label} category

+

Hail Size

+ {storm.maxHailSize ? ( + <> +

{storm.maxHailSize}"

+

+ {storm.maxHailSize >= 2.0 ? 'Golf ball — major roof damage' : + storm.maxHailSize >= 1.5 ? 'Ping pong — significant bruising' : + storm.maxHailSize >= 1.0 ? 'Quarter — moderate impact' : + 'Dime — cosmetic impact'} +

+ + ) : ( + <> +

+

not reported

+ + )}
- )} - {storm.estimatedHomes && (
-

Est. Homes

-

{storm.estimatedHomes.toLocaleString()}

-

in impact zone

+

Wind Speed

+ {storm.windSpeed ? ( + <> +

{storm.windSpeed}

+

+ mph — {storm.windSpeed >= 65 ? 'destructive' : storm.windSpeed >= 58 ? 'significant damage' : 'moderate damage'} +

+ + ) : ( + <> +

+

not reported

+ + )} +
+
+

Days Ago

+

{days}

+

since impact

+
+
+

Severity

+

{sty.label}

+

{storm.phenomenaCode} warning

- )} -
-

Days Ago

-

{days}

-

since impact

-
-
-

Source

-

{storm.source}

-

data origin

+ {/* Impact zone */}
-

Intel

-

- {storm.description} +

Impact Zone

+
+ {storm.areaSqMi > 0 && ( +
+

Warning Area

+

{storm.areaSqMi.toLocaleString()}

+

sq miles covered

+
+ )} + {storm.estimatedHomes > 0 && ( +
+

Est. Homes

+

{storm.estimatedHomes.toLocaleString()}

+

in warning zone

+
+ )} +
+
+ + {/* Warning timeline */} +
+

Warning Timeline

+
+
+ Issued + + {storm.date ? new Date(storm.date).toLocaleString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }) : '—'} + +
+
+ Expired + + {storm.expireTime ? new Date(storm.expireTime).toLocaleString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }) : '—'} + +
+
+ Duration + + {storm.warningDurationMins ? `${storm.warningDurationMins} min` : '—'} + +
+
+ Issuing Office + NWS {storm.wfo ?? 'FWD'} — Fort Worth +
+
+
+ + {/* NWS boundary note */} +
+

NWS Warning Boundary

+

+ The polygon on the map is the official NWS warning zone — drawn by a meteorologist to cover the storm's projected track and affected area, not the exact cloud outline. Shape is typical for the storm's direction of travel.