Spaces:
Running
Running
| 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) | |
| } | |
| } | |