gridloc / src /components /MapView.tsx
lassanmonster's picture
GridLoc: Bengaluru parking congestion digital twin
db3dbe1
Raw
History Blame Contribute Delete
23.4 kB
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<HTMLDivElement | null>(null)
const mapRef = useRef<any>(null)
const overlayRef = useRef<any>(null)
const layersModRef = useRef<LayersModule | null>(null)
// Currently DISPLAYED selection (what the layers + camera reflect).
const currentRef = useRef<string | null>(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<Array<string>>([])
const flyingRef = useRef(false)
// Dwell timer between queued hops (cleared on deselect / direct fly).
const dwellRef = useRef<number | null>(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<string | null>(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<Array<[number, number]> | 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<unknown> = [
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 (
<div className="fixed inset-0 bg-bg">
<div ref={containerRef} className="absolute inset-0 h-full w-full" />
{/* Blur + dim everywhere EXCEPT the selected hex, punched out as a clear
hole so the real map shows through crisply. Click-through. */}
<BlurWithHole active={!!selected} hexScreen={hexScreen} />
{status === 'loading' && (
<div className="pointer-events-none absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 font-mono text-sm" style={{ color: 'rgba(133,149,170,0.85)' }}>
Loading map…
</div>
)}
{status === 'error' && (
<div className="absolute left-1/2 top-1/2 max-w-sm -translate-x-1/2 -translate-y-1/2 px-4 py-3 text-center font-mono text-sm" style={{ color: '#aab6c8' }}>
Map failed to load. Check the network connection to CARTO basemaps.
</div>
)}
</div>
)
}
/**
* 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 (
<>
<div
className="pointer-events-none absolute inset-0"
style={{
zIndex: 10,
opacity: active ? 1 : 0,
transition: 'opacity 600ms ease',
background: 'rgba(0,0,0,0.72)',
backdropFilter: 'blur(8px)',
WebkitBackdropFilter: 'blur(8px)',
clipPath,
WebkitClipPath: clipPath,
}}
/>
{/* Warm-white rim around the clear hole - spotlight frame. */}
{active && hasHole && (
<svg className="pointer-events-none absolute inset-0 h-full w-full" style={{ zIndex: 11 }}>
<polygon
points={hexScreen!.map(([x, y]) => `${x},${y}`).join(' ')}
fill="none"
stroke="#F5EFE6"
strokeOpacity={0.7}
strokeWidth={2}
/>
</svg>
)}
</>
)
}
// ── 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<string, boolean>) {
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<string, number> = {
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)
}
}