navi/src/components/MapView.jsx
Matt 4fcc3d1af4 fix(map): resolve clicks to rendered label features
Click handler now queries rendered features at click point and uses
the highest-priority labeled feature (POI > locality > region) as
the place identifier. Previously, clicks went straight to coord-based
reverse-geocode, causing 'Twin Falls' label clicks to resolve to
whatever feature was nearest (e.g., a radio tower).

Falls back to reverse-geocode when no labeled feature exists at the
click point. Two-click selection model and hover affordance unchanged.
2026-04-26 07:49:08 +00:00

1356 lines
42 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
// 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)
// Set click marker
store.setClickMarker({
lat,
lon: lng,
circleRadiusPx: MARKER_RADIUS_PX,
})
if (labelFeature) {
// Clicked a labeled feature — use its properties
const props = labelFeature.properties
store.setSelectedPlace({
lat,
lon: lng,
name: props.name || 'Unknown',
address: null,
type: props.kind_detail || props.kind || null,
source: 'basemap_label',
matchCode: 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 — fall back to reverse geocode
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 {}
// POI/label hover affordance — cursor pointer
const interactiveLayers = ['pois', 'places_locality', 'places_region', 'places_country', 'places_subplace']
interactiveLayers.forEach(layerId => {
map.on('mouseenter', layerId, () => {
map.getCanvas().style.cursor = 'pointer'
})
map.on('mouseleave', layerId, () => {
map.getCanvas().style.cursor = ''
})
})
})
mapInstance.current = map
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