/* ============================================================ City Analysis mode — split layout matching Homeowner. Reuses the global polygon-draw flow (pendingPolygon / setPolygon / PolygonToggle) so the action of drawing a polygon is identical to homeowner mode. ============================================================ */ /* global React, CityMapView, Icon, PolygonToggle, AddrBar */ const { useState: useStateC, useEffect: useEffectC, useRef: useRefC } = React; const POLL_INTERVAL_MS = 1500; function fmtNum(n, digits = 1) { if (n == null || !Number.isFinite(n)) return "—"; if (Math.abs(n) >= 100) return Math.round(n).toLocaleString("en-IN"); return n.toFixed(digits); } /* ---------- Side-panel sections (sp-section blocks) ---------- */ function RegionBlock({ polygon, estimate }) { const hasPoly = polygon && polygon.length >= 3; return (
Region of interest
{hasPoly &&
{polygon.length} pts
}
{!hasPoly ? (
Click Draw polygon (bottom-left of the map), then click on the map to add vertices. Click the first vertex again to close.
) : (
Bbox area
{fmtNum(estimate?.bboxKm2, 2)}km²
bounding box of polygon
Solar API tiles
{estimate?.tileCount ?? "—"}{estimate?.tileCount === 1 ? "tile" : "tiles"}
1 km radius · 1 m / pixel
)}
); } // Side-panel status block. Renders ONLY during 'running' and 'done' — the // pre-run "Run analysis" action lives in the floating PolygonInfoCard on the // map (single CTA, mirroring how Homeowner uses LocationPanel for the action // and the side panel for displayed state). function StatusBlock({ running, done, onClear, job }) { if (!running && !done) return null; const progressPct = job ? Math.round((job.progress.done / Math.max(1, job.progress.total)) * 100) : 0; return (
{running ? "Running" : "Result"}
{done && job && (
{job.tileCount} {job.tileCount === 1 ? "tile" : "tiles"}
)}
{running && ( <>
{job.progress.done} / {job.progress.total} tiles {progressPct}%
Fetching annual-flux GeoTIFFs · cached on disk for re-runs
)} {done && ( <>
Done BASE quality
)}
); } function ResultsBlock({ kpis, status }) { const ready = status === "done" && kpis; return (
Headline KPIs
municipal planner
Polygon area
{ready ? fmtNum(kpis.totalAreaKm2, 2) : "—"}km²
analysed surface (clipped)
Suitable area
{ready ? fmtNum(kpis.suitableAreaKm2, 2) : "—"}km²
{ready ? `${Math.round((kpis.suitableFraction || 0) * 100)}% of polygon · flux ≥ 1400` : "kWh/kW/yr threshold"}
Mean flux
{ready ? fmtNum(kpis.meanFluxKwhPerKwYr, 0) : "—"}kWh/kW/yr
{ready ? `P90 ${fmtNum(kpis.p90FluxKwhPerKwYr, 0)}` : "annual irradiance"}
Potential capacity
{ready ? fmtNum((kpis.totalKwp || 0) / 1000, 1) : "—"}MW
@ 165 W/m², suitable pixels only
Annual generation
{ready ? fmtNum(kpis.yearlyGwh, 2) : "—"}GWh/yr
after 80% derate
CO₂ offset
{ready ? fmtNum((kpis.co2OffsetTonnesPerYear || 0) / 1000, 1) : "—"}kt/yr
India grid · 0.82 kg/kWh
); } function SourcesBlock({ done }) { return (
Method
Polygon's bbox is covered by 1 km-radius disks. For each disk we call Solar API dataLayers:get at 1 m / pixel for the annual-flux + mask GeoTIFFs. Tiles are mosaiced and clipped to your polygon. Annual flux is computed for every location (not just rooftops), so the heatmap covers ground too — that's what makes solar-farm screening possible.
); } /* ---------- Bottom-left card on the map when polygon is set ---------- */ function PolygonInfoCard({ polygon, estimate, pendingRun, running, done, onRun, onClear }) { if (running || done) return null; if (!polygon || polygon.length < 3) return null; const cx = polygon.reduce((a, p) => a + p.lat, 0) / polygon.length; const cy = polygon.reduce((a, p) => a + p.lng, 0) / polygon.length; const requireConfirm = estimate?.requiresConfirmation; return (
Drawn polygon {polygon.length} pts
centroid {cx.toFixed(5)}°N · {cy.toFixed(5)}°E
{estimate && (
~{estimate.tileCount} tiles · est. ${fmtNum(estimate.estimatedCostUsd, 2)} {requireConfirm && · confirm to run}
)}
); } /* ---------- Main ---------- */ function CityMode() { const data = window.useLeptonData(); const pendingPolygon = data.pendingPolygon; const selectionMode = data.selectionMode; const [job, setJob] = useStateC(null); const [estimate, setEstimate] = useStateC(null); const [pendingRun, setPendingRun] = useStateC(false); const [error, setError] = useStateC(null); const pollRef = useRefC(null); const autoFitRef = useRefC(true); // On mount: enter polygon-draw mode if there's nothing pending — makes the // main interaction discoverable. On unmount: clear the polygon from the global // store so it doesn't leak to Homeowner (where the same `pendingPolygon` slot // means "building outline override", which is a different concept). useEffectC(() => { if (selectionMode !== "polygon" && !pendingPolygon) { window.LEPTON_LOAD.setSelectionMode("polygon"); } return () => { if (pollRef.current) clearInterval(pollRef.current); window.LEPTON_LOAD.clearPending(); }; // eslint-disable-next-line }, []); // Whenever polygon changes, refresh cost estimate (cheap, server-side computed // from bbox math — no Solar API calls). useEffectC(() => { if (!pendingPolygon || pendingPolygon.length < 3) { setEstimate(null); setJob(null); return; } let cancelled = false; (async () => { try { const r = await fetch("/api/city-analysis/estimate", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ polygon: pendingPolygon.map(p => [p.lat, p.lng]) }), }); if (cancelled || !r.ok) return; setEstimate(await r.json()); } catch (e) { /* ignore */ } })(); return () => { cancelled = true; }; }, [pendingPolygon]); function startPolling(jobId) { if (pollRef.current) clearInterval(pollRef.current); pollRef.current = setInterval(async () => { try { const r = await fetch(`/api/city-analysis/${jobId}`); if (!r.ok) throw new Error(`status ${r.status}`); const j = await r.json(); setJob(j); if (j.status === "done" || j.status === "error") { clearInterval(pollRef.current); pollRef.current = null; if (j.status === "error") setError(j.error || "analysis failed"); } } catch (e) { clearInterval(pollRef.current); pollRef.current = null; setError(String(e.message || e)); } }, POLL_INTERVAL_MS); } async function runAnalysis() { if (!pendingPolygon || pendingPolygon.length < 3) return; setPendingRun(true); setError(null); try { const r = await fetch("/api/city-analysis", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ polygon: pendingPolygon.map(p => [p.lat, p.lng]) }), }); if (!r.ok) throw new Error(`start failed (${r.status})`); const j = await r.json(); setJob({ jobId: j.jobId, status: "queued", progress: { done: 0, total: j.tileCount }, tileCount: j.tileCount, }); startPolling(j.jobId); } catch (e) { setError(String(e.message || e)); } finally { setPendingRun(false); } } function clearAll() { if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null; } setJob(null); setEstimate(null); setError(null); window.LEPTON_LOAD.clearPending(); // clears polygon AND pendingLocation window.LEPTON_LOAD.setSelectionMode("polygon"); // stay in draw mode autoFitRef.current = true; } const running = job && (job.status === "running" || job.status === "queued"); const done = job && job.status === "done"; // Auto-fit camera to polygon only on initial polygon creation, not on every // re-render — otherwise the camera fights the user's zoom while running. const autoFit = autoFitRef.current && !running && !done; useEffectC(() => { if (pendingPolygon) autoFitRef.current = false; }, [pendingPolygon]); return (
{/* MAP */}
{/* Same place / lat-lng search as homeowner. skipSetPending so a search doesn't create a stray pin (CityMode only needs the map to pan). */} {/* Same draw-polygon pill as homeowner mode. */} {/* Bottom-left card with polygon info + run button (mirrors LocationPanel UX). */} {/* Flux legend (bottom-right) once heatmap is up. */} {done && (
Annual flux (kWh/kW/yr)
lowhigh
)}
{/* SIDE PANEL — display only; the Run action lives in the floating card. */}
{error && (
{error}
)}
); } window.CityMode = CityMode;