import { useEffect, useRef, forwardRef, useImperativeHandle, useState } from 'react' import maplibregl from 'maplibre-gl' import 'maplibre-gl/dist/maplibre-gl.css' import { Protocol } from 'pmtiles' import { layers, namedTheme } from 'protomaps-themes-base' import { useStore } from '../store' import { decodePolyline } from '../utils/decode' import { fetchReverse } from '../api' import { getConfig, hasFeature } from '../config' import { MapPin, Navigation, ArrowUpRight, ArrowDownLeft, Plus, Star, Ruler, X } from 'lucide-react' import RadialMenu from './RadialMenu' import useContextMenu from '../hooks/useContextMenu' import toast from 'react-hot-toast' const ROUTE_SOURCE = 'route-source' const BOUNDARY_SOURCE = 'boundary-source' const BOUNDARY_LAYER = 'boundary-layer' const ROUTE_LAYER_PREFIX = 'route-layer-' const HILLSHADE_SOURCE = 'hillshade-dem' const HILLSHADE_LAYER = 'hillshade-layer' const TRAFFIC_SOURCE = 'traffic-tiles' const TRAFFIC_LAYER = 'traffic-layer' const PUBLIC_LANDS_SOURCE = 'public-lands-tiles' const PUBLIC_LANDS_FILL = 'public-lands-fill' const PUBLIC_LANDS_LINE = 'public-lands-line' const PUBLIC_LANDS_LABEL = 'public-lands-label' const CONTOUR_SOURCE = 'contour-tiles' const CONTOUR_MINOR = 'contour-minor' const CONTOUR_INTERMEDIATE = 'contour-intermediate' const CONTOUR_INDEX = 'contour-index' const CONTOUR_LABEL = 'contour-label' const CONTOUR_TEST_SOURCE = 'contour-test-tiles' const CONTOUR_TEST_MINOR = 'contour-test-minor' const CONTOUR_TEST_INTERMEDIATE = 'contour-test-intermediate' const CONTOUR_TEST_INDEX = 'contour-test-index' const CONTOUR_TEST_LABEL = 'contour-test-label' const CONTOUR_TEST_10FT_SOURCE = 'contour-test-10ft-tiles' const CONTOUR_TEST_10FT_MINOR = 'contour-test-10ft-minor' const CONTOUR_TEST_10FT_INTERMEDIATE = 'contour-test-10ft-intermediate' const CONTOUR_TEST_10FT_INDEX = 'contour-test-10ft-index' const CONTOUR_TEST_10FT_LABEL = 'contour-test-10ft-label' const MEASURE_SOURCE = 'measure-source' const MEASURE_LINE_LAYER = 'measure-line-layer' const MEASURE_POINT_LAYER = 'measure-point-layer' // Highlight layers (filter-based for PMTiles compatibility) const HIGHLIGHT_SOURCE_LAYERS = ['places', 'pois'] const EMPTY_FILTER = ['==', ['get', 'name'], '___NOMATCH___'] function setupHighlightLayers(map, isDark) { const accentColor = getComputedStyle(document.documentElement).getPropertyValue('--accent').trim() || '#7a9a6b' HIGHLIGHT_SOURCE_LAYERS.forEach(sl => { if (map.getLayer('hover-hl-' + sl)) map.removeLayer('hover-hl-' + sl) if (map.getLayer('selected-hl-' + sl)) map.removeLayer('selected-hl-' + sl) }) HIGHLIGHT_SOURCE_LAYERS.forEach(sourceLayer => { map.addLayer({ id: 'hover-hl-' + sourceLayer, type: 'symbol', source: 'protomaps', 'source-layer': sourceLayer, filter: EMPTY_FILTER, layout: { 'text-field': ['coalesce', ['get', 'name:en'], ['get', 'name']], 'text-font': ['Noto Sans Regular'], 'text-size': ['interpolate', ['linear'], ['zoom'], 4, 10, 10, 14, 16, 18], 'text-allow-overlap': true, 'text-ignore-placement': true }, paint: { 'text-color': isDark ? '#ffffff' : '#000000', 'text-halo-color': isDark ? 'rgba(255,255,255,0.3)' : 'rgba(0,0,0,0.2)', 'text-halo-width': 2.5 }, }) map.addLayer({ id: 'selected-hl-' + sourceLayer, type: 'symbol', source: 'protomaps', 'source-layer': sourceLayer, filter: EMPTY_FILTER, layout: { 'text-field': ['coalesce', ['get', 'name:en'], ['get', 'name']], 'text-font': ['Noto Sans Regular'], 'text-size': ['interpolate', ['linear'], ['zoom'], 4, 10, 10, 14, 16, 18], 'text-allow-overlap': true, 'text-ignore-placement': true }, paint: { 'text-color': accentColor, 'text-halo-color': isDark ? 'rgba(122,154,107,0.5)' : 'rgba(122,154,107,0.3)', 'text-halo-width': 3 }, }) }) } function setHoverHighlight(map, feature) { HIGHLIGHT_SOURCE_LAYERS.forEach(sl => { if (map.getLayer('hover-hl-' + sl)) map.setFilter('hover-hl-' + sl, EMPTY_FILTER) }) if (!feature) return const name = feature.properties?.name, sourceLayer = feature.sourceLayer if (name && sourceLayer && map.getLayer('hover-hl-' + sourceLayer)) map.setFilter('hover-hl-' + sourceLayer, ['==', ['get', 'name'], name]) } function setSelectedHighlight(map, feature) { HIGHLIGHT_SOURCE_LAYERS.forEach(sl => { if (map.getLayer('selected-hl-' + sl)) map.setFilter('selected-hl-' + sl, EMPTY_FILTER) }) if (!feature) return const name = feature.properties?.name, sourceLayer = feature.sourceLayer if (name && sourceLayer && map.getLayer('selected-hl-' + sourceLayer)) map.setFilter('selected-hl-' + sourceLayer, ['==', ['get', 'name'], name]) } /** Build a full MapLibre style object for the given theme */ function buildStyle(themeName) { const config = getConfig() const tileUrl = config?.tileset?.url || '/tiles/na.pmtiles' const attribution = config?.tileset?.attribution || 'Protomaps \u00a9 OSM' return { version: 8, glyphs: 'https://protomaps.github.io/basemaps-assets/fonts/{fontstack}/{range}.pbf', sprite: `https://protomaps.github.io/basemaps-assets/sprites/v4/${themeName}`, sources: { protomaps: { type: 'vector', url: `pmtiles://${tileUrl}`, attribution, }, }, layers: layers('protomaps', namedTheme(themeName), { lang: 'en' }), } } /** SVG for ATAK-style chevron pointing up (will be rotated via CSS) */ /** Calculate haversine distance between two points in meters */ function haversineDistance(lat1, lon1, lat2, lon2) { const R = 6371000 // Earth radius in meters const dLat = (lat2 - lat1) * Math.PI / 180 const dLon = (lon2 - lon1) * Math.PI / 180 const a = Math.sin(dLat / 2) * Math.sin(dLat / 2) + Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) * Math.sin(dLon / 2) * Math.sin(dLon / 2) const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)) return R * c } /** Format distance for display (feet/miles, imperial) */ function formatDistance(meters) { const feet = meters * 3.28084 if (feet < 1000) return Math.round(feet) + " ft" const miles = feet / 5280 return miles < 10 ? miles.toFixed(2) + " mi" : miles.toFixed(1) + " mi" } const CHEVRON_SVG = `` /** Add hillshade raster-dem source + layer to the map */ function addHillshade(map) { if (!map || map.getSource(HILLSHADE_SOURCE)) return const config = getConfig() const hs = config?.tileset_hillshade if (!hs?.url) return map.addSource(HILLSHADE_SOURCE, { type: 'raster-dem', url: `pmtiles://${hs.url}`, encoding: hs.encoding || 'terrarium', tileSize: 256, maxzoom: hs.max_zoom || 12, }) // Insert below the first symbol/label layer for proper z-ordering let beforeId = undefined for (const layer of map.getStyle().layers) { if (layer.type === 'symbol') { beforeId = layer.id break } } map.addLayer({ id: HILLSHADE_LAYER, type: 'hillshade', source: HILLSHADE_SOURCE, paint: { 'hillshade-exaggeration': 0.5, 'hillshade-illumination-direction': 315, 'hillshade-shadow-color': '#000000', 'hillshade-highlight-color': '#ffffff', }, }, beforeId) } /** Remove hillshade layer + source */ function removeHillshade(map) { if (!map) return if (map.getLayer(HILLSHADE_LAYER)) map.removeLayer(HILLSHADE_LAYER) if (map.getSource(HILLSHADE_SOURCE)) map.removeSource(HILLSHADE_SOURCE) } /** Add traffic raster tile source + layer */ function addTraffic(map) { if (!map || map.getSource(TRAFFIC_SOURCE)) return const config = getConfig() const tr = config?.traffic if (!tr?.proxy_url) return const tileUrl = tr.proxy_url.replace('{z}', '{z}').replace('{x}', '{x}').replace('{y}', '{y}') map.addSource(TRAFFIC_SOURCE, { type: 'raster', tiles: [tileUrl], tileSize: 256, maxzoom: 18, }) map.addLayer({ id: TRAFFIC_LAYER, type: 'raster', source: TRAFFIC_SOURCE, paint: { 'raster-opacity': 0.6, }, }) } /** Remove traffic layer + source */ function removeTraffic(map) { if (!map) return if (map.getLayer(TRAFFIC_LAYER)) map.removeLayer(TRAFFIC_LAYER) if (map.getSource(TRAFFIC_SOURCE)) map.removeSource(TRAFFIC_SOURCE) } /** Add public lands vector tile overlay (PAD-US) */ function addPublicLands(map) { if (!map || map.getSource(PUBLIC_LANDS_SOURCE)) return map.addSource(PUBLIC_LANDS_SOURCE, { type: 'vector', url: 'pmtiles:///tiles/public-lands.pmtiles', }) // Insert below symbol layers for proper z-ordering let beforeId = undefined for (const layer of map.getStyle().layers) { if (layer.type === 'symbol') { beforeId = layer.id break } } const isDark = document.documentElement.getAttribute('data-theme') === 'dark' const opacityMod = isDark ? 0.7 : 1.0 // Fill layer — data-driven color by agency + designation map.addLayer({ id: PUBLIC_LANDS_FILL, type: 'fill', source: PUBLIC_LANDS_SOURCE, 'source-layer': 'public_lands', paint: { 'fill-color': [ 'case', ['==', ['get', 'designation'], 'WA'], '#7c6b2f', ['==', ['get', 'designation'], 'WSA'], '#7c6b2f', ['==', ['get', 'agency'], 'NPS'], '#3d6b1f', ['==', ['get', 'agency'], 'USFS'], '#5a7c2f', ['==', ['get', 'agency'], 'BLM'], '#c4a672', ['==', ['get', 'agency'], 'FWS'], '#4a7a5a', ['any', ['==', ['get', 'manager_type'], 'STAT'], ['==', ['get', 'agency'], 'SPR'], ['==', ['get', 'agency'], 'SDC'], ['==', ['get', 'agency'], 'SLB'] ], '#5a8c7c', ['any', ['==', ['get', 'manager_type'], 'LOC'], ['==', ['get', 'manager_type'], 'DIST'] ], '#8ca694', '#a0a0a0' ], 'fill-opacity': [ 'case', ['==', ['get', 'designation'], 'WA'], 0.30 * opacityMod, ['==', ['get', 'designation'], 'WSA'], 0.30 * opacityMod, ['==', ['get', 'agency'], 'NPS'], 0.30 * opacityMod, ['==', ['get', 'agency'], 'USFS'], 0.25 * opacityMod, ['==', ['get', 'agency'], 'BLM'], 0.20 * opacityMod, ['any', ['==', ['get', 'manager_type'], 'STAT'], ['==', ['get', 'agency'], 'SPR'] ], 0.25 * opacityMod, ['any', ['==', ['get', 'manager_type'], 'LOC'], ['==', ['get', 'manager_type'], 'DIST'] ], 0.20 * opacityMod, 0.15 * opacityMod ], }, }, beforeId) // Outline layer map.addLayer({ id: PUBLIC_LANDS_LINE, type: 'line', source: PUBLIC_LANDS_SOURCE, 'source-layer': 'public_lands', paint: { 'line-color': [ 'case', ['==', ['get', 'designation'], 'WA'], '#5a4d20', ['==', ['get', 'designation'], 'WSA'], '#5a4d20', ['==', ['get', 'agency'], 'NPS'], '#2a4a15', ['==', ['get', 'agency'], 'USFS'], '#3d5520', ['==', ['get', 'agency'], 'BLM'], '#8a7343', ['==', ['get', 'agency'], 'FWS'], '#2d5a3a', ['any', ['==', ['get', 'manager_type'], 'STAT'], ['==', ['get', 'agency'], 'SPR'] ], '#3d6055', ['any', ['==', ['get', 'manager_type'], 'LOC'], ['==', ['get', 'manager_type'], 'DIST'] ], '#5c6e66', '#707070' ], 'line-opacity': [ 'case', ['==', ['get', 'agency'], 'NPS'], 0.7, ['==', ['get', 'agency'], 'USFS'], 0.6, ['==', ['get', 'agency'], 'BLM'], 0.5, 0.5 ], 'line-width': [ 'interpolate', ['linear'], ['zoom'], 4, 0.3, 8, 0.8, 12, 1.2 ], }, }, beforeId) // Label layer — unit names at zoom 10+ map.addLayer({ id: PUBLIC_LANDS_LABEL, type: 'symbol', source: PUBLIC_LANDS_SOURCE, 'source-layer': 'public_lands', minzoom: 10, layout: { 'text-field': ['get', 'name'], 'text-size': ['interpolate', ['linear'], ['zoom'], 10, 10, 14, 13], 'text-font': ['Noto Sans Regular'], 'symbol-placement': 'point', 'text-anchor': 'center', 'text-max-width': 8, 'text-allow-overlap': false, 'text-ignore-placement': false, }, paint: { 'text-color': isDark ? '#c0c8b8' : '#3a4a30', 'text-halo-color': isDark ? '#1a1a1a' : '#ffffff', 'text-halo-width': 1.5, 'text-opacity': 0.85, }, }) } /** Remove public lands layers + source */ function removePublicLands(map) { if (!map) return if (map.getLayer(PUBLIC_LANDS_LABEL)) map.removeLayer(PUBLIC_LANDS_LABEL) if (map.getLayer(PUBLIC_LANDS_LINE)) map.removeLayer(PUBLIC_LANDS_LINE) if (map.getLayer(PUBLIC_LANDS_FILL)) map.removeLayer(PUBLIC_LANDS_FILL) if (map.getSource(PUBLIC_LANDS_SOURCE)) map.removeSource(PUBLIC_LANDS_SOURCE) } /** Add topographic contour vector tile overlay */ function addContours(map) { if (!map || map.getSource(CONTOUR_SOURCE)) return map.addSource(CONTOUR_SOURCE, { type: 'vector', url: 'pmtiles:///tiles/contours-na.pmtiles', }) // Insert below first symbol layer (above hillshade, below labels) let beforeId = undefined for (const layer of map.getStyle().layers) { if (layer.type === 'symbol') { beforeId = layer.id break } } const isDark = document.documentElement.getAttribute('data-theme') === 'dark' const opMod = isDark ? 0.8 : 1.0 // Minor contours (40ft) — visible z11+ map.addLayer({ id: CONTOUR_MINOR, type: 'line', source: CONTOUR_SOURCE, 'source-layer': 'contours', minzoom: 11, filter: ['==', ['get', 'tier'], 'minor'], paint: { 'line-color': '#8b6f47', 'line-opacity': 0.4 * opMod, 'line-width': ['interpolate', ['linear'], ['zoom'], 11, 0.5, 14, 1.0], }, }, beforeId) // Intermediate contours (200ft) — visible z8+ map.addLayer({ id: CONTOUR_INTERMEDIATE, type: 'line', source: CONTOUR_SOURCE, 'source-layer': 'contours', minzoom: 8, filter: ['==', ['get', 'tier'], 'intermediate'], paint: { 'line-color': '#8b6f47', 'line-opacity': 0.7 * opMod, 'line-width': ['interpolate', ['linear'], ['zoom'], 8, 0.8, 14, 1.2], }, }, beforeId) // Index contours (1000ft) — visible z4+ map.addLayer({ id: CONTOUR_INDEX, type: 'line', source: CONTOUR_SOURCE, 'source-layer': 'contours', minzoom: 4, filter: ['==', ['get', 'tier'], 'index'], paint: { 'line-color': '#6b4f2a', 'line-opacity': 0.9 * opMod, 'line-width': ['interpolate', ['linear'], ['zoom'], 4, 1.2, 14, 1.8], }, }, beforeId) // Elevation labels on index contours (z12+) map.addLayer({ id: CONTOUR_LABEL, type: 'symbol', source: CONTOUR_SOURCE, 'source-layer': 'contours', minzoom: 12, filter: ['==', ['get', 'tier'], 'index'], layout: { 'text-field': ['concat', ['to-string', ['get', 'elevation_ft']], "'"], 'text-size': 10, 'text-font': ['Noto Sans Regular'], 'symbol-placement': 'line', 'text-anchor': 'center', 'symbol-spacing': 400, 'text-max-angle': 30, 'text-allow-overlap': false, }, paint: { 'text-color': isDark ? '#c0b898' : '#5a4020', 'text-halo-color': isDark ? '#1a1a1a' : '#ffffff', 'text-halo-width': 1.5, 'text-opacity': 0.85, }, }) } /** Remove contour layers + source */ function removeContours(map) { if (!map) return if (map.getLayer(CONTOUR_LABEL)) map.removeLayer(CONTOUR_LABEL) if (map.getLayer(CONTOUR_INDEX)) map.removeLayer(CONTOUR_INDEX) if (map.getLayer(CONTOUR_INTERMEDIATE)) map.removeLayer(CONTOUR_INTERMEDIATE) if (map.getLayer(CONTOUR_MINOR)) map.removeLayer(CONTOUR_MINOR) if (map.getSource(CONTOUR_SOURCE)) map.removeSource(CONTOUR_SOURCE) } /** Add TEST topographic contour overlay (blue color scheme) */ function addContoursTest(map) { if (!map || map.getSource(CONTOUR_TEST_SOURCE)) return map.addSource(CONTOUR_TEST_SOURCE, { type: "vector", url: "pmtiles:///tiles/contours-test.pmtiles", }) let beforeId = undefined for (const layer of map.getStyle().layers) { if (layer.type === "symbol") { beforeId = layer.id break } } const isDark = document.documentElement.getAttribute("data-theme") === "dark" const opMod = isDark ? 0.8 : 1.0 // Minor contours (40ft) — blue scheme map.addLayer({ id: CONTOUR_TEST_MINOR, type: "line", source: CONTOUR_TEST_SOURCE, "source-layer": "contours", minzoom: 11, filter: ["==", ["get", "tier"], "minor"], paint: { "line-color": "#4a7c9b", "line-opacity": 0.4 * opMod, "line-width": ["interpolate", ["linear"], ["zoom"], 11, 0.5, 14, 1.0], }, }, beforeId) // Intermediate contours (200ft) map.addLayer({ id: CONTOUR_TEST_INTERMEDIATE, type: "line", source: CONTOUR_TEST_SOURCE, "source-layer": "contours", minzoom: 8, filter: ["==", ["get", "tier"], "intermediate"], paint: { "line-color": "#4a7c9b", "line-opacity": 0.7 * opMod, "line-width": ["interpolate", ["linear"], ["zoom"], 8, 0.8, 14, 1.2], }, }, beforeId) // Index contours (1000ft) map.addLayer({ id: CONTOUR_TEST_INDEX, type: "line", source: CONTOUR_TEST_SOURCE, "source-layer": "contours", minzoom: 4, filter: ["==", ["get", "tier"], "index"], paint: { "line-color": "#2a5a7c", "line-opacity": 0.9 * opMod, "line-width": ["interpolate", ["linear"], ["zoom"], 4, 1.2, 14, 1.8], }, }, beforeId) // Labels map.addLayer({ id: CONTOUR_TEST_LABEL, type: "symbol", source: CONTOUR_TEST_SOURCE, "source-layer": "contours", minzoom: 12, filter: ["==", ["get", "tier"], "index"], layout: { "text-field": ["concat", ["to-string", ["get", "elevation_ft"]], ""], "text-size": 10, "text-font": ["Noto Sans Regular"], "symbol-placement": "line", "text-anchor": "center", "symbol-spacing": 400, "text-max-angle": 30, "text-allow-overlap": false, }, paint: { "text-color": isDark ? "#98b8d0" : "#205080", "text-halo-color": isDark ? "#1a1a1a" : "#ffffff", "text-halo-width": 1.5, "text-opacity": 0.85, }, }) } /** Remove TEST contour layers + source */ function removeContoursTest(map) { if (!map) return if (map.getLayer(CONTOUR_TEST_LABEL)) map.removeLayer(CONTOUR_TEST_LABEL) if (map.getLayer(CONTOUR_TEST_INDEX)) map.removeLayer(CONTOUR_TEST_INDEX) if (map.getLayer(CONTOUR_TEST_INTERMEDIATE)) map.removeLayer(CONTOUR_TEST_INTERMEDIATE) if (map.getLayer(CONTOUR_TEST_MINOR)) map.removeLayer(CONTOUR_TEST_MINOR) if (map.getSource(CONTOUR_TEST_SOURCE)) map.removeSource(CONTOUR_TEST_SOURCE) } /** Add TEST 10ft topographic contour overlay (green color scheme) */ function addContoursTest10ft(map) { if (!map || map.getSource(CONTOUR_TEST_10FT_SOURCE)) return map.addSource(CONTOUR_TEST_10FT_SOURCE, { type: "vector", url: "pmtiles:///tiles/contours-test-10ft.pmtiles", }) let beforeId = undefined for (const layer of map.getStyle().layers) { if (layer.type === "symbol") { beforeId = layer.id break } } const isDark = document.documentElement.getAttribute("data-theme") === "dark" const opMod = isDark ? 0.8 : 1.0 // Minor contours (10ft) — green scheme map.addLayer({ id: CONTOUR_TEST_10FT_MINOR, type: "line", source: CONTOUR_TEST_10FT_SOURCE, "source-layer": "contours", minzoom: 11, filter: ["==", ["get", "tier"], "minor"], paint: { "line-color": "#3a7c4f", "line-opacity": 0.4 * opMod, "line-width": ["interpolate", ["linear"], ["zoom"], 11, 0.5, 14, 1.0], }, }, beforeId) // Intermediate contours (50ft) — green scheme map.addLayer({ id: CONTOUR_TEST_10FT_INTERMEDIATE, type: "line", source: CONTOUR_TEST_10FT_SOURCE, "source-layer": "contours", minzoom: 8, filter: ["==", ["get", "tier"], "intermediate"], paint: { "line-color": "#3a7c4f", "line-opacity": 0.7 * opMod, "line-width": ["interpolate", ["linear"], ["zoom"], 8, 0.8, 14, 1.2], }, }, beforeId) // Index contours (250ft) — darker green map.addLayer({ id: CONTOUR_TEST_10FT_INDEX, type: "line", source: CONTOUR_TEST_10FT_SOURCE, "source-layer": "contours", minzoom: 4, filter: ["==", ["get", "tier"], "index"], paint: { "line-color": "#2a5c3a", "line-opacity": 0.9 * opMod, "line-width": ["interpolate", ["linear"], ["zoom"], 4, 1.2, 14, 1.8], }, }, beforeId) // Elevation labels on index contours (z12+) map.addLayer({ id: CONTOUR_TEST_10FT_LABEL, type: "symbol", source: CONTOUR_TEST_10FT_SOURCE, "source-layer": "contours", minzoom: 12, filter: ["==", ["get", "tier"], "index"], layout: { "text-field": ["concat", ["to-string", ["get", "elevation_ft"]], "'"], "text-size": 10, "text-font": ["Noto Sans Regular"], "symbol-placement": "line", "text-anchor": "center", "symbol-spacing": 400, "text-max-angle": 30, "text-allow-overlap": false, }, paint: { "text-color": isDark ? "#98c0a8" : "#2a4030", "text-halo-color": isDark ? "#1a1a1a" : "#ffffff", "text-halo-width": 1.5, "text-opacity": 0.85, }, }) } /** Remove test 10ft contour layers + source */ function removeContoursTest10ft(map) { if (!map) return if (map.getLayer(CONTOUR_TEST_10FT_LABEL)) map.removeLayer(CONTOUR_TEST_10FT_LABEL) if (map.getLayer(CONTOUR_TEST_10FT_INDEX)) map.removeLayer(CONTOUR_TEST_10FT_INDEX) if (map.getLayer(CONTOUR_TEST_10FT_INTERMEDIATE)) map.removeLayer(CONTOUR_TEST_10FT_INTERMEDIATE) if (map.getLayer(CONTOUR_TEST_10FT_MINOR)) map.removeLayer(CONTOUR_TEST_10FT_MINOR) if (map.getSource(CONTOUR_TEST_10FT_SOURCE)) map.removeSource(CONTOUR_TEST_10FT_SOURCE) } /** Add boundary polygon layer with computed accent color (MapLibre rejects CSS vars in paint) */ function addBoundaryLayer(map) { if (!map || map.getLayer(BOUNDARY_LAYER)) return if (!map.getSource(BOUNDARY_SOURCE)) { map.addSource(BOUNDARY_SOURCE, { type: "geojson", data: { type: "FeatureCollection", features: [] }, }) } const accentColor = getComputedStyle(document.documentElement).getPropertyValue("--accent").trim() || "#7a9a6b" map.addLayer({ id: BOUNDARY_LAYER, type: "line", source: BOUNDARY_SOURCE, paint: { "line-color": accentColor, "line-width": 2, "line-opacity": 0.7, "line-dasharray": [3, 2], }, }) } const MapView = forwardRef(function MapView(_, ref) { const mapRef = useRef(null) const mapInstance = useRef(null) const markersRef = useRef([]) const popupRef = useRef(null) const gpsMarkerRef = useRef(null) const previewMarkerRef = useRef(null) const watchIdRef = useRef(null) const currentThemeRef = useRef('dark') // Track which overlay layers are currently active (for theme swap re-add) const activeLayersRef = useRef({ hillshade: false, traffic: false, contours: false, contoursTest: false, contoursTest10ft: false }) // Flag to suppress map-click when a stop pin was clicked const pinClickedRef = useRef(false) const highlightedFeatureRef = useRef(null) // { source, sourceLayer, id } for setFeatureState const hoveredFeatureRef = useRef(null) // for hover highlight // Refs for measurement state (accessible in click handlers) const measuringRef = useRef({ active: false, points: [] }) const measureLabelsRef = useRef([]) // HTML label elements const stops = useStore((s) => s.stops) const route = useStore((s) => s.route) const theme = useStore((s) => s.theme) const selectedPlace = useStore((s) => s.selectedPlace) const clickMarker = useStore((s) => s.clickMarker) const setClickMarker = useStore((s) => s.setClickMarker) const clearClickMarker = useStore((s) => s.clearClickMarker) const gpsOrigin = useStore((s) => s.gpsOrigin) const geoPermission = useStore((s) => s.geoPermission) const setSheetState = useStore((s) => s.setSheetState) const setMapCenter = useStore((s) => s.setMapCenter) const pickingLocationFor = useStore((s) => s.pickingLocationFor) const setEditingContact = useStore((s) => s.setEditingContact) const clearPickingLocationFor = useStore((s) => s.clearPickingLocationFor) // Zoom level indicator state const [zoomLevel, setZoomLevel] = useState(10) // Radial menu state const [radialMenu, setRadialMenu] = useState({ open: false, x: 0, y: 0, lat: 0, lon: 0, centerLabel: null, }) // Measurement mode state (for UI rendering) const [measuring, setMeasuring] = useState({ active: false, points: [], totalMeters: 0 }) // Sync state to ref for click handler access const updateMeasuringState = (newState) => { measuringRef.current = newState setMeasuring(newState) } // Update measurement layer with current points const updateMeasureLayer = (points) => { const map = mapInstance.current if (!map || !map.getSource(MEASURE_SOURCE)) return const features = [] // Add points points.forEach((p, i) => { features.push({ type: "Feature", geometry: { type: "Point", coordinates: [p.lon, p.lat] }, properties: { index: i }, }) }) // Add line if more than one point if (points.length > 1) { features.push({ type: "Feature", geometry: { type: "LineString", coordinates: points.map((p) => [p.lon, p.lat]), }, properties: {}, }) } map.getSource(MEASURE_SOURCE).setData({ type: "FeatureCollection", features, }) } // Update segment labels (HTML overlays) const updateMeasureLabels = (points) => { const map = mapInstance.current if (!map) return // Remove old labels measureLabelsRef.current.forEach(el => el.remove()) measureLabelsRef.current = [] if (points.length < 2) return const container = mapRef.current if (!container) return // Create label for each segment for (let i = 1; i < points.length; i++) { const p1 = points[i - 1] const p2 = points[i] const midLat = (p1.lat + p2.lat) / 2 const midLon = (p1.lon + p2.lon) / 2 const dist = haversineDistance(p1.lat, p1.lon, p2.lat, p2.lon) const label = document.createElement('div') label.className = 'measure-label' label.textContent = formatDistance(dist) label.style.cssText = ` position: absolute; background: rgba(0, 0, 0, 0.75); color: white; padding: 2px 6px; border-radius: 10px; font-size: 11px; font-weight: 500; pointer-events: none; white-space: nowrap; z-index: 100; transform: translate(-50%, -50%); ` const pos = map.project([midLon, midLat]) label.style.left = pos.x + 'px' label.style.top = pos.y + 'px' container.appendChild(label) measureLabelsRef.current.push(label) } } // Reposition labels on map move/zoom const repositionLabels = () => { const map = mapInstance.current const points = measuringRef.current.points if (!map || points.length < 2) return measureLabelsRef.current.forEach((label, i) => { if (i >= points.length - 1) return const p1 = points[i] const p2 = points[i + 1] const midLat = (p1.lat + p2.lat) / 2 const midLon = (p1.lon + p2.lon) / 2 const pos = map.project([midLon, midLat]) label.style.left = pos.x + 'px' label.style.top = pos.y + 'px' }) } // Clear measurement mode completely const clearMeasuring = () => { const map = mapInstance.current updateMeasuringState({ active: false, points: [], totalMeters: 0 }) // Remove labels measureLabelsRef.current.forEach(el => el.remove()) measureLabelsRef.current = [] if (map) { map.getCanvas().style.cursor = "" map.doubleClickZoom.enable() if (map.getLayer(MEASURE_LINE_LAYER)) map.removeLayer(MEASURE_LINE_LAYER) if (map.getLayer(MEASURE_POINT_LAYER)) map.removeLayer(MEASURE_POINT_LAYER) if (map.getSource(MEASURE_SOURCE)) map.removeSource(MEASURE_SOURCE) } } // End measurement (keep line visible, exit active mode) const endMeasuring = () => { const map = mapInstance.current if (map) { map.getCanvas().style.cursor = "" map.doubleClickZoom.enable() } updateMeasuringState({ ...measuringRef.current, active: false }) } // Start new measurement const startMeasuring = (lat, lon) => { const map = mapInstance.current if (!map) return // Clear any existing measurement first measureLabelsRef.current.forEach(el => el.remove()) measureLabelsRef.current = [] if (map.getLayer(MEASURE_LINE_LAYER)) map.removeLayer(MEASURE_LINE_LAYER) if (map.getLayer(MEASURE_POINT_LAYER)) map.removeLayer(MEASURE_POINT_LAYER) if (map.getSource(MEASURE_SOURCE)) map.removeSource(MEASURE_SOURCE) // Set up new measurement updateMeasuringState({ active: true, points: [{ lat, lon }], totalMeters: 0 }) map.getCanvas().style.cursor = "crosshair" map.doubleClickZoom.disable() // Add source and layers map.addSource(MEASURE_SOURCE, { type: "geojson", data: { type: "FeatureCollection", features: [] }, }) const accentColor = getComputedStyle(document.documentElement).getPropertyValue("--accent").trim() || "#7a9a6b" map.addLayer({ id: MEASURE_LINE_LAYER, type: "line", source: MEASURE_SOURCE, paint: { "line-color": accentColor, "line-width": 2, "line-dasharray": [8, 4], }, }) map.addLayer({ id: MEASURE_POINT_LAYER, type: "circle", source: MEASURE_SOURCE, filter: ["==", "$type", "Point"], paint: { "circle-radius": 5, "circle-color": accentColor, "circle-stroke-width": 2, "circle-stroke-color": "#1a1a1a", }, }) updateMeasureLayer([{ lat, lon }]) } // Add a point to the measurement const addMeasurePoint = (lat, lon) => { const current = measuringRef.current if (!current.active) return const newPoints = [...current.points, { lat, lon }] // Calculate total distance let totalMeters = 0 for (let i = 1; i < newPoints.length; i++) { totalMeters += haversineDistance( newPoints[i - 1].lat, newPoints[i - 1].lon, newPoints[i].lat, newPoints[i].lon ) } updateMeasuringState({ active: true, points: newPoints, totalMeters }) updateMeasureLayer(newPoints) updateMeasureLabels(newPoints) } const radialWedges = [ { id: "directions-to", label: "To here", icon: ArrowDownLeft, onSelect: () => { setRadialMenu((m) => ({ ...m, open: false })) const place = { lat: radialMenu.lat, lon: radialMenu.lon, name: radialMenu.centerLabel || radialMenu.lat.toFixed(5) + ", " + radialMenu.lon.toFixed(5), source: "radial_menu", matchCode: null, } useStore.getState().startDirections(place) }, }, { id: "directions-from", label: "From here", icon: ArrowUpRight, onSelect: () => { setRadialMenu((m) => ({ ...m, open: false })) const { clearStops, addStop } = useStore.getState() clearStops() const place = { lat: radialMenu.lat, lon: radialMenu.lon, name: radialMenu.centerLabel || radialMenu.lat.toFixed(5) + ", " + radialMenu.lon.toFixed(5), source: "radial_menu", matchCode: null, } addStop(place) useStore.setState({ gpsOrigin: false }) }, }, { id: "add-stop", label: "Add stop", icon: Plus, onSelect: () => { setRadialMenu((m) => ({ ...m, open: false })) const { stops, addStop, clearStops } = useStore.getState() const place = { lat: radialMenu.lat, lon: radialMenu.lon, name: radialMenu.centerLabel || radialMenu.lat.toFixed(5) + ", " + radialMenu.lon.toFixed(5), source: "radial_menu", matchCode: null, } if (stops.length === 0) { addStop(place) useStore.setState({ gpsOrigin: false }) } else { const success = addStop(place) if (!success) { toast("Maximum 10 stops reached") } } }, }, { id: "save-place", label: "Save", icon: Star, requiresAuth: true, onSelect: () => { setRadialMenu((m) => ({ ...m, open: false })) const { auth, setEditingContact } = useStore.getState() if (auth.authenticated) { setEditingContact({ label: "", lat: radialMenu.lat, lon: radialMenu.lon, }) } else { toast("Log in to save places") } }, }, { id: "measure", label: "Measure", icon: Ruler, onSelect: () => { setRadialMenu((m) => ({ ...m, open: false })) startMeasuring(radialMenu.lat, radialMenu.lon) }, }, ] // Context menu trigger handler const handleContextMenuTrigger = ({ x, y }) => { const map = mapInstance.current if (!map || !mapRef.current) return // Suppress context menu during measurement mode if (measuringRef.current.active) return // Convert screen coords to lat/lon const rect = mapRef.current.getBoundingClientRect() const lngLat = map.unproject([x - rect.left, y - rect.top]) setRadialMenu({ open: true, x, y, lat: lngLat.lat, lon: lngLat.lng, centerLabel: null, }) // Async reverse geocode for center label fetchReverse(lngLat.lat, lngLat.lng).then((place) => { if (place) { setRadialMenu((m) => { if (m.open && Math.abs(m.lat - lngLat.lat) < 0.00001) { return { ...m, centerLabel: place.name } } return m }) } }) } // Context menu hook const contextMenuHandlers = useContextMenu(handleContextMenuTrigger) useImperativeHandle(ref, () => ({ flyTo(lat, lon, zoom = 14) { mapInstance.current?.flyTo({ center: [lon, lat], zoom }) }, getMap() { return mapInstance.current }, addHillshadeLayer() { const map = mapInstance.current if (!map) return addHillshade(map) activeLayersRef.current.hillshade = true }, removeHillshadeLayer() { const map = mapInstance.current if (!map) return removeHillshade(map) activeLayersRef.current.hillshade = false }, addTrafficLayer() { const map = mapInstance.current if (!map) return addTraffic(map) activeLayersRef.current.traffic = true }, removeTrafficLayer() { const map = mapInstance.current if (!map) return removeTraffic(map) activeLayersRef.current.traffic = false }, addPublicLandsLayer() { const map = mapInstance.current if (!map) return addPublicLands(map) activeLayersRef.current.publicLands = true }, removePublicLandsLayer() { const map = mapInstance.current if (!map) return removePublicLands(map) activeLayersRef.current.publicLands = false }, addContoursLayer() { const map = mapInstance.current if (!map) return addContours(map) activeLayersRef.current.contours = true }, removeContoursLayer() { const map = mapInstance.current if (!map) return removeContours(map) activeLayersRef.current.contours = false }, addContoursTestLayer() { const map = mapInstance.current if (!map) return addContoursTest(map) activeLayersRef.current.contoursTest = true }, removeContoursTestLayer() { const map = mapInstance.current if (!map) return removeContoursTest(map) activeLayersRef.current.contoursTest = false }, addContoursTest10ftLayer() { const map = mapInstance.current if (!map) return addContoursTest10ft(map) activeLayersRef.current.contoursTest10ft = true }, removeContoursTest10ftLayer() { const map = mapInstance.current if (!map) return removeContoursTest10ft(map) activeLayersRef.current.contoursTest10ft = false }, })) // Initialize map useEffect(() => { const protocol = new Protocol() maplibregl.addProtocol('pmtiles', protocol.tile) const config = getConfig() const DEFAULT_CENTER = config?.defaults?.center ? [config.defaults.center[1], config.defaults.center[0]] // config is [lat,lon], MapLibre wants [lon,lat] : [-114.6066, 42.5736] const DEFAULT_ZOOM = config?.defaults?.zoom || 10 const initialTheme = document.documentElement.getAttribute('data-theme') || 'dark' currentThemeRef.current = initialTheme const map = new maplibregl.Map({ container: mapRef.current, style: buildStyle(initialTheme), center: DEFAULT_CENTER, zoom: DEFAULT_ZOOM, }) map.addControl(new maplibregl.NavigationControl(), 'top-right') // Scale bar control map.addControl(new maplibregl.ScaleControl({ maxWidth: 120, unit: 'imperial', }), 'bottom-right') // Map click — two-click selection model map.on('click', (e) => { // If a stop pin was just clicked, skip if (pinClickedRef.current) { pinClickedRef.current = false return } // CRITICAL: Check measuring mode FIRST using ref (not stale closure) if (measuringRef.current.active) { const { lng, lat } = e.lngLat addMeasurePoint(lat, lng) return } // Handle location pick mode for contacts const pickState = useStore.getState().pickingLocationFor if (pickState) { const { lng, lat } = e.lngLat map.getCanvas().style.cursor = '' // Reverse geocode for address fetchReverse(lat, lng).then((place) => { const addr = place?.address || place?.name || '' // Rebuild form data with new location useStore.getState().setEditingContact({ ...pickState, lat, lon: lng, address: addr || pickState.address || '', }) useStore.getState().clearPickingLocationFor() }).catch(() => { // Even if reverse geocode fails, set the location useStore.getState().setEditingContact({ ...pickState, lat, lon: lng, }) useStore.getState().clearPickingLocationFor() }) return } const store = useStore.getState() const marker = store.clickMarker if (marker) { // State B: marker present — check if click is inside the circle const markerScreen = map.project([marker.lon, marker.lat]) const dx = e.point.x - markerScreen.x const dy = e.point.y - markerScreen.y const dist = Math.sqrt(dx * dx + dy * dy) if (dist <= marker.circleRadiusPx) { // Inside circle → open radial at marker location const rect = mapRef.current?.getBoundingClientRect() const screenX = rect ? markerScreen.x + rect.left : markerScreen.x const screenY = rect ? markerScreen.y + rect.top : markerScreen.y setRadialMenu({ open: true, x: screenX, y: screenY, lat: marker.lat, lon: marker.lon, centerLabel: store.selectedPlace?.name || null, }) // Fetch reverse geocode for center label if not already loaded if (!store.selectedPlace?.name || store.selectedPlace.name === 'Dropped pin') { fetchReverse(marker.lat, marker.lon).then((place) => { if (place) { setRadialMenu((m) => { if (m.open && Math.abs(m.lat - marker.lat) < 0.00001) { return { ...m, centerLabel: place.name } } return m }) } }) } } else { // Outside circle → deselect, no new selection store.clearClickMarker() store.clearSelectedPlace() } } else { // State A: nothing selected → select if (window.innerWidth < 768) setSheetState('collapsed') const { lng, lat } = e.lngLat const MARKER_RADIUS_PX = 14 // half of 28px preview marker // Query rendered features at click point (label/POI priority) const labelLayers = ['pois', 'places_subplace', 'places_locality', 'places_region', 'places_country'] const features = map.queryRenderedFeatures(e.point, { layers: labelLayers }) // Find first feature with a name (respects layer order = priority) const labelFeature = features.find(f => f.properties?.name) // Clear previous feature highlight if (highlightedFeatureRef.current) { const { source, sourceLayer, id } = highlightedFeatureRef.current try { map.setFeatureState({ source, sourceLayer, id }, { selected: false }) } catch (e) { /* ignore if layer removed */ } highlightedFeatureRef.current = null } setSelectedHighlight(map, null) if (labelFeature) { // Clicked a labeled feature — snap to geometry and highlight const props = labelFeature.properties const geom = labelFeature.geometry // Get feature coordinates (Point geometry) let featureLat = lat let featureLon = lng if (geom && geom.type === 'Point' && geom.coordinates) { featureLon = geom.coordinates[0] featureLat = geom.coordinates[1] } // Apply feature state highlight const featureId = labelFeature.id ?? props.mvt_id const sourceLayer = labelFeature.sourceLayer const source = labelFeature.source if (featureId != null && source) { try { map.setFeatureState({ source, sourceLayer, id: featureId }, { selected: true }) highlightedFeatureRef.current = { source, sourceLayer, id: featureId } } catch (e) { console.warn('setFeatureState error:', e) } } // Filter-based highlight (works with PMTiles) setSelectedHighlight(map, labelFeature) setHoverHighlight(map, null) // For feature clicks, don't show pin marker store.clearClickMarker() store.setSelectedPlace({ lat: featureLat, lon: featureLon, name: props.name || 'Unknown', address: null, type: props.kind_detail || props.kind || null, source: 'basemap_label', matchCode: null, mode: 'feature', featureId: featureId, featureLayer: labelFeature.layer?.id || null, wikidata: props.wikidata || null, raw: { wikidata: props.wikidata || null, population: props.population || null, kind: props.kind || null, kind_detail: props.kind_detail || null, elevation: props.elevation || null, }, }) } else { // No labeled feature — show reticle at click point store.setClickMarker({ lat, lon: lng, circleRadiusPx: MARKER_RADIUS_PX, }) store.setSelectedPlace({ lat, lon: lng, name: 'Dropped pin', address: null, type: null, source: 'map_click', matchCode: null, mode: 'reticle', raw: {}, }) // Reverse geocode in background fetchReverse(lat, lng).then((place) => { if (!place) return const current = useStore.getState().selectedPlace if (current && Math.abs(current.lat - lat) < 0.00001 && Math.abs(current.lon - lng) < 0.00001) { useStore.getState().setSelectedPlace({ ...place, lat, lon: lng, }) } }) } } }) // Double-click ends measurement mode (and prevents zoom) map.on('dblclick', (e) => { if (measuringRef.current.active) { e.preventDefault() // Add final point and end const { lng, lat } = e.lngLat addMeasurePoint(lat, lng) endMeasuring() } }) // Reposition measure labels on map move map.on('move', repositionLabels) // Initialize mapCenter immediately when map loads (Fix 1: search viewport) map.once('load', () => { const center = map.getCenter() const zoom = map.getZoom() setMapCenter({ lat: center.lat, lon: center.lng, zoom }) }) map.on('load', () => { // Guard against double-mount in React strict mode if (!map.getSource(ROUTE_SOURCE)) { map.addSource(ROUTE_SOURCE, { type: 'geojson', data: { type: 'FeatureCollection', features: [] }, }) } // Boundary polygon layer for selected places if (!map.getLayer(BOUNDARY_LAYER)) { addBoundaryLayer(map) } // Restore overlay layers from localStorage prefs try { const raw = localStorage.getItem('navi-layer-prefs') if (raw) { const prefs = JSON.parse(raw) if (prefs.hillshade && hasFeature('has_hillshade')) { addHillshade(map) activeLayersRef.current.hillshade = true } if (prefs.traffic && hasFeature('has_traffic_overlay')) { addTraffic(map) activeLayersRef.current.traffic = true } if (prefs.publicLands && hasFeature('has_public_lands_layer')) { addPublicLands(map) activeLayersRef.current.publicLands = true } if (prefs.contours && hasFeature('has_contours')) { addContours(map) activeLayersRef.current.contours = true } } else if (hasFeature('has_hillshade')) { // Default: hillshade ON if available addHillshade(map) activeLayersRef.current.hillshade = true } } catch {} // Set up highlight layers setupHighlightLayers(map, document.documentElement.getAttribute('data-theme') === 'dark') // POI/label hover affordance — cursor pointer + highlight const interactiveLayers = ['pois', 'places_locality', 'places_region', 'places_country', 'places_subplace'] interactiveLayers.forEach(layerId => { map.on('mouseenter', layerId, (e) => { if (!measuringRef.current.active) { map.getCanvas().style.cursor = 'pointer' const feature = e.features?.[0] if (feature?.properties?.name) { setHoverHighlight(map, feature) hoveredFeatureRef.current = feature } } }) map.on('mouseleave', layerId, () => { if (!measuringRef.current.active) { map.getCanvas().style.cursor = '' setHoverHighlight(map, null) hoveredFeatureRef.current = null } }) }) }) mapInstance.current = map // ResizeObserver to handle layout settling, panel changes, window resize const ro = new ResizeObserver(() => { map.resize() }) ro.observe(mapRef.current) return () => { ro.disconnect() if (watchIdRef.current != null) navigator.geolocation.clearWatch(watchIdRef.current) if (gpsMarkerRef.current) gpsMarkerRef.current.remove() // Clean up measure labels measureLabelsRef.current.forEach(el => el.remove()) measureLabelsRef.current = [] maplibregl.removeProtocol('pmtiles') map.remove() } }, [setSheetState]) /** Create or update the GPS chevron/dot marker */ function createOrUpdateGpsMarker(map, lat, lon, heading) { if (!gpsMarkerRef.current) { const el = document.createElement('div') if (heading != null && !isNaN(heading)) { el.className = 'navi-chevron' el.innerHTML = CHEVRON_SVG el.style.transform = `rotate(${heading}deg)` } else { el.className = 'navi-gps-dot' } gpsMarkerRef.current = new maplibregl.Marker({ element: el }) .setLngLat([lon, lat]) .addTo(map) } else { gpsMarkerRef.current.setLngLat([lon, lat]) const el = gpsMarkerRef.current.getElement() if (heading != null && !isNaN(heading)) { if (!el.classList.contains('navi-chevron')) { el.className = 'navi-chevron' el.innerHTML = CHEVRON_SVG } el.style.transform = `rotate(${heading}deg)` } else { if (!el.classList.contains('navi-gps-dot')) { el.className = 'navi-gps-dot' el.innerHTML = '' } } } } // React to permission changes from LocateButton (when user grants after initial denial) useEffect(() => { const map = mapInstance.current if (!map || geoPermission !== 'granted') return // If marker already exists, watchPosition is already running — nothing to do if (gpsMarkerRef.current) return // Permission was just granted (likely from LocateButton) — create marker + start tracking const loc = useStore.getState().userLocation if (loc) { createOrUpdateGpsMarker(map, loc.lat, loc.lon, null) } if (!watchIdRef.current) { watchIdRef.current = navigator.geolocation.watchPosition( (pos) => { const { latitude, longitude, heading } = pos.coords useStore.getState().setUserLocation({ lat: latitude, lon: longitude }) createOrUpdateGpsMarker(map, latitude, longitude, heading) }, () => {}, { enableHighAccuracy: true, maximumAge: 5000 } ) } }, [geoPermission]) // Swap map theme when store.theme changes useEffect(() => { const map = mapInstance.current if (!map || currentThemeRef.current === theme) return currentThemeRef.current = theme const center = map.getCenter() const zoom = map.getZoom() const bearing = map.getBearing() const pitch = map.getPitch() map.setStyle(buildStyle(theme), { diff: false }) // Re-add sources/layers after style swap map.once('style.load', () => { // Guard against source already existing if (!map.getSource(ROUTE_SOURCE)) { map.addSource(ROUTE_SOURCE, { type: 'geojson', data: { type: 'FeatureCollection', features: [] }, }) } // Boundary polygon layer if (!map.getLayer(BOUNDARY_LAYER)) { addBoundaryLayer(map) } // Re-add active overlay layers if (activeLayersRef.current.hillshade) addHillshade(map) if (activeLayersRef.current.traffic) addTraffic(map) if (activeLayersRef.current.publicLands) addPublicLands(map) if (activeLayersRef.current.contours) addContours(map) // Re-setup highlight layers setupHighlightLayers(map, theme === 'dark') // Restore view map.jumpTo({ center, zoom, bearing, pitch }) // Re-render route if exists const currentRoute = useStore.getState().route if (currentRoute) updateRoute(map, currentRoute) }) }, [theme]) // Preview pin for selected place useEffect(() => { const map = mapInstance.current if (!map) return // Remove old preview marker if (previewMarkerRef.current) { previewMarkerRef.current.remove() previewMarkerRef.current = null } if (!selectedPlace) return // Only fly to place if it came from search (not map-click which already centered) if (selectedPlace.source !== 'map_click' && selectedPlace.source !== 'basemap_label') { map.flyTo({ center: [selectedPlace.lon, selectedPlace.lat], zoom: 14, duration: 800 }) } // Different visual feedback based on mode const isFeatureMode = selectedPlace.mode === 'feature' // Create marker element const el = document.createElement('div') if (isFeatureMode) { // Feature mode: subtle ring indicator el.className = 'navi-feature-highlight' } else { // Reticle mode: pin with center dot el.className = 'navi-pin-preview' const dot = document.createElement('div') dot.className = 'navi-pin-center-dot' el.appendChild(dot) } previewMarkerRef.current = new maplibregl.Marker({ element: el }) .setLngLat([selectedPlace.lon, selectedPlace.lat]) .addTo(map) return () => { if (previewMarkerRef.current) { previewMarkerRef.current.remove() previewMarkerRef.current = null } } }, [selectedPlace]) // Boundary polygon and zoom-to-feature useEffect(() => { const map = mapInstance.current if (!map) return const updateBoundary = () => { const source = map.getSource(BOUNDARY_SOURCE) if (!source) return // Clear boundary if no place selected if (!selectedPlace) { source.setData({ type: 'FeatureCollection', features: [] }) return } // Get boundary from selectedPlace (may come from API response) const boundary = selectedPlace.boundary || selectedPlace.raw?.boundary // Update boundary layer if (boundary && (boundary.type === 'Polygon' || boundary.type === 'MultiPolygon')) { source.setData({ type: 'Feature', geometry: boundary, properties: {}, }) // Zoom to fit boundary try { const coords = boundary.type === 'Polygon' ? boundary.coordinates[0] : boundary.coordinates.flat(1) if (coords.length > 0) { let minLng = Infinity, maxLng = -Infinity, minLat = Infinity, maxLat = -Infinity for (const [lng, lat] of coords) { if (lng < minLng) minLng = lng if (lng > maxLng) maxLng = lng if (lat < minLat) minLat = lat if (lat > maxLat) maxLat = lat } map.fitBounds([[minLng, minLat], [maxLng, maxLat]], { padding: 50, duration: 700, maxZoom: 16, }) } } catch (e) { console.warn('fitBounds error:', e) } } else { // No boundary - clear the layer and zoom based on feature kind source.setData({ type: 'FeatureCollection', features: [] }) // Only zoom for feature mode selections (not terrain clicks) if (selectedPlace.mode === 'feature' && selectedPlace.source === 'basemap_label') { const kind = selectedPlace.raw?.kind || selectedPlace.type || '' let targetZoom = null if (kind.includes('country')) targetZoom = 5 else if (kind.includes('region' ) || kind.includes('state')) targetZoom = 7 else if (kind.includes('locality' ) || kind.includes('city')) targetZoom = 11 else if (kind.includes('subplace' ) || kind.includes('neighbourhood') || kind.includes('neighborhood')) targetZoom = 13 else if (kind.includes('poi')) targetZoom = 16 // Only zoom in, never zoom out if (targetZoom && map.getZoom() < targetZoom) { map.flyTo({ center: [selectedPlace.lon, selectedPlace.lat], zoom: targetZoom, duration: 700, }) } } } } // If style is loaded, update immediately; otherwise wait for load event if (map.isStyleLoaded()) { updateBoundary() } else { map.once('load', updateBoundary) return () => map.off('load', updateBoundary) } }, [selectedPlace]) // Update route polyline when route changes useEffect(() => { const map = mapInstance.current if (!map) return if (!map.isStyleLoaded()) { const handler = () => updateRoute(map, route) map.once('idle', handler) return () => map.off('idle', handler) } updateRoute(map, route) }, [route]) function updateRoute(map, routeData) { if (!map) return // Remove old route layers const style = map.getStyle() if (style) { for (const layer of style.layers) { if (layer.id.startsWith(ROUTE_LAYER_PREFIX)) { map.removeLayer(layer.id) } } } if (!routeData || !routeData.legs) { if (map.getSource(ROUTE_SOURCE)) { map.getSource(ROUTE_SOURCE).setData({ type: 'FeatureCollection', features: [] }) } return } const features = [] for (let i = 0; i < routeData.legs.length; i++) { const leg = routeData.legs[i] if (!leg.shape) continue const coords = decodePolyline(leg.shape, 6) features.push({ type: 'Feature', properties: { legIndex: i }, geometry: { type: 'LineString', coordinates: coords }, }) } const source = map.getSource(ROUTE_SOURCE) if (source) { source.setData({ type: 'FeatureCollection', features }) } else { map.addSource(ROUTE_SOURCE, { type: 'geojson', data: { type: 'FeatureCollection', features }, }) } // Use CSS variable for route color (read computed value) const routeColor = getComputedStyle(document.documentElement).getPropertyValue('--route-line').trim() for (let i = 0; i < features.length; i++) { const layerId = `${ROUTE_LAYER_PREFIX}${i}` if (!map.getLayer(layerId)) { map.addLayer({ id: layerId, type: 'line', source: ROUTE_SOURCE, filter: ['==', ['get', 'legIndex'], i], layout: { 'line-join': 'round', 'line-cap': 'round' }, paint: { 'line-color': routeColor || '#7a9a6b', 'line-width': 5, 'line-opacity': 0.85, }, }) } } // Fit bounds to route if (features.length > 0) { const allCoords = features.flatMap((f) => f.geometry.coordinates) const bounds = allCoords.reduce( (b, c) => b.extend(c), new maplibregl.LngLatBounds(allCoords[0], allCoords[0]) ) // Single-panel: no floating detail const leftPad = 420 // 360px panel + margin map.fitBounds(bounds, { padding: { top: 60, bottom: 60, left: leftPad, right: 60 } }) } } // Update stop markers when stops change useEffect(() => { const map = mapInstance.current if (!map) return // Remove old markers for (const m of markersRef.current) m.remove() markersRef.current = [] if (popupRef.current) { popupRef.current.remove() popupRef.current = null } const hasGpsOrigin = gpsOrigin && geoPermission === 'granted' const indexOffset = hasGpsOrigin ? 1 : 0 stops.forEach((stop, i) => { const displayIndex = i + indexOffset const effectiveTotal = stops.length + indexOffset let pinClass = 'navi-pin navi-pin--intermediate' if (displayIndex === 0) pinClass = 'navi-pin navi-pin--origin' else if (displayIndex === effectiveTotal - 1 && effectiveTotal > 1) pinClass = 'navi-pin navi-pin--destination' const label = String.fromCharCode(65 + Math.min(displayIndex, 25)) const el = document.createElement('div') el.className = pinClass el.textContent = label el.addEventListener('click', (e) => { e.stopPropagation() // Flag so the map-level click handler doesn't fire pinClickedRef.current = true if (popupRef.current) popupRef.current.remove() const popup = new maplibregl.Popup({ offset: 20, closeButton: true }) .setLngLat([stop.lon, stop.lat]) .setHTML( `