navi/frontend/src/components/MapView.jsx
Matt 76cd8666c6 feat(map): two-click selection model with precise center dot
Replaces 'every click selects something' with a deliberate two-click
flow:
- First click drops marker (existing circle plus new precise center dot)
  and opens place panel
- Second click INSIDE the marker circle opens the radial menu
- Second click OUTSIDE the circle deselects without selecting the new
  spot — requires another click to select

The 4px filled center dot at exact click coordinates gives precise
visual feedback for GPS-coord readout. The existing circle's radius
defines the same-spot tolerance, visually showing the radial-trigger
hit area.

Right-click radial unchanged. Search-dropdown selection drops a marker
for consistency.
2026-04-26 07:17:33 +00:00

1315 lines
40 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 } from 'lucide-react'
import RadialMenu from './RadialMenu'
import useContextMenu from '../hooks/useContextMenu'
import toast from 'react-hot-toast'
const ROUTE_SOURCE = 'route-source'
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'
/** 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) */
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)
}
const MapView = forwardRef(function MapView(_, ref) {
const mapRef = useRef(null)
const mapInstance = useRef(null)
const markersRef = useRef([])
const popupRef = useRef(null)
const gpsMarkerRef = useRef(null)
const previewMarkerRef = useRef(null)
const watchIdRef = useRef(null)
const currentThemeRef = useRef('dark')
// Track which overlay layers are currently active (for theme swap re-add)
const activeLayersRef = useRef({ hillshade: false, traffic: false, contours: false, contoursTest: false, contoursTest10ft: false })
// Flag to suppress map-click when a stop pin was clicked
const pinClickedRef = useRef(false)
const 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)
// 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,
})
// Expose map methods to parent
// Radial menu wedges configuration
const radialWedges = [
{
id: 'drop-pin',
label: 'Drop pin',
icon: MapPin,
onSelect: () => toast('Drop pin coming soon', { icon: '📍' }),
},
{
id: 'directions-to',
label: 'To here',
icon: ArrowDownLeft,
onSelect: () => toast('Directions to here coming soon', { icon: '🧭' }),
},
{
id: 'save-place',
label: 'Save',
icon: Star,
requiresAuth: true,
onSelect: () => toast('Save place coming soon', { icon: '⭐' }),
},
{
id: 'add-stop',
label: 'Add stop',
icon: Plus,
onSelect: () => toast('Add stop coming soon', { icon: '' }),
},
{
id: 'directions-from',
label: 'From here',
icon: ArrowUpRight,
onSelect: () => toast('Directions from here coming soon', { icon: '🧭' }),
},
]
// Context menu trigger handler
const handleContextMenuTrigger = ({ x, y }) => {
const map = mapInstance.current
if (!map || !mapRef.current) return
// Convert screen coords to lat/lon
const rect = mapRef.current.getBoundingClientRect()
const lngLat = map.unproject([x - rect.left, y - rect.top])
setRadialMenu({
open: true,
x,
y,
lat: lngLat.lat,
lon: lngLat.lng,
centerLabel: null,
})
// Async reverse geocode for center label
fetchReverse(lngLat.lat, lngLat.lng).then((place) => {
if (place) {
setRadialMenu((m) => {
if (m.open && Math.abs(m.lat - lngLat.lat) < 0.00001) {
return { ...m, centerLabel: place.name }
}
return m
})
}
})
}
// Context menu hook
const contextMenuHandlers = useContextMenu(handleContextMenuTrigger)
useImperativeHandle(ref, () => ({
flyTo(lat, lon, zoom = 14) {
mapInstance.current?.flyTo({ center: [lon, lat], zoom })
},
getMap() {
return mapInstance.current
},
addHillshadeLayer() {
const map = mapInstance.current
if (!map) return
addHillshade(map)
activeLayersRef.current.hillshade = true
},
removeHillshadeLayer() {
const map = mapInstance.current
if (!map) return
removeHillshade(map)
activeLayersRef.current.hillshade = false
},
addTrafficLayer() {
const map = mapInstance.current
if (!map) return
addTraffic(map)
activeLayersRef.current.traffic = true
},
removeTrafficLayer() {
const map = mapInstance.current
if (!map) return
removeTraffic(map)
activeLayersRef.current.traffic = false
},
addPublicLandsLayer() {
const map = mapInstance.current
if (!map) return
addPublicLands(map)
activeLayersRef.current.publicLands = true
},
removePublicLandsLayer() {
const map = mapInstance.current
if (!map) return
removePublicLands(map)
activeLayersRef.current.publicLands = false
},
addContoursLayer() {
const map = mapInstance.current
if (!map) return
addContours(map)
activeLayersRef.current.contours = true
},
removeContoursLayer() {
const map = mapInstance.current
if (!map) return
removeContours(map)
activeLayersRef.current.contours = false
},
addContoursTestLayer() {
const map = mapInstance.current
if (!map) return
addContoursTest(map)
activeLayersRef.current.contoursTest = true
},
removeContoursTestLayer() {
const map = mapInstance.current
if (!map) return
removeContoursTest(map)
activeLayersRef.current.contoursTest = false
},
addContoursTest10ftLayer() {
const map = mapInstance.current
if (!map) return
addContoursTest10ft(map)
activeLayersRef.current.contoursTest10ft = true
},
removeContoursTest10ftLayer() {
const map = mapInstance.current
if (!map) return
removeContoursTest10ft(map)
activeLayersRef.current.contoursTest10ft = false
},
}))
// Initialize map
useEffect(() => {
const protocol = new Protocol()
maplibregl.addProtocol('pmtiles', protocol.tile)
const config = getConfig()
const DEFAULT_CENTER = config?.defaults?.center
? [config.defaults.center[1], config.defaults.center[0]] // config is [lat,lon], MapLibre wants [lon,lat]
: [-114.6066, 42.5736]
const DEFAULT_ZOOM = config?.defaults?.zoom || 10
const initialTheme = document.documentElement.getAttribute('data-theme') || 'dark'
currentThemeRef.current = initialTheme
const map = new maplibregl.Map({
container: mapRef.current,
style: buildStyle(initialTheme),
center: DEFAULT_CENTER,
zoom: DEFAULT_ZOOM,
})
map.addControl(new maplibregl.NavigationControl(), 'top-right')
// 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
}
const store = useStore.getState()
const marker = store.clickMarker
if (marker) {
// State B: marker present — check if click is inside the circle
const markerScreen = map.project([marker.lon, marker.lat])
const dx = e.point.x - markerScreen.x
const dy = e.point.y - markerScreen.y
const dist = Math.sqrt(dx * dx + dy * dy)
if (dist <= marker.circleRadiusPx) {
// Inside circle → open radial at marker location
const rect = mapRef.current?.getBoundingClientRect()
const screenX = rect ? markerScreen.x + rect.left : markerScreen.x
const screenY = rect ? markerScreen.y + rect.top : markerScreen.y
setRadialMenu({
open: true,
x: screenX,
y: screenY,
lat: marker.lat,
lon: marker.lon,
centerLabel: store.selectedPlace?.name || null,
})
// Fetch reverse geocode for center label if not already loaded
if (!store.selectedPlace?.name || store.selectedPlace.name === 'Dropped pin') {
fetchReverse(marker.lat, marker.lon).then((place) => {
if (place) {
setRadialMenu((m) => {
if (m.open && Math.abs(m.lat - marker.lat) < 0.00001) {
return { ...m, centerLabel: place.name }
}
return m
})
}
})
}
} else {
// Outside circle → deselect, no new selection
store.clearClickMarker()
store.clearSelectedPlace()
}
} else {
// State A: nothing selected → select
if (window.innerWidth < 768) setSheetState('collapsed')
const { lng, lat } = e.lngLat
const MARKER_RADIUS_PX = 14 // half of 28px preview marker
// Set click marker
store.setClickMarker({
lat,
lon: lng,
circleRadiusPx: MARKER_RADIUS_PX,
})
// Immediately set a "Dropped pin" placeholder
store.setSelectedPlace({
lat,
lon: lng,
name: 'Dropped pin',
address: null,
type: null,
source: 'map_click',
matchCode: null,
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,
})
}
})
}
})
map.on('load', () => {
map.addSource(ROUTE_SOURCE, {
type: 'geojson',
data: { type: 'FeatureCollection', features: [] },
})
// 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 {}
})
mapInstance.current = map
return () => {
if (watchIdRef.current != null) navigator.geolocation.clearWatch(watchIdRef.current)
if (gpsMarkerRef.current) gpsMarkerRef.current.remove()
maplibregl.removeProtocol('pmtiles')
map.remove()
}
}, [setSheetState])
/** Create or update the GPS chevron/dot marker */
function createOrUpdateGpsMarker(map, lat, lon, heading) {
if (!gpsMarkerRef.current) {
const el = document.createElement('div')
if (heading != null && !isNaN(heading)) {
el.className = 'navi-chevron'
el.innerHTML = CHEVRON_SVG
el.style.transform = `rotate(${heading}deg)`
} else {
el.className = 'navi-gps-dot'
}
gpsMarkerRef.current = new maplibregl.Marker({ element: el })
.setLngLat([lon, lat])
.addTo(map)
} else {
gpsMarkerRef.current.setLngLat([lon, lat])
const el = gpsMarkerRef.current.getElement()
if (heading != null && !isNaN(heading)) {
if (!el.classList.contains('navi-chevron')) {
el.className = 'navi-chevron'
el.innerHTML = CHEVRON_SVG
}
el.style.transform = `rotate(${heading}deg)`
} else {
if (!el.classList.contains('navi-gps-dot')) {
el.className = 'navi-gps-dot'
el.innerHTML = ''
}
}
}
}
// React to permission changes from LocateButton (when user grants after initial denial)
useEffect(() => {
const map = mapInstance.current
if (!map || geoPermission !== 'granted') return
// If marker already exists, watchPosition is already running — nothing to do
if (gpsMarkerRef.current) return
// Permission was just granted (likely from LocateButton) — create marker + start tracking
const loc = useStore.getState().userLocation
if (loc) {
createOrUpdateGpsMarker(map, loc.lat, loc.lon, null)
}
if (!watchIdRef.current) {
watchIdRef.current = navigator.geolocation.watchPosition(
(pos) => {
const { latitude, longitude, heading } = pos.coords
useStore.getState().setUserLocation({ lat: latitude, lon: longitude })
createOrUpdateGpsMarker(map, latitude, longitude, heading)
},
() => {},
{ enableHighAccuracy: true, maximumAge: 5000 }
)
}
}, [geoPermission])
// Swap map theme when store.theme changes
useEffect(() => {
const map = mapInstance.current
if (!map || currentThemeRef.current === theme) return
currentThemeRef.current = theme
const center = map.getCenter()
const zoom = map.getZoom()
const bearing = map.getBearing()
const pitch = map.getPitch()
map.setStyle(buildStyle(theme), { diff: false })
// Re-add sources/layers after style swap
map.once('style.load', () => {
map.addSource(ROUTE_SOURCE, {
type: 'geojson',
data: { type: 'FeatureCollection', features: [] },
})
// Re-add active overlay layers
if (activeLayersRef.current.hillshade) addHillshade(map)
if (activeLayersRef.current.traffic) addTraffic(map)
if (activeLayersRef.current.publicLands) addPublicLands(map)
if (activeLayersRef.current.contours) addContours(map)
// Restore view
map.jumpTo({ center, zoom, bearing, pitch })
// Re-render route if exists
const currentRoute = useStore.getState().route
if (currentRoute) updateRoute(map, currentRoute)
})
}, [theme])
// Preview pin for selected place
useEffect(() => {
const map = mapInstance.current
if (!map) return
// Remove old preview marker
if (previewMarkerRef.current) {
previewMarkerRef.current.remove()
previewMarkerRef.current = null
}
if (!selectedPlace) return
// Only fly to place if it came from search (not map-click which already centered)
if (selectedPlace.source !== 'map_click') {
map.flyTo({ center: [selectedPlace.lon, selectedPlace.lat], zoom: 14, duration: 800 })
}
// Create preview marker
const el = document.createElement('div')
el.className = 'navi-pin-preview'
// Add precise center dot
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])
// Update route polyline when route changes
useEffect(() => {
const map = mapInstance.current
if (!map) return
if (!map.isStyleLoaded()) {
const handler = () => updateRoute(map, route)
map.once('load', handler)
return () => map.off('load', 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])
)
const hasDetail = useStore.getState().selectedPlace != null
const leftPad = hasDetail ? 700 : 340
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: 340, right: 60 } })
}
}
}, [stops, route, gpsOrigin, geoPermission])
// 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>
{/* 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