/* ============================================================
MapView — Google Maps JS + real Solar API overlays.
Replaces the prototype's Leaflet + Esri + canvas-heatmap path.
============================================================ */
/* global React */
const { useEffect: useEffectM, useRef: useRefM, useState: useStateM, useMemo: useMemoM } = React;
// ----- meters → degrees ---------------------------------------
function metersAt(lat) {
return {
dLat: 1 / 110540,
dLng: 1 / (111320 * Math.cos(lat * Math.PI / 180)),
};
}
// Compute the 4 corners of a panel given its center lat/lng, the segment azimuth (deg),
// the orientation, and the building's panel dimensions in meters.
function panelCorners({ center, azimuthDeg, orientation, panelWidthMeters, panelHeightMeters }) {
const [clat, clng] = center;
if (clat == null || clng == null) return null;
const w = orientation === "PORTRAIT" ? panelWidthMeters : panelHeightMeters;
const h = orientation === "PORTRAIT" ? panelHeightMeters : panelWidthMeters;
// azimuth: 0° = north, clockwise. We want to rotate the rectangle so its long axis aligns with az.
const a = (azimuthDeg || 0) * Math.PI / 180;
const cos = Math.cos(a), sin = Math.sin(a);
const { dLat, dLng } = metersAt(clat);
const local = [
[-w / 2, -h / 2],
[ w / 2, -h / 2],
[ w / 2, h / 2],
[-w / 2, h / 2],
];
return local.map(([lx, ly]) => {
// x along east (right), y along north (up). Rotate by azimuth.
const rx = lx * cos + ly * sin;
const ry = -lx * sin + ly * cos;
return { lat: clat + ry * dLat, lng: clng + rx * dLng };
});
}
// Convert a [lat, lng] array to a google.maps.LatLngLiteral.
function toLL(p) { return { lat: p[0], lng: p[1] }; }
// Tiny color ramp (used by enterprise marker coloring).
const FLUX_RAMP = [
[26, 17, 71], [74, 29, 110], [138, 40, 118],
[198, 61, 104], [238, 109, 61], [247, 167, 34], [253, 231, 37],
];
const SHADE_RAMP = FLUX_RAMP; // unused but kept for icons.jsx swatches reference
const DSM_RAMP = FLUX_RAMP;
function rampColor(stops, t) {
t = Math.max(0, Math.min(1, t));
const n = stops.length - 1;
const i = Math.floor(t * n);
const f = t * n - i;
const a = stops[i], b = stops[Math.min(n, i + 1)];
return [
Math.round(a[0] + (b[0] - a[0]) * f),
Math.round(a[1] + (b[1] - a[1]) * f),
Math.round(a[2] + (b[2] - a[2]) * f),
];
}
// ----- Wait for the Google Maps JS API to load ----------------
let mapsReadyPromise = null;
function waitForMaps() {
if (window.google && window.google.maps && window.google.maps.Map) {
return Promise.resolve();
}
if (mapsReadyPromise) return mapsReadyPromise;
mapsReadyPromise = new Promise(resolve => {
const t = setInterval(() => {
if (window.google && window.google.maps && window.google.maps.Map) {
clearInterval(t);
resolve();
}
}, 50);
});
return mapsReadyPromise;
}
const MAP_OPTS_BASE = {
mapTypeId: "satellite",
tilt: 0,
disableDefaultUI: true,
zoomControl: true,
zoomControlOptions: { position: 0 }, // position will be remapped after google loads
gestureHandling: "greedy",
backgroundColor: "#1c1f24",
};
// ----- Layer fetch (resolves to { url, bounds }) --------------
async function fetchLayer(layer, lat, lng, monthIdx) {
let url = `/api/layers/${layer}?lat=${lat}&lng=${lng}`;
if (layer === "monthly_flux" && monthIdx != null) url += `&month=${monthIdx}`;
const r = await fetch(url);
if (!r.ok) throw new Error(`layer fetch failed: ${r.status}`);
const j = await r.json();
return j; // { png, bounds: {south,west,north,east} }
}
// ============================================================
// HomeMapView
// ============================================================
function HomeMapView(props) {
const {
building, activeLayers, monthIdx, panelCount, hoveredSegment, onHoverSegment,
pendingLocation, pendingPolygon, selectionMode, lastSelection,
} = props;
const containerRef = useRefM(null);
const mapRef = useRefM(null);
const overlaysRef = useRefM({}); // active GroundOverlay instances by layer key
const layerCacheRef = useRefM({}); // layer key → { url, bounds }
const vectorRef = useRefM({ segments: [], panels: [], footprint: null, hoverSeg: null });
const interactiveRef = useRefM({
pendingMarker: null,
drawingPolyline: null,
drawingVertices: [],
drawingMarkers: [],
pendingPolygonShape: null,
drawnPolygonShape: null,
mapClickListener: null,
selectionMarker: null,
selectionLink: null,
});
const [ready, setReady] = useStateM(false);
// Always-current monthIdx for use inside async callbacks (so the preload
// .then() picks the right month even if the user kept scrubbing).
const monthIdxRef = useRefM(monthIdx);
useEffectM(() => { monthIdxRef.current = monthIdx; }, [monthIdx]);
// Init map
useEffectM(() => {
let cancelled = false;
waitForMaps().then(() => {
if (cancelled || mapRef.current) return;
const center = building?.center || [28.4941, 77.0907];
const opts = {
...MAP_OPTS_BASE,
zoom: 20,
center: { lat: center[0], lng: center[1] },
mapId: (window.LEPTON_CFG?.mapId && window.LEPTON_CFG.mapId !== "__GOOGLE_MAPS_MAP_ID__") ? window.LEPTON_CFG.mapId : undefined,
zoomControlOptions: { position: window.google.maps.ControlPosition.RIGHT_BOTTOM },
};
mapRef.current = new window.google.maps.Map(containerRef.current, opts);
// Expose for debugging / e2e tests.
window.__leptonHomeMap = mapRef.current;
setReady(true);
});
return () => { cancelled = true; };
}, []);
// Listen for the search-bar 'pan-to' event. Just navigates the camera — no pin,
// no fetch. User then clicks/draws to make a selection.
useEffectM(() => {
if (!ready) return;
const map = mapRef.current;
const handler = (e) => {
const { lat, lng, zoom } = e.detail || {};
if (typeof lat !== "number" || typeof lng !== "number") return;
map.panTo({ lat, lng });
if (typeof zoom === "number") map.setZoom(zoom);
};
window.addEventListener("lepton:pan-to", handler);
return () => window.removeEventListener("lepton:pan-to", handler);
}, [ready]);
// Recenter on building change. Hard setCenter + zoom reset (panTo is too gentle
// for far-off jumps and keeps any user-zoomed level).
useEffectM(() => {
if (!ready || !building?.center) return;
const map = mapRef.current;
if (!map) return;
map.setCenter({ lat: building.center[0], lng: building.center[1] });
map.setZoom(20);
// Wipe all raster overlays — they belong to the OLD building's GeoTIFF
// bounds and would otherwise stick until the user toggles each layer.
Object.values(overlaysRef.current).forEach(o => {
if (o.overlay) o.overlay.setMap(null);
if (o.overlays) o.overlays.forEach(ov => ov.setMap(null));
});
overlaysRef.current = {};
// Also drop the layer fetch cache for the previous building.
layerCacheRef.current = {};
}, [ready, building?.id]);
// ----- Raster layers via GroundOverlay -----------------------
useEffectM(() => {
if (!ready) return;
const map = mapRef.current;
const g = window.google.maps;
const center = building.center;
// Map of layer-name in our UI → backend layer key
const RASTER_LAYERS = [
{ key: "rgb", backend: "rgb", enabled: !!activeLayers.rgb, opacity: 1.0 },
{ key: "mask", backend: "mask", enabled: !!activeLayers.mask, opacity: 0.50 },
{ key: "annualFlux", backend: "annual_flux", enabled: !!activeLayers.annualFlux, opacity: 0.78 },
{ key: "monthlyFlux", backend: "monthly_flux", enabled: !!activeLayers.monthlyFlux, opacity: 0.82, refreshOnMonth: true },
{ key: "shaded", backend: "shaded_hours", enabled: !!activeLayers.shaded, opacity: 0.72 },
{ key: "dsm", backend: "dsm", enabled: !!activeLayers.dsm, opacity: 0.78 },
];
RASTER_LAYERS.forEach(L => {
// ----- Monthly-flux special path: keep 12 pre-loaded overlay instances
// so temporal playback is just a setMap() toggle (~instant). The actual
// month switching lives in a separate useEffect below. -----
if (L.key === "monthlyFlux") {
const slot = overlaysRef.current[L.key];
const slotKey = `${building.id}/monthlyFlux-all`;
if (!L.enabled) {
if (slot && slot.overlays) slot.overlays.forEach(o => o.setMap(null));
if (slot) delete overlaysRef.current[L.key];
return;
}
// If preload is already done or in flight for this building → noop here.
// The visibility-toggle effect handles which month is shown.
if (slot && slot.cacheKey === slotKey) return;
// Start parallel preload of all 12 months.
overlaysRef.current[L.key] = { cacheKey: slotKey, overlays: null };
Promise.all(Array.from({ length: 12 }, async (_, m) => {
const k = `${building.id}/monthlyFlux-m${m}`;
if (layerCacheRef.current[k]) return layerCacheRef.current[k];
const j = await fetchLayer("monthly_flux", center[0], center[1], m);
layerCacheRef.current[k] = j;
return j;
})).then(results => {
const cur = overlaysRef.current[L.key];
if (!cur || cur.cacheKey !== slotKey) return; // got swapped during load
// Warm the browser's HTTP cache for all 12 PNGs up front.
// GroundOverlay only fetches its image when first attached to a map,
// so without this, scrubbing through months for the first time still
// shows a brief flash per month. Preloading s in parallel removes
// that latency entirely.
results.forEach(j => { const im = new Image(); im.src = j.png; });
const overlays = results.map(j => {
const b = j.bounds;
const bounds = new g.LatLngBounds(
{ lat: b.south, lng: b.west },
{ lat: b.north, lng: b.east },
);
return new g.GroundOverlay(j.png, bounds, { clickable: false, opacity: L.opacity });
});
overlaysRef.current[L.key] = { cacheKey: slotKey, overlays };
const m = monthIdxRef.current;
overlays.forEach((o, i) => o.setMap(i === m ? map : null));
}).catch(err => console.warn("monthly flux preload failed:", err.message));
return;
}
// ----- Default path for non-temporal layers -----
const cacheKey = `${building.id}/${L.key}`;
const active = overlaysRef.current[L.key];
if (!L.enabled) {
if (active) { active.overlay.setMap(null); delete overlaysRef.current[L.key]; }
return;
}
if (active && active.cacheKey === cacheKey) return;
if (active) { active.overlay.setMap(null); delete overlaysRef.current[L.key]; }
let cancelled = false;
const promise = layerCacheRef.current[cacheKey]
? Promise.resolve(layerCacheRef.current[cacheKey])
: fetchLayer(L.backend, center[0], center[1])
.then(j => { layerCacheRef.current[cacheKey] = j; return j; });
promise.then(j => {
if (cancelled || !overlaysRef.current || overlaysRef.current[L.key]?.cacheKey === cacheKey) return;
const b = j.bounds;
const bounds = new g.LatLngBounds(
{ lat: b.south, lng: b.west },
{ lat: b.north, lng: b.east },
);
const ov = new g.GroundOverlay(j.png, bounds, { clickable: false, opacity: L.opacity });
ov.setMap(map);
overlaysRef.current[L.key] = { overlay: ov, cacheKey };
}).catch(err => console.warn(`Layer ${L.key} failed:`, err.message));
});
}, [ready, building.id, activeLayers.rgb, activeLayers.mask, activeLayers.annualFlux, activeLayers.monthlyFlux, activeLayers.shaded, activeLayers.dsm, monthIdx]);
// ----- Monthly-flux visibility toggle (instant — just setMap on overlays). -----
useEffectM(() => {
if (!ready) return;
if (!activeLayers.monthlyFlux) return;
const map = mapRef.current;
const slot = overlaysRef.current.monthlyFlux;
if (!slot || !slot.overlays) return; // preload not done yet — .then() will handle it
slot.overlays.forEach((o, m) => o.setMap(m === monthIdx ? map : null));
}, [ready, monthIdx, activeLayers.monthlyFlux]);
// ----- Footprint + roof segments + panels (vector) -----------
useEffectM(() => {
if (!ready) return;
const map = mapRef.current;
const g = window.google.maps;
// Clear previous vector layers
vectorRef.current.segments.forEach(s => s.setMap(null));
vectorRef.current.panels.forEach(p => p.setMap(null));
if (vectorRef.current.footprint) vectorRef.current.footprint.setMap(null);
if (vectorRef.current.hoverSeg) vectorRef.current.hoverSeg.setMap(null);
vectorRef.current = { segments: [], panels: [], footprint: null, hoverSeg: null };
// Footprint (boundingBox rectangle)
if (building.footprint && building.footprint.length) {
const path = building.footprint.map(toLL);
const fp = new g.Polygon({
paths: path,
strokeColor: "#ffffff",
strokeOpacity: 0.55,
strokeWeight: 1.0,
fillOpacity: 0,
clickable: false,
map,
});
vectorRef.current.footprint = fp;
}
// Roof segments
if (activeLayers.segments) {
const segColors = ["#f59e0b", "#3b82f6", "#10b981", "#ec4899", "#a855f7", "#ef4444"];
(building.roofSegments || []).forEach((seg, idx) => {
if (!seg.polygon || seg.polygon.length < 3) return;
const poly = new g.Polygon({
paths: seg.polygon.map(toLL),
strokeColor: segColors[idx % segColors.length],
strokeOpacity: 0.95,
strokeWeight: 1.8,
fillOpacity: 0,
// Don't swallow map clicks — pin-drop / polygon vertex placement must work
// even when the cursor is over a roof segment outline.
clickable: false,
map,
});
vectorRef.current.segments.push(poly);
});
}
// Panels: top-N by yearlyEnergyDcKwh
if (activeLayers.panels && building.panels && building.panels.length) {
const sorted = [...building.panels].sort((a, b) => (b.yearlyEnergyDcKwh || 0) - (a.yearlyEnergyDcKwh || 0));
const visible = sorted.slice(0, panelCount);
visible.forEach(p => {
const seg = building.roofSegments[p.segIdx] || building.roofSegments[0];
const corners = panelCorners({
center: p.center,
azimuthDeg: seg ? seg.azimuthDeg : 0,
orientation: p.orientation,
panelWidthMeters: building.panelWidthMeters,
panelHeightMeters: building.panelHeightMeters,
});
if (!corners) return;
const poly = new g.Polygon({
paths: corners,
strokeColor: "#0d2c54",
strokeWeight: 0.6,
fillColor: "#1e40af",
fillOpacity: 0.75,
clickable: false,
map,
});
vectorRef.current.panels.push(poly);
});
}
// Hovered segment highlight
if (hoveredSegment) {
const seg = (building.roofSegments || []).find(s => s.id === hoveredSegment);
if (seg && seg.polygon && seg.polygon.length >= 3) {
vectorRef.current.hoverSeg = new g.Polygon({
paths: seg.polygon.map(toLL),
strokeColor: "#ffffff",
strokeOpacity: 0.95,
strokeWeight: 2.5,
fillColor: "#ffffff",
fillOpacity: 0.18,
clickable: false,
map,
});
}
}
}, [ready, building.id, activeLayers.segments, activeLayers.panels, panelCount, hoveredSegment]);
// ----- Pending marker (draggable) -----------------------------
useEffectM(() => {
if (!ready) return;
const map = mapRef.current;
const g = window.google.maps;
const ir = interactiveRef.current;
// Remove any existing pending marker.
if (ir.pendingMarker) {
ir.pendingMarker.map = null;
ir.pendingMarker = null;
}
if (!pendingLocation) return;
// Build a small pin-shaped element.
const el = document.createElement("div");
el.style.cssText = `
width: 20px; height: 20px; border-radius: 50%;
background: var(--accent-strong, #d46a14);
border: 3px solid white;
box-shadow: 0 2px 8px rgba(0,0,0,0.25), 0 0 0 1px var(--ink-1, #000);
cursor: grab;
transition: transform 0.1s;
`;
el.title = "Drag to refine, then Compute solar potential";
const marker = new g.marker.AdvancedMarkerElement({
position: { lat: pendingLocation.lat, lng: pendingLocation.lng },
map,
content: el,
gmpDraggable: true,
});
marker.addListener("dragend", (e) => {
const lat = e.latLng?.lat ?? marker.position?.lat;
const lng = e.latLng?.lng ?? marker.position?.lng;
if (typeof lat === "number" && typeof lng === "number") {
window.LEPTON_LOAD.setPending({
lat, lng,
label: pendingLocation.source === "polygon" ? "Drawn polygon" : null,
source: pendingLocation.source === "polygon" ? "polygon" : "pin",
});
}
});
ir.pendingMarker = marker;
}, [ready, pendingLocation?.lat, pendingLocation?.lng]);
// ----- "Your click" marker after Compute (when offset > threshold) -----
// Helps the user see that the API resolved a building NEAR their click rather
// than AT it. Renders only when offset > 15 m; otherwise it'd just clutter.
useEffectM(() => {
if (!ready) return;
const map = mapRef.current;
const g = window.google.maps;
const ir = interactiveRef.current;
if (ir.selectionMarker) { ir.selectionMarker.map = null; ir.selectionMarker = null; }
if (ir.selectionLink) { ir.selectionLink.setMap(null); ir.selectionLink = null; }
if (!lastSelection || lastSelection.offsetMeters < 15) return;
if (!building?.center) return;
// Small ringed dot at the user's original click.
const el = document.createElement("div");
el.style.cssText = `
width: 14px; height: 14px; border-radius: 50%;
background: rgba(255,255,255,0.95); border: 2px solid #ef4444;
box-shadow: 0 0 0 1.5px rgba(0,0,0,0.4);
`;
el.title = `Your click (${lastSelection.offsetMeters.toFixed(0)} m from resolved building)`;
ir.selectionMarker = new g.marker.AdvancedMarkerElement({
position: { lat: lastSelection.lat, lng: lastSelection.lng },
map,
content: el,
});
// Dashed line from click → resolved building center, so the offset is visible.
ir.selectionLink = new g.Polyline({
path: [
{ lat: lastSelection.lat, lng: lastSelection.lng },
{ lat: building.center[0], lng: building.center[1] },
],
strokeColor: "#ef4444",
strokeOpacity: 0,
icons: [{
icon: { path: "M 0,-1 0,1", strokeOpacity: 0.7, scale: 2 },
offset: "0",
repeat: "8px",
}],
clickable: false,
map,
});
}, [ready, lastSelection?.lat, lastSelection?.lng, lastSelection?.offsetMeters, building?.id]);
// ----- Click-to-drop pin (when in 'pin' selection mode) -------
useEffectM(() => {
if (!ready) return;
const map = mapRef.current;
const g = window.google.maps;
const ir = interactiveRef.current;
if (ir.mapClickListener) {
g.event.removeListener(ir.mapClickListener);
ir.mapClickListener = null;
}
// Map clicks ALWAYS handle either pin drop or polygon vertex add.
// Pin is the default; polygon is the explicit toggle.
ir.mapClickListener = map.addListener("click", (e) => {
const lat = e.latLng?.lat();
const lng = e.latLng?.lng();
if (typeof lat !== "number" || typeof lng !== "number") return;
if (selectionMode !== "polygon") {
// Default: drop a pin.
window.LEPTON_LOAD.setPending({ lat, lng, label: null, source: "pin" });
} else {
// Append to drawing vertices.
const vs = ir.drawingVertices.concat({ lat, lng });
ir.drawingVertices = vs;
renderPolygonDraft();
// Close on click near first vertex (within ~4 m at this zoom)
if (vs.length >= 3) {
const first = vs[0];
const d = Math.hypot((first.lat - lat) * 110540, (first.lng - lng) * 95000);
if (d < 6) {
// Close polygon
window.LEPTON_LOAD.setPolygon(vs.slice(0, -1)); // drop the duplicate "close" click
ir.drawingVertices = [];
clearPolygonDraft();
return;
}
}
}
});
function clearPolygonDraft() {
if (ir.drawingPolyline) { ir.drawingPolyline.setMap(null); ir.drawingPolyline = null; }
ir.drawingMarkers.forEach(m => { m.map = null; });
ir.drawingMarkers = [];
}
function renderPolygonDraft() {
// Polyline through current vertices
if (ir.drawingPolyline) ir.drawingPolyline.setMap(null);
ir.drawingMarkers.forEach(m => { m.map = null; });
ir.drawingMarkers = [];
ir.drawingPolyline = new g.Polyline({
path: ir.drawingVertices.map(v => ({ lat: v.lat, lng: v.lng })),
strokeColor: "#f59e0b",
strokeOpacity: 0.95,
strokeWeight: 2,
map,
});
ir.drawingVertices.forEach((v, i) => {
const el = document.createElement("div");
el.style.cssText = `
width: 9px; height: 9px; border-radius: 50%;
background: white; border: 2px solid #f59e0b;
${i === 0 ? "box-shadow: 0 0 0 2px rgba(245,158,11,0.4);" : ""}
`;
const m = new g.marker.AdvancedMarkerElement({
position: { lat: v.lat, lng: v.lng }, map, content: el,
});
ir.drawingMarkers.push(m);
});
}
return () => {
if (ir.mapClickListener) {
g.event.removeListener(ir.mapClickListener);
ir.mapClickListener = null;
}
};
}, [ready, selectionMode]);
// Clear in-progress polygon when leaving polygon mode.
useEffectM(() => {
const ir = interactiveRef.current;
if (selectionMode !== "polygon" && ir.drawingVertices.length) {
ir.drawingVertices = [];
if (ir.drawingPolyline) { ir.drawingPolyline.setMap(null); ir.drawingPolyline = null; }
ir.drawingMarkers.forEach(m => { m.map = null; });
ir.drawingMarkers = [];
}
}, [selectionMode]);
// ----- Render the completed pendingPolygon as a filled shape ---
useEffectM(() => {
if (!ready) return;
const g = window.google.maps;
const map = mapRef.current;
const ir = interactiveRef.current;
if (ir.pendingPolygonShape) { ir.pendingPolygonShape.setMap(null); ir.pendingPolygonShape = null; }
if (!pendingPolygon || pendingPolygon.length < 3) return;
ir.pendingPolygonShape = new g.Polygon({
paths: pendingPolygon.map(p => ({ lat: p.lat, lng: p.lng })),
strokeColor: "#f59e0b",
strokeOpacity: 1.0,
strokeWeight: 2,
fillColor: "#f59e0b",
fillOpacity: 0.12,
clickable: false,
map,
});
}, [ready, pendingPolygon]);
// ----- Render user's confirmed polygon (after Compute) ---------
useEffectM(() => {
if (!ready) return;
const g = window.google.maps;
const map = mapRef.current;
const ir = interactiveRef.current;
if (ir.drawnPolygonShape) { ir.drawnPolygonShape.setMap(null); ir.drawnPolygonShape = null; }
if (!building.userFootprint || building.userFootprint.length < 3) return;
ir.drawnPolygonShape = new g.Polygon({
paths: building.userFootprint.map(p => ({ lat: p[0], lng: p[1] })),
strokeColor: "#10b981",
strokeOpacity: 0.9,
strokeWeight: 2,
fillColor: "#10b981",
fillOpacity: 0.08,
clickable: false,
map,
});
}, [ready, building.id, building.userFootprint]);
// Cleanup overlays + vectors + interactive on unmount
useEffectM(() => () => {
Object.values(overlaysRef.current || {}).forEach(o => {
if (o.overlay) o.overlay.setMap(null);
if (o.overlays) o.overlays.forEach(ov => ov.setMap(null)); // monthly-flux: 12-overlay slot
});
vectorRef.current.segments.forEach(s => s.setMap(null));
vectorRef.current.panels.forEach(p => p.setMap(null));
if (vectorRef.current.footprint) vectorRef.current.footprint.setMap(null);
if (vectorRef.current.hoverSeg) vectorRef.current.hoverSeg.setMap(null);
const ir = interactiveRef.current;
if (ir.pendingMarker) ir.pendingMarker.map = null;
if (ir.drawingPolyline) ir.drawingPolyline.setMap(null);
ir.drawingMarkers.forEach(m => { m.map = null; });
if (ir.pendingPolygonShape) ir.pendingPolygonShape.setMap(null);
if (ir.drawnPolygonShape) ir.drawnPolygonShape.setMap(null);
if (ir.selectionMarker) ir.selectionMarker.map = null;
if (ir.selectionLink) ir.selectionLink.setMap(null);
}, []);
return