navi/src/components/MapView.jsx

2409 lines
77 KiB
React
Raw Normal View History

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'
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'
// 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 = document.documentElement.getAttribute('data-theme') === 'dark'
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 = document.documentElement.getAttribute('data-theme') === 'dark'
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'
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 = `<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
<path d="M8 1 L14 13 L8 10 L2 13 Z" fill="var(--accent)" stroke="var(--bg-raised)" stroke-width="1.5" stroke-linejoin="round"/>
</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 USFS trails and roads vector tile overlay */
function addUsfsTrails(map) {
if (!map || map.getSource(USFS_SOURCE)) return
map.addSource(USFS_SOURCE, {
type: "vector",
url: "pmtiles:///tiles/usfs-trails-roads.pmtiles",
})
// Insert below first symbol layer (above other overlays, 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
// Roads layer - solid khaki/tan line
map.addLayer({
id: USFS_ROADS_LAYER,
type: "line",
source: USFS_SOURCE,
"source-layer": "roads",
minzoom: 10,
paint: {
"line-color": isDark ? "#9a8b70" : "#b8a87a",
"line-opacity": 0.65 * opMod,
"line-width": ["interpolate", ["linear"], ["zoom"], 10, 1.0, 14, 2.0, 16, 3.0],
},
}, beforeId)
// Trails layer - dashed earth-tone brown
map.addLayer({
id: USFS_TRAILS_LAYER,
type: "line",
source: USFS_SOURCE,
"source-layer": "trails",
minzoom: 10,
paint: {
"line-color": isDark ? "#a88960" : "#8b7355",
"line-opacity": 0.7 * opMod,
"line-width": ["interpolate", ["linear"], ["zoom"], 10, 1.5, 14, 2.5, 16, 3.5],
"line-dasharray": [2, 1.5],
},
}, 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": 10,
"text-font": ["Noto Sans Regular"],
"symbol-placement": "line",
"text-anchor": "center",
"symbol-spacing": 300,
"text-max-angle": 25,
"text-allow-overlap": false,
},
paint: {
"text-color": isDark ? "#c0b090" : "#6a5a40",
"text-halo-color": isDark ? "#1a1a1a" : "#ffffff",
"text-halo-width": 1.5,
"text-opacity": 0.85,
},
})
// 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": 10,
"text-font": ["Noto Sans Regular"],
"symbol-placement": "line",
"text-anchor": "center",
"symbol-spacing": 300,
"text-max-angle": 25,
"text-allow-overlap": false,
},
paint: {
"text-color": isDark ? "#c8a878" : "#5a4530",
"text-halo-color": isDark ? "#1a1a1a" : "#ffffff",
"text-halo-width": 1.5,
"text-opacity": 0.85,
},
})
}
/** Remove USFS trails/roads layers and source */
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.getSource(USFS_SOURCE)) map.removeSource(USFS_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 })
// 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)
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
},
addUsfsTrailsLayer() {
const map = mapInstance.current
if (!map) return
addUsfsTrails(map)
activeLayersRef.current.usfsTrails = true
},
removeUsfsTrailsLayer() {
const map = mapInstance.current
if (!map) return
removeUsfsTrails(map)
activeLayersRef.current.usfsTrails = 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_LAYER, USFS_ROADS_LAYER]
const usfsFeatures = 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_LAYER
const name = isTrail ? (props.TRAIL_NAME || 'Unnamed Trail') : (props.NAME || 'Unnamed Road')
const typeLabel = isTrail ? 'USFS Trail' : 'USFS Road'
// Build popup content
let html = '<div style="font-size:12px;max-width:240px;line-height:1.4">'
html += '<strong style="font-size:13px">' + name + '</strong>'
html += '<div style="color:var(--text-secondary);font-size:11px;margin-bottom:4px">' + typeLabel + '</div>'
if (isTrail) {
// Trail-specific info
if (props.TRAIL_TYPE) html += '<div><b>Type:</b> ' + props.TRAIL_TYPE + '</div>'
if (props.TRAIL_SURF) html += '<div><b>Surface:</b> ' + props.TRAIL_SURF + '</div>'
if (props.GIS_MILES) html += '<div><b>Length:</b> ' + parseFloat(props.GIS_MILES).toFixed(1) + ' mi</div>'
// 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 += '<div><b>Allowed:</b> ' + uses.join(', ') + '</div>'
} else {
// Road-specific info
if (props.OPER_MAINT) html += '<div><b>Maintenance:</b> ' + props.OPER_MAINT + '</div>'
if (props.SURFACE_TY) html += '<div><b>Surface:</b> ' + props.SURFACE_TY + '</div>'
if (props.ROUTE_STAT) html += '<div><b>Status:</b> ' + props.ROUTE_STAT + '</div>'
}
html += '</div>'
// 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)
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 {}
// 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)
if (activeLayersRef.current.traffic) addTraffic(map)
if (activeLayersRef.current.publicLands) addPublicLands(map)
if (activeLayersRef.current.contours) addContours(map)
// 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(
`<div style="font-size:12px;max-width:200px">
<strong>${stop.name}</strong>
<br/><button id="remove-stop-${stop.id}" style="margin-top:4px;padding:2px 8px;background:var(--status-danger);border:none;border-radius:4px;color:white;cursor:pointer;font-size:11px">Remove</button>
</div>`
)
.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 (
<div className="relative w-full h-full">
<div ref={mapRef} className="w-full h-full" {...contextMenuHandlers} />
{/* Zoom level indicator - bottom-left corner */}
<div
className="absolute bottom-4 left-4 z-50 px-2 py-1 rounded-full text-xs font-mono pointer-events-none"
style={{
backgroundColor: "rgba(0, 0, 0, 0.6)",
color: "white",
fontSize: "12px",
padding: "4px 8px",
borderRadius: "12px",
}}
>
Z {zoomLevel.toFixed(1)}
</div>
{/* Measurement info bar */}
{(measuring.active || measuring.points.length > 1) && (
<div
className="absolute top-4 left-1/2 transform -translate-x-1/2 z-50 flex items-center gap-3 px-4 py-2 rounded-lg"
style={{
backgroundColor: "rgba(0, 0, 0, 0.8)",
color: "white",
fontSize: "13px",
boxShadow: "0 2px 8px rgba(0,0,0,0.3)",
}}
>
<Ruler size={16} style={{ opacity: 0.8 }} />
<span>
<strong>{formatDistance(measuring.totalMeters)}</strong>
<span style={{ opacity: 0.7, marginLeft: "6px" }}>
({measuring.points.length} {measuring.points.length === 1 ? "point" : "points"})
</span>
</span>
{measuring.active && (
<span style={{ opacity: 0.6, fontSize: "11px" }}>
Click to add points
</span>
)}
<button
onClick={endMeasuring}
className="px-2 py-1 rounded text-xs font-medium"
style={{
background: "var(--accent)",
color: "white",
border: "none",
cursor: "pointer",
}}
>
Done
</button>
<button
onClick={clearMeasuring}
className="p-1 rounded"
style={{
background: "transparent",
color: "white",
border: "none",
cursor: "pointer",
opacity: 0.7,
}}
title="Clear measurement"
>
<X size={16} />
</button>
</div>
)}
{/* Radial context menu */}
<RadialMenu
open={radialMenu.open}
x={radialMenu.x}
y={radialMenu.y}
lat={radialMenu.lat}
lon={radialMenu.lon}
wedges={radialWedges}
centerLabel={radialMenu.centerLabel}
onDismiss={() => setRadialMenu((m) => ({ ...m, open: false }))}
/>
</div>
)
})
export default MapView