feat(storm-intel): damage-probability homes estimate + pinned events total in header
useStormEvents: - Replace flat 1800/sq mi estimate with damage probability multiplier per type: hail ≥2.0" = 95%, 1.75" = 80%, 1.5" = 65%, 1.25" = 45%, 1.0" = 30%, <1.0" = 15%; tornado = 20% (path << polygon), flood = 25%, wind = 40%, winter = 20-30% - Expose damagePct on event object for display in drawer StormIntelPage: - pinnedHomes useMemo — sums estimatedHomes across event-pinned events - Header homes stat switches to amber + "Est. Homes · N pinned" label when events are pinned; hover tooltip notes zones may overlap - Drawer: "Est. Homes Affected" now shows "~X% damage probability" as sub-label
This commit is contained in:
@@ -111,9 +111,7 @@ function featureToEvent(feature, idx) {
|
|||||||
? Math.round((new Date(expireTime) - new Date(issueTime)) / 60000)
|
? Math.round((new Date(expireTime) - new Date(issueTime)) / 60000)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const areaSqMi = Math.round(polygonAreaSqMi(polygon));
|
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'
|
const type = ph === 'TO' ? 'tornado'
|
||||||
: ph === 'FF' ? 'flood'
|
: ph === 'FF' ? 'flood'
|
||||||
@@ -128,6 +126,25 @@ function featureToEvent(feature, idx) {
|
|||||||
: ph === 'FF' ? 'moderate'
|
: ph === 'FF' ? 'moderate'
|
||||||
: 'significant';
|
: 'significant';
|
||||||
|
|
||||||
|
// Damage probability: fraction of homes in the warning zone likely to have claimable damage.
|
||||||
|
// Tornado path is far narrower than the warning polygon, so we discount heavily.
|
||||||
|
// Hail probability scales with hail size — golf ball (2"+) damages nearly everything it hits.
|
||||||
|
const damagePct =
|
||||||
|
ph === 'TO' ? 20
|
||||||
|
: ph === 'FF' ? 25
|
||||||
|
: ph === 'WS' || ph === 'BZ' ? 30
|
||||||
|
: ph === 'WW' || ph === 'IS' ? 20
|
||||||
|
: hailIn === null ? 40 // SV wind only
|
||||||
|
: hailIn >= 2.0 ? 95
|
||||||
|
: hailIn >= 1.75 ? 80
|
||||||
|
: hailIn >= 1.5 ? 65
|
||||||
|
: hailIn >= 1.25 ? 45
|
||||||
|
: hailIn >= 1.0 ? 30
|
||||||
|
: 15; // <1" — cosmetic, rarely claimed
|
||||||
|
|
||||||
|
// Homes in zone × suburban DFW density (1 800/sq mi) × damage probability
|
||||||
|
const estHomes = Math.round(areaSqMi * 1800 * (damagePct / 100));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: `IEM-${WFO}-${ph}-${p.year ?? ''}-${p.eventid ?? idx}`,
|
id: `IEM-${WFO}-${ph}-${p.year ?? ''}-${p.eventid ?? idx}`,
|
||||||
date: issueTime || new Date().toISOString(),
|
date: issueTime || new Date().toISOString(),
|
||||||
@@ -142,6 +159,7 @@ function featureToEvent(feature, idx) {
|
|||||||
areaName: derivedAreaName(rawCoords, ph),
|
areaName: derivedAreaName(rawCoords, ph),
|
||||||
county: 'Collin',
|
county: 'Collin',
|
||||||
areaSqMi,
|
areaSqMi,
|
||||||
|
damagePct,
|
||||||
estimatedHomes: estHomes > 0 ? estHomes : null,
|
estimatedHomes: estHomes > 0 ? estHomes : null,
|
||||||
description: `${PH_LABEL[ph] ?? ph} — NWS ${WFO}`,
|
description: `${PH_LABEL[ph] ?? ph} — NWS ${WFO}`,
|
||||||
source: 'IEM/NWS',
|
source: 'IEM/NWS',
|
||||||
|
|||||||
@@ -873,9 +873,11 @@ const StormDetailDrawer = ({ storm, open, onClose, onFlyTo, onAssignZone, canMan
|
|||||||
)}
|
)}
|
||||||
{storm.estimatedHomes > 0 && (
|
{storm.estimatedHomes > 0 && (
|
||||||
<div className="rounded-xl bg-zinc-50 dark:bg-zinc-800/60 border border-zinc-100 dark:border-white/[0.06] p-3">
|
<div className="rounded-xl bg-zinc-50 dark:bg-zinc-800/60 border border-zinc-100 dark:border-white/[0.06] p-3">
|
||||||
<p className="text-[10px] font-bold uppercase tracking-wider text-zinc-400 mb-1">Est. Homes</p>
|
<p className="text-[10px] font-bold uppercase tracking-wider text-zinc-400 mb-1">Est. Homes Affected</p>
|
||||||
<p className="text-xl font-black text-zinc-900 dark:text-white">{storm.estimatedHomes.toLocaleString()}</p>
|
<p className="text-xl font-black text-zinc-900 dark:text-white">{storm.estimatedHomes.toLocaleString()}</p>
|
||||||
<p className="text-[10px] text-zinc-400">in warning zone</p>
|
<p className="text-[10px] text-zinc-400">
|
||||||
|
~{storm.damagePct}% damage probability
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -1273,6 +1275,15 @@ const StormIntelPage = () => {
|
|||||||
const { forecast, loading: forecastLoading, alertDay, hasStormAlert } = useStormForecast();
|
const { forecast, loading: forecastLoading, alertDay, hasStormAlert } = useStormForecast();
|
||||||
const { reports } = useStormReports();
|
const { reports } = useStormReports();
|
||||||
|
|
||||||
|
// Homes affected by pinned events (sum; note polygons may overlap)
|
||||||
|
const pinnedHomes = useMemo(() => {
|
||||||
|
if (pinnedIds.size === 0) return null;
|
||||||
|
return [...pinnedIds]
|
||||||
|
.map(id => events.find(e => e.id === id))
|
||||||
|
.filter(Boolean)
|
||||||
|
.reduce((sum, e) => sum + (e.estimatedHomes ?? 0), 0);
|
||||||
|
}, [pinnedIds, events]);
|
||||||
|
|
||||||
// Text search filter
|
// Text search filter
|
||||||
const displayEvents = useMemo(() => {
|
const displayEvents = useMemo(() => {
|
||||||
if (!searchQuery.trim()) return events;
|
if (!searchQuery.trim()) return events;
|
||||||
@@ -1458,9 +1469,16 @@ const StormIntelPage = () => {
|
|||||||
<p className="text-[10px] font-bold uppercase tracking-wider text-zinc-400">Storms</p>
|
<p className="text-[10px] font-bold uppercase tracking-wider text-zinc-400">Storms</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-px h-8 bg-zinc-200 dark:bg-white/10" />
|
<div className="w-px h-8 bg-zinc-200 dark:bg-white/10" />
|
||||||
<div className="text-right">
|
<div className="text-right" title={pinnedHomes !== null ? 'Sum of est. damaged homes across pinned events — zones may overlap' : 'Est. homes with claimable damage across all visible events'}>
|
||||||
<p className="text-xl font-black text-zinc-900 dark:text-white">{(totalHomes / 1000).toFixed(1)}k</p>
|
<p className={`text-xl font-black ${pinnedHomes !== null ? 'text-amber-500' : 'text-zinc-900 dark:text-white'}`}>
|
||||||
<p className="text-[10px] font-bold uppercase tracking-wider text-zinc-400">Homes</p>
|
{pinnedHomes !== null
|
||||||
|
? (pinnedHomes >= 1000 ? `${(pinnedHomes / 1000).toFixed(1)}k` : pinnedHomes.toLocaleString())
|
||||||
|
: (totalHomes >= 1000 ? `${(totalHomes / 1000).toFixed(1)}k` : totalHomes.toLocaleString())
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
<p className="text-[10px] font-bold uppercase tracking-wider text-zinc-400">
|
||||||
|
{pinnedHomes !== null ? `Est. Homes · ${pinnedIds.size} pinned` : 'Est. Homes'}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-px h-8 bg-zinc-200 dark:bg-white/10" />
|
<div className="w-px h-8 bg-zinc-200 dark:bg-white/10" />
|
||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
|
|||||||
Reference in New Issue
Block a user