);
}
// 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 (
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. */}