From 110790d5aead3e45cb02c0ee2d371ba4d1fea0cb Mon Sep 17 00:00:00 2001 From: Mayur Shinde Date: Tue, 9 Jun 2026 16:15:21 +0530 Subject: [PATCH] locate adress on map and find hail risk --- src/modules/estimateWizard/api/geocode.js | 65 +++++++++++++ .../components/HailRiskSnapshot.jsx | 83 ++++++++++++++++ .../estimateWizard/components/PropertyMap.jsx | 48 ++++++++++ .../estimateWizard/components/WizardShell.jsx | 3 +- .../components/steps/AddressStep.jsx | 79 +++++++++++---- .../components/steps/HailRiskStep.jsx | 83 ++++++++++++++++ .../estimateWizard/engine/hailProbability.js | 95 +++++++++++++++++++ .../estimateWizard/hooks/useWizardHailRisk.js | 87 +++++++++++++++++ 8 files changed, 522 insertions(+), 21 deletions(-) create mode 100644 src/modules/estimateWizard/api/geocode.js create mode 100644 src/modules/estimateWizard/components/HailRiskSnapshot.jsx create mode 100644 src/modules/estimateWizard/components/PropertyMap.jsx create mode 100644 src/modules/estimateWizard/components/steps/HailRiskStep.jsx create mode 100644 src/modules/estimateWizard/engine/hailProbability.js create mode 100644 src/modules/estimateWizard/hooks/useWizardHailRisk.js diff --git a/src/modules/estimateWizard/api/geocode.js b/src/modules/estimateWizard/api/geocode.js new file mode 100644 index 0000000..62d5bf5 --- /dev/null +++ b/src/modules/estimateWizard/api/geocode.js @@ -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 }; + } +} diff --git a/src/modules/estimateWizard/components/HailRiskSnapshot.jsx b/src/modules/estimateWizard/components/HailRiskSnapshot.jsx new file mode 100644 index 0000000..c429054 --- /dev/null +++ b/src/modules/estimateWizard/components/HailRiskSnapshot.jsx @@ -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 }) => ( +
+
+ {Icon && } {label} +
+
{value}
+ {sub &&
{sub}
} +
+); + +const HailRiskSnapshot = ({ snapshot }) => { + if (!snapshot) return null; + const tier = tierStyle[snapshot.exposureTier] || tierStyle.low; + const p = snapshot.probability10yr; + + return ( +
+ {/* Exposure banner */} +
+ +
+
{tier.label}
+

{snapshot.wording.probability}

+
+
+ + {/* Metrics */} +
+ + + +
+ + {/* Source + freshness */} +
+
+ Source: {snapshot.source} +
+
+ {snapshot.wording.lastUpdated} +
+
{snapshot.wording.confidence}
+
+ + {/* Trust rule (spec §6): historical, not a forecast */} +

+ This is a historical hazard estimate for your area — not a forecast or + guarantee for your specific home. Final assessment requires an on-site inspection. +

+
+ ); +}; + +export default HailRiskSnapshot; diff --git a/src/modules/estimateWizard/components/PropertyMap.jsx b/src/modules/estimateWizard/components/PropertyMap.jsx new file mode 100644 index 0000000..f5549cf --- /dev/null +++ b/src/modules/estimateWizard/components/PropertyMap.jsx @@ -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 ( +
+ + + + + +
+ ); +}; + +export default PropertyMap; diff --git a/src/modules/estimateWizard/components/WizardShell.jsx b/src/modules/estimateWizard/components/WizardShell.jsx index fe6f3cf..f3789a3 100644 --- a/src/modules/estimateWizard/components/WizardShell.jsx +++ b/src/modules/estimateWizard/components/WizardShell.jsx @@ -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) => , + hailRisk: HailRiskStep, options: (props) => , addons: (props) => , recommendation: (props) => , diff --git a/src/modules/estimateWizard/components/steps/AddressStep.jsx b/src/modules/estimateWizard/components/steps/AddressStep.jsx index 8ae650f..991968c 100644 --- a/src/modules/estimateWizard/components/steps/AddressStep.jsx +++ b/src/modules/estimateWizard/components/steps/AddressStep.jsx @@ -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 (
@@ -17,22 +32,46 @@ const AddressStep = () => { Confirm your property address - setInput('address', e.target.value)} - autoComplete="street-address" - /> +
+ { setInput('address', e.target.value); if (property) patchSession({ property: null, hailSnapshot: null }); }} + autoComplete="street-address" + /> + +
- {/* Map preview placeholder — Leaflet + geocode wired in Phase 2 */} -
- -

- We’ll confirm the location on a map and pull your area’s historical storm data in the next step. -

-
+ {property ? ( + <> + +
+ + + {property.fallback + ? 'We placed an approximate pin for your area. Your rep will confirm the exact location.' + : <>Located: {property.displayName}} + +
+ + ) : ( +
+ +

+ Tap Locate to confirm your home on the map and pull local storm history. +

+
+ )}

Final roof measurements are validated during your inspection — nothing here is binding. diff --git a/src/modules/estimateWizard/components/steps/HailRiskStep.jsx b/src/modules/estimateWizard/components/steps/HailRiskStep.jsx new file mode 100644 index 0000000..a09143a --- /dev/null +++ b/src/modules/estimateWizard/components/steps/HailRiskStep.jsx @@ -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 ( +

+ +

+ Checking historical storm reports for your area… +

+
+ ); + } + + if (error && !shownSnapshot) { + return ( +
+ +
+

Storm history unavailable

+

+ We couldn’t load storm data right now — your rep will review local hail history during your visit. + You can still continue. +

+
+
+ ); + } + + return ( +
+ {shownProperty && ( + + )} + +
+ ); +}; + +export default HailRiskStep; diff --git a/src/modules/estimateWizard/engine/hailProbability.js b/src/modules/estimateWizard/engine/hailProbability.js new file mode 100644 index 0000000..16977ae --- /dev/null +++ b/src/modules/estimateWizard/engine/hailProbability.js @@ -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()}.`, + }, + }; +} diff --git a/src/modules/estimateWizard/hooks/useWizardHailRisk.js b/src/modules/estimateWizard/hooks/useWizardHailRisk.js new file mode 100644 index 0000000..410ce66 --- /dev/null +++ b/src/modules/estimateWizard/hooks/useWizardHailRisk.js @@ -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 }; +}