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 { getTheme, getThemeSprite, getOverlayConfig } from '../themes/registry' 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' /** Check if current theme is dark based on registry */ function isCurrentThemeDark() { const themeId = document.documentElement.getAttribute('data-theme') || 'dark' return getTheme(themeId).dark } 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' const USFS_SOURCE = 'usfs-trails-source' const USFS_ROADS_LAYER = 'usfs-roads-layer' const USFS_TRAILS_LAYER = 'usfs-trails-layer' const USFS_ROADS_LABEL = 'usfs-roads-label' const USFS_TRAILS_LABEL = 'usfs-trails-label' const USFS_ROADS_HIT = 'usfs-roads-hit' const USFS_TRAILS_HIT = 'usfs-trails-hit' const BLM_SOURCE = 'blm-trails-source' const BLM_ROUTES_NATURAL = 'blm-routes-natural' const BLM_ROUTES_IMPROVED = 'blm-routes-improved' const BLM_ROUTES_AGGREGATE = 'blm-routes-aggregate' const BLM_ROUTES_SNOW = 'blm-routes-snow' const BLM_ROUTES_OTHER = 'blm-routes-other' const BLM_ROUTES_LABEL = 'blm-routes-label' const BLM_ROUTES_HIT = 'blm-routes-hit' // Highlight state - use data-driven expressions to target specific features const INTERACTIVE_LABEL_LAYERS = ['pois', 'places_subplace', 'places_locality', 'places_region', 'places_country'] let originalPaintValues = {} // Store original paint values for restoration let highlightState = { hoveredLayer: null, hoveredName: null, selectedLayer: null, selectedName: null, } function storeOriginalPaint(map, layerId) { if (originalPaintValues[layerId]) return if (!map.getLayer(layerId)) return originalPaintValues[layerId] = { 'text-color': map.getPaintProperty(layerId, 'text-color'), 'text-halo-color': map.getPaintProperty(layerId, 'text-halo-color'), 'text-halo-width': map.getPaintProperty(layerId, 'text-halo-width'), } } function restoreOriginalPaint(map, layerId) { if (!originalPaintValues[layerId] || !map.getLayer(layerId)) return const orig = originalPaintValues[layerId] if (orig['text-color'] !== undefined) map.setPaintProperty(layerId, 'text-color', orig['text-color']) if (orig['text-halo-color'] !== undefined) map.setPaintProperty(layerId, 'text-halo-color', orig['text-halo-color']) if (orig['text-halo-width'] !== undefined) map.setPaintProperty(layerId, 'text-halo-width', orig['text-halo-width']) } function applyHighlightExpression(map, layerId) { if (!map.getLayer(layerId)) return storeOriginalPaint(map, layerId) const orig = originalPaintValues[layerId] const isDark = isCurrentThemeDark() const accentColor = getComputedStyle(document.documentElement).getPropertyValue('--accent').trim() || '#7a9a6b' // Hover: darken text slightly, bump halo to full opacity for focus effect const hoverColor = isDark ? '#ffffff' : '#000000' const hoverHaloColor = isDark ? 'rgba(30,30,30,1)' : 'rgba(255,255,255,1)' // Selected: accent color text with solid white halo at full opacity const selectedHaloColor = isDark ? 'rgba(30,30,30,1)' : 'rgba(255,255,255,1)' const isHovered = highlightState.hoveredLayer === layerId && highlightState.hoveredName const isSelected = highlightState.selectedLayer === layerId && highlightState.selectedName // Build case expressions for each paint property // Priority: selected > hover > original if (isSelected && isHovered && highlightState.selectedName !== highlightState.hoveredName) { // Both selected and hover active on different features map.setPaintProperty(layerId, 'text-color', [ 'case', ['==', ['get', 'name'], highlightState.selectedName], accentColor, ['==', ['get', 'name'], highlightState.hoveredName], hoverColor, orig['text-color'] || (isDark ? '#e0e0e0' : '#2a2a2a') ]) map.setPaintProperty(layerId, 'text-halo-color', [ 'case', ['==', ['get', 'name'], highlightState.selectedName], selectedHaloColor, ['==', ['get', 'name'], highlightState.hoveredName], hoverHaloColor, orig['text-halo-color'] || (isDark ? 'rgba(20,20,20,0.9)' : 'rgba(255,255,255,0.9)') ]) map.setPaintProperty(layerId, 'text-halo-width', [ 'case', ['==', ['get', 'name'], highlightState.selectedName], 2.2, ['==', ['get', 'name'], highlightState.hoveredName], 2, orig['text-halo-width'] || 1.5 ]) } else if (isSelected) { // Only selected map.setPaintProperty(layerId, 'text-color', [ 'case', ['==', ['get', 'name'], highlightState.selectedName], accentColor, orig['text-color'] || (isDark ? '#e0e0e0' : '#2a2a2a') ]) map.setPaintProperty(layerId, 'text-halo-color', [ 'case', ['==', ['get', 'name'], highlightState.selectedName], selectedHaloColor, orig['text-halo-color'] || (isDark ? 'rgba(20,20,20,0.9)' : 'rgba(255,255,255,0.9)') ]) map.setPaintProperty(layerId, 'text-halo-width', [ 'case', ['==', ['get', 'name'], highlightState.selectedName], 2.2, orig['text-halo-width'] || 1.8 ]) } else if (isHovered) { // Only hovered map.setPaintProperty(layerId, 'text-color', [ 'case', ['==', ['get', 'name'], highlightState.hoveredName], hoverColor, orig['text-color'] || (isDark ? '#e0e0e0' : '#2a2a2a') ]) map.setPaintProperty(layerId, 'text-halo-color', [ 'case', ['==', ['get', 'name'], highlightState.hoveredName], hoverHaloColor, orig['text-halo-color'] || (isDark ? 'rgba(20,20,20,0.9)' : 'rgba(255,255,255,0.9)') ]) map.setPaintProperty(layerId, 'text-halo-width', [ 'case', ['==', ['get', 'name'], highlightState.hoveredName], 2, orig['text-halo-width'] || 1.8 ]) } else { // No highlight on this layer - restore original restoreOriginalPaint(map, layerId) } } function setHoverHighlight(map, feature) { const prevLayer = highlightState.hoveredLayer if (!feature) { highlightState.hoveredLayer = null highlightState.hoveredName = null if (prevLayer) applyHighlightExpression(map, prevLayer) return } const layerId = feature.layer?.id const name = feature.properties?.name if (!layerId || !name || !map.getLayer(layerId)) return // Don't hover the selected feature if (layerId === highlightState.selectedLayer && name === highlightState.selectedName) return highlightState.hoveredLayer = layerId highlightState.hoveredName = name // Update previous layer if different if (prevLayer && prevLayer !== layerId) { applyHighlightExpression(map, prevLayer) } // Update current layer applyHighlightExpression(map, layerId) } function setSelectedHighlight(map, feature) { const prevLayer = highlightState.selectedLayer if (!feature) { highlightState.selectedLayer = null highlightState.selectedName = null highlightState.hoveredLayer = null highlightState.hoveredName = null if (prevLayer) applyHighlightExpression(map, prevLayer) return } const layerId = feature.layer?.id const name = feature.properties?.name if (!layerId || !name || !map.getLayer(layerId)) return highlightState.selectedLayer = layerId highlightState.selectedName = name // Clear hover when selecting highlightState.hoveredLayer = null highlightState.hoveredName = null // Update previous layer if different if (prevLayer && prevLayer !== layerId) { applyHighlightExpression(map, prevLayer) } // Update current layer applyHighlightExpression(map, layerId) } function clearAllHighlights(map) { const layers = [highlightState.hoveredLayer, highlightState.selectedLayer].filter(Boolean) highlightState.hoveredLayer = null highlightState.hoveredName = null highlightState.selectedLayer = null highlightState.selectedName = null layers.forEach(layerId => restoreOriginalPaint(map, layerId)) } /** Apply improved base label styling for readability (Google Maps style) */ function applyBaseLabelStyling(map) { const isDark = isCurrentThemeDark() INTERACTIVE_LABEL_LAYERS.forEach(layerId => { if (!map.getLayer(layerId)) return // Base styling: dark text with solid opaque white halo for knockout effect // This ensures labels read cleanly over any background (parks, water, terrain) map.setPaintProperty(layerId, 'text-color', isDark ? '#e0e0e0' : '#2a2a2a') map.setPaintProperty(layerId, 'text-halo-color', isDark ? 'rgba(20,20,20,0.9)' : 'rgba(255,255,255,0.9)') map.setPaintProperty(layerId, 'text-halo-width', 1.8) // Store these as the original values for highlight restoration originalPaintValues[layerId] = { 'text-color': isDark ? '#e0e0e0' : '#2a2a2a', 'text-halo-color': isDark ? 'rgba(20,20,20,0.9)' : 'rgba(255,255,255,0.9)', 'text-halo-width': 1.8, } }) } /** 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' // Use namedTheme directly for built-in themes, custom colors for others const theme = getTheme(themeName) const colors = theme.colors || namedTheme(themeName) return { version: 8, glyphs: 'https://protomaps.github.io/basemaps-assets/fonts/{fontstack}/{range}.pbf', sprite: getThemeSprite(themeName), sources: { protomaps: { type: 'vector', url: `pmtiles://${tileUrl}`, attribution, }, }, layers: layers('protomaps', colors, { 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, themeId) { if (!map || map.getSource(HILLSHADE_SOURCE)) return const config = getConfig() const hs = config?.tileset_hillshade if (!hs?.url) return const c = getOverlayConfig(themeId, 'hillshade') 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': c.exaggeration, 'hillshade-illumination-direction': c.illuminationDirection, 'hillshade-shadow-color': c.shadowColor, 'hillshade-highlight-color': c.highlightColor, }, }, 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, themeId) { if (!map || map.getSource(TRAFFIC_SOURCE)) return const config = getConfig() const tr = config?.traffic if (!tr?.proxy_url) return const c = getOverlayConfig(themeId, 'traffic') 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': c.opacity, }, }) } /** 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, themeId) { if (!map || map.getSource(PUBLIC_LANDS_SOURCE)) return const c = getOverlayConfig(themeId, 'publicLands') 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 } } // 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'], c.fillWA, ['==', ['get', 'designation'], 'WSA'], c.fillWA, ['==', ['get', 'agency'], 'NPS'], c.fillNPS, ['==', ['get', 'agency'], 'USFS'], c.fillUSFS, ['==', ['get', 'agency'], 'BLM'], c.fillBLM, ['==', ['get', 'agency'], 'FWS'], c.fillFWS, ['any', ['==', ['get', 'manager_type'], 'STAT'], ['==', ['get', 'agency'], 'SPR'], ['==', ['get', 'agency'], 'SDC'], ['==', ['get', 'agency'], 'SLB'] ], c.fillSTAT, ['any', ['==', ['get', 'manager_type'], 'LOC'], ['==', ['get', 'manager_type'], 'DIST'] ], c.fillLOC, c.fillDefault ], 'fill-opacity': [ 'case', ['==', ['get', 'designation'], 'WA'], c.fillOpacityWA * c.opacityMod, ['==', ['get', 'designation'], 'WSA'], c.fillOpacityWA * c.opacityMod, ['==', ['get', 'agency'], 'NPS'], c.fillOpacityNPS * c.opacityMod, ['==', ['get', 'agency'], 'USFS'], c.fillOpacityUSFS * c.opacityMod, ['==', ['get', 'agency'], 'BLM'], c.fillOpacityBLM * c.opacityMod, ['any', ['==', ['get', 'manager_type'], 'STAT'], ['==', ['get', 'agency'], 'SPR'] ], c.fillOpacitySTAT * c.opacityMod, ['any', ['==', ['get', 'manager_type'], 'LOC'], ['==', ['get', 'manager_type'], 'DIST'] ], c.fillOpacityLOC * c.opacityMod, c.fillOpacityDefault * c.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'], c.outlineWA, ['==', ['get', 'designation'], 'WSA'], c.outlineWA, ['==', ['get', 'agency'], 'NPS'], c.outlineNPS, ['==', ['get', 'agency'], 'USFS'], c.outlineUSFS, ['==', ['get', 'agency'], 'BLM'], c.outlineBLM, ['==', ['get', 'agency'], 'FWS'], c.outlineFWS, ['any', ['==', ['get', 'manager_type'], 'STAT'], ['==', ['get', 'agency'], 'SPR'] ], c.outlineSTAT, ['any', ['==', ['get', 'manager_type'], 'LOC'], ['==', ['get', 'manager_type'], 'DIST'] ], c.outlineLOC, c.outlineDefault ], 'line-opacity': [ 'case', ['==', ['get', 'agency'], 'NPS'], c.outlineOpacityNPS, ['==', ['get', 'agency'], 'USFS'], c.outlineOpacityUSFS, ['==', ['get', 'agency'], 'BLM'], c.outlineOpacityDefault, c.outlineOpacityDefault ], 'line-width': [ 'interpolate', ['linear'], ['zoom'], 4, c.outlineWidth.z4, 8, c.outlineWidth.z8, 12, c.outlineWidth.z12 ], }, }, 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, c.labelSize.z10, 14, c.labelSize.z14], 'text-font': c.labelFont, 'symbol-placement': 'point', 'text-anchor': 'center', 'text-max-width': 8, 'text-allow-overlap': false, 'text-ignore-placement': false, }, paint: { 'text-color': c.labelColor, 'text-halo-color': c.labelHaloColor, 'text-halo-width': c.labelHaloWidth, 'text-opacity': c.labelOpacity, }, }) } /** 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, themeId) { if (!map || map.getSource(CONTOUR_SOURCE)) return const c = getOverlayConfig(themeId, 'contours') 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 } } // 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': c.minorColor, 'line-opacity': c.minorOpacity * c.opacityMod, 'line-width': ['interpolate', ['linear'], ['zoom'], 11, c.minorWidth.z11, 14, c.minorWidth.z14], }, }, 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': c.intermediateColor, 'line-opacity': c.intermediateOpacity * c.opacityMod, 'line-width': ['interpolate', ['linear'], ['zoom'], 8, c.intermediateWidth.z8, 14, c.intermediateWidth.z14], }, }, 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': c.indexColor, 'line-opacity': c.indexOpacity * c.opacityMod, 'line-width': ['interpolate', ['linear'], ['zoom'], 4, c.indexWidth.z4, 14, c.indexWidth.z14], }, }, 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': c.labelSize, 'text-font': c.labelFont, 'symbol-placement': 'line', 'text-anchor': 'center', 'symbol-spacing': 400, 'text-max-angle': 30, 'text-allow-overlap': false, }, paint: { 'text-color': c.labelColor, 'text-halo-color': c.labelHaloColor, 'text-halo-width': c.labelHaloWidth, 'text-opacity': c.labelOpacity, }, }) } /** 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, themeId) { if (!map || map.getSource(CONTOUR_TEST_SOURCE)) return const c = getOverlayConfig(themeId, 'contoursTest') 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 } } // 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": c.minorColor, "line-opacity": c.minorOpacity * c.opacityMod, "line-width": ["interpolate", ["linear"], ["zoom"], 11, c.minorWidth.z11, 14, c.minorWidth.z14], }, }, 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": c.intermediateColor, "line-opacity": c.intermediateOpacity * c.opacityMod, "line-width": ["interpolate", ["linear"], ["zoom"], 8, c.intermediateWidth.z8, 14, c.intermediateWidth.z14], }, }, 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": c.indexColor, "line-opacity": c.indexOpacity * c.opacityMod, "line-width": ["interpolate", ["linear"], ["zoom"], 4, c.indexWidth.z4, 14, c.indexWidth.z14], }, }, 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": c.labelSize, "text-font": c.labelFont, "symbol-placement": "line", "text-anchor": "center", "symbol-spacing": 400, "text-max-angle": 30, "text-allow-overlap": false, }, paint: { "text-color": c.labelColor, "text-halo-color": c.labelHaloColor, "text-halo-width": c.labelHaloWidth, "text-opacity": c.labelOpacity, }, }) } /** 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, themeId) { if (!map || map.getSource(CONTOUR_TEST_10FT_SOURCE)) return const c = getOverlayConfig(themeId, 'contoursTest10ft') 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 } } // 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": c.minorColor, "line-opacity": c.minorOpacity * c.opacityMod, "line-width": ["interpolate", ["linear"], ["zoom"], 11, c.minorWidth.z11, 14, c.minorWidth.z14], }, }, 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": c.intermediateColor, "line-opacity": c.intermediateOpacity * c.opacityMod, "line-width": ["interpolate", ["linear"], ["zoom"], 8, c.intermediateWidth.z8, 14, c.intermediateWidth.z14], }, }, 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": c.indexColor, "line-opacity": c.indexOpacity * c.opacityMod, "line-width": ["interpolate", ["linear"], ["zoom"], 4, c.indexWidth.z4, 14, c.indexWidth.z14], }, }, 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": c.labelSize, "text-font": c.labelFont, "symbol-placement": "line", "text-anchor": "center", "symbol-spacing": 400, "text-max-angle": 30, "text-allow-overlap": false, }, paint: { "text-color": c.labelColor, "text-halo-color": c.labelHaloColor, "text-halo-width": c.labelHaloWidth, "text-opacity": c.labelOpacity, }, }) } /** 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 USFS trails and roads vector tile overlay */ function addUsfsTrails(map, themeId) { if (!map || map.getSource(USFS_SOURCE)) return const c = getOverlayConfig(themeId, 'usfsTrails') map.addSource(USFS_SOURCE, { type: "vector", url: "pmtiles:///tiles/usfs-trails-roads.pmtiles", }) // Insert below first symbol layer let beforeId = undefined for (const layer of map.getStyle().layers) { if (layer.type === "symbol") { beforeId = layer.id break } } // Invisible hit-area layers for easier clicking map.addLayer({ id: USFS_ROADS_HIT, type: "line", source: USFS_SOURCE, "source-layer": "roads", minzoom: 10, paint: { "line-color": "#000000", "line-opacity": 0, "line-width": c.hitWidth, }, }, beforeId) map.addLayer({ id: USFS_TRAILS_HIT, type: "line", source: USFS_SOURCE, "source-layer": "trails", minzoom: 10, paint: { "line-color": "#000000", "line-opacity": 0, "line-width": c.hitWidth, }, }, beforeId) // Roads layer - solid amber/tan line map.addLayer({ id: USFS_ROADS_LAYER, type: "line", source: USFS_SOURCE, "source-layer": "roads", minzoom: 10, paint: { "line-color": c.roadsColor, "line-opacity": c.roadsOpacity, "line-width": ["interpolate", ["linear"], ["zoom"], 10, c.roadsWidth.z10, 14, c.roadsWidth.z14, 16, c.roadsWidth.z16], }, }, beforeId) // Trails layer - color by allowed use map.addLayer({ id: USFS_TRAILS_LAYER, type: "line", source: USFS_SOURCE, "source-layer": "trails", minzoom: 10, paint: { "line-color": [ "case", // Motorcycle/ATV trails - orange ["any", ["==", ["slice", ["get", "MOTORCYCLE"], 0, 1], "0"], ["==", ["slice", ["get", "ATV_MANAGE"], 0, 1], "0"] ], c.trailsMotorized, // Bike trails - amber ["==", ["slice", ["get", "BICYCLE_MA"], 0, 1], "0"], c.trailsBicycle, // Hiker/Horse only - green ["any", ["==", ["slice", ["get", "HIKER_PEDE"], 0, 1], "0"], ["==", ["slice", ["get", "HORSE_MANA"], 0, 1], "0"] ], c.trailsHiker, // Default - tan c.trailsDefault ], "line-opacity": c.trailsOpacity, "line-width": ["interpolate", ["linear"], ["zoom"], 10, c.trailsWidth.z10, 14, c.trailsWidth.z14, 16, c.trailsWidth.z16], "line-dasharray": c.trailsDash, }, }, beforeId) // Road labels (zoom 12+) map.addLayer({ id: USFS_ROADS_LABEL, type: "symbol", source: USFS_SOURCE, "source-layer": "roads", minzoom: 12, filter: ["has", "NAME"], layout: { "text-field": ["get", "NAME"], "text-size": c.roadsLabelSize, "text-font": c.labelFont, "symbol-placement": "line", "text-anchor": "center", "symbol-spacing": 300, "text-max-angle": 25, "text-allow-overlap": false, }, paint: { "text-color": c.roadsLabelColor, "text-halo-color": c.roadsLabelHaloColor, "text-halo-width": c.roadsLabelHaloWidth, "text-opacity": c.roadsLabelOpacity, }, }) // Trail labels (zoom 12+) map.addLayer({ id: USFS_TRAILS_LABEL, type: "symbol", source: USFS_SOURCE, "source-layer": "trails", minzoom: 12, filter: ["has", "TRAIL_NAME"], layout: { "text-field": ["get", "TRAIL_NAME"], "text-size": c.trailsLabelSize, "text-font": c.labelFont, "symbol-placement": "line", "text-anchor": "center", "symbol-spacing": 300, "text-max-angle": 25, "text-allow-overlap": false, }, paint: { "text-color": c.trailsLabelColor, "text-halo-color": c.trailsLabelHaloColor, "text-halo-width": c.trailsLabelHaloWidth, "text-opacity": c.trailsLabelOpacity, }, }) // Cursor pointer on hover ;[USFS_TRAILS_HIT, USFS_ROADS_HIT].forEach(layerId => { map.on("mouseenter", layerId, () => { map.getCanvas().style.cursor = "pointer" }) map.on("mouseleave", layerId, () => { map.getCanvas().style.cursor = "" }) }) } function removeUsfsTrails(map) { if (!map) return if (map.getLayer(USFS_TRAILS_LABEL)) map.removeLayer(USFS_TRAILS_LABEL) if (map.getLayer(USFS_ROADS_LABEL)) map.removeLayer(USFS_ROADS_LABEL) if (map.getLayer(USFS_TRAILS_LAYER)) map.removeLayer(USFS_TRAILS_LAYER) if (map.getLayer(USFS_ROADS_LAYER)) map.removeLayer(USFS_ROADS_LAYER) if (map.getLayer(USFS_TRAILS_HIT)) map.removeLayer(USFS_TRAILS_HIT) if (map.getLayer(USFS_ROADS_HIT)) map.removeLayer(USFS_ROADS_HIT) if (map.getSource(USFS_SOURCE)) map.removeSource(USFS_SOURCE) } /** Add BLM trails/roads vector tile overlay with surface-type styling */ function addBlmTrails(map, themeId) { if (!map || map.getSource(BLM_SOURCE)) return const c = getOverlayConfig(themeId, 'blmTrails') map.addSource(BLM_SOURCE, { type: "vector", url: "pmtiles:///tiles/blm-trails-roads.pmtiles", }) // Insert below first symbol layer let beforeId = undefined for (const layer of map.getStyle().layers) { if (layer.type === "symbol") { beforeId = layer.id break } } // Color expression based on route use class const colorExpr = [ "case", ["any", ["==", ["get", "OBSRVE_ROUTE_USE_CLASS"], "4WD HIGH CLEARANCE / SPECIALIZED"], ["==", ["get", "OBSRVE_ROUTE_USE_CLASS"], "4WD High Clearance/Specialized"], ["==", ["get", "OBSRVE_ROUTE_USE_CLASS"], "4wd High Clearance / Specialized"] ], c.color4wdHigh, ["any", ["==", ["get", "OBSRVE_ROUTE_USE_CLASS"], "4WD LOW"], ["==", ["get", "OBSRVE_ROUTE_USE_CLASS"], "4WD Low"], ["==", ["get", "OBSRVE_ROUTE_USE_CLASS"], "4wd Low"] ], c.color4wdLow, ["==", ["get", "OBSRVE_ROUTE_USE_CLASS"], "ATV"], c.colorAtv, ["any", ["==", ["get", "OBSRVE_ROUTE_USE_CLASS"], "MOTORIZED SINGLE TRACK"], ["==", ["get", "OBSRVE_ROUTE_USE_CLASS"], "Motorized Single Track"] ], c.colorMotoSingle, ["any", ["==", ["get", "OBSRVE_ROUTE_USE_CLASS"], "2WD LOW"], ["==", ["get", "OBSRVE_ROUTE_USE_CLASS"], "2WD Low"], ["==", ["get", "OBSRVE_ROUTE_USE_CLASS"], "2wd Low"] ], c.color2wdLow, ["any", ["==", ["get", "OBSRVE_ROUTE_USE_CLASS"], "NON-MECHANIZED"], ["==", ["get", "OBSRVE_ROUTE_USE_CLASS"], "Non-Mechanized"] ], c.colorNonMech, c.colorDefault ] const lineWidth = ["interpolate", ["linear"], ["zoom"], 10, c.lineWidth.z10, 14, c.lineWidth.z14, 16, c.lineWidth.z16] // Filter out paved, arterial, collector, local, and highways const excludeUrban = [ "all", // Exclude paved ["!=", ["get", "OBSRVE_SRFCE_TYPE"], "SOLID SURFACE"], ["!=", ["get", "OBSRVE_SRFCE_TYPE"], "Solid Surface"], // Exclude arterial roads ["!=", ["get", "OBSRVE_FUNC_CLASS"], "ARTERIAL"], ["!=", ["get", "OBSRVE_FUNC_CLASS"], "Arterial"], // Exclude collector roads ["!=", ["get", "OBSRVE_FUNC_CLASS"], "COLLECTOR"], ["!=", ["get", "OBSRVE_FUNC_CLASS"], "Collector"], // Exclude local roads ["!=", ["get", "OBSRVE_FUNC_CLASS"], "LOCAL"], ["!=", ["get", "OBSRVE_FUNC_CLASS"], "Local"], // Exclude designated highways ["!", ["has", "HWY_CLASS"]] ] // Invisible hit-area layer for clicking map.addLayer({ id: BLM_ROUTES_HIT, type: "line", source: BLM_SOURCE, "source-layer": "blm_routes", minzoom: 10, filter: excludeUrban, paint: { "line-color": "#000000", "line-opacity": 0, "line-width": c.hitWidth, }, }, beforeId) // NATURAL surface - solid line map.addLayer({ id: BLM_ROUTES_NATURAL, type: "line", source: BLM_SOURCE, "source-layer": "blm_routes", minzoom: 10, filter: ["all", excludeUrban, ["any", ["==", ["get", "OBSRVE_SRFCE_TYPE"], "NATURAL"], ["==", ["get", "OBSRVE_SRFCE_TYPE"], "Natural"] ] ], paint: { "line-color": colorExpr, "line-opacity": c.lineOpacity, "line-width": lineWidth, }, }, beforeId) // NATURAL IMPROVED surface - dashed map.addLayer({ id: BLM_ROUTES_IMPROVED, type: "line", source: BLM_SOURCE, "source-layer": "blm_routes", minzoom: 10, filter: ["all", excludeUrban, ["any", ["==", ["get", "OBSRVE_SRFCE_TYPE"], "NATURAL IMPROVED"], ["==", ["get", "OBSRVE_SRFCE_TYPE"], "Natural Improved"] ] ], paint: { "line-color": colorExpr, "line-opacity": c.lineOpacity, "line-width": lineWidth, "line-dasharray": c.dashImproved, }, }, beforeId) // AGGREGATE surface - dotted map.addLayer({ id: BLM_ROUTES_AGGREGATE, type: "line", source: BLM_SOURCE, "source-layer": "blm_routes", minzoom: 10, filter: ["all", excludeUrban, ["any", ["==", ["get", "OBSRVE_SRFCE_TYPE"], "AGGREGATE"], ["==", ["get", "OBSRVE_SRFCE_TYPE"], "Aggregate"] ] ], paint: { "line-color": colorExpr, "line-opacity": c.lineOpacity, "line-width": lineWidth, "line-dasharray": c.dashAggregate, }, }, beforeId) // SNOW surface - dash-dot, blue map.addLayer({ id: BLM_ROUTES_SNOW, type: "line", source: BLM_SOURCE, "source-layer": "blm_routes", minzoom: 10, filter: ["all", excludeUrban, ["any", ["==", ["get", "OBSRVE_SRFCE_TYPE"], "SNOW"], ["==", ["get", "OBSRVE_SRFCE_TYPE"], "Snow"] ] ], paint: { "line-color": c.colorSnow, "line-opacity": c.lineOpacity, "line-width": lineWidth, "line-dasharray": c.dashSnow, }, }, beforeId) // OTHER/UNKNOWN surface - dash-dot-dot map.addLayer({ id: BLM_ROUTES_OTHER, type: "line", source: BLM_SOURCE, "source-layer": "blm_routes", minzoom: 10, filter: ["all", excludeUrban, ["!", ["any", ["==", ["get", "OBSRVE_SRFCE_TYPE"], "NATURAL"], ["==", ["get", "OBSRVE_SRFCE_TYPE"], "Natural"], ["==", ["get", "OBSRVE_SRFCE_TYPE"], "NATURAL IMPROVED"], ["==", ["get", "OBSRVE_SRFCE_TYPE"], "Natural Improved"], ["==", ["get", "OBSRVE_SRFCE_TYPE"], "AGGREGATE"], ["==", ["get", "OBSRVE_SRFCE_TYPE"], "Aggregate"], ["==", ["get", "OBSRVE_SRFCE_TYPE"], "SNOW"], ["==", ["get", "OBSRVE_SRFCE_TYPE"], "Snow"] ]] ], paint: { "line-color": colorExpr, "line-opacity": c.lineOpacityOther, "line-width": lineWidth, "line-dasharray": c.dashOther, }, }, beforeId) // Route labels (zoom 12+) map.addLayer({ id: BLM_ROUTES_LABEL, type: "symbol", source: BLM_SOURCE, "source-layer": "blm_routes", minzoom: 12, filter: ["all", excludeUrban, ["has", "ROUTE_PRMRY_NM"]], layout: { "text-field": ["get", "ROUTE_PRMRY_NM"], "text-size": c.labelSize, "text-font": c.labelFont, "symbol-placement": "line", "text-anchor": "center", "symbol-spacing": 300, "text-max-angle": 25, "text-allow-overlap": false, }, paint: { "text-color": c.labelColor, "text-halo-color": c.labelHaloColor, "text-halo-width": c.labelHaloWidth, "text-opacity": c.labelOpacity, }, }) // Cursor pointer on hover map.on("mouseenter", BLM_ROUTES_HIT, () => { map.getCanvas().style.cursor = "pointer" }) map.on("mouseleave", BLM_ROUTES_HIT, () => { map.getCanvas().style.cursor = "" }) } /** Remove BLM trails/roads layers and source */ function removeBlmTrails(map) { if (!map) return if (map.getLayer(BLM_ROUTES_LABEL)) map.removeLayer(BLM_ROUTES_LABEL) if (map.getLayer(BLM_ROUTES_OTHER)) map.removeLayer(BLM_ROUTES_OTHER) if (map.getLayer(BLM_ROUTES_SNOW)) map.removeLayer(BLM_ROUTES_SNOW) if (map.getLayer(BLM_ROUTES_AGGREGATE)) map.removeLayer(BLM_ROUTES_AGGREGATE) if (map.getLayer(BLM_ROUTES_IMPROVED)) map.removeLayer(BLM_ROUTES_IMPROVED) if (map.getLayer(BLM_ROUTES_NATURAL)) map.removeLayer(BLM_ROUTES_NATURAL) if (map.getLayer(BLM_ROUTES_HIT)) map.removeLayer(BLM_ROUTES_HIT) if (map.getSource(BLM_SOURCE)) map.removeSource(BLM_SOURCE) } /** Add boundary polygon layers with computed accent color (MapLibre rejects CSS vars in paint) */ const BOUNDARY_FILL_LAYER = 'boundary-fill-layer' 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" // Find first symbol layer to insert boundary layers below labels const layers = map.getStyle().layers let firstSymbolId = null for (const layer of layers) { if (layer.type === 'symbol') { firstSymbolId = layer.id break } } // Add subtle fill layer (barely visible tint) map.addLayer({ id: BOUNDARY_FILL_LAYER, type: "fill", source: BOUNDARY_SOURCE, paint: { "fill-color": accentColor, "fill-opacity": 0.05, }, }, firstSymbolId) // Add dashed outline layer 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], }, }, firstSymbolId) } 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, usfsTrails: false, blmTrails: false }) // Flag to suppress map-click when a stop pin was clicked const pinClickedRef = useRef(false) const highlightedFeatureRef = useRef(null) // { source, sourceLayer, id } for setFeatureState const hoveredFeatureRef = useRef(null) // for hover highlight const updateBoundaryRef = useRef(null) // boundary update function // 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, currentThemeRef.current) 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, currentThemeRef.current) 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, currentThemeRef.current) 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, currentThemeRef.current) 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, currentThemeRef.current) 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, currentThemeRef.current) activeLayersRef.current.contoursTest10ft = true }, removeContoursTest10ftLayer() { const map = mapInstance.current if (!map) return removeContoursTest10ft(map) activeLayersRef.current.contoursTest10ft = false }, addUsfsTrailsLayer() { const map = mapInstance.current if (!map) return addUsfsTrails(map, currentThemeRef.current) activeLayersRef.current.usfsTrails = true }, removeUsfsTrailsLayer() { const map = mapInstance.current if (!map) return removeUsfsTrails(map) activeLayersRef.current.usfsTrails = false }, addBlmTrailsLayer() { const map = mapInstance.current if (!map) return addBlmTrails(map, currentThemeRef.current) activeLayersRef.current.blmTrails = true }, removeBlmTrailsLayer() { const map = mapInstance.current if (!map) return removeBlmTrails(map) activeLayersRef.current.blmTrails = 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() // Clear boundary when deselecting if (updateBoundaryRef.current) updateBoundaryRef.current(null) setSelectedHighlight(map, null) } } 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 // Check for USFS trails/roads click (show info popup) const usfsLayers = [USFS_TRAILS_HIT, USFS_ROADS_HIT].filter(id => map.getLayer(id)) const usfsFeatures = usfsLayers.length > 0 ? map.queryRenderedFeatures(e.point, { layers: usfsLayers }) : [] const usfsFeature = usfsFeatures.find(f => f.properties) if (usfsFeature && hasFeature('has_usfs_trails')) { const props = usfsFeature.properties const isTrail = usfsFeature.layer?.id === USFS_TRAILS_HIT const name = isTrail ? (props.TRAIL_NAME || 'Unnamed Trail') : (props.NAME || 'Unnamed Road') const typeLabel = isTrail ? 'USFS Trail' : 'USFS Road' // Build popup content let html = '
' html += '' + name + '' html += '
' + typeLabel + '
' if (isTrail) { // Trail-specific info if (props.TRAIL_TYPE) html += '
Type: ' + props.TRAIL_TYPE + '
' if (props.TRAIL_SURF) html += '
Surface: ' + props.TRAIL_SURF + '
' if (props.GIS_MILES) html += '
Length: ' + parseFloat(props.GIS_MILES).toFixed(1) + ' mi
' // Allowed uses const uses = [] if (props.HIKER_PEDE === 'Y') uses.push('Hiking') if (props.BICYCLE_MA === 'Y') uses.push('Biking') if (props.MOTORCYCLE === 'Y') uses.push('Motorcycle') if (props.ATV_MANAGE === 'Y') uses.push('ATV') if (props.HORSE_MANA === 'Y') uses.push('Horse') if (uses.length > 0) html += '
Allowed: ' + uses.join(', ') + '
' } else { // Road-specific info if (props.OPER_MAINT) html += '
Maintenance: ' + props.OPER_MAINT + '
' if (props.SURFACE_TY) html += '
Surface: ' + props.SURFACE_TY + '
' if (props.ROUTE_STAT) html += '
Status: ' + props.ROUTE_STAT + '
' } html += '
' // Remove existing popup if (popupRef.current) popupRef.current.remove() const popup = new maplibregl.Popup({ offset: 10, closeButton: true }) .setLngLat([lng, lat]) .setHTML(html) .addTo(map) popupRef.current = popup return } // Check for BLM routes click (show info popup) const blmLayers = [BLM_ROUTES_HIT].filter(id => map.getLayer(id)) const blmFeatures = blmLayers.length > 0 ? map.queryRenderedFeatures(e.point, { layers: blmLayers }) : [] const blmFeature = blmFeatures.find(f => f.properties) if (blmFeature && hasFeature("has_blm_trails")) { const props = blmFeature.properties const name = props.ROUTE_PRMRY_NM || "Unnamed Route" // Build popup content let html = "
" html += "" + name + "" html += "
BLM Route
" // Route info - handle potential field name variations across states if (props.PLAN_ASSET_CLASS) html += "
Asset Class: " + props.PLAN_ASSET_CLASS + "
" if (props.PLAN_MODE_TRNSPRT) html += "
Transport: " + props.PLAN_MODE_TRNSPRT + "
" if (props.OBSRVE_SRFCE_TYPE) html += "
Surface: " + props.OBSRVE_SRFCE_TYPE + "
" if (props.GIS_MILES) html += "
Length: " + parseFloat(props.GIS_MILES).toFixed(1) + " mi
" html += "
" // Remove existing popup if (popupRef.current) popupRef.current.remove() const popup = new maplibregl.Popup({ offset: 10, closeButton: true }) .setLngLat([lng, lat]) .setHTML(html) .addTo(map) popupRef.current = popup return } // 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 and boundary if (highlightedFeatureRef.current) { const { source, sourceLayer, id } = highlightedFeatureRef.current try { map.setFeatureState({ source, sourceLayer, id }, { selected: false }) } catch (e) { /* ignore if layer removed */ } highlightedFeatureRef.current = null } setSelectedHighlight(map, null) // Note: do not clear boundary here - new data replaces old when API returns if (labelFeature) { // Clicked a labeled feature — snap to geometry and highlight const props = labelFeature.properties const geom = labelFeature.geometry // Get feature coordinates (Point geometry) let featureLat = lat let featureLon = lng if (geom && geom.type === 'Point' && geom.coordinates) { featureLon = geom.coordinates[0] featureLat = geom.coordinates[1] } // Apply feature state highlight const featureId = labelFeature.id ?? props.mvt_id const sourceLayer = labelFeature.sourceLayer const source = labelFeature.source if (featureId != null && source) { try { map.setFeatureState({ source, sourceLayer, id: featureId }, { selected: true }) highlightedFeatureRef.current = { source, sourceLayer, id: featureId } } catch (e) { console.warn('setFeatureState error:', e) } } // Filter-based highlight (works with PMTiles) setSelectedHighlight(map, labelFeature) setHoverHighlight(map, null) // For feature clicks, don't show pin marker store.clearClickMarker() store.setSelectedPlace({ lat: featureLat, lon: featureLon, name: props.name || 'Unknown', address: null, type: props.kind_detail || props.kind || null, source: 'basemap_label', matchCode: null, mode: 'feature', featureId: featureId, featureLayer: labelFeature.layer?.id || null, wikidata: props.wikidata || null, raw: { wikidata: props.wikidata || null, population: props.population || null, kind: props.kind || null, kind_detail: props.kind_detail || null, elevation: props.elevation || null, }, }) } else { // No labeled feature — show reticle at click point // Clear any existing boundary when clicking empty map if (updateBoundaryRef.current) updateBoundaryRef.current(null) store.setClickMarker({ lat, lon: lng, circleRadiusPx: MARKER_RADIUS_PX, }) store.setSelectedPlace({ lat, lon: lng, name: 'Dropped pin', address: null, type: null, source: 'map_click', matchCode: null, mode: 'reticle', raw: {}, }) // Reverse geocode in background fetchReverse(lat, lng).then((place) => { if (!place) return const current = useStore.getState().selectedPlace if (current && Math.abs(current.lat - lat) < 0.00001 && Math.abs(current.lon - lng) < 0.00001) { useStore.getState().setSelectedPlace({ ...place, lat, lon: lng, }) } }) } } }) // Double-click ends measurement mode (and prevents zoom) map.on('dblclick', (e) => { if (measuringRef.current.active) { e.preventDefault() // Add final point and end const { lng, lat } = e.lngLat addMeasurePoint(lat, lng) endMeasuring() } }) // Reposition measure labels on map move map.on('move', repositionLabels) // Initialize mapCenter immediately when map loads (Fix 1: search viewport) map.once('load', () => { const center = map.getCenter() const zoom = map.getZoom() setMapCenter({ lat: center.lat, lon: center.lng, zoom }) }) map.on('load', () => { // Guard against double-mount in React strict mode if (!map.getSource(ROUTE_SOURCE)) { map.addSource(ROUTE_SOURCE, { type: 'geojson', data: { type: 'FeatureCollection', features: [] }, }) } // Boundary polygon layer for selected places if (!map.getLayer(BOUNDARY_LAYER)) { addBoundaryLayer(map) } // Apply improved base label styling for readability applyBaseLabelStyling(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, currentThemeRef.current) activeLayersRef.current.hillshade = true } if (prefs.traffic && hasFeature('has_traffic_overlay')) { addTraffic(map, currentThemeRef.current) activeLayersRef.current.traffic = true } if (prefs.publicLands && hasFeature('has_public_lands_layer')) { addPublicLands(map, currentThemeRef.current) activeLayersRef.current.publicLands = true } if (prefs.contours && hasFeature('has_contours')) { addContours(map, currentThemeRef.current) activeLayersRef.current.contours = true } } else if (hasFeature('has_hillshade')) { // Default: hillshade ON if available addHillshade(map, currentThemeRef.current) activeLayersRef.current.hillshade = true } } catch {} // Register updateBoundary function - called directly when boundary data arrives const updateBoundaryFn = (boundaryGeometry) => { const source = map.getSource(BOUNDARY_SOURCE) if (!source) return if (!boundaryGeometry) { source.setData({ type: 'FeatureCollection', features: [] }) return } if (boundaryGeometry.type === 'Polygon' || boundaryGeometry.type === 'MultiPolygon') { source.setData({ type: 'Feature', geometry: boundaryGeometry, properties: {}, }) // Zoom to fit boundary try { const coords = boundaryGeometry.type === 'Polygon' ? boundaryGeometry.coordinates[0] : boundaryGeometry.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) } } } updateBoundaryRef.current = updateBoundaryFn useStore.getState().setUpdateBoundary(updateBoundaryFn) // POI/label hover affordance — cursor pointer + highlight const interactiveLayers = ['pois', 'places_locality', 'places_region', 'places_country', 'places_subplace'] interactiveLayers.forEach(layerId => { map.on('mouseenter', layerId, (e) => { if (!measuringRef.current.active) { map.getCanvas().style.cursor = 'pointer' const feature = e.features?.[0] if (feature?.properties?.name) { setHoverHighlight(map, feature) hoveredFeatureRef.current = feature } } }) map.on('mouseleave', layerId, () => { if (!measuringRef.current.active) { map.getCanvas().style.cursor = '' setHoverHighlight(map, null) hoveredFeatureRef.current = null } }) }) }) mapInstance.current = map // ResizeObserver to handle layout settling, panel changes, window resize const ro = new ResizeObserver(() => { map.resize() }) ro.observe(mapRef.current) return () => { ro.disconnect() if (watchIdRef.current != null) navigator.geolocation.clearWatch(watchIdRef.current) if (gpsMarkerRef.current) gpsMarkerRef.current.remove() // Clean up measure labels measureLabelsRef.current.forEach(el => el.remove()) measureLabelsRef.current = [] maplibregl.removeProtocol('pmtiles') map.remove() } }, [setSheetState]) /** Create or update the GPS chevron/dot marker */ function createOrUpdateGpsMarker(map, lat, lon, heading) { if (!gpsMarkerRef.current) { const el = document.createElement('div') if (heading != null && !isNaN(heading)) { el.className = 'navi-chevron' el.innerHTML = CHEVRON_SVG el.style.transform = `rotate(${heading}deg)` } else { el.className = 'navi-gps-dot' } gpsMarkerRef.current = new maplibregl.Marker({ element: el }) .setLngLat([lon, lat]) .addTo(map) } else { gpsMarkerRef.current.setLngLat([lon, lat]) const el = gpsMarkerRef.current.getElement() if (heading != null && !isNaN(heading)) { if (!el.classList.contains('navi-chevron')) { el.className = 'navi-chevron' el.innerHTML = CHEVRON_SVG } el.style.transform = `rotate(${heading}deg)` } else { if (!el.classList.contains('navi-gps-dot')) { el.className = 'navi-gps-dot' el.innerHTML = '' } } } } // React to permission changes from LocateButton (when user grants after initial denial) useEffect(() => { const map = mapInstance.current if (!map || geoPermission !== 'granted') return // If marker already exists, watchPosition is already running — nothing to do if (gpsMarkerRef.current) return // Permission was just granted (likely from LocateButton) — create marker + start tracking const loc = useStore.getState().userLocation if (loc) { createOrUpdateGpsMarker(map, loc.lat, loc.lon, null) } if (!watchIdRef.current) { watchIdRef.current = navigator.geolocation.watchPosition( (pos) => { const { latitude, longitude, heading } = pos.coords useStore.getState().setUserLocation({ lat: latitude, lon: longitude }) createOrUpdateGpsMarker(map, latitude, longitude, heading) }, () => {}, { enableHighAccuracy: true, maximumAge: 5000 } ) } }, [geoPermission]) // Swap map theme when store.theme changes useEffect(() => { const map = mapInstance.current if (!map || currentThemeRef.current === theme) return currentThemeRef.current = theme const center = map.getCenter() const zoom = map.getZoom() const bearing = map.getBearing() const pitch = map.getPitch() map.setStyle(buildStyle(theme), { diff: false }) // Re-add sources/layers after style swap map.once('style.load', () => { // Guard against source already existing if (!map.getSource(ROUTE_SOURCE)) { map.addSource(ROUTE_SOURCE, { type: 'geojson', data: { type: 'FeatureCollection', features: [] }, }) } // Boundary polygon layer if (!map.getLayer(BOUNDARY_LAYER)) { addBoundaryLayer(map) } // Apply improved base label styling for readability applyBaseLabelStyling(map) // Re-add active overlay layers if (activeLayersRef.current.hillshade) addHillshade(map, currentThemeRef.current) if (activeLayersRef.current.traffic) addTraffic(map, currentThemeRef.current) if (activeLayersRef.current.publicLands) addPublicLands(map, currentThemeRef.current) if (activeLayersRef.current.contours) addContours(map, currentThemeRef.current) if (activeLayersRef.current.contoursTest) addContoursTest(map, currentThemeRef.current) if (activeLayersRef.current.contoursTest10ft) addContoursTest10ft(map, currentThemeRef.current) if (activeLayersRef.current.usfsTrails) addUsfsTrails(map, currentThemeRef.current) if (activeLayersRef.current.blmTrails) addBlmTrails(map, currentThemeRef.current) // Clear highlights on theme change (paint values will be re-stored on next interaction) clearAllHighlights(map) originalPaintValues = {} // 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]) // Escape key to close/deselect place card useEffect(() => { if (!selectedPlace) return const handleKeyDown = (e) => { if (e.key === 'Escape') { const map = mapInstance.current const store = useStore.getState() // Clear selected place and click marker store.clearSelectedPlace() store.clearClickMarker() // Clear boundary if (updateBoundaryRef.current) updateBoundaryRef.current(null) // Clear highlight if (map) setSelectedHighlight(map, null) } } document.addEventListener('keydown', handleKeyDown) return () => document.removeEventListener('keydown', handleKeyDown) }, [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