/* ============================================================ 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
; } // ============================================================ // EnterpriseMapView // ============================================================ function EnterpriseMapView({ scenario, colorMode, selectedId, onSelect, hoveredId, onHover }) { const containerRef = useRefM(null); const mapRef = useRefM(null); const markersRef = useRefM([]); const [ready, setReady] = useStateM(false); useEffectM(() => { let cancelled = false; waitForMaps().then(() => { if (cancelled || mapRef.current) return; const opts = { ...MAP_OPTS_BASE, zoom: scenario.zoom, center: { lat: scenario.center[0], lng: scenario.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); setReady(true); }); return () => { cancelled = true; }; }, []); // Re-center when scenario changes useEffectM(() => { if (!ready) return; const map = mapRef.current; if (!map) return; map.setCenter({ lat: scenario.center[0], lng: scenario.center[1] }); map.setZoom(scenario.zoom); }, [ready, scenario.id]); // Markers useEffectM(() => { if (!ready) return; const map = mapRef.current; const g = window.google.maps; markersRef.current.forEach(m => { m.setMap && m.setMap(null); if (m.map) m.map = null; }); markersRef.current = []; if (!scenario.buildings || !scenario.buildings.length) return; const values = scenario.buildings.map(b => { if (colorMode === "kwp") return b.kWp || 0; if (colorMode === "payback") return -(b.payback || 99); return b.suitability || 0; }); const vmin = Math.min(...values), vmax = Math.max(...values); const norm = v => (v - vmin) / ((vmax - vmin) || 1); scenario.buildings.forEach(b => { const v = colorMode === "kwp" ? (b.kWp || 0) : colorMode === "payback" ? -(b.payback || 99) : (b.suitability || 0); const t = norm(v); const rgb = rampColor(FLUX_RAMP, t); const isSel = b.id === selectedId; const isHov = b.id === hoveredId; const size = isSel ? 18 : isHov ? 16 : 12; const el = document.createElement("div"); el.className = `bldg-marker${isSel ? " is-selected" : ""}`; el.style.background = `rgb(${rgb.join(",")})`; el.style.width = el.style.height = `${size}px`; el.title = `${b.id} · ${b.clusterName}\n${b.kWp} kWp · ${(b.yearlyKwh/1000).toFixed(1)} MWh/yr · payback ${b.payback} yrs`; const marker = new g.marker.AdvancedMarkerElement({ position: { lat: b.lat, lng: b.lng }, map, content: el, }); el.addEventListener("click", () => onSelect && onSelect(b.id)); el.addEventListener("mouseenter", () => onHover && onHover(b.id)); el.addEventListener("mouseleave", () => onHover && onHover(null)); markersRef.current.push(marker); }); }, [ready, scenario.id, scenario.buildings, colorMode, selectedId, hoveredId]); useEffectM(() => () => { markersRef.current.forEach(m => { if (m.map) m.map = null; }); markersRef.current = []; }, []); return
; } // ============================================================ // CityMapView — heatmap + polygon for the city-analysis tab. // Uses the same pendingPolygon / selectionMode flow as the // homeowner map (driven by window.LEPTON_LOAD.setPolygon / // setSelectionMode), so the PolygonToggle pill and the draw // experience are identical across tabs. // ============================================================ function CityMapView(props) { const { pendingPolygon, selectionMode, heatmapUrl, heatmapBounds, autoFitOnPolygon } = props; const containerRef = useRefM(null); const mapRef = useRefM(null); const overlayRef = useRefM(null); // GroundOverlay for heatmap const drawingRef = useRefM({ vertices: [], // in-progress vertices (before close) polyline: null, markers: [], polygonShape: null, // closed polygon shape clickListener: null, }); // Track the polygon prop value via ref so the click listener (bound once per // selectionMode change) sees the latest closed polygon. const polygonRef = useRefM(pendingPolygon); useEffectM(() => { polygonRef.current = pendingPolygon; }, [pendingPolygon]); const [ready, setReady] = useStateM(false); // Init map (default Delhi NCR; pan/search elsewhere as needed). useEffectM(() => { let cancelled = false; waitForMaps().then(() => { if (cancelled || mapRef.current) return; const opts = { ...MAP_OPTS_BASE, zoom: 13, center: { lat: 28.5275, lng: 77.0732 }, 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); window.__leptonCityMap = mapRef.current; setReady(true); }); return () => { cancelled = true; }; }, []); // Listen for search-bar 'pan-to' events (same as HomeMapView), so the existing // autocomplete in homeowner mode also works to navigate the city map. 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]); // ----- Polygon drawing: same flow as HomeMapView ---------------- useEffectM(() => { if (!ready) return; const map = mapRef.current; const g = window.google.maps; const d = drawingRef.current; function clearDraft() { if (d.polyline) { d.polyline.setMap(null); d.polyline = null; } d.markers.forEach(m => { m.map = null; }); d.markers = []; } function renderDraft() { if (d.polyline) d.polyline.setMap(null); d.markers.forEach(m => { m.map = null; }); d.markers = []; d.polyline = new g.Polyline({ path: d.vertices.map(v => ({ lat: v.lat, lng: v.lng })), strokeColor: "#f59e0b", strokeOpacity: 0.95, strokeWeight: 2, map, }); d.vertices.forEach((v, i) => { const el = document.createElement("div"); el.style.cssText = ` width: 10px; height: 10px; border-radius: 50%; background: white; border: 2px solid #f59e0b; ${i === 0 ? "box-shadow: 0 0 0 3px rgba(245,158,11,0.35);" : ""} `; const m = new g.marker.AdvancedMarkerElement({ position: { lat: v.lat, lng: v.lng }, map, content: el, }); d.markers.push(m); }); } if (d.clickListener) { g.event.removeListener(d.clickListener); d.clickListener = null; } if (selectionMode !== "polygon") return; d.clickListener = map.addListener("click", (e) => { const lat = e.latLng?.lat(); const lng = e.latLng?.lng(); if (typeof lat !== "number" || typeof lng !== "number") return; // Drawing while a closed polygon is shown: starting a fresh draft, so // clear the existing polygon via the global setter. if (polygonRef.current && polygonRef.current.length >= 3 && d.vertices.length === 0) { window.LEPTON_LOAD.setPolygon(null); } const vs = d.vertices.concat({ lat, lng }); d.vertices = vs; renderDraft(); if (vs.length >= 4) { const first = vs[0]; const dLat = (first.lat - lat) * 110540; const dLng = (first.lng - lng) * 95000; const dist = Math.sqrt(dLat * dLat + dLng * dLng); if (dist < 18) { const closed = vs.slice(0, -1); d.vertices = []; clearDraft(); // Same global flow homeowner uses — store hooks all other // listeners (e.g. pendingLocation centroid) too. window.LEPTON_LOAD.setPolygon(closed); } } }); return () => { if (d.clickListener) { g.event.removeListener(d.clickListener); d.clickListener = null; } }; }, [ready, selectionMode]); // ----- Render closed polygon ----------------------------------- useEffectM(() => { if (!ready) return; const map = mapRef.current; const g = window.google.maps; const d = drawingRef.current; if (d.polygonShape) { d.polygonShape.setMap(null); d.polygonShape = null; } if (!pendingPolygon || pendingPolygon.length < 3) return; d.polygonShape = new g.Polygon({ paths: pendingPolygon.map(p => ({ lat: p.lat, lng: p.lng })), strokeColor: "#f59e0b", strokeOpacity: 0.95, strokeWeight: 2, fillColor: "#f59e0b", fillOpacity: 0.10, clickable: false, map, }); if (autoFitOnPolygon) { const bounds = new g.LatLngBounds(); pendingPolygon.forEach(p => bounds.extend({ lat: p.lat, lng: p.lng })); map.fitBounds(bounds, { top: 80, right: 80, bottom: 80, left: 80 }); } }, [ready, pendingPolygon]); // Clear draft when leaving polygon mode. useEffectM(() => { if (selectionMode !== "polygon") { const d = drawingRef.current; d.vertices = []; if (d.polyline) { d.polyline.setMap(null); d.polyline = null; } d.markers.forEach(m => { m.map = null; }); d.markers = []; } }, [selectionMode]); // ----- Heatmap GroundOverlay ---------------------------------- useEffectM(() => { if (!ready) return; const map = mapRef.current; const g = window.google.maps; if (overlayRef.current) { overlayRef.current.setMap(null); overlayRef.current = null; } if (!heatmapUrl || !heatmapBounds) return; const b = heatmapBounds; const bounds = new g.LatLngBounds( { lat: b.south, lng: b.west }, { lat: b.north, lng: b.east }, ); const ov = new g.GroundOverlay(heatmapUrl, bounds, { clickable: false, opacity: 0.82 }); ov.setMap(map); overlayRef.current = ov; }, [ready, heatmapUrl, heatmapBounds?.south, heatmapBounds?.west, heatmapBounds?.north, heatmapBounds?.east]); // Cleanup on unmount. useEffectM(() => () => { if (overlayRef.current) overlayRef.current.setMap(null); const d = drawingRef.current; if (d.polyline) d.polyline.setMap(null); d.markers.forEach(m => { m.map = null; }); if (d.polygonShape) d.polygonShape.setMap(null); }, []); return
; } Object.assign(window, { HomeMapView, EnterpriseMapView, CityMapView, FLUX_RAMP, SHADE_RAMP, DSM_RAMP });