diff --git a/src/components/MapView.jsx b/src/components/MapView.jsx
index 6f21ba7..fdd1602 100644
--- a/src/components/MapView.jsx
+++ b/src/components/MapView.jsx
@@ -1,2057 +1,2029 @@
-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'
-
-// Interactive layers that respond to hover/click
-const INTERACTIVE_LAYERS = ['pois', 'places_subplace', 'places_locality', 'places_region', 'places_country']
-
-/** Set up interactive layer paint properties for hover and selected states */
-function setupInteractiveLayerStyles(map, isDark) {
- const accentColor = getComputedStyle(document.documentElement).getPropertyValue('--accent').trim() || '#7a9a6b'
-
- INTERACTIVE_LAYERS.forEach(layerId => {
- if (!map.getLayer(layerId)) return
-
- // Get current text color as base
- const currentColor = map.getPaintProperty(layerId, 'text-color') || (isDark ? '#c0c0c0' : '#333333')
- const currentHaloColor = map.getPaintProperty(layerId, 'text-halo-color') || (isDark ? '#1a1a1a' : '#ffffff')
- const currentHaloWidth = map.getPaintProperty(layerId, 'text-halo-width') || 1.5
-
- // Text color: brighten on hover, accent on selected
- map.setPaintProperty(layerId, 'text-color', [
- 'case',
- ['boolean', ['feature-state', 'selected'], false],
- accentColor,
- ['boolean', ['feature-state', 'hover'], false],
- isDark ? '#ffffff' : '#000000',
- currentColor
- ])
-
- // Halo width: increase on hover/selected for glow effect
- map.setPaintProperty(layerId, 'text-halo-width', [
- 'case',
- ['boolean', ['feature-state', 'selected'], false],
- 2.5,
- ['boolean', ['feature-state', 'hover'], false],
- 2.0,
- currentHaloWidth
- ])
-
- // Halo color: accent glow on selected, subtle glow on hover
- map.setPaintProperty(layerId, 'text-halo-color', [
- 'case',
- ['boolean', ['feature-state', 'selected'], false],
- isDark ? 'rgba(122, 154, 107, 0.5)' : 'rgba(122, 154, 107, 0.3)',
- ['boolean', ['feature-state', 'hover'], false],
- isDark ? 'rgba(255, 255, 255, 0.3)' : 'rgba(0, 0, 0, 0.15)',
- currentHaloColor
- ])
- })
-}
-
-/** 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) // { source, sourceLayer, id } for hover state
- // 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 features = map.queryRenderedFeatures(e.point, { layers: INTERACTIVE_LAYERS })
-
- // 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
- }
-
- 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) }
- }
-
- // 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', () => {
- map.addSource(ROUTE_SOURCE, {
- type: 'geojson',
- data: { type: 'FeatureCollection', features: [] },
- })
-
- // Boundary polygon layer for selected places
- 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 interactive layer styles for hover/selected states
- const isDark = document.documentElement.getAttribute('data-theme') === 'dark'
- setupInteractiveLayerStyles(map, isDark)
-
- // POI/label hover affordance — cursor pointer + feature state highlight
- INTERACTIVE_LAYERS.forEach(layerId => {
- map.on('mouseenter', layerId, (e) => {
- if (measuringRef.current.active) return
-
- map.getCanvas().style.cursor = 'pointer'
-
- // Clear previous hover state
- if (hoveredFeatureRef.current) {
- const { source, sourceLayer, id } = hoveredFeatureRef.current
- try {
- map.setFeatureState({ source, sourceLayer, id }, { hover: false })
- } catch (err) { /* layer may have been removed */ }
- hoveredFeatureRef.current = null
- }
-
- // Set new hover state
- const feature = e.features?.[0]
- if (feature) {
- const featureId = feature.id ?? feature.properties?.mvt_id
- if (featureId != null) {
- try {
- map.setFeatureState(
- { source: feature.source, sourceLayer: feature.sourceLayer, id: featureId },
- { hover: true }
- )
- hoveredFeatureRef.current = {
- source: feature.source,
- sourceLayer: feature.sourceLayer,
- id: featureId
- }
- } catch (err) { /* ignore */ }
- }
- }
- })
-
- map.on('mouseleave', layerId, () => {
- if (measuringRef.current.active) return
-
- map.getCanvas().style.cursor = ''
-
- // Clear hover state
- if (hoveredFeatureRef.current) {
- const { source, sourceLayer, id } = hoveredFeatureRef.current
- try {
- map.setFeatureState({ source, sourceLayer, id }, { hover: false })
- } catch (err) { /* layer may have been removed */ }
- 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', () => {
- map.addSource(ROUTE_SOURCE, {
- type: 'geojson',
- data: { type: 'FeatureCollection', features: [] },
- })
-
- // Boundary polygon 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 interactive layer styles for new theme
- const isDark = theme === 'dark'
- setupInteractiveLayerStyles(map, isDark)
-
- // 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'
-
- // Feature mode uses paint property highlight (no DOM marker needed)
- // Reticle mode shows pin with center dot
- if (!isFeatureMode) {
- const el = document.createElement('div')
- 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 || !map.isStyleLoaded()) return
-
- 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,
- })
- }
- }
- }
- }, [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(
- `
- ${stop.name}
-
-
`
- )
- .addTo(map)
-
- popup.getElement().querySelector(`#remove-stop-${stop.id}`)?.addEventListener('click', () => {
- useStore.getState().removeStop(stop.id)
- popup.remove()
- })
- popupRef.current = popup
- })
-
- const marker = new maplibregl.Marker({ element: el })
- .setLngLat([stop.lon, stop.lat])
- .addTo(map)
-
- markersRef.current.push(marker)
- })
-
- // If stops but no route yet, fit to stops
- if (stops.length > 0 && !route) {
- if (stops.length === 1) {
- map.flyTo({ center: [stops[0].lon, stops[0].lat], zoom: 13 })
- } else {
- const bounds = stops.reduce(
- (b, s) => b.extend([s.lon, s.lat]),
- new maplibregl.LngLatBounds([stops[0].lon, stops[0].lat], [stops[0].lon, stops[0].lat])
- )
- map.fitBounds(bounds, { padding: { top: 60, bottom: 60, left: 420, right: 60 } })
- }
- }
- }, [stops, route, gpsOrigin, geoPermission])
-
-
- // ESC key handler for measurement mode
- useEffect(() => {
- const handleKeyDown = (e) => {
- if (e.key === "Escape" && measuringRef.current.active) {
- endMeasuring()
- }
- }
- window.addEventListener("keydown", handleKeyDown)
- return () => window.removeEventListener("keydown", handleKeyDown)
- }, [])
-
- // Handle location pick mode for contacts
- useEffect(() => {
- const map = mapInstance.current
- if (!map) return
- if (pickingLocationFor) {
- map.getCanvas().style.cursor = 'crosshair'
- }
- return () => {
- if (map && !measuringRef.current.active) {
- map.getCanvas().style.cursor = ''
- }
- }
- }, [pickingLocationFor])
-
- // ESC key handler for location pick mode
- useEffect(() => {
- const handleKeyDown = (e) => {
- if (e.key === 'Escape' && pickingLocationFor) {
- // Cancel pick mode, reopen modal with original form data
- const map = mapInstance.current
- if (map) map.getCanvas().style.cursor = ''
- setEditingContact(pickingLocationFor)
- clearPickingLocationFor()
- }
- }
- window.addEventListener('keydown', handleKeyDown)
- return () => window.removeEventListener('keydown', handleKeyDown)
- }, [pickingLocationFor, setEditingContact, clearPickingLocationFor])
-
-
- // Track zoom level for indicator
- useEffect(() => {
- const map = mapInstance.current
- if (!map) return
-
- const updateZoom = () => setZoomLevel(map.getZoom())
-
- // Set initial zoom
- if (map.loaded()) {
- updateZoom()
- } else {
- map.once("load", updateZoom)
- }
-
- // Subscribe to zoom changes
- map.on("zoom", updateZoom)
-
- return () => {
- map.off("zoom", updateZoom)
- }
- }, [])
-
-
- // Track map center for search viewport bias
- useEffect(() => {
- const map = mapInstance.current
- if (!map) return
-
- const updateCenter = () => {
- const center = map.getCenter()
- const zoom = map.getZoom()
- setMapCenter({ lat: center.lat, lon: center.lng, zoom })
- }
-
- // Set initial center
- if (map.loaded()) {
- updateCenter()
- } else {
- map.once("load", updateCenter)
- }
-
- // Update on move end (not every frame)
- map.on("moveend", updateCenter)
-
- return () => {
- map.off("moveend", updateCenter)
- }
- }, [setMapCenter])
-
- return (
-
-
- {/* Zoom level indicator - bottom-left corner */}
-
- Z {zoomLevel.toFixed(1)}
-
-
- {/* Measurement info bar */}
- {(measuring.active || measuring.points.length > 1) && (
-
-
-
- {formatDistance(measuring.totalMeters)}
-
- ({measuring.points.length} {measuring.points.length === 1 ? "point" : "points"})
-
-
- {measuring.active && (
-
- Click to add points
-
- )}
-
-
-
- )}
-
- {/* Radial context menu */}
-
setRadialMenu((m) => ({ ...m, open: false }))}
- />
-
- )
-})
-
-export default MapView
+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 Medium'], '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 Bold'], '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', () => {
+ map.addSource(ROUTE_SOURCE, {
+ type: 'geojson',
+ data: { type: 'FeatureCollection', features: [] },
+ })
+
+ // Boundary polygon layer for selected places
+ 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', () => {
+ map.addSource(ROUTE_SOURCE, {
+ type: 'geojson',
+ data: { type: 'FeatureCollection', features: [] },
+ })
+
+ // Boundary polygon 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 || !map.isStyleLoaded()) return
+
+ 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,
+ })
+ }
+ }
+ }
+ }, [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(
+ `
+ ${stop.name}
+
+
`
+ )
+ .addTo(map)
+
+ popup.getElement().querySelector(`#remove-stop-${stop.id}`)?.addEventListener('click', () => {
+ useStore.getState().removeStop(stop.id)
+ popup.remove()
+ })
+ popupRef.current = popup
+ })
+
+ const marker = new maplibregl.Marker({ element: el })
+ .setLngLat([stop.lon, stop.lat])
+ .addTo(map)
+
+ markersRef.current.push(marker)
+ })
+
+ // If stops but no route yet, fit to stops
+ if (stops.length > 0 && !route) {
+ if (stops.length === 1) {
+ map.flyTo({ center: [stops[0].lon, stops[0].lat], zoom: 13 })
+ } else {
+ const bounds = stops.reduce(
+ (b, s) => b.extend([s.lon, s.lat]),
+ new maplibregl.LngLatBounds([stops[0].lon, stops[0].lat], [stops[0].lon, stops[0].lat])
+ )
+ map.fitBounds(bounds, { padding: { top: 60, bottom: 60, left: 420, right: 60 } })
+ }
+ }
+ }, [stops, route, gpsOrigin, geoPermission])
+
+
+ // ESC key handler for measurement mode
+ useEffect(() => {
+ const handleKeyDown = (e) => {
+ if (e.key === "Escape" && measuringRef.current.active) {
+ endMeasuring()
+ }
+ }
+ window.addEventListener("keydown", handleKeyDown)
+ return () => window.removeEventListener("keydown", handleKeyDown)
+ }, [])
+
+ // Handle location pick mode for contacts
+ useEffect(() => {
+ const map = mapInstance.current
+ if (!map) return
+ if (pickingLocationFor) {
+ map.getCanvas().style.cursor = 'crosshair'
+ }
+ return () => {
+ if (map && !measuringRef.current.active) {
+ map.getCanvas().style.cursor = ''
+ }
+ }
+ }, [pickingLocationFor])
+
+ // ESC key handler for location pick mode
+ useEffect(() => {
+ const handleKeyDown = (e) => {
+ if (e.key === 'Escape' && pickingLocationFor) {
+ // Cancel pick mode, reopen modal with original form data
+ const map = mapInstance.current
+ if (map) map.getCanvas().style.cursor = ''
+ setEditingContact(pickingLocationFor)
+ clearPickingLocationFor()
+ }
+ }
+ window.addEventListener('keydown', handleKeyDown)
+ return () => window.removeEventListener('keydown', handleKeyDown)
+ }, [pickingLocationFor, setEditingContact, clearPickingLocationFor])
+
+
+ // Track zoom level for indicator
+ useEffect(() => {
+ const map = mapInstance.current
+ if (!map) return
+
+ const updateZoom = () => setZoomLevel(map.getZoom())
+
+ // Set initial zoom
+ if (map.loaded()) {
+ updateZoom()
+ } else {
+ map.once("load", updateZoom)
+ }
+
+ // Subscribe to zoom changes
+ map.on("zoom", updateZoom)
+
+ return () => {
+ map.off("zoom", updateZoom)
+ }
+ }, [])
+
+
+ // Track map center for search viewport bias
+ useEffect(() => {
+ const map = mapInstance.current
+ if (!map) return
+
+ const updateCenter = () => {
+ const center = map.getCenter()
+ const zoom = map.getZoom()
+ setMapCenter({ lat: center.lat, lon: center.lng, zoom })
+ }
+
+ // Set initial center
+ if (map.loaded()) {
+ updateCenter()
+ } else {
+ map.once("load", updateCenter)
+ }
+
+ // Update on move end (not every frame)
+ map.on("moveend", updateCenter)
+
+ return () => {
+ map.off("moveend", updateCenter)
+ }
+ }, [setMapCenter])
+
+ return (
+
+
+ {/* Zoom level indicator - bottom-left corner */}
+
+ Z {zoomLevel.toFixed(1)}
+
+
+ {/* Measurement info bar */}
+ {(measuring.active || measuring.points.length > 1) && (
+
+
+
+ {formatDistance(measuring.totalMeters)}
+
+ ({measuring.points.length} {measuring.points.length === 1 ? "point" : "points"})
+
+
+ {measuring.active && (
+
+ Click to add points
+
+ )}
+
+
+
+ )}
+
+ {/* Radial context menu */}
+
setRadialMenu((m) => ({ ...m, open: false }))}
+ />
+
+ )
+})
+
+export default MapView