From a1f929e10ad643fb4f68c78e4fa7724c1fb6c13c Mon Sep 17 00:00:00 2001 From: Matt Date: Tue, 28 Apr 2026 23:41:09 +0000 Subject: [PATCH] =?UTF-8?q?fix:=20measure=20tool=20=E2=80=94=20multi-point?= =?UTF-8?q?=20with=20segment=20distances=20and=20running=20total?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/MapView.jsx | 3761 +++++++++++++++++++----------------- 1 file changed, 1970 insertions(+), 1791 deletions(-) diff --git a/src/components/MapView.jsx b/src/components/MapView.jsx index 3f70f41..4105403 100644 --- a/src/components/MapView.jsx +++ b/src/components/MapView.jsx @@ -1,1791 +1,1970 @@ -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 } 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' - -/** 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 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 - const [measuring, setMeasuring] = useState({ active: false, points: [] }) - - - // 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, - }) - } - - // Clear measurement mode - const clearMeasuring = () => { - const map = mapInstance.current - setMeasuring({ active: false, points: [] }) - if (map) { - map.getCanvas().style.cursor = "" - 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) - } - } - - 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 })) - const map = mapInstance.current - if (!map) return - setMeasuring({ active: true, points: [{ lat: radialMenu.lat, lon: radialMenu.lon }] }) - map.getCanvas().style.cursor = "crosshair" - if (!map.getSource(MEASURE_SOURCE)) { - 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": 4, - "circle-color": accentColor, - "circle-stroke-width": 1, - "circle-stroke-color": "#fff", - }, - }) - } - updateMeasureLayer([{ lat: radialMenu.lat, lon: radialMenu.lon }]) - }, - }, - ] - // Context menu trigger handler - const handleContextMenuTrigger = ({ x, y }) => { - const map = mapInstance.current - if (!map || !mapRef.current) 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 - } - - // Handle measuring mode - const measureState = measuring - if (measureState.active) { - const { lng, lat } = e.lngLat - const newPoints = [...measureState.points, { lat, lon: lng }] - setMeasuring({ ...measureState, points: newPoints }) - updateMeasureLayer(newPoints) - // Calculate and show total distance - if (newPoints.length > 1) { - 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 - ) - } - toast(formatDistance(totalMeters), { icon: "📏", duration: 2000 }) - } - 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 - } - - 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 - map.on('dblclick', (e) => { - if (measuring.active) { - e.preventDefault() - // Keep the measurement visible but exit measuring mode - setMeasuring((m) => ({ ...m, active: false })) - map.getCanvas().style.cursor = '' - } - }) - - // 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 {} - - // POI/label hover affordance — cursor pointer - const interactiveLayers = ['pois', 'places_locality', 'places_region', 'places_country', 'places_subplace'] - - interactiveLayers.forEach(layerId => { - map.on('mouseenter', layerId, () => { - map.getCanvas().style.cursor = 'pointer' - }) - - map.on('mouseleave', layerId, () => { - map.getCanvas().style.cursor = '' - }) - }) - }) - - 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() - 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) - - // 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" && measuring.active) { - clearMeasuring() - } - } - window.addEventListener("keydown", handleKeyDown) - return () => window.removeEventListener("keydown", handleKeyDown) - }, [measuring.active]) - - // Handle location pick mode for contacts - useEffect(() => { - const map = mapInstance.current - if (!map) return - if (pickingLocationFor) { - map.getCanvas().style.cursor = 'crosshair' - } - return () => { - if (map && !measuring.active) { - map.getCanvas().style.cursor = '' - } - } - }, [pickingLocationFor, measuring.active]) - - // 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)} -
- {/* 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' + +/** 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 + // 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 + } + + 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 {} + + // POI/label hover affordance — cursor pointer + const interactiveLayers = ['pois', 'places_locality', 'places_region', 'places_country', 'places_subplace'] + + interactiveLayers.forEach(layerId => { + map.on('mouseenter', layerId, () => { + if (!measuringRef.current.active) { + map.getCanvas().style.cursor = 'pointer' + } + }) + + map.on('mouseleave', layerId, () => { + if (!measuringRef.current.active) { + map.getCanvas().style.cursor = '' + } + }) + }) + }) + + 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) + + // 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