locate adress on map and find hail risk
This commit is contained in:
@@ -0,0 +1,65 @@
|
|||||||
|
/**
|
||||||
|
* geocode.js — module-local address geocoder for the Estimate Wizard.
|
||||||
|
*
|
||||||
|
* Uses OpenStreetMap Nominatim (keyless, CORS-open public service), consistent
|
||||||
|
* with the repo's existing keyless-public-API approach (IEM / NOAA in the storm
|
||||||
|
* module). This is NEW module code — it does not modify any existing hail/storm
|
||||||
|
* module; it only produces the lat/lon those modules' endpoints consume.
|
||||||
|
*
|
||||||
|
* Always resolves: on empty input, network failure, or no match it falls back to
|
||||||
|
* the demo service-area center (Plano, TX) so the wizard never dead-ends. The
|
||||||
|
* caller is told via `confidence` / `fallback` so wording stays honest (spec §6).
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Demo service-area center — matches the storm module's WFO=FWD coverage.
|
||||||
|
const FALLBACK = {
|
||||||
|
lat: 33.0198,
|
||||||
|
lon: -96.6989,
|
||||||
|
displayName: 'Plano, TX (approximate area)',
|
||||||
|
county: 'Collin',
|
||||||
|
state: 'TX',
|
||||||
|
confidence: 'low',
|
||||||
|
fallback: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const NOMINATIM = 'https://nominatim.openstreetmap.org/search';
|
||||||
|
|
||||||
|
export async function geocodeAddress(address) {
|
||||||
|
const q = (address || '').trim();
|
||||||
|
if (q.length < 4) return { ...FALLBACK };
|
||||||
|
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
q,
|
||||||
|
format: 'json',
|
||||||
|
addressdetails: '1',
|
||||||
|
limit: '1',
|
||||||
|
countrycodes: 'us',
|
||||||
|
});
|
||||||
|
const res = await fetch(`${NOMINATIM}?${params.toString()}`, {
|
||||||
|
headers: { Accept: 'application/json' },
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`geocode ${res.status}`);
|
||||||
|
const data = await res.json();
|
||||||
|
const hit = Array.isArray(data) ? data[0] : null;
|
||||||
|
if (!hit) return { ...FALLBACK };
|
||||||
|
|
||||||
|
const a = hit.address || {};
|
||||||
|
// Nominatim "importance" (0..1) is a rough match-quality signal.
|
||||||
|
const importance = typeof hit.importance === 'number' ? hit.importance : 0.4;
|
||||||
|
const confidence = importance >= 0.5 ? 'high' : importance >= 0.3 ? 'moderate' : 'low';
|
||||||
|
|
||||||
|
return {
|
||||||
|
lat: parseFloat(hit.lat),
|
||||||
|
lon: parseFloat(hit.lon),
|
||||||
|
displayName: hit.display_name || q,
|
||||||
|
county: (a.county || '').replace(/ County$/i, ''),
|
||||||
|
state: a.state || a['ISO3166-2-lvl4'] || '',
|
||||||
|
confidence,
|
||||||
|
fallback: false,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
// Network/CORS/parse failure → safe fallback, flagged as such.
|
||||||
|
return { ...FALLBACK };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
/**
|
||||||
|
* HailRiskSnapshot — presentational hail-risk card (spec §6).
|
||||||
|
* Strictly historical framing + mandatory confidence/source/disclaimer.
|
||||||
|
* Never says "your home will get hit" — only area-level historical exposure.
|
||||||
|
*/
|
||||||
|
import React from 'react';
|
||||||
|
import { CloudHail, TrendingUp, ShieldQuestion, Clock, Info } from 'lucide-react';
|
||||||
|
|
||||||
|
const tierStyle = {
|
||||||
|
high: { text: 'text-red-600 dark:text-red-400', bg: 'bg-red-50 dark:bg-red-500/10', border: 'border-red-200 dark:border-red-500/20', label: 'Higher historical exposure' },
|
||||||
|
moderate: { text: 'text-orange-600 dark:text-orange-400', bg: 'bg-orange-50 dark:bg-orange-500/10', border: 'border-orange-200 dark:border-orange-500/20', label: 'Moderate historical exposure' },
|
||||||
|
low: { text: 'text-emerald-600 dark:text-emerald-400', bg: 'bg-emerald-50 dark:bg-emerald-500/10', border: 'border-emerald-200 dark:border-emerald-500/20', label: 'Lower historical exposure' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const Metric = ({ icon: Icon, label, value, sub }) => (
|
||||||
|
<div className="rounded-xl border border-zinc-200 dark:border-white/10 bg-white dark:bg-zinc-900 p-3.5">
|
||||||
|
<div className="flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wide text-zinc-400">
|
||||||
|
{Icon && <Icon size={13} />} {label}
|
||||||
|
</div>
|
||||||
|
<div className="text-lg font-bold text-zinc-900 dark:text-white mt-1">{value}</div>
|
||||||
|
{sub && <div className="text-xs text-zinc-500 dark:text-zinc-400 mt-0.5">{sub}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const HailRiskSnapshot = ({ snapshot }) => {
|
||||||
|
if (!snapshot) return null;
|
||||||
|
const tier = tierStyle[snapshot.exposureTier] || tierStyle.low;
|
||||||
|
const p = snapshot.probability10yr;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Exposure banner */}
|
||||||
|
<div className={`rounded-2xl border ${tier.border} ${tier.bg} p-4 flex items-start gap-3`}>
|
||||||
|
<CloudHail size={24} className={tier.text} />
|
||||||
|
<div>
|
||||||
|
<div className={`font-bold ${tier.text}`}>{tier.label}</div>
|
||||||
|
<p className="text-sm text-zinc-600 dark:text-zinc-300 mt-0.5">{snapshot.wording.probability}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Metrics */}
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
|
||||||
|
<Metric
|
||||||
|
icon={TrendingUp}
|
||||||
|
label="10-yr probability"
|
||||||
|
value={snapshot.eventCount === 0 ? '—' : `${p.low}–${p.high}%`}
|
||||||
|
sub={`Base ${p.base}%`}
|
||||||
|
/>
|
||||||
|
<Metric
|
||||||
|
icon={CloudHail}
|
||||||
|
label="Annualized rate"
|
||||||
|
value={`${snapshot.annualRate}/yr`}
|
||||||
|
sub={`${snapshot.eventCount} events / ${snapshot.windowYears} yrs`}
|
||||||
|
/>
|
||||||
|
<Metric
|
||||||
|
icon={ShieldQuestion}
|
||||||
|
label="Confidence"
|
||||||
|
value={snapshot.confidence}
|
||||||
|
sub={`~${snapshot.radiusMiles} mi radius`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Source + freshness */}
|
||||||
|
<div className="rounded-xl bg-zinc-50 dark:bg-white/5 p-3.5 space-y-1.5 text-xs text-zinc-500 dark:text-zinc-400">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Info size={13} /> Source: {snapshot.source}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Clock size={13} /> {snapshot.wording.lastUpdated}
|
||||||
|
</div>
|
||||||
|
<div>{snapshot.wording.confidence}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Trust rule (spec §6): historical, not a forecast */}
|
||||||
|
<p className="text-xs text-zinc-400 leading-relaxed">
|
||||||
|
This is a historical hazard estimate for your <span className="font-medium">area</span> — not a forecast or
|
||||||
|
guarantee for your specific home. Final assessment requires an on-site inspection.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default HailRiskSnapshot;
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
/**
|
||||||
|
* PropertyMap — small Leaflet preview of the geocoded property + risk radius.
|
||||||
|
* Uses the same default-marker-icon fix the rest of the app uses (Maps.jsx).
|
||||||
|
*/
|
||||||
|
import React from 'react';
|
||||||
|
import { MapContainer, TileLayer, Marker, Circle } from 'react-leaflet';
|
||||||
|
import 'leaflet/dist/leaflet.css';
|
||||||
|
import L from 'leaflet';
|
||||||
|
import icon from 'leaflet/dist/images/marker-icon.png';
|
||||||
|
import iconShadow from 'leaflet/dist/images/marker-shadow.png';
|
||||||
|
|
||||||
|
const DefaultIcon = L.icon({
|
||||||
|
iconUrl: icon,
|
||||||
|
shadowUrl: iconShadow,
|
||||||
|
iconSize: [25, 41],
|
||||||
|
iconAnchor: [12, 41],
|
||||||
|
});
|
||||||
|
L.Marker.prototype.options.icon = DefaultIcon;
|
||||||
|
|
||||||
|
const PropertyMap = ({ lat, lon, radiusMiles = 20, height = 220 }) => {
|
||||||
|
if (lat == null || lon == null) return null;
|
||||||
|
const center = [lat, lon];
|
||||||
|
return (
|
||||||
|
<div className="rounded-2xl overflow-hidden border border-zinc-200 dark:border-white/10" style={{ height }}>
|
||||||
|
<MapContainer
|
||||||
|
key={`${lat},${lon}`}
|
||||||
|
center={center}
|
||||||
|
zoom={11}
|
||||||
|
scrollWheelZoom={false}
|
||||||
|
dragging={false}
|
||||||
|
doubleClickZoom={false}
|
||||||
|
zoomControl={false}
|
||||||
|
attributionControl={false}
|
||||||
|
style={{ height: '100%', width: '100%' }}
|
||||||
|
>
|
||||||
|
<TileLayer url="https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png" />
|
||||||
|
<Circle
|
||||||
|
center={center}
|
||||||
|
radius={radiusMiles * 1609.34}
|
||||||
|
pathOptions={{ color: '#fda913', fillColor: '#fda913', fillOpacity: 0.08, weight: 1.5 }}
|
||||||
|
/>
|
||||||
|
<Marker position={center} />
|
||||||
|
</MapContainer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PropertyMap;
|
||||||
@@ -15,6 +15,7 @@ import ChoiceCards from './ChoiceCards';
|
|||||||
import StartStep from './steps/StartStep';
|
import StartStep from './steps/StartStep';
|
||||||
import AddressStep from './steps/AddressStep';
|
import AddressStep from './steps/AddressStep';
|
||||||
import PremiumStep from './steps/PremiumStep';
|
import PremiumStep from './steps/PremiumStep';
|
||||||
|
import HailRiskStep from './steps/HailRiskStep';
|
||||||
import PlaceholderStep from './steps/PlaceholderStep';
|
import PlaceholderStep from './steps/PlaceholderStep';
|
||||||
|
|
||||||
// Step registry — later phases swap PlaceholderStep entries for real components.
|
// Step registry — later phases swap PlaceholderStep entries for real components.
|
||||||
@@ -22,7 +23,7 @@ const STEP_COMPONENTS = {
|
|||||||
start: StartStep,
|
start: StartStep,
|
||||||
address: AddressStep,
|
address: AddressStep,
|
||||||
premium: PremiumStep,
|
premium: PremiumStep,
|
||||||
hailRisk: (props) => <PlaceholderStep stepId="hailRisk" {...props} />,
|
hailRisk: HailRiskStep,
|
||||||
options: (props) => <PlaceholderStep stepId="options" {...props} />,
|
options: (props) => <PlaceholderStep stepId="options" {...props} />,
|
||||||
addons: (props) => <PlaceholderStep stepId="addons" {...props} />,
|
addons: (props) => <PlaceholderStep stepId="addons" {...props} />,
|
||||||
recommendation: (props) => <PlaceholderStep stepId="recommendation" {...props} />,
|
recommendation: (props) => <PlaceholderStep stepId="recommendation" {...props} />,
|
||||||
|
|||||||
@@ -1,15 +1,30 @@
|
|||||||
/**
|
/**
|
||||||
* AddressStep — Screen 2 "Property validation" (spec §4).
|
* AddressStep — Screen 2 "Property validation" (spec §4).
|
||||||
* Phase 1: confirm/edit the address captured at Start.
|
* Geocodes the address (module-local Nominatim geocoder) and shows a Leaflet map
|
||||||
* Phase 2 will add geocoding (lat/lon, county, timezone) + a Leaflet map preview
|
* preview, populating property_profiles (spec §12). Hail history is pulled in the
|
||||||
* and populate property_profiles via /api/properties/validate-address.
|
* next step from the existing /api/storm-history endpoint.
|
||||||
*/
|
*/
|
||||||
import React from 'react';
|
import React, { useState } from 'react';
|
||||||
import { MapPin, Map as MapIcon } from 'lucide-react';
|
import { MapPin, Loader2, CheckCircle2, Search } from 'lucide-react';
|
||||||
import { useEstimateWizard } from '../../EstimateWizardContext';
|
import { useEstimateWizard } from '../../EstimateWizardContext';
|
||||||
|
import { geocodeAddress } from '../../api/geocode';
|
||||||
|
import PropertyMap from '../PropertyMap';
|
||||||
|
|
||||||
const AddressStep = () => {
|
const AddressStep = () => {
|
||||||
const { inputs, setInput } = useEstimateWizard();
|
const { inputs, setInput, session, patchSession, logEvent } = useEstimateWizard();
|
||||||
|
const [locating, setLocating] = useState(false);
|
||||||
|
const property = session.property;
|
||||||
|
|
||||||
|
const handleLocate = async () => {
|
||||||
|
setLocating(true);
|
||||||
|
try {
|
||||||
|
const prop = await geocodeAddress(inputs.address);
|
||||||
|
patchSession({ property: prop, hailSnapshot: null }); // re-locating invalidates a stale snapshot
|
||||||
|
logEvent('address_geocoded', { confidence: prop.confidence, fallback: prop.fallback });
|
||||||
|
} finally {
|
||||||
|
setLocating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
@@ -17,22 +32,46 @@ const AddressStep = () => {
|
|||||||
<span className="text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-1.5 flex items-center gap-1.5">
|
<span className="text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-1.5 flex items-center gap-1.5">
|
||||||
<MapPin size={15} className="text-amber-500" /> Confirm your property address
|
<MapPin size={15} className="text-amber-500" /> Confirm your property address
|
||||||
</span>
|
</span>
|
||||||
<input
|
<div className="flex gap-2">
|
||||||
className="w-full rounded-xl border border-zinc-200 dark:border-white/10 bg-white dark:bg-zinc-900 px-3.5 py-2.5 text-zinc-900 dark:text-white placeholder-zinc-400 focus:outline-none focus:ring-2 focus:ring-amber-500"
|
<input
|
||||||
placeholder="Street, city, state, ZIP"
|
className="flex-1 rounded-xl border border-zinc-200 dark:border-white/10 bg-white dark:bg-zinc-900 px-3.5 py-2.5 text-zinc-900 dark:text-white placeholder-zinc-400 focus:outline-none focus:ring-2 focus:ring-amber-500"
|
||||||
value={inputs.address}
|
placeholder="Street, city, state, ZIP"
|
||||||
onChange={(e) => setInput('address', e.target.value)}
|
value={inputs.address}
|
||||||
autoComplete="street-address"
|
onChange={(e) => { setInput('address', e.target.value); if (property) patchSession({ property: null, hailSnapshot: null }); }}
|
||||||
/>
|
autoComplete="street-address"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleLocate}
|
||||||
|
disabled={locating || !inputs.address?.trim()}
|
||||||
|
className="rounded-xl bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 px-4 py-2.5 text-sm font-semibold flex items-center gap-1.5 disabled:opacity-40"
|
||||||
|
>
|
||||||
|
{locating ? <Loader2 size={16} className="animate-spin" /> : <Search size={16} />}
|
||||||
|
Locate
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
{/* Map preview placeholder — Leaflet + geocode wired in Phase 2 */}
|
{property ? (
|
||||||
<div className="rounded-2xl border border-dashed border-zinc-300 dark:border-white/10 bg-zinc-50 dark:bg-white/5 p-6 flex flex-col items-center justify-center text-center">
|
<>
|
||||||
<MapIcon size={28} className="text-zinc-400 mb-2" />
|
<PropertyMap lat={property.lat} lon={property.lon} radiusMiles={20} />
|
||||||
<p className="text-sm text-zinc-500 dark:text-zinc-400">
|
<div className="flex items-start gap-2 text-sm">
|
||||||
We’ll confirm the location on a map and pull your area’s historical storm data in the next step.
|
<CheckCircle2 size={16} className="text-emerald-500 mt-0.5 shrink-0" />
|
||||||
</p>
|
<span className="text-zinc-600 dark:text-zinc-300">
|
||||||
</div>
|
{property.fallback
|
||||||
|
? 'We placed an approximate pin for your area. Your rep will confirm the exact location.'
|
||||||
|
: <>Located: <span className="font-medium">{property.displayName}</span></>}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="rounded-2xl border border-dashed border-zinc-300 dark:border-white/10 bg-zinc-50 dark:bg-white/5 p-6 text-center">
|
||||||
|
<MapPin size={26} className="text-zinc-400 mx-auto mb-2" />
|
||||||
|
<p className="text-sm text-zinc-500 dark:text-zinc-400">
|
||||||
|
Tap <span className="font-medium">Locate</span> to confirm your home on the map and pull local storm history.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<p className="text-xs text-zinc-400">
|
<p className="text-xs text-zinc-400">
|
||||||
Final roof measurements are validated during your inspection — nothing here is binding.
|
Final roof measurements are validated during your inspection — nothing here is binding.
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
/**
|
||||||
|
* HailRiskStep — Screen 6 "Hail risk" (spec §6).
|
||||||
|
* Runs geocode → /api/storm-history → Poisson model, freezes the snapshot into
|
||||||
|
* the session (spec §12), and renders the historical-only risk card + map.
|
||||||
|
*/
|
||||||
|
import React, { useEffect } from 'react';
|
||||||
|
import { Loader2, AlertCircle } from 'lucide-react';
|
||||||
|
import { useEstimateWizard } from '../../EstimateWizardContext';
|
||||||
|
import { useWizardHailRisk } from '../../hooks/useWizardHailRisk';
|
||||||
|
import PropertyMap from '../PropertyMap';
|
||||||
|
import HailRiskSnapshot from '../HailRiskSnapshot';
|
||||||
|
|
||||||
|
const HailRiskStep = () => {
|
||||||
|
const { inputs, session, patchSession, tenantConfig, logEvent } = useEstimateWizard();
|
||||||
|
|
||||||
|
// Reuse a previously frozen snapshot if the user navigates back/forward.
|
||||||
|
const alreadyHave = !!session.hailSnapshot;
|
||||||
|
|
||||||
|
const { loading, error, property, snapshot } = useWizardHailRisk({
|
||||||
|
address: inputs.address,
|
||||||
|
property: session.property,
|
||||||
|
enabled: !alreadyHave,
|
||||||
|
stormSettings: tenantConfig.stormSettings,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Freeze property + snapshot into the session once resolved.
|
||||||
|
useEffect(() => {
|
||||||
|
if (alreadyHave) return;
|
||||||
|
if (snapshot && property) {
|
||||||
|
patchSession({ property, hailSnapshot: snapshot });
|
||||||
|
logEvent('hail_snapshot_generated', {
|
||||||
|
exposureTier: snapshot.exposureTier,
|
||||||
|
p10Base: snapshot.probability10yr.base,
|
||||||
|
confidence: snapshot.confidence,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [snapshot, property, alreadyHave]);
|
||||||
|
|
||||||
|
const shownProperty = session.property || property;
|
||||||
|
const shownSnapshot = session.hailSnapshot || snapshot;
|
||||||
|
|
||||||
|
if (loading && !shownSnapshot) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||||
|
<Loader2 size={28} className="text-amber-500 animate-spin mb-3" />
|
||||||
|
<p className="text-sm text-zinc-500 dark:text-zinc-400">
|
||||||
|
Checking historical storm reports for your area…
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error && !shownSnapshot) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-2xl border border-red-200 dark:border-red-500/20 bg-red-50 dark:bg-red-500/10 p-4 flex items-start gap-3">
|
||||||
|
<AlertCircle size={20} className="text-red-500 shrink-0" />
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold text-red-700 dark:text-red-300">Storm history unavailable</p>
|
||||||
|
<p className="text-sm text-red-600 dark:text-red-400 mt-0.5">
|
||||||
|
We couldn’t load storm data right now — your rep will review local hail history during your visit.
|
||||||
|
You can still continue.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{shownProperty && (
|
||||||
|
<PropertyMap
|
||||||
|
lat={shownProperty.lat}
|
||||||
|
lon={shownProperty.lon}
|
||||||
|
radiusMiles={shownSnapshot?.radiusMiles ?? 20}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<HailRiskSnapshot snapshot={shownSnapshot} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default HailRiskStep;
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
/**
|
||||||
|
* hailProbability.js — conservative historical hail-risk model (spec §6).
|
||||||
|
*
|
||||||
|
* IMPORTANT framing (spec §6): the output is a HISTORICAL hazard estimate for the
|
||||||
|
* surrounding area, NOT a property-level forecast or guarantee. The model computes
|
||||||
|
* a RANGE (confidence band), not one overconfident number, and stores every
|
||||||
|
* assumption so the report can be defended.
|
||||||
|
*
|
||||||
|
* lambda (λ) = deduped hail events / years
|
||||||
|
* P(at least one hail event over N years) = 1 - e^(-λN)
|
||||||
|
*
|
||||||
|
* Pure function — no I/O. Fed by /api/storm-history (existing storm module, used
|
||||||
|
* read-only). Returns the hail_risk_snapshot shape (spec §12).
|
||||||
|
*/
|
||||||
|
|
||||||
|
function clamp(n, lo, hi) { return Math.max(lo, Math.min(hi, n)); }
|
||||||
|
function pct(n) { return Math.round(n * 100); }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {object} input
|
||||||
|
* eventCount {number} deduped hail events observed in the window
|
||||||
|
* windowYears {number} historical window analyzed
|
||||||
|
* radiusMiles {number} search radius around the property
|
||||||
|
* geocodeConfidence {'high'|'moderate'|'low'}
|
||||||
|
* maxHailInches {number|null}
|
||||||
|
* lastEventDate {string|null} ISO date of most recent hail event
|
||||||
|
* source {string} human-readable data source label
|
||||||
|
* generatedAt {string} ISO timestamp the snapshot was frozen
|
||||||
|
*/
|
||||||
|
export function computeHailRisk(input) {
|
||||||
|
const {
|
||||||
|
eventCount = 0,
|
||||||
|
windowYears = 5,
|
||||||
|
radiusMiles = 20,
|
||||||
|
geocodeConfidence = 'moderate',
|
||||||
|
maxHailInches = null,
|
||||||
|
lastEventDate = null,
|
||||||
|
source = 'NOAA / IEM Local Storm Reports',
|
||||||
|
generatedAt = new Date().toISOString(),
|
||||||
|
} = input || {};
|
||||||
|
|
||||||
|
const years = Math.max(1, windowYears);
|
||||||
|
const count = Math.max(0, eventCount);
|
||||||
|
|
||||||
|
// Annualized rate (events/year).
|
||||||
|
const lambda = count / years;
|
||||||
|
|
||||||
|
// Poisson confidence band using event-count standard error (√count).
|
||||||
|
const se = Math.sqrt(count);
|
||||||
|
const lambdaLow = Math.max(0, (count - se) / years);
|
||||||
|
const lambdaHigh = (count + se) / years;
|
||||||
|
|
||||||
|
const prob = (lam, n) => 1 - Math.exp(-lam * n);
|
||||||
|
const p10Base = prob(lambda, 10);
|
||||||
|
const p10Low = prob(lambdaLow, 10);
|
||||||
|
const p10High = prob(lambdaHigh, 10);
|
||||||
|
|
||||||
|
// Confidence score (0..1): sample size + window length + geocode precision.
|
||||||
|
const sampleScore = clamp(count / 8, 0, 1); // ~8+ events → strong
|
||||||
|
const windowScore = clamp(years / 10, 0, 1); // 10y window → full
|
||||||
|
const geoScore = geocodeConfidence === 'high' ? 1 : geocodeConfidence === 'moderate' ? 0.6 : 0.3;
|
||||||
|
const confidenceValue = +(0.4 * sampleScore + 0.3 * windowScore + 0.3 * geoScore).toFixed(2);
|
||||||
|
const confidenceLabel = confidenceValue >= 0.66 ? 'High' : confidenceValue >= 0.4 ? 'Moderate' : 'Low';
|
||||||
|
|
||||||
|
// Coarse exposure tier used downstream by the scoring engine (Phase 4).
|
||||||
|
const exposureTier = p10Base >= 0.66 ? 'high' : p10Base >= 0.33 ? 'moderate' : 'low';
|
||||||
|
|
||||||
|
return {
|
||||||
|
// hail_risk_snapshot fields (spec §12)
|
||||||
|
source,
|
||||||
|
windowYears: years,
|
||||||
|
radiusMiles,
|
||||||
|
eventCount: count,
|
||||||
|
annualRate: +lambda.toFixed(3),
|
||||||
|
maxHailInches,
|
||||||
|
lastEventDate,
|
||||||
|
probability10yr: { low: pct(p10Low), base: pct(p10Base), high: pct(p10High) },
|
||||||
|
confidence: confidenceLabel,
|
||||||
|
confidenceValue,
|
||||||
|
exposureTier,
|
||||||
|
generatedAt,
|
||||||
|
|
||||||
|
// Customer-facing wording (spec §6) — historical, never a forecast.
|
||||||
|
wording: {
|
||||||
|
annualRate: count === 0
|
||||||
|
? 'No qualifying hail events were found in the available reports for this area.'
|
||||||
|
: `This area has averaged about ${lambda.toFixed(1)} observed hail event(s) per year in the available dataset.`,
|
||||||
|
probability: count === 0
|
||||||
|
? 'There is not enough historical hail activity nearby to estimate a meaningful probability.'
|
||||||
|
: `Based on historical reports, the chance of at least one hail event in the next 10 years is estimated at ${pct(p10Low)}–${pct(p10High)}%, assuming future patterns resemble the past.`,
|
||||||
|
confidence: `Confidence: ${confidenceLabel}. This is based on county-level and nearby reports within ~${radiusMiles} miles, not a roof-specific inspection.`,
|
||||||
|
lastUpdated: `Storm data reflects reports through ${new Date(generatedAt).toLocaleDateString()}.`,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
/**
|
||||||
|
* useWizardHailRisk — produces a frozen hail-risk snapshot for the wizard.
|
||||||
|
*
|
||||||
|
* Pipeline (all read-only against existing services):
|
||||||
|
* 1. geocode the address (module-local geocode.js → Nominatim)
|
||||||
|
* 2. GET /api/storm-history (EXISTING storm module endpoint — not modified)
|
||||||
|
* 3. run computeHailRisk() (module-local Poisson model, spec §6)
|
||||||
|
*
|
||||||
|
* Returns { loading, error, property, snapshot }. The caller freezes `snapshot`
|
||||||
|
* into the session (spec §12 hail_risk_snapshots) so the report stays immutable.
|
||||||
|
*/
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { geocodeAddress } from '../api/geocode';
|
||||||
|
import { computeHailRisk } from '../engine/hailProbability';
|
||||||
|
|
||||||
|
export function useWizardHailRisk({ address, property, enabled = true, stormSettings = {} }) {
|
||||||
|
const [loading, setLoading] = useState(enabled);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const [resolvedProperty, setResolvedProperty] = useState(property || null);
|
||||||
|
const [snapshot, setSnapshot] = useState(null);
|
||||||
|
|
||||||
|
const radiusMiles = stormSettings.radiusMiles ?? 20;
|
||||||
|
// storm-history caps months at 60 (5 years); window honesty is stored in the snapshot.
|
||||||
|
const months = 60;
|
||||||
|
const windowYears = months / 12;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled) return;
|
||||||
|
let cancelled = false;
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
// 1. Resolve coordinates (reuse stored property if already geocoded).
|
||||||
|
let prop = property;
|
||||||
|
if (!prop || prop.lat == null || prop.lon == null) {
|
||||||
|
prop = await geocodeAddress(address);
|
||||||
|
}
|
||||||
|
if (cancelled) return;
|
||||||
|
setResolvedProperty(prop);
|
||||||
|
|
||||||
|
// 2. Existing keyless storm endpoint (read-only).
|
||||||
|
let hailCount = 0;
|
||||||
|
let maxHail = null;
|
||||||
|
let lastEventDate = null;
|
||||||
|
let source = 'NOAA / IEM Local Storm Reports';
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`/api/storm-history?lat=${prop.lat}&lng=${prop.lon}&months=${months}&radiusMi=${Math.min(radiusMiles, 50)}`
|
||||||
|
);
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
hailCount = data?.summary?.byType?.hail ?? 0;
|
||||||
|
maxHail = data?.summary?.maxHail ?? null;
|
||||||
|
lastEventDate = data?.summary?.lastEvent?.date ?? null;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Leave defaults (count 0) — computeHailRisk handles the empty case honestly.
|
||||||
|
}
|
||||||
|
if (cancelled) return;
|
||||||
|
|
||||||
|
// 3. Conservative Poisson model.
|
||||||
|
const snap = computeHailRisk({
|
||||||
|
eventCount: hailCount,
|
||||||
|
windowYears,
|
||||||
|
radiusMiles,
|
||||||
|
geocodeConfidence: prop.confidence,
|
||||||
|
maxHailInches: maxHail,
|
||||||
|
lastEventDate,
|
||||||
|
source,
|
||||||
|
generatedAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
if (!cancelled) setSnapshot(snap);
|
||||||
|
} catch (e) {
|
||||||
|
if (!cancelled) setError(e.message || 'Could not load storm history.');
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) setLoading(false);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [address, enabled, radiusMiles]);
|
||||||
|
|
||||||
|
return { loading, error, property: resolvedProperty, snapshot };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user