locate adress on map and find hail risk

This commit is contained in:
Mayur Shinde
2026-06-09 16:15:21 +05:30
parent 716d096fc8
commit 110790d5ae
8 changed files with 522 additions and 21 deletions
+65
View File
@@ -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">
Well confirm the location on a map and pull your areas 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 couldnt 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 };
}