import { useEffect, useRef, useState } from 'react' import { cellToBoundary } from 'h3-js' import 'maplibre-gl/dist/maplibre-gl.css' import { useTwinStore } from '../lib/twin/store' import { getBasemap } from '../lib/twin/basemaps' // Bengaluru center - MapLibre uses [lng, lat] order. const CENTER: [number, number] = [77.5946, 12.9716] // Tilted overview camera (the default view, and where deselect returns to). const OVERVIEW = { center: CENTER, zoom: 11.5, pitch: 62, bearing: -20, } // Camera used when a hex is selected - fully top-down (vertical), modestly // zoomed so the hex fills more of the frame while keeping street context. const SELECTED_ZOOM = 16.4 const SELECTED_PITCH = 0 const SELECTED_BEARING = 0 // Hex-to-hex / select transition duration - slow enough to clearly perceive // the camera gliding from one zone to the next, even when stepping fast. const HEX_TRANSITION_MS = 1900 // While a hex is selected the camera is CONSTRAINED (not frozen): you can still // pan a little and nudge the zoom, but you can't zoom far out or wander off the // hex. These define how much slack to allow around the selected view. const SELECTED_MIN_ZOOM = SELECTED_ZOOM - 0.8 // can't zoom out far const SELECTED_MAX_ZOOM = SELECTED_ZOOM + 2.5 // can zoom in for detail const SELECTED_PAN_PAD = 0.012 // ~1.3km of pan slack around the hex (degrees) // Layer-builder module, loaded once and cached (keeps deck.gl out of SSR). type LayersModule = typeof import('../lib/twin/layers') // How long each zone stays clear + settled before the camera moves to the next // queued zone. Guarantees a beat of unblurred visibility per zone even when // stepping fast through the list. const DWELL_MS = 650 export function MapView() { const containerRef = useRef(null) const mapRef = useRef(null) const overlayRef = useRef(null) const layersModRef = useRef(null) // Currently DISPLAYED selection (what the layers + camera reflect). const currentRef = useRef(null) // Pending camera targets - every list step pushes one so fast stepping still // plays a continuous fly-through rather than jumping to the last. const queueRef = useRef>([]) const flyingRef = useRef(false) // Dwell timer between queued hops (cleared on deselect / direct fly). const dwellRef = useRef(null) const [status, setStatus] = useState<'loading' | 'ready' | 'error'>('loading') // Selected hex mirrored into React state to drive the blur/spotlight overlay. const [selected, setSelected] = useState(null) // Screen-pixel corners of the selected hex (the blur hole). Null until the // camera settles so the hole only appears once the view is crisp. const [hexScreen, setHexScreen] = useState | null>(null) // Compose deck.gl layers for a given selection. roads=true adds the road // slices (only after the camera settles). const renderLayers = (hexId: string | null, withRoads: boolean) => { const mod = layersModRef.current const overlay = overlayRef.current if (!mod || !overlay) return const { buildHexLayer, buildPatrolGlowLayer, buildRoadLayers, buildSelectedHexLayer, segmentsInHex } = mod if (!hexId) { overlay.setProps({ layers: [buildPatrolGlowLayer(null), buildHexLayer(null)] }) return } const layers: Array = [ buildPatrolGlowLayer(hexId), buildHexLayer(hexId), buildSelectedHexLayer(hexId), ] if (withRoads) layers.push(...buildRoadLayers(segmentsInHex(hexId))) overlay.setProps({ layers }) } // Project a hex's H3 boundary to screen-pixel corners for the blur hole. const computeHexScreen = (hexId: string) => { const map = mapRef.current if (!map) return const pts = cellToBoundary(hexId).map(([lat, lng]) => { const p = map.project([lng, lat]) return [p.x, p.y] as [number, number] }) setHexScreen(pts) } // Drain the camera queue one target at a time. Each hop is a brief flight so // a fast run through the list reads as continuous motion. When the backlog // grows (fast scrolling), shorten each hop a little so the camera keeps up. const drainQueue = () => { const map = mapRef.current if (!map || flyingRef.current) return const next = queueRef.current.shift() if (next === undefined) return flyingRef.current = true currentRef.current = next setSelected(next) const hex = layersModRef.current?.HEXES?.find?.((h: any) => h.h3 === next) renderLayers(next, false) // Position the hole on the target NOW (using current camera) so the blur // appears with a clear hex immediately; the move handler keeps it aligned // as the camera flies in. computeHexScreen(next) const center = hex ? ([hex.center[1], hex.center[0]] as [number, number]) : undefined // Release constraints so the flight to the next hex isn't fenced in. unlockCamera() map.flyTo({ center: center ?? map.getCenter(), zoom: SELECTED_ZOOM, // Stay fully top-down throughout - never inherit/flash the overview tilt // when moving between already-selected hexes. pitch: SELECTED_PITCH, bearing: SELECTED_BEARING, duration: HEX_TRANSITION_MS, essential: true, easing: (t: number) => 1 - Math.pow(1 - t, 3), }) map.once('moveend', () => { // The flight for THIS zone has landed - render it clearly (roads + crisp // hole) so the user actually sees the unblurred hex... if (currentRef.current === next) { renderLayers(next, true) settleHole(next) // Re-fence the camera around the newly-landed hex (only if this is the // final queued hop - otherwise the next flight unlocks again anyway). if (queueRef.current.length === 0) lockCameraToHex(next) } // ...then DWELL before moving on, even if more zones are queued. This is // what gives each zone a beat of clear, settled visibility instead of // snapping straight through to the next. if (dwellRef.current) window.clearTimeout(dwellRef.current) dwellRef.current = window.setTimeout(() => { flyingRef.current = false drainQueue() }, DWELL_MS) }) } // Fly straight to a single target (map-click selection) - clears any backlog. const flyDirect = (hexId: string) => { const map = mapRef.current if (!map) return if (dwellRef.current) window.clearTimeout(dwellRef.current) queueRef.current = [] currentRef.current = hexId setSelected(hexId) renderLayers(hexId, false) computeHexScreen(hexId) const hex = layersModRef.current?.HEXES?.find?.((h: any) => h.h3 === hexId) const center = hex ? ([hex.center[1], hex.center[0]] as [number, number]) : map.getCenter() flyingRef.current = true unlockCamera() map.flyTo({ center, zoom: SELECTED_ZOOM, pitch: SELECTED_PITCH, bearing: SELECTED_BEARING, duration: HEX_TRANSITION_MS, essential: true, }) map.once('moveend', () => { flyingRef.current = false if (currentRef.current === hexId) { renderLayers(hexId, true) settleHole(hexId) lockCameraToHex(hexId) } }) } // Constrain (not freeze) the camera around the selected hex: clamp the zoom // range and fence panning to a padded box around the hex boundary, so the // user keeps a little freedom of movement but can't zoom way out or wander // off the zone. const lockCameraToHex = (hexId: string) => { const map = mapRef.current if (!map) return let minLng = Infinity let minLat = Infinity let maxLng = -Infinity let maxLat = -Infinity for (const [lat, lng] of cellToBoundary(hexId)) { if (lng < minLng) minLng = lng if (lng > maxLng) maxLng = lng if (lat < minLat) minLat = lat if (lat > maxLat) maxLat = lat } map.setMaxBounds([ [minLng - SELECTED_PAN_PAD, minLat - SELECTED_PAN_PAD], [maxLng + SELECTED_PAN_PAD, maxLat + SELECTED_PAN_PAD], ]) map.setMinZoom(SELECTED_MIN_ZOOM) map.setMaxZoom(SELECTED_MAX_ZOOM) } // Release all selection constraints so the camera can fly back to the wide // overview on deselect. const unlockCamera = () => { const map = mapRef.current if (!map) return map.setMaxBounds(null) map.setMinZoom(0) map.setMaxZoom(22) } // After the camera stops, wait for crisp high-zoom tiles, then align the hole. const settleHole = (hexId: string) => { const map = mapRef.current if (!map) return const ensure = () => { if (currentRef.current !== hexId) return computeHexScreen(hexId) if (!map.areTilesLoaded()) map.once('idle', ensure) } ensure() } const deselect = () => { if (dwellRef.current) window.clearTimeout(dwellRef.current) queueRef.current = [] currentRef.current = null flyingRef.current = false setSelected(null) setHexScreen(null) unlockCamera() renderLayers(null, false) // Explicitly restore the tilted overview angle - MapLibre keeps the last // pitch/bearing (top-down) unless we set them back here, which is what // otherwise leaves the camera stuck flat after a deselect. mapRef.current?.flyTo({ center: OVERVIEW.center, zoom: OVERVIEW.zoom, pitch: OVERVIEW.pitch, bearing: OVERVIEW.bearing, duration: 1400, essential: true, }) } // Subscribe to the store imperatively so EVERY selection transition is // captured (React render coalescing would drop intermediate steps during // fast keyboard stepping). useEffect(() => { let prev = useTwinStore.getState().selectedHexId const unsub = useTwinStore.subscribe((state) => { const id = state.selectedHexId if (id === prev) return prev = id if (id === null) { deselect() } else { // Both list and map selections fly STRAIGHT to the latest pick, // interrupting any in-flight hop. Fast stepping through the list now // lands on the final selection instead of visiting every intermediate // zone in turn. flyDirect(id) } }) return unsub // eslint-disable-next-line react-hooks/exhaustive-deps }, []) // React to basemap changes (style switcher / theme toggle): swap the style, // then re-install our decorations + overlay once the new style has parsed. useEffect(() => { let prevBasemap = useTwinStore.getState().basemapId const unsub = useTwinStore.subscribe((state) => { if (state.basemapId === prevBasemap) return prevBasemap = state.basemapId const map = mapRef.current if (!map) return map.setStyle(getBasemap(state.basemapId).style as any) map.once('styledata', () => { declutterLabels(map) addBoundaryLine(map) applyMapLayers(map, useTwinStore.getState().layers) // Re-render the current overlay (deck layers are dropped on setStyle). renderLayers(currentRef.current, !!currentRef.current) }) }) return unsub // eslint-disable-next-line react-hooks/exhaustive-deps }, []) // React to layer-visibility toggles (Map panel) - apply to the live style. useEffect(() => { let prevLayers = useTwinStore.getState().layers const unsub = useTwinStore.subscribe((state) => { if (state.layers === prevLayers) return prevLayers = state.layers const map = mapRef.current if (map) applyMapLayers(map, state.layers) }) return unsub // eslint-disable-next-line react-hooks/exhaustive-deps }, []) useEffect(() => { let cancelled = false // Re-gate the panels on (re)mount until the fly-in completes. useTwinStore.getState().setFlyInDone(false) import('maplibre-gl').then(({ Map, NavigationControl }) => { if (cancelled || !containerRef.current) return const map = new Map({ container: containerRef.current, style: getBasemap(useTwinStore.getState().basemapId).style as any, center: CENTER, zoom: 5, pitch: 0, bearing: 0, attributionControl: { compact: true }, pixelRatio: window.devicePixelRatio || 1, }) mapRef.current = map map.addControl(new NavigationControl({ visualizePitch: true }), 'top-right') map.on('load', () => { if (cancelled) return setStatus('ready') declutterLabels(map) addBoundaryLine(map) applyMapLayers(map, useTwinStore.getState().layers) addTwinOverlay(map, overlayRef, layersModRef) // Click routing: pick a hex → select via store (map source); empty // space → deselect. map.on('click', (e: any) => { const overlay = overlayRef.current const picked = overlay?.pickObject?.({ x: e.point.x, y: e.point.y, layerIds: ['twin-hexagons', 'twin-hex-selected'], }) const store = useTwinStore.getState() if (picked?.object?.h3 && picked?.object?.type !== 'calm') { if (picked.object.h3 !== store.selectedHexId) { store.selectFromMap(picked.object.h3) } } else { store.selectFromMap(null) } }) // Keep the blur hole aligned with the selected hex at all times - // including DURING the flight - so the background is blurred from the // start and the hex is already clear well before the camera settles. map.on('move', () => { if (currentRef.current) computeHexScreen(currentRef.current) }) // Cinematic swoop from the high aerial view down into the overview. // Signal completion ONCE so the dashboard panels animate in only after // the fly-in fully settles. map.flyTo({ ...OVERVIEW, duration: 3500, essential: true, easing: (t: number) => 1 - Math.pow(1 - t, 3), }) map.once('moveend', () => { useTwinStore.getState().setFlyInDone(true) }) }) map.on('error', (e: any) => { console.error('[MapLibre]', e?.error ?? e) if (!cancelled) setStatus('error') }) }) // Esc deselects (clears list selection + camera + blur). const onKey = (ev: KeyboardEvent) => { if (ev.key === 'Escape') useTwinStore.getState().selectFromMap(null) } window.addEventListener('keydown', onKey) return () => { cancelled = true window.removeEventListener('keydown', onKey) if (dwellRef.current) window.clearTimeout(dwellRef.current) try { overlayRef.current?.finalize?.() mapRef.current?.remove() } catch { // ignore teardown errors during hot reload } overlayRef.current = null mapRef.current = null } }, []) return (
{/* Blur + dim everywhere EXCEPT the selected hex, punched out as a clear hole so the real map shows through crisply. Click-through. */} {status === 'loading' && (
Loading map…
)} {status === 'error' && (
Map failed to load. Check the network connection to CARTO basemaps.
)}
) } /** * Full-screen dark blur overlay with the selected hex punched out as a clear * hole, using a donut CSS clip-path so the browser never paints/blurs pixels * inside the hex region. Sits behind the floating panels (z-10) and is * click-through. */ function BlurWithHole({ active, hexScreen, }: { active: boolean hexScreen: Array<[number, number]> | null }) { const [size, setSize] = useState<[number, number]>([0, 0]) useEffect(() => { const update = () => setSize([window.innerWidth, window.innerHeight]) update() window.addEventListener('resize', update) return () => window.removeEventListener('resize', update) }, []) const [w, h] = size const hasHole = !!(hexScreen && hexScreen.length >= 3) let clipPath: string | undefined if (w && h) { const rect = `0px 0px, ${w}px 0px, ${w}px ${h}px, 0px ${h}px, 0px 0px` if (hasHole) { const hex = hexScreen!.map(([x, y]) => `${x}px ${y}px`).join(', ') const first = `${hexScreen![0][0]}px ${hexScreen![0][1]}px` clipPath = `polygon(evenodd, ${rect}, ${hex}, ${first})` } else { clipPath = `polygon(${rect})` } } return ( <>
{/* Warm-white rim around the clear hole - spotlight frame. */} {active && hasHole && ( `${x},${y}`).join(' ')} fill="none" stroke="#F5EFE6" strokeOpacity={0.7} strokeWidth={2} /> )} ) } // ── Basemap layer visibility ───────────────────────────────────────────── // Map each style layer to a toggleable category by id pattern (checked in // priority order so e.g. "roadname" counts as a label, not a road). type LayerCat = 'label' | 'border' | 'water' | 'building' | 'road' | 'land' function categoryForLayer(id: string): LayerCat | null { if (/label|place_|poi|roadname|watername|housenumber|country|state|continent|marine|airport/i.test(id)) return 'label' if (/boundary|admin|border/i.test(id)) return 'border' if (/water|ocean|sea|river|lake/i.test(id)) return 'water' if (/building/i.test(id)) return 'building' if (/road|bridge|tunnel|highway|street|transit|rail/i.test(id)) return 'road' if (/land|park|wood|forest|grass|sand|wetland|landuse|landcover|pier|aeroway/i.test(id)) return 'land' return null } /** Apply 2D layer visibility + the extruded 3D building/road overlays. */ function applyMapLayers(map: any, vis: Record) { try { const layers = map.getStyle()?.layers ?? [] for (const layer of layers) { if (layer.id.startsWith('twin-') || layer.id.startsWith('blr-')) continue const cat = categoryForLayer(layer.id) if (!cat) continue map.setLayoutProperty(layer.id, 'visibility', vis[cat] ? 'visible' : 'none') } } catch (err) { console.warn('[MapView] could not apply layer visibility', err) } apply3DLayer(map, 'building', !!vis.building3d) apply3DLayer(map, 'road', !!vis.road3d) } /** * Add or remove an extruded overlay derived from the basemap's vector source. * Buildings extrude by render_height; roads get a thin uniform extrusion so the * arterial network reads as a raised 3D ribbon. */ function apply3DLayer(map: any, kind: 'building' | 'road', on: boolean) { const layerId = `twin-3d-${kind}` try { const existing = map.getLayer(layerId) if (!on) { if (existing) map.removeLayer(layerId) return } if (existing) return // Find a source style layer of this kind to borrow source + source-layer. const src = (map.getStyle()?.layers ?? []).find((l: any) => { if (kind === 'building') return /building/i.test(l.id) && l['source-layer'] return /road|transportation/i.test(l.id) && l['source-layer'] }) if (!src) return map.addLayer({ id: layerId, type: 'fill-extrusion', source: src.source, 'source-layer': src['source-layer'], ...(kind === 'building' ? {} : { filter: ['==', ['geometry-type'], 'Polygon'] }), paint: { 'fill-extrusion-color': kind === 'building' ? '#8a93a6' : '#c2ccd9', 'fill-extrusion-opacity': 0.85, 'fill-extrusion-height': kind === 'building' ? ['*', ['coalesce', ['get', 'render_height'], 10], 2.2] : 6, 'fill-extrusion-base': kind === 'building' ? ['coalesce', ['get', 'render_min_height'], 0] : 0, }, }) } catch (err) { console.warn(`[MapView] could not toggle 3D ${kind}`, err) } } function addBoundaryLine(map: any) { try { if (map.getSource('blr-outer')) return map.addSource('blr-outer', { type: 'geojson', data: '/bangalore-outer-boundary.geojson', }) map.addLayer({ id: 'blr-outer-line', type: 'line', source: 'blr-outer', layout: { 'line-join': 'round', 'line-cap': 'round' }, paint: { 'line-color': '#8595aa', 'line-width': 2, 'line-opacity': 0.55, // Very short dash + round cap renders as round dots, not dashes. 'line-dasharray': [0.3, 1.8], }, }) } catch (err) { console.warn('[MapView] could not add outer boundary line', err) } } /** * Wire deck.gl onto the MapLibre map via MapboxOverlay (interleaved). Caches * the layers module so the selection handlers can build layers synchronously. */ function addTwinOverlay( map: any, overlayRef: { current: any }, layersModRef: { current: LayersModule | null }, ) { Promise.all([import('@deck.gl/mapbox'), import('../lib/twin/layers')]) .then(([{ MapboxOverlay }, layersMod]) => { layersModRef.current = layersMod const overlay = new MapboxOverlay({ interleaved: true, layers: [layersMod.buildPatrolGlowLayer(null), layersMod.buildHexLayer()], }) overlayRef.current = overlay map.addControl(overlay) }) .catch((err) => { console.warn('[MapView] could not add deck.gl twin overlay', err) }) } /** * Reduce label clutter on the CARTO dark-matter style by raising the minzoom of * small-place / minor-road label layers. State + major-city labels untouched. */ function declutterLabels(map: any) { try { const layers = map.getStyle()?.layers ?? [] const symbolLayers = layers.filter((l: any) => l.type === 'symbol') const minZoomById: Record = { place_hamlet: 13, place_villages: 13, place_suburbs: 10, place_town: 9, roadname_minor: 13, roadname_sec: 12, housenumber: 16, } for (const layer of symbolLayers) { const target = minZoomById[layer.id] if (target === undefined) continue const currentMax = typeof layer.maxzoom === 'number' ? layer.maxzoom : 24 map.setLayerZoomRange(layer.id, target, currentMax) } } catch (err) { console.warn('[MapView] could not declutter labels', err) } }