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 AddressStep from './steps/AddressStep';
|
||||
import PremiumStep from './steps/PremiumStep';
|
||||
import HailRiskStep from './steps/HailRiskStep';
|
||||
import PlaceholderStep from './steps/PlaceholderStep';
|
||||
|
||||
// Step registry — later phases swap PlaceholderStep entries for real components.
|
||||
@@ -22,7 +23,7 @@ const STEP_COMPONENTS = {
|
||||
start: StartStep,
|
||||
address: AddressStep,
|
||||
premium: PremiumStep,
|
||||
hailRisk: (props) => <PlaceholderStep stepId="hailRisk" {...props} />,
|
||||
hailRisk: HailRiskStep,
|
||||
options: (props) => <PlaceholderStep stepId="options" {...props} />,
|
||||
addons: (props) => <PlaceholderStep stepId="addons" {...props} />,
|
||||
recommendation: (props) => <PlaceholderStep stepId="recommendation" {...props} />,
|
||||
|
||||
@@ -1,15 +1,30 @@
|
||||
/**
|
||||
* AddressStep — Screen 2 "Property validation" (spec §4).
|
||||
* Phase 1: confirm/edit the address captured at Start.
|
||||
* Phase 2 will add geocoding (lat/lon, county, timezone) + a Leaflet map preview
|
||||
* and populate property_profiles via /api/properties/validate-address.
|
||||
* Geocodes the address (module-local Nominatim geocoder) and shows a Leaflet map
|
||||
* preview, populating property_profiles (spec §12). Hail history is pulled in the
|
||||
* next step from the existing /api/storm-history endpoint.
|
||||
*/
|
||||
import React from 'react';
|
||||
import { MapPin, Map as MapIcon } from 'lucide-react';
|
||||
import React, { useState } from 'react';
|
||||
import { MapPin, Loader2, CheckCircle2, Search } from 'lucide-react';
|
||||
import { useEstimateWizard } from '../../EstimateWizardContext';
|
||||
import { geocodeAddress } from '../../api/geocode';
|
||||
import PropertyMap from '../PropertyMap';
|
||||
|
||||
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 (
|
||||
<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">
|
||||
<MapPin size={15} className="text-amber-500" /> Confirm your property address
|
||||
</span>
|
||||
<input
|
||||
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"
|
||||
placeholder="Street, city, state, ZIP"
|
||||
value={inputs.address}
|
||||
onChange={(e) => setInput('address', e.target.value)}
|
||||
autoComplete="street-address"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
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"
|
||||
placeholder="Street, city, state, ZIP"
|
||||
value={inputs.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>
|
||||
|
||||
{/* Map preview placeholder — Leaflet + geocode wired in Phase 2 */}
|
||||
<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" />
|
||||
<p className="text-sm text-zinc-500 dark:text-zinc-400">
|
||||
We’ll confirm the location on a map and pull your area’s historical storm data in the next step.
|
||||
</p>
|
||||
</div>
|
||||
{property ? (
|
||||
<>
|
||||
<PropertyMap lat={property.lat} lon={property.lon} radiusMiles={20} />
|
||||
<div className="flex items-start gap-2 text-sm">
|
||||
<CheckCircle2 size={16} className="text-emerald-500 mt-0.5 shrink-0" />
|
||||
<span className="text-zinc-600 dark:text-zinc-300">
|
||||
{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">
|
||||
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