diff --git a/src/App.jsx b/src/App.jsx
index 94a5de8..7576c31 100644
--- a/src/App.jsx
+++ b/src/App.jsx
@@ -1,7 +1,7 @@
import { useEffect, useRef, useCallback } from 'react'
import { useStore } from './store'
import { useTheme } from './hooks/useTheme'
-import { requestRoute } from './api'
+import { requestRoute, fetchAuthState } from './api'
import { decodePolyline } from './utils/decode'
import MapView from './components/MapView'
import Panel from './components/Panel'
@@ -26,6 +26,12 @@ export default function App() {
const setRouteLoading = useStore((s) => s.setRouteLoading)
const setRouteError = useStore((s) => s.setRouteError)
const clearRoute = useStore((s) => s.clearRoute)
+ const setAuth = useStore((s) => s.setAuth)
+
+ // Initialize auth state on app load (single fetch, no polling)
+ useEffect(() => {
+ fetchAuthState().then(setAuth)
+ }, [setAuth])
// Fetch route when stops, mode, gpsOrigin, or geoPermission change (debounced 500ms)
useEffect(() => {
diff --git a/src/api.js b/src/api.js
index e8f2189..d933c20 100644
--- a/src/api.js
+++ b/src/api.js
@@ -286,3 +286,27 @@ export async function fetchLandclass(lat, lon, signal) {
return null
}
}
+
+
+// ── Auth API ──
+
+/**
+ * Check authentication state via whoami endpoint.
+ * Uses redirect: manual to detect auth without triggering navigation.
+ * @returns {Promise<{authenticated: boolean, username: string|null}>}
+ */
+export async function fetchAuthState() {
+ try {
+ const resp = await fetch('/api/auth/whoami', { redirect: 'manual' })
+ // Redirect response means unauthenticated (Authentik SSO flow)
+ if (resp.type === 'opaqueredirect' || resp.status === 302) {
+ return { authenticated: false, username: null }
+ }
+ if (!resp.ok) {
+ return { authenticated: false, username: null }
+ }
+ return resp.json()
+ } catch {
+ return { authenticated: false, username: null }
+ }
+}
diff --git a/src/api.js.bak.viewport b/src/api.js.bak.viewport
index 35d724b..f67a9d5 100644
--- a/src/api.js.bak.viewport
+++ b/src/api.js.bak.viewport
@@ -10,8 +10,11 @@ const VALHALLA_HEIGHT_URL = '/valhalla/height'
* @param {AbortSignal} signal
* @returns {Promise<{query, results, count}>}
*/
-export async function searchGeocode(query, limit = 6, signal) {
+export async function searchGeocode(query, limit = 6, signal, viewport = null) {
const params = new URLSearchParams({ q: query, limit: String(limit) })
+ if (viewport?.lat != null) params.set('lat', String(viewport.lat))
+ if (viewport?.lon != null) params.set('lon', String(viewport.lon))
+ if (viewport?.zoom != null) params.set('zoom', String(Math.round(viewport.zoom)))
const resp = await fetch(`${GEOCODE_URL}?${params}`, { signal, timeout: 5000 })
if (!resp.ok) throw new Error(`Geocode error: ${resp.status}`)
return resp.json()
@@ -191,6 +194,19 @@ export async function fetchPlaceDetails(osmType, osmId, signal) {
}
}
+export async function fetchPlaceByWikidata(wikidataId, signal) {
+ try {
+ const resp = await fetch(`/api/place/wikidata/${wikidataId}`, {
+ signal,
+ headers: { "Accept": "application/json" },
+ })
+ if (!resp.ok) return null
+ return resp.json()
+ } catch {
+ return null
+ }
+}
+
// ── Contacts API ──
export async function fetchContacts(signal) {
diff --git a/src/components/ContactList.jsx b/src/components/ContactList.jsx
index 3fd8d09..80d7720 100644
--- a/src/components/ContactList.jsx
+++ b/src/components/ContactList.jsx
@@ -1,5 +1,5 @@
import { useEffect, useState, useCallback } from 'react'
-import { Plus, MapPin, User, Phone, Radio } from 'lucide-react'
+import { Plus, MapPin, User, Phone, Radio, LogIn } from 'lucide-react'
import { useStore } from '../store'
import { fetchContacts } from '../api'
@@ -9,30 +9,40 @@ export default function ContactList() {
const setContacts = useStore((s) => s.setContacts)
const setEditingContact = useStore((s) => s.setEditingContact)
const setSelectedPlace = useStore((s) => s.setSelectedPlace)
+ const auth = useStore((s) => s.auth)
const [filter, setFilter] = useState('')
- const [authFailed, setAuthFailed] = useState(false)
const loadContacts = useCallback(async () => {
+ // Skip fetch entirely if not authenticated
+ if (!auth.authenticated) return
const data = await fetchContacts()
- if (data?.auth === false) {
- setAuthFailed(true)
- return
- }
if (Array.isArray(data)) {
setContacts(data)
- setAuthFailed(false)
}
- }, [setContacts])
+ }, [setContacts, auth.authenticated])
useEffect(() => {
- if (!contactsLoaded) loadContacts()
- }, [contactsLoaded, loadContacts])
+ if (auth.loaded && auth.authenticated && !contactsLoaded) {
+ loadContacts()
+ }
+ }, [auth.loaded, auth.authenticated, contactsLoaded, loadContacts])
- if (authFailed) {
+ // Show login prompt if not authenticated
+ if (auth.loaded && !auth.authenticated) {
return (
-
-
Sign in to use contacts
+
+
+ Sign in to save and sync your contacts
+
+
{ window.location.href = '/api/auth/whoami' }}
+ className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium"
+ style={{ background: 'var(--accent)', color: 'var(--text-inverse)' }}
+ >
+
+ Log in
+
)
}
diff --git a/src/components/MapView.jsx.bak.boundary b/src/components/MapView.jsx.bak.boundary
new file mode 100644
index 0000000..c717749
--- /dev/null
+++ b/src/components/MapView.jsx.bak.boundary
@@ -0,0 +1,1403 @@
+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 = `
+
+ `
+
+/** 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 highlightedFeatureRef = useRef(null) // { source, sourceLayer, id } for setFeatureState
+
+ const stops = useStore((s) => s.stops)
+ const route = useStore((s) => s.route)
+ const theme = useStore((s) => s.theme)
+ const selectedPlace = useStore((s) => s.selectedPlace)
+ const clickMarker = useStore((s) => s.clickMarker)
+ const setClickMarker = useStore((s) => s.setClickMarker)
+ const clearClickMarker = useStore((s) => s.clearClickMarker)
+ const gpsOrigin = useStore((s) => s.gpsOrigin)
+ const geoPermission = useStore((s) => s.geoPermission)
+ const setSheetState = useStore((s) => s.setSheetState)
+ const setMapCenter = useStore((s) => s.setMapCenter)
+
+ // 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)
+
+ // Clear previous feature highlight
+ if (highlightedFeatureRef.current) {
+ const { source, sourceLayer, id } = highlightedFeatureRef.current
+ try {
+ map.setFeatureState({ source, sourceLayer, id }, { selected: false })
+ } catch (e) { /* ignore if layer removed */ }
+ highlightedFeatureRef.current = null
+ }
+
+ if (labelFeature) {
+ // Clicked a labeled feature — snap to geometry and highlight
+ const props = labelFeature.properties
+ const geom = labelFeature.geometry
+
+ // Get feature coordinates (Point geometry)
+ let featureLat = lat
+ let featureLon = lng
+ if (geom && geom.type === 'Point' && geom.coordinates) {
+ featureLon = geom.coordinates[0]
+ featureLat = geom.coordinates[1]
+ }
+
+ // Apply feature state highlight
+ const featureId = labelFeature.id ?? props.mvt_id
+ const sourceLayer = labelFeature.sourceLayer
+ const source = labelFeature.source
+ if (featureId != null && source) {
+ try {
+ map.setFeatureState({ source, sourceLayer, id: featureId }, { selected: true })
+ highlightedFeatureRef.current = { source, sourceLayer, id: featureId }
+ } catch (e) { console.warn('setFeatureState error:', e) }
+ }
+
+ // For feature clicks, don't show pin marker
+ store.clearClickMarker()
+
+ store.setSelectedPlace({
+ lat: featureLat,
+ lon: featureLon,
+ name: props.name || 'Unknown',
+ address: null,
+ type: props.kind_detail || props.kind || null,
+ source: 'basemap_label',
+ matchCode: null,
+ mode: 'feature',
+ featureId: featureId,
+ featureLayer: labelFeature.layer?.id || null,
+ wikidata: props.wikidata || null,
+ raw: {
+ wikidata: props.wikidata || null,
+ population: props.population || null,
+ kind: props.kind || null,
+ kind_detail: props.kind_detail || null,
+ elevation: props.elevation || null,
+ },
+ })
+ } else {
+ // No labeled feature — show reticle at click point
+ store.setClickMarker({
+ lat,
+ lon: lng,
+ circleRadiusPx: MARKER_RADIUS_PX,
+ })
+
+ store.setSelectedPlace({
+ lat,
+ lon: lng,
+ name: 'Dropped pin',
+ address: null,
+ type: null,
+ source: 'map_click',
+ matchCode: null,
+ mode: 'reticle',
+ raw: {},
+ })
+
+ // Reverse geocode in background
+ fetchReverse(lat, lng).then((place) => {
+ if (!place) return
+ const current = useStore.getState().selectedPlace
+ if (current && Math.abs(current.lat - lat) < 0.00001 && Math.abs(current.lon - lng) < 0.00001) {
+ useStore.getState().setSelectedPlace({
+ ...place,
+ lat,
+ lon: lng,
+ })
+ }
+ })
+ }
+ }
+ })
+ 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' && 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])
+
+ // 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(
+ `
+ ${stop.name}
+ Remove
+
`
+ )
+ .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 (
+
+
+ {/* Zoom level indicator - bottom-left corner */}
+
+ Z {zoomLevel.toFixed(1)}
+
+ {/* Radial context menu */}
+
setRadialMenu((m) => ({ ...m, open: false }))}
+ />
+
+ )
+})
+
+export default MapView
diff --git a/src/components/MapView.jsx.bak.labelclick b/src/components/MapView.jsx.bak.labelclick
new file mode 100644
index 0000000..da86ab9
--- /dev/null
+++ b/src/components/MapView.jsx.bak.labelclick
@@ -0,0 +1,1328 @@
+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 = `
+
+ `
+
+/** 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 {}
+
+ // 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(
+ `
+ ${stop.name}
+ Remove
+
`
+ )
+ .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 (
+
+
+ {/* Zoom level indicator - bottom-left corner */}
+
+ Z {zoomLevel.toFixed(1)}
+
+ {/* Radial context menu */}
+
setRadialMenu((m) => ({ ...m, open: false }))}
+ />
+
+ )
+})
+
+export default MapView
diff --git a/src/components/MapView.jsx.bak.poihover b/src/components/MapView.jsx.bak.poihover
new file mode 100644
index 0000000..4116f1d
--- /dev/null
+++ b/src/components/MapView.jsx.bak.poihover
@@ -0,0 +1,1315 @@
+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 = `
+
+ `
+
+/** 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(
+ `
+ ${stop.name}
+ Remove
+
`
+ )
+ .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 (
+
+
+ {/* Zoom level indicator - bottom-left corner */}
+
+ Z {zoomLevel.toFixed(1)}
+
+ {/* Radial context menu */}
+
setRadialMenu((m) => ({ ...m, open: false }))}
+ />
+
+ )
+})
+
+export default MapView
diff --git a/src/components/MapView.jsx.bak.regressions b/src/components/MapView.jsx.bak.regressions
new file mode 100644
index 0000000..9cc0498
--- /dev/null
+++ b/src/components/MapView.jsx.bak.regressions
@@ -0,0 +1,1514 @@
+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 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'
+
+/** 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 = `
+
+ `
+
+/** 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 highlightedFeatureRef = useRef(null) // { source, sourceLayer, id } for setFeatureState
+
+ const stops = useStore((s) => s.stops)
+ const route = useStore((s) => s.route)
+ const theme = useStore((s) => s.theme)
+ const selectedPlace = useStore((s) => s.selectedPlace)
+ const clickMarker = useStore((s) => s.clickMarker)
+ const setClickMarker = useStore((s) => s.setClickMarker)
+ const clearClickMarker = useStore((s) => s.clearClickMarker)
+ const gpsOrigin = useStore((s) => s.gpsOrigin)
+ const geoPermission = useStore((s) => s.geoPermission)
+ const setSheetState = useStore((s) => s.setSheetState)
+ const setMapCenter = useStore((s) => s.setMapCenter)
+
+ // 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)
+
+ // Clear previous feature highlight
+ if (highlightedFeatureRef.current) {
+ const { source, sourceLayer, id } = highlightedFeatureRef.current
+ try {
+ map.setFeatureState({ source, sourceLayer, id }, { selected: false })
+ } catch (e) { /* ignore if layer removed */ }
+ highlightedFeatureRef.current = null
+ }
+
+ if (labelFeature) {
+ // Clicked a labeled feature — snap to geometry and highlight
+ const props = labelFeature.properties
+ const geom = labelFeature.geometry
+
+ // Get feature coordinates (Point geometry)
+ let featureLat = lat
+ let featureLon = lng
+ if (geom && geom.type === 'Point' && geom.coordinates) {
+ featureLon = geom.coordinates[0]
+ featureLat = geom.coordinates[1]
+ }
+
+ // Apply feature state highlight
+ const featureId = labelFeature.id ?? props.mvt_id
+ const sourceLayer = labelFeature.sourceLayer
+ const source = labelFeature.source
+ if (featureId != null && source) {
+ try {
+ map.setFeatureState({ source, sourceLayer, id: featureId }, { selected: true })
+ highlightedFeatureRef.current = { source, sourceLayer, id: featureId }
+ } catch (e) { console.warn('setFeatureState error:', e) }
+ }
+
+ // For feature clicks, don't show pin marker
+ store.clearClickMarker()
+
+ store.setSelectedPlace({
+ lat: featureLat,
+ lon: featureLon,
+ name: props.name || 'Unknown',
+ address: null,
+ type: props.kind_detail || props.kind || null,
+ source: 'basemap_label',
+ matchCode: null,
+ mode: 'feature',
+ featureId: featureId,
+ featureLayer: labelFeature.layer?.id || null,
+ wikidata: props.wikidata || null,
+ raw: {
+ wikidata: props.wikidata || null,
+ population: props.population || null,
+ kind: props.kind || null,
+ kind_detail: props.kind_detail || null,
+ elevation: props.elevation || null,
+ },
+ })
+ } else {
+ // No labeled feature — show reticle at click point
+ store.setClickMarker({
+ lat,
+ lon: lng,
+ circleRadiusPx: MARKER_RADIUS_PX,
+ })
+
+ store.setSelectedPlace({
+ lat,
+ lon: lng,
+ name: 'Dropped pin',
+ address: null,
+ type: null,
+ source: 'map_click',
+ matchCode: null,
+ mode: 'reticle',
+ raw: {},
+ })
+
+ // Reverse geocode in background
+ fetchReverse(lat, lng).then((place) => {
+ if (!place) return
+ const current = useStore.getState().selectedPlace
+ if (current && Math.abs(current.lat - lat) < 0.00001 && Math.abs(current.lon - lng) < 0.00001) {
+ useStore.getState().setSelectedPlace({
+ ...place,
+ lat,
+ lon: lng,
+ })
+ }
+ })
+ }
+ }
+ })
+ map.on('load', () => {
+ map.addSource(ROUTE_SOURCE, {
+ type: 'geojson',
+ data: { type: 'FeatureCollection', features: [] },
+ })
+
+ // Boundary polygon source for selected places
+ map.addSource(BOUNDARY_SOURCE, {
+ type: 'geojson',
+ data: { type: 'FeatureCollection', features: [] },
+ })
+ map.addLayer({
+ id: BOUNDARY_LAYER,
+ type: 'line',
+ source: BOUNDARY_SOURCE,
+ paint: {
+ 'line-color': 'var(--accent)',
+ 'line-width': 2,
+ 'line-opacity': 0.7,
+ 'line-dasharray': [3, 2],
+ },
+ })
+
+ // 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: [] },
+ })
+
+ // Boundary polygon source
+ map.addSource(BOUNDARY_SOURCE, {
+ type: 'geojson',
+ data: { type: 'FeatureCollection', features: [] },
+ })
+ map.addLayer({
+ id: BOUNDARY_LAYER,
+ type: 'line',
+ source: BOUNDARY_SOURCE,
+ paint: {
+ 'line-color': 'var(--accent)',
+ 'line-width': 2,
+ 'line-opacity': 0.7,
+ 'line-dasharray': [3, 2],
+ },
+ })
+
+ // Re-add active overlay layers
+ if (activeLayersRef.current.hillshade) addHillshade(map)
+ if (activeLayersRef.current.traffic) addTraffic(map)
+ if (activeLayersRef.current.publicLands) addPublicLands(map)
+ if (activeLayersRef.current.contours) addContours(map)
+
+ // Restore view
+ map.jumpTo({ center, zoom, bearing, pitch })
+ // Re-render route if exists
+ const currentRoute = useStore.getState().route
+ if (currentRoute) updateRoute(map, currentRoute)
+ })
+ }, [theme])
+
+ // Preview pin for selected place
+ useEffect(() => {
+ const map = mapInstance.current
+ if (!map) return
+
+ // Remove old preview marker
+ if (previewMarkerRef.current) {
+ previewMarkerRef.current.remove()
+ previewMarkerRef.current = null
+ }
+
+ if (!selectedPlace) return
+
+ // Only fly to place if it came from search (not map-click which already centered)
+ if (selectedPlace.source !== 'map_click' && selectedPlace.source !== 'basemap_label') {
+ map.flyTo({ center: [selectedPlace.lon, selectedPlace.lat], zoom: 14, duration: 800 })
+ }
+
+ // Different visual feedback based on mode
+ const isFeatureMode = selectedPlace.mode === 'feature'
+
+ // Create marker element
+ const el = document.createElement('div')
+ if (isFeatureMode) {
+ // Feature mode: subtle ring indicator
+ el.className = 'navi-feature-highlight'
+ } else {
+ // Reticle mode: pin with center dot
+ el.className = 'navi-pin-preview'
+ const dot = document.createElement('div')
+ dot.className = 'navi-pin-center-dot'
+ el.appendChild(dot)
+ }
+
+ previewMarkerRef.current = new maplibregl.Marker({ element: el })
+ .setLngLat([selectedPlace.lon, selectedPlace.lat])
+ .addTo(map)
+
+ return () => {
+ if (previewMarkerRef.current) {
+ previewMarkerRef.current.remove()
+ previewMarkerRef.current = null
+ }
+ }
+ }, [selectedPlace])
+
+ // Boundary polygon and zoom-to-feature
+ useEffect(() => {
+ const map = mapInstance.current
+ if (!map || !map.isStyleLoaded()) return
+
+ const source = map.getSource(BOUNDARY_SOURCE)
+ if (!source) return
+
+ // Clear boundary if no place selected
+ if (!selectedPlace) {
+ source.setData({ type: 'FeatureCollection', features: [] })
+ return
+ }
+
+ // Get boundary from selectedPlace (may come from API response)
+ const boundary = selectedPlace.boundary || selectedPlace.raw?.boundary
+
+ // Update boundary layer
+ if (boundary && (boundary.type === 'Polygon' || boundary.type === 'MultiPolygon')) {
+ source.setData({
+ type: 'Feature',
+ geometry: boundary,
+ properties: {},
+ })
+
+ // Zoom to fit boundary
+ try {
+ const coords = boundary.type === 'Polygon'
+ ? boundary.coordinates[0]
+ : boundary.coordinates.flat(1)
+
+ if (coords.length > 0) {
+ let minLng = Infinity, maxLng = -Infinity, minLat = Infinity, maxLat = -Infinity
+ for (const [lng, lat] of coords) {
+ if (lng < minLng) minLng = lng
+ if (lng > maxLng) maxLng = lng
+ if (lat < minLat) minLat = lat
+ if (lat > maxLat) maxLat = lat
+ }
+ map.fitBounds([[minLng, minLat], [maxLng, maxLat]], {
+ padding: 50,
+ duration: 700,
+ maxZoom: 16,
+ })
+ }
+ } catch (e) {
+ console.warn('fitBounds error:', e)
+ }
+ } else {
+ // No boundary - clear the layer and zoom based on feature kind
+ source.setData({ type: 'FeatureCollection', features: [] })
+
+ // Only zoom for feature mode selections (not terrain clicks)
+ if (selectedPlace.mode === 'feature' && selectedPlace.source === 'basemap_label') {
+ const kind = selectedPlace.raw?.kind || selectedPlace.type || ''
+ let targetZoom = null
+
+ if (kind.includes('country')) targetZoom = 5
+ else if (kind.includes('region' ) || kind.includes('state')) targetZoom = 7
+ else if (kind.includes('locality' ) || kind.includes('city')) targetZoom = 11
+ else if (kind.includes('subplace' ) || kind.includes('neighbourhood') || kind.includes('neighborhood')) targetZoom = 13
+ else if (kind.includes('poi')) targetZoom = 16
+
+ // Only zoom in, never zoom out
+ if (targetZoom && map.getZoom() < targetZoom) {
+ map.flyTo({
+ center: [selectedPlace.lon, selectedPlace.lat],
+ zoom: targetZoom,
+ duration: 700,
+ })
+ }
+ }
+ }
+ }, [selectedPlace])
+
+ // Update route polyline when route changes
+ useEffect(() => {
+ const map = mapInstance.current
+ if (!map) return
+ if (!map.isStyleLoaded()) {
+ const handler = () => updateRoute(map, route)
+ map.once('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])
+ )
+ // Single-panel: no floating detail
+ const leftPad = 420 // 360px panel + margin
+ map.fitBounds(bounds, { padding: { top: 60, bottom: 60, left: leftPad, right: 60 } })
+ }
+ }
+
+ // Update stop markers when stops change
+ useEffect(() => {
+ const map = mapInstance.current
+ if (!map) return
+
+ // Remove old markers
+ for (const m of markersRef.current) m.remove()
+ markersRef.current = []
+ if (popupRef.current) {
+ popupRef.current.remove()
+ popupRef.current = null
+ }
+
+ const hasGpsOrigin = gpsOrigin && geoPermission === 'granted'
+ const indexOffset = hasGpsOrigin ? 1 : 0
+
+ stops.forEach((stop, i) => {
+ const displayIndex = i + indexOffset
+ const effectiveTotal = stops.length + indexOffset
+
+ let pinClass = 'navi-pin navi-pin--intermediate'
+ if (displayIndex === 0) pinClass = 'navi-pin navi-pin--origin'
+ else if (displayIndex === effectiveTotal - 1 && effectiveTotal > 1) pinClass = 'navi-pin navi-pin--destination'
+
+ const label = String.fromCharCode(65 + Math.min(displayIndex, 25))
+
+ const el = document.createElement('div')
+ el.className = pinClass
+ el.textContent = label
+
+ el.addEventListener('click', (e) => {
+ e.stopPropagation()
+ // Flag so the map-level click handler doesn't fire
+ pinClickedRef.current = true
+ if (popupRef.current) popupRef.current.remove()
+ const popup = new maplibregl.Popup({ offset: 20, closeButton: true })
+ .setLngLat([stop.lon, stop.lat])
+ .setHTML(
+ `
+ ${stop.name}
+ Remove
+
`
+ )
+ .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])
+
+ // Track zoom level for indicator
+ useEffect(() => {
+ const map = mapInstance.current
+ if (!map) return
+
+ const updateZoom = () => setZoomLevel(map.getZoom())
+
+ // Set initial zoom
+ if (map.loaded()) {
+ updateZoom()
+ } else {
+ map.once("load", updateZoom)
+ }
+
+ // Subscribe to zoom changes
+ map.on("zoom", updateZoom)
+
+ return () => {
+ map.off("zoom", updateZoom)
+ }
+ }, [])
+
+
+ // Track map center for search viewport bias
+ useEffect(() => {
+ const map = mapInstance.current
+ if (!map) return
+
+ const updateCenter = () => {
+ const center = map.getCenter()
+ const zoom = map.getZoom()
+ setMapCenter({ lat: center.lat, lon: center.lng, zoom })
+ }
+
+ // Set initial center
+ if (map.loaded()) {
+ updateCenter()
+ } else {
+ map.once("load", updateCenter)
+ }
+
+ // Update on move end (not every frame)
+ map.on("moveend", updateCenter)
+
+ return () => {
+ map.off("moveend", updateCenter)
+ }
+ }, [setMapCenter])
+
+ return (
+
+
+ {/* Zoom level indicator - bottom-left corner */}
+
+ Z {zoomLevel.toFixed(1)}
+
+ {/* Radial context menu */}
+
setRadialMenu((m) => ({ ...m, open: false }))}
+ />
+
+ )
+})
+
+export default MapView
diff --git a/src/components/MapView.jsx.bak.twoclick b/src/components/MapView.jsx.bak.twoclick
new file mode 100644
index 0000000..9715c8d
--- /dev/null
+++ b/src/components/MapView.jsx.bak.twoclick
@@ -0,0 +1,1256 @@
+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 = `
+
+ `
+
+/** 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 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 — drop pin and reverse geocode
+ map.on('click', (e) => {
+ // If a stop pin was just clicked, skip the pin-drop
+ if (pinClickedRef.current) {
+ pinClickedRef.current = false
+ return
+ }
+
+ if (window.innerWidth < 768) setSheetState('collapsed')
+
+ const { lng, lat } = e.lngLat
+
+ // Immediately set a "Dropped pin" placeholder so PlaceDetail opens with coords
+ useStore.getState().setSelectedPlace({
+ lat,
+ lon: lng,
+ name: 'Dropped pin',
+ address: null,
+ type: null,
+ source: 'map_click',
+ matchCode: null,
+ raw: {},
+ })
+
+ // Reverse geocode in background — update place when result arrives
+ fetchReverse(lat, lng).then((place) => {
+ if (!place) return
+ // Only update if the selected place is still this pin (user hasn't clicked elsewhere)
+ 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'
+ 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(
+ `
+ ${stop.name}
+ Remove
+
`
+ )
+ .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 (
+
+
+ {/* Zoom level indicator - bottom-left corner */}
+
+ Z {zoomLevel.toFixed(1)}
+
+ {/* Radial context menu */}
+
setRadialMenu((m) => ({ ...m, open: false }))}
+ />
+
+ )
+})
+
+export default MapView
diff --git a/src/components/MapView.jsx.bak.uxfix2 b/src/components/MapView.jsx.bak.uxfix2
new file mode 100644
index 0000000..ad7f8aa
--- /dev/null
+++ b/src/components/MapView.jsx.bak.uxfix2
@@ -0,0 +1,1514 @@
+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 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'
+
+/** 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 = `
+
+ `
+
+/** 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 highlightedFeatureRef = useRef(null) // { source, sourceLayer, id } for setFeatureState
+
+ const stops = useStore((s) => s.stops)
+ const route = useStore((s) => s.route)
+ const theme = useStore((s) => s.theme)
+ const selectedPlace = useStore((s) => s.selectedPlace)
+ const clickMarker = useStore((s) => s.clickMarker)
+ const setClickMarker = useStore((s) => s.setClickMarker)
+ const clearClickMarker = useStore((s) => s.clearClickMarker)
+ const gpsOrigin = useStore((s) => s.gpsOrigin)
+ const geoPermission = useStore((s) => s.geoPermission)
+ const setSheetState = useStore((s) => s.setSheetState)
+ const setMapCenter = useStore((s) => s.setMapCenter)
+
+ // 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)
+
+ // Clear previous feature highlight
+ if (highlightedFeatureRef.current) {
+ const { source, sourceLayer, id } = highlightedFeatureRef.current
+ try {
+ map.setFeatureState({ source, sourceLayer, id }, { selected: false })
+ } catch (e) { /* ignore if layer removed */ }
+ highlightedFeatureRef.current = null
+ }
+
+ if (labelFeature) {
+ // Clicked a labeled feature — snap to geometry and highlight
+ const props = labelFeature.properties
+ const geom = labelFeature.geometry
+
+ // Get feature coordinates (Point geometry)
+ let featureLat = lat
+ let featureLon = lng
+ if (geom && geom.type === 'Point' && geom.coordinates) {
+ featureLon = geom.coordinates[0]
+ featureLat = geom.coordinates[1]
+ }
+
+ // Apply feature state highlight
+ const featureId = labelFeature.id ?? props.mvt_id
+ const sourceLayer = labelFeature.sourceLayer
+ const source = labelFeature.source
+ if (featureId != null && source) {
+ try {
+ map.setFeatureState({ source, sourceLayer, id: featureId }, { selected: true })
+ highlightedFeatureRef.current = { source, sourceLayer, id: featureId }
+ } catch (e) { console.warn('setFeatureState error:', e) }
+ }
+
+ // For feature clicks, don't show pin marker
+ store.clearClickMarker()
+
+ store.setSelectedPlace({
+ lat: featureLat,
+ lon: featureLon,
+ name: props.name || 'Unknown',
+ address: null,
+ type: props.kind_detail || props.kind || null,
+ source: 'basemap_label',
+ matchCode: null,
+ mode: 'feature',
+ featureId: featureId,
+ featureLayer: labelFeature.layer?.id || null,
+ wikidata: props.wikidata || null,
+ raw: {
+ wikidata: props.wikidata || null,
+ population: props.population || null,
+ kind: props.kind || null,
+ kind_detail: props.kind_detail || null,
+ elevation: props.elevation || null,
+ },
+ })
+ } else {
+ // No labeled feature — show reticle at click point
+ store.setClickMarker({
+ lat,
+ lon: lng,
+ circleRadiusPx: MARKER_RADIUS_PX,
+ })
+
+ store.setSelectedPlace({
+ lat,
+ lon: lng,
+ name: 'Dropped pin',
+ address: null,
+ type: null,
+ source: 'map_click',
+ matchCode: null,
+ mode: 'reticle',
+ raw: {},
+ })
+
+ // Reverse geocode in background
+ fetchReverse(lat, lng).then((place) => {
+ if (!place) return
+ const current = useStore.getState().selectedPlace
+ if (current && Math.abs(current.lat - lat) < 0.00001 && Math.abs(current.lon - lng) < 0.00001) {
+ useStore.getState().setSelectedPlace({
+ ...place,
+ lat,
+ lon: lng,
+ })
+ }
+ })
+ }
+ }
+ })
+ map.on('load', () => {
+ map.addSource(ROUTE_SOURCE, {
+ type: 'geojson',
+ data: { type: 'FeatureCollection', features: [] },
+ })
+
+ // Boundary polygon source for selected places
+ map.addSource(BOUNDARY_SOURCE, {
+ type: 'geojson',
+ data: { type: 'FeatureCollection', features: [] },
+ })
+ map.addLayer({
+ id: BOUNDARY_LAYER,
+ type: 'line',
+ source: BOUNDARY_SOURCE,
+ paint: {
+ 'line-color': 'var(--accent)',
+ 'line-width': 2,
+ 'line-opacity': 0.7,
+ 'line-dasharray': [3, 2],
+ },
+ })
+
+ // 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: [] },
+ })
+
+ // Boundary polygon source
+ map.addSource(BOUNDARY_SOURCE, {
+ type: 'geojson',
+ data: { type: 'FeatureCollection', features: [] },
+ })
+ map.addLayer({
+ id: BOUNDARY_LAYER,
+ type: 'line',
+ source: BOUNDARY_SOURCE,
+ paint: {
+ 'line-color': 'var(--accent)',
+ 'line-width': 2,
+ 'line-opacity': 0.7,
+ 'line-dasharray': [3, 2],
+ },
+ })
+
+ // Re-add active overlay layers
+ if (activeLayersRef.current.hillshade) addHillshade(map)
+ if (activeLayersRef.current.traffic) addTraffic(map)
+ if (activeLayersRef.current.publicLands) addPublicLands(map)
+ if (activeLayersRef.current.contours) addContours(map)
+
+ // Restore view
+ map.jumpTo({ center, zoom, bearing, pitch })
+ // Re-render route if exists
+ const currentRoute = useStore.getState().route
+ if (currentRoute) updateRoute(map, currentRoute)
+ })
+ }, [theme])
+
+ // Preview pin for selected place
+ useEffect(() => {
+ const map = mapInstance.current
+ if (!map) return
+
+ // Remove old preview marker
+ if (previewMarkerRef.current) {
+ previewMarkerRef.current.remove()
+ previewMarkerRef.current = null
+ }
+
+ if (!selectedPlace) return
+
+ // Only fly to place if it came from search (not map-click which already centered)
+ if (selectedPlace.source !== 'map_click' && selectedPlace.source !== 'basemap_label') {
+ map.flyTo({ center: [selectedPlace.lon, selectedPlace.lat], zoom: 14, duration: 800 })
+ }
+
+ // Different visual feedback based on mode
+ const isFeatureMode = selectedPlace.mode === 'feature'
+
+ // Create marker element
+ const el = document.createElement('div')
+ if (isFeatureMode) {
+ // Feature mode: subtle ring indicator
+ el.className = 'navi-feature-highlight'
+ } else {
+ // Reticle mode: pin with center dot
+ el.className = 'navi-pin-preview'
+ const dot = document.createElement('div')
+ dot.className = 'navi-pin-center-dot'
+ el.appendChild(dot)
+ }
+
+ previewMarkerRef.current = new maplibregl.Marker({ element: el })
+ .setLngLat([selectedPlace.lon, selectedPlace.lat])
+ .addTo(map)
+
+ return () => {
+ if (previewMarkerRef.current) {
+ previewMarkerRef.current.remove()
+ previewMarkerRef.current = null
+ }
+ }
+ }, [selectedPlace])
+
+ // Boundary polygon and zoom-to-feature
+ useEffect(() => {
+ const map = mapInstance.current
+ if (!map || !map.isStyleLoaded()) return
+
+ const source = map.getSource(BOUNDARY_SOURCE)
+ if (!source) return
+
+ // Clear boundary if no place selected
+ if (!selectedPlace) {
+ source.setData({ type: 'FeatureCollection', features: [] })
+ return
+ }
+
+ // Get boundary from selectedPlace (may come from API response)
+ const boundary = selectedPlace.boundary || selectedPlace.raw?.boundary
+
+ // Update boundary layer
+ if (boundary && (boundary.type === 'Polygon' || boundary.type === 'MultiPolygon')) {
+ source.setData({
+ type: 'Feature',
+ geometry: boundary,
+ properties: {},
+ })
+
+ // Zoom to fit boundary
+ try {
+ const coords = boundary.type === 'Polygon'
+ ? boundary.coordinates[0]
+ : boundary.coordinates.flat(1)
+
+ if (coords.length > 0) {
+ let minLng = Infinity, maxLng = -Infinity, minLat = Infinity, maxLat = -Infinity
+ for (const [lng, lat] of coords) {
+ if (lng < minLng) minLng = lng
+ if (lng > maxLng) maxLng = lng
+ if (lat < minLat) minLat = lat
+ if (lat > maxLat) maxLat = lat
+ }
+ map.fitBounds([[minLng, minLat], [maxLng, maxLat]], {
+ padding: 50,
+ duration: 700,
+ maxZoom: 16,
+ })
+ }
+ } catch (e) {
+ console.warn('fitBounds error:', e)
+ }
+ } else {
+ // No boundary - clear the layer and zoom based on feature kind
+ source.setData({ type: 'FeatureCollection', features: [] })
+
+ // Only zoom for feature mode selections (not terrain clicks)
+ if (selectedPlace.mode === 'feature' && selectedPlace.source === 'basemap_label') {
+ const kind = selectedPlace.raw?.kind || selectedPlace.type || ''
+ let targetZoom = null
+
+ if (kind.includes('country')) targetZoom = 5
+ else if (kind.includes('region' ) || kind.includes('state')) targetZoom = 7
+ else if (kind.includes('locality' ) || kind.includes('city')) targetZoom = 11
+ else if (kind.includes('subplace' ) || kind.includes('neighbourhood') || kind.includes('neighborhood')) targetZoom = 13
+ else if (kind.includes('poi')) targetZoom = 16
+
+ // Only zoom in, never zoom out
+ if (targetZoom && map.getZoom() < targetZoom) {
+ map.flyTo({
+ center: [selectedPlace.lon, selectedPlace.lat],
+ zoom: targetZoom,
+ duration: 700,
+ })
+ }
+ }
+ }
+ }, [selectedPlace])
+
+ // Update route polyline when route changes
+ useEffect(() => {
+ const map = mapInstance.current
+ if (!map) return
+ if (!map.isStyleLoaded()) {
+ const handler = () => updateRoute(map, route)
+ map.once('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])
+ )
+ // Single-panel: no floating detail
+ const leftPad = 380 // 360px panel + margin
+ map.fitBounds(bounds, { padding: { top: 60, bottom: 60, left: leftPad, right: 60 } })
+ }
+ }
+
+ // Update stop markers when stops change
+ useEffect(() => {
+ const map = mapInstance.current
+ if (!map) return
+
+ // Remove old markers
+ for (const m of markersRef.current) m.remove()
+ markersRef.current = []
+ if (popupRef.current) {
+ popupRef.current.remove()
+ popupRef.current = null
+ }
+
+ const hasGpsOrigin = gpsOrigin && geoPermission === 'granted'
+ const indexOffset = hasGpsOrigin ? 1 : 0
+
+ stops.forEach((stop, i) => {
+ const displayIndex = i + indexOffset
+ const effectiveTotal = stops.length + indexOffset
+
+ let pinClass = 'navi-pin navi-pin--intermediate'
+ if (displayIndex === 0) pinClass = 'navi-pin navi-pin--origin'
+ else if (displayIndex === effectiveTotal - 1 && effectiveTotal > 1) pinClass = 'navi-pin navi-pin--destination'
+
+ const label = String.fromCharCode(65 + Math.min(displayIndex, 25))
+
+ const el = document.createElement('div')
+ el.className = pinClass
+ el.textContent = label
+
+ el.addEventListener('click', (e) => {
+ e.stopPropagation()
+ // Flag so the map-level click handler doesn't fire
+ pinClickedRef.current = true
+ if (popupRef.current) popupRef.current.remove()
+ const popup = new maplibregl.Popup({ offset: 20, closeButton: true })
+ .setLngLat([stop.lon, stop.lat])
+ .setHTML(
+ `
+ ${stop.name}
+ Remove
+
`
+ )
+ .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: 380, 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 (
+
+
+ {/* Zoom level indicator - bottom-left corner */}
+
+ Z {zoomLevel.toFixed(1)}
+
+ {/* Radial context menu */}
+
setRadialMenu((m) => ({ ...m, open: false }))}
+ />
+
+ )
+})
+
+export default MapView
diff --git a/src/components/Panel.jsx b/src/components/Panel.jsx
index b54cb95..cd2edb1 100644
--- a/src/components/Panel.jsx
+++ b/src/components/Panel.jsx
@@ -1,284 +1,315 @@
-import { useRef, useCallback, useEffect, useState } from 'react'
-import { Sun, Moon } from 'lucide-react'
-import { useStore, usePanelState } from '../store'
-import { hasFeature } from '../config'
-import SearchBar from './SearchBar'
-import StopList from './StopList'
-import ModeSelector from './ModeSelector'
-import ManeuverList from './ManeuverList'
-import ContactList from './ContactList'
-import { PlaceCard } from './PlaceCard'
-import { requestOptimizedRoute } from '../api'
-
-export default function Panel({ onManeuverClick }) {
- const selectedPlace = useStore((s) => s.selectedPlace)
- const pendingDestination = useStore((s) => s.pendingDestination)
- const clearSelectedPlace = useStore((s) => s.clearSelectedPlace)
- const stops = useStore((s) => s.stops)
- const mode = useStore((s) => s.mode)
- const route = useStore((s) => s.route)
- const routeLoading = useStore((s) => s.routeLoading)
- const routeError = useStore((s) => s.routeError)
- const setStops = useStore((s) => s.setStops)
- const setRoute = useStore((s) => s.setRoute)
- const setRouteError = useStore((s) => s.setRouteError)
- const setRouteLoading = useStore((s) => s.setRouteLoading)
- const sheetState = useStore((s) => s.sheetState)
- const setSheetState = useStore((s) => s.setSheetState)
- const theme = useStore((s) => s.theme)
- const themeOverride = useStore((s) => s.themeOverride)
- const setThemeOverride = useStore((s) => s.setThemeOverride)
- const gpsOrigin = useStore((s) => s.gpsOrigin)
- const geoPermission = useStore((s) => s.geoPermission)
- const activeTab = useStore((s) => s.activeTab)
- const setActiveTab = useStore((s) => s.setActiveTab)
-
- const panelState = usePanelState()
-
- const [isMobile, setIsMobile] = useState(false)
- const [optimizing, setOptimizing] = useState(false)
- const sheetRef = useRef(null)
- const dragStartY = useRef(0)
- const dragStartState = useRef('half')
-
- const showContacts = hasFeature('has_contacts')
-
- // Responsive detection
- useEffect(() => {
- const check = () => setIsMobile(window.innerWidth < 768)
- check()
- window.addEventListener('resize', check)
- return () => window.removeEventListener('resize', check)
- }, [])
-
- // Theme toggle
- const toggleTheme = () => {
- const next = theme === 'dark' ? 'light' : 'dark'
- setThemeOverride(next)
- }
-
- // Optimize stops
- const hasGpsOrigin = gpsOrigin && geoPermission === 'granted'
- const effectiveCount = stops.length + (hasGpsOrigin ? 1 : 0)
-
- const handleOptimize = useCallback(async () => {
- if (effectiveCount < 3 || optimizing) return
- setOptimizing(true)
- try {
- const { userLocation } = useStore.getState()
- let locations = stops.map((s) => ({ lat: s.lat, lon: s.lon }))
- if (hasGpsOrigin && userLocation) {
- locations = [{ lat: userLocation.lat, lon: userLocation.lon }, ...locations]
- }
- const data = await requestOptimizedRoute(locations, mode)
- if (data.trip) {
- const wpOrder = hasGpsOrigin && userLocation
- ? (data.trip.locations || []).slice(1)
- : data.trip.locations
- if (wpOrder && wpOrder.length === stops.length) {
- const reordered = wpOrder.map((wp) => {
- let closest = stops[0]
- let minDist = Infinity
- for (const s of stops) {
- const d = Math.abs(s.lat - wp.lat) + Math.abs(s.lon - wp.lon)
- if (d < minDist) {
- minDist = d
- closest = s
- }
- }
- return closest
- })
- const seen = new Set()
- const unique = reordered.filter((s) => {
- if (seen.has(s.id)) return false
- seen.add(s.id)
- return true
- })
- if (unique.length === stops.length) {
- setStops(unique)
- }
- }
- setRoute(data.trip)
- }
- } catch (e) {
- setRouteError(e.message)
- } finally {
- setOptimizing(false)
- }
- }, [stops, mode, optimizing, effectiveCount, hasGpsOrigin, setStops, setRoute, setRouteError])
-
- // Mobile sheet drag handling
- const handleTouchStart = useCallback((e) => {
- dragStartY.current = e.touches[0].clientY
- dragStartState.current = sheetState
- }, [sheetState])
-
- const handleTouchEnd = useCallback((e) => {
- const deltaY = e.changedTouches[0].clientY - dragStartY.current
- if (Math.abs(deltaY) < 30) return
- if (deltaY < 0) {
- if (dragStartState.current === 'collapsed') setSheetState('half')
- else if (dragStartState.current === 'half') setSheetState('full')
- } else {
- if (dragStartState.current === 'full') setSheetState('half')
- else if (dragStartState.current === 'half') setSheetState('collapsed')
- }
- }, [setSheetState])
-
- const showOptimize = effectiveCount >= 3
-
- // Determine what to show based on panel state
- const showPreviewCard = panelState.startsWith('PREVIEW')
- const showRouteSection = ['ROUTING', 'ROUTE_CALCULATED', 'PREVIEW_ROUTING', 'PREVIEW_CALCULATED'].includes(panelState) || !!pendingDestination
- const showManeuvers = panelState === 'ROUTE_CALCULATED' || panelState === 'PREVIEW_CALCULATED'
- const showEmptyState = panelState === 'IDLE' && !pendingDestination
-
- // Routes tab content - now state-driven
- const routesContent = (
- <>
-
-
- {/* Preview card when place is selected */}
- {showPreviewCard && selectedPlace && (
-
- )}
-
- {/* Route section with stops */}
- {showRouteSection && (
- <>
-
-
-
-
-
-
- {showOptimize && (
-
- {optimizing ? 'Optimizing...' : 'Optimize stop order'}
-
- )}
-
- >
- )}
-
- {/* Maneuvers when route is calculated */}
- {showManeuvers && (route || routeLoading || routeError) && (
-
-
-
- )}
-
- {/* Empty state */}
- {showEmptyState && (
-
-
Search or tap the map to explore
-
- )}
- >
- )
-
- const content = (
- <>
- {showContacts && (
-
- setActiveTab('routes')}
- >
- Routes
-
- setActiveTab('contacts')}
- >
- Contacts
-
-
- )}
-
- {(!showContacts || activeTab === 'routes') ? routesContent :
}
- >
- )
-
- const header = (
-
-
Navi
-
- {theme === 'dark' ? : }
-
-
- )
-
- // Desktop: side panel (now 360px to accommodate PlaceCard)
- if (!isMobile) {
- return (
-
- {header}
- {content}
-
- )
- }
-
- // Mobile: bottom sheet
- const sheetHeights = {
- collapsed: 'h-12',
- half: 'h-[45vh]',
- full: 'h-[85vh]',
- }
-
- return (
-
- {/* Drag handle */}
-
{
- if (sheetState === 'collapsed') setSheetState('half')
- else if (sheetState === 'half') setSheetState('full')
- else setSheetState('half')
- }}
- >
-
-
-
- {sheetState !== 'collapsed' && (
-
- {header}
- {content}
-
- )}
-
- )
-}
+import { useRef, useCallback, useEffect, useState } from 'react'
+import { Sun, Moon, LogIn, LogOut } from 'lucide-react'
+import { useStore, usePanelState } from '../store'
+import { hasFeature } from '../config'
+import SearchBar from './SearchBar'
+import StopList from './StopList'
+import ModeSelector from './ModeSelector'
+import ManeuverList from './ManeuverList'
+import ContactList from './ContactList'
+import { PlaceCard } from './PlaceCard'
+import { requestOptimizedRoute } from '../api'
+
+export default function Panel({ onManeuverClick }) {
+ const selectedPlace = useStore((s) => s.selectedPlace)
+ const pendingDestination = useStore((s) => s.pendingDestination)
+ const clearSelectedPlace = useStore((s) => s.clearSelectedPlace)
+ const stops = useStore((s) => s.stops)
+ const mode = useStore((s) => s.mode)
+ const route = useStore((s) => s.route)
+ const routeLoading = useStore((s) => s.routeLoading)
+ const routeError = useStore((s) => s.routeError)
+ const setStops = useStore((s) => s.setStops)
+ const setRoute = useStore((s) => s.setRoute)
+ const setRouteError = useStore((s) => s.setRouteError)
+ const setRouteLoading = useStore((s) => s.setRouteLoading)
+ const sheetState = useStore((s) => s.sheetState)
+ const setSheetState = useStore((s) => s.setSheetState)
+ const theme = useStore((s) => s.theme)
+ const themeOverride = useStore((s) => s.themeOverride)
+ const setThemeOverride = useStore((s) => s.setThemeOverride)
+ const gpsOrigin = useStore((s) => s.gpsOrigin)
+ const geoPermission = useStore((s) => s.geoPermission)
+ const activeTab = useStore((s) => s.activeTab)
+ const auth = useStore((s) => s.auth)
+ const setActiveTab = useStore((s) => s.setActiveTab)
+
+ const panelState = usePanelState()
+
+ const [isMobile, setIsMobile] = useState(false)
+ const [optimizing, setOptimizing] = useState(false)
+ const sheetRef = useRef(null)
+ const dragStartY = useRef(0)
+ const dragStartState = useRef('half')
+
+ // Show contacts tab only if feature enabled AND user is authenticated
+ const showContacts = hasFeature('has_contacts') && auth.authenticated
+
+ // Responsive detection
+ useEffect(() => {
+ const check = () => setIsMobile(window.innerWidth < 768)
+ check()
+ window.addEventListener('resize', check)
+ return () => window.removeEventListener('resize', check)
+ }, [])
+
+ // Theme toggle
+ const toggleTheme = () => {
+ const next = theme === 'dark' ? 'light' : 'dark'
+ setThemeOverride(next)
+ }
+
+ // Auth handlers
+ const handleLogin = () => { window.location.href = '/api/auth/whoami' }
+ const handleLogout = () => { window.location.href = '/outpost.goauthentik.io/sign_out' }
+
+ // Optimize stops
+ const hasGpsOrigin = gpsOrigin && geoPermission === 'granted'
+ const effectiveCount = stops.length + (hasGpsOrigin ? 1 : 0)
+
+ const handleOptimize = useCallback(async () => {
+ if (effectiveCount < 3 || optimizing) return
+ setOptimizing(true)
+ try {
+ const { userLocation } = useStore.getState()
+ let locations = stops.map((s) => ({ lat: s.lat, lon: s.lon }))
+ if (hasGpsOrigin && userLocation) {
+ locations = [{ lat: userLocation.lat, lon: userLocation.lon }, ...locations]
+ }
+ const data = await requestOptimizedRoute(locations, mode)
+ if (data.trip) {
+ const wpOrder = hasGpsOrigin && userLocation
+ ? (data.trip.locations || []).slice(1)
+ : data.trip.locations
+ if (wpOrder && wpOrder.length === stops.length) {
+ const reordered = wpOrder.map((wp) => {
+ let closest = stops[0]
+ let minDist = Infinity
+ for (const s of stops) {
+ const d = Math.abs(s.lat - wp.lat) + Math.abs(s.lon - wp.lon)
+ if (d < minDist) {
+ minDist = d
+ closest = s
+ }
+ }
+ return closest
+ })
+ const seen = new Set()
+ const unique = reordered.filter((s) => {
+ if (seen.has(s.id)) return false
+ seen.add(s.id)
+ return true
+ })
+ if (unique.length === stops.length) {
+ setStops(unique)
+ }
+ }
+ setRoute(data.trip)
+ }
+ } catch (e) {
+ setRouteError(e.message)
+ } finally {
+ setOptimizing(false)
+ }
+ }, [stops, mode, optimizing, effectiveCount, hasGpsOrigin, setStops, setRoute, setRouteError])
+
+ // Mobile sheet drag handling
+ const handleTouchStart = useCallback((e) => {
+ dragStartY.current = e.touches[0].clientY
+ dragStartState.current = sheetState
+ }, [sheetState])
+
+ const handleTouchEnd = useCallback((e) => {
+ const deltaY = e.changedTouches[0].clientY - dragStartY.current
+ if (Math.abs(deltaY) < 30) return
+ if (deltaY < 0) {
+ if (dragStartState.current === 'collapsed') setSheetState('half')
+ else if (dragStartState.current === 'half') setSheetState('full')
+ } else {
+ if (dragStartState.current === 'full') setSheetState('half')
+ else if (dragStartState.current === 'half') setSheetState('collapsed')
+ }
+ }, [setSheetState])
+
+ const showOptimize = effectiveCount >= 3
+
+ // Determine what to show based on panel state
+ const showPreviewCard = panelState.startsWith('PREVIEW')
+ const showRouteSection = ['ROUTING', 'ROUTE_CALCULATED', 'PREVIEW_ROUTING', 'PREVIEW_CALCULATED'].includes(panelState) || !!pendingDestination
+ const showManeuvers = panelState === 'ROUTE_CALCULATED' || panelState === 'PREVIEW_CALCULATED'
+ const showEmptyState = panelState === 'IDLE' && !pendingDestination
+
+ // Routes tab content - now state-driven
+ const routesContent = (
+ <>
+
+
+ {/* Preview card when place is selected */}
+ {showPreviewCard && selectedPlace && (
+
+ )}
+
+ {/* Route section with stops */}
+ {showRouteSection && (
+ <>
+
+
+
+
+
+
+ {showOptimize && (
+
+ {optimizing ? 'Optimizing...' : 'Optimize stop order'}
+
+ )}
+
+ >
+ )}
+
+ {/* Maneuvers when route is calculated */}
+ {showManeuvers && (route || routeLoading || routeError) && (
+
+
+
+ )}
+
+ {/* Empty state */}
+ {showEmptyState && (
+
+
Search or tap the map to explore
+
+ )}
+ >
+ )
+
+ const content = (
+ <>
+ {showContacts && (
+
+ setActiveTab('routes')}
+ >
+ Routes
+
+ setActiveTab('contacts')}
+ >
+ Contacts
+
+
+ )}
+
+ {(!showContacts || activeTab === 'routes') ? routesContent :
}
+ >
+ )
+
+ const header = (
+
+
Navi
+
+ {auth.loaded && (
+ auth.authenticated ? (
+
+ {auth.username}
+
+
+ ) : (
+
+
+ Log in
+
+ )
+ )}
+
+ {theme === 'dark' ? : }
+
+
+
+ )
+
+ // Desktop: side panel (now 360px to accommodate PlaceCard)
+ if (!isMobile) {
+ return (
+
+ {header}
+ {content}
+
+ )
+ }
+
+ // Mobile: bottom sheet
+ const sheetHeights = {
+ collapsed: 'h-12',
+ half: 'h-[45vh]',
+ full: 'h-[85vh]',
+ }
+
+ return (
+
+ {/* Drag handle */}
+
{
+ if (sheetState === 'collapsed') setSheetState('half')
+ else if (sheetState === 'half') setSheetState('full')
+ else setSheetState('half')
+ }}
+ >
+
+
+
+ {sheetState !== 'collapsed' && (
+
+ {header}
+ {content}
+
+ )}
+
+ )
+}
diff --git a/src/components/Panel.jsx.bak.regressions b/src/components/Panel.jsx.bak.regressions
new file mode 100644
index 0000000..d06c1de
--- /dev/null
+++ b/src/components/Panel.jsx.bak.regressions
@@ -0,0 +1,283 @@
+import { useRef, useCallback, useEffect, useState } from 'react'
+import { Sun, Moon } from 'lucide-react'
+import { useStore, usePanelState } from '../store'
+import { hasFeature } from '../config'
+import SearchBar from './SearchBar'
+import StopList from './StopList'
+import ModeSelector from './ModeSelector'
+import ManeuverList from './ManeuverList'
+import ContactList from './ContactList'
+import { PlaceCard } from './PlaceCard'
+import { requestOptimizedRoute } from '../api'
+
+export default function Panel({ onManeuverClick }) {
+ const selectedPlace = useStore((s) => s.selectedPlace)
+ const clearSelectedPlace = useStore((s) => s.clearSelectedPlace)
+ const stops = useStore((s) => s.stops)
+ const mode = useStore((s) => s.mode)
+ const route = useStore((s) => s.route)
+ const routeLoading = useStore((s) => s.routeLoading)
+ const routeError = useStore((s) => s.routeError)
+ const setStops = useStore((s) => s.setStops)
+ const setRoute = useStore((s) => s.setRoute)
+ const setRouteError = useStore((s) => s.setRouteError)
+ const setRouteLoading = useStore((s) => s.setRouteLoading)
+ const sheetState = useStore((s) => s.sheetState)
+ const setSheetState = useStore((s) => s.setSheetState)
+ const theme = useStore((s) => s.theme)
+ const themeOverride = useStore((s) => s.themeOverride)
+ const setThemeOverride = useStore((s) => s.setThemeOverride)
+ const gpsOrigin = useStore((s) => s.gpsOrigin)
+ const geoPermission = useStore((s) => s.geoPermission)
+ const activeTab = useStore((s) => s.activeTab)
+ const setActiveTab = useStore((s) => s.setActiveTab)
+
+ const panelState = usePanelState()
+
+ const [isMobile, setIsMobile] = useState(false)
+ const [optimizing, setOptimizing] = useState(false)
+ const sheetRef = useRef(null)
+ const dragStartY = useRef(0)
+ const dragStartState = useRef('half')
+
+ const showContacts = hasFeature('has_contacts')
+
+ // Responsive detection
+ useEffect(() => {
+ const check = () => setIsMobile(window.innerWidth < 768)
+ check()
+ window.addEventListener('resize', check)
+ return () => window.removeEventListener('resize', check)
+ }, [])
+
+ // Theme toggle
+ const toggleTheme = () => {
+ const next = theme === 'dark' ? 'light' : 'dark'
+ setThemeOverride(next)
+ }
+
+ // Optimize stops
+ const hasGpsOrigin = gpsOrigin && geoPermission === 'granted'
+ const effectiveCount = stops.length + (hasGpsOrigin ? 1 : 0)
+
+ const handleOptimize = useCallback(async () => {
+ if (effectiveCount < 3 || optimizing) return
+ setOptimizing(true)
+ try {
+ const { userLocation } = useStore.getState()
+ let locations = stops.map((s) => ({ lat: s.lat, lon: s.lon }))
+ if (hasGpsOrigin && userLocation) {
+ locations = [{ lat: userLocation.lat, lon: userLocation.lon }, ...locations]
+ }
+ const data = await requestOptimizedRoute(locations, mode)
+ if (data.trip) {
+ const wpOrder = hasGpsOrigin && userLocation
+ ? (data.trip.locations || []).slice(1)
+ : data.trip.locations
+ if (wpOrder && wpOrder.length === stops.length) {
+ const reordered = wpOrder.map((wp) => {
+ let closest = stops[0]
+ let minDist = Infinity
+ for (const s of stops) {
+ const d = Math.abs(s.lat - wp.lat) + Math.abs(s.lon - wp.lon)
+ if (d < minDist) {
+ minDist = d
+ closest = s
+ }
+ }
+ return closest
+ })
+ const seen = new Set()
+ const unique = reordered.filter((s) => {
+ if (seen.has(s.id)) return false
+ seen.add(s.id)
+ return true
+ })
+ if (unique.length === stops.length) {
+ setStops(unique)
+ }
+ }
+ setRoute(data.trip)
+ }
+ } catch (e) {
+ setRouteError(e.message)
+ } finally {
+ setOptimizing(false)
+ }
+ }, [stops, mode, optimizing, effectiveCount, hasGpsOrigin, setStops, setRoute, setRouteError])
+
+ // Mobile sheet drag handling
+ const handleTouchStart = useCallback((e) => {
+ dragStartY.current = e.touches[0].clientY
+ dragStartState.current = sheetState
+ }, [sheetState])
+
+ const handleTouchEnd = useCallback((e) => {
+ const deltaY = e.changedTouches[0].clientY - dragStartY.current
+ if (Math.abs(deltaY) < 30) return
+ if (deltaY < 0) {
+ if (dragStartState.current === 'collapsed') setSheetState('half')
+ else if (dragStartState.current === 'half') setSheetState('full')
+ } else {
+ if (dragStartState.current === 'full') setSheetState('half')
+ else if (dragStartState.current === 'half') setSheetState('collapsed')
+ }
+ }, [setSheetState])
+
+ const showOptimize = effectiveCount >= 3
+
+ // Determine what to show based on panel state
+ const showPreviewCard = panelState === 'PREVIEW' || panelState === 'PREVIEW_ROUTING'
+ const showRouteSection = panelState === 'ROUTING' || panelState === 'PREVIEW_ROUTING' || panelState === 'ROUTE_CALCULATED'
+ const showManeuvers = panelState === 'ROUTE_CALCULATED'
+ const showEmptyState = panelState === 'IDLE'
+
+ // Routes tab content - now state-driven
+ const routesContent = (
+ <>
+
+
+ {/* Preview card when place is selected */}
+ {showPreviewCard && selectedPlace && (
+
+ )}
+
+ {/* Route section with stops */}
+ {showRouteSection && (
+ <>
+
+
+
+
+
+
+ {showOptimize && (
+
+ {optimizing ? 'Optimizing...' : 'Optimize stop order'}
+
+ )}
+
+ >
+ )}
+
+ {/* Maneuvers when route is calculated */}
+ {showManeuvers && (route || routeLoading || routeError) && (
+
+
+
+ )}
+
+ {/* Empty state */}
+ {showEmptyState && (
+
+
Search or tap the map to explore
+
+ )}
+ >
+ )
+
+ const content = (
+ <>
+ {showContacts && (
+
+ setActiveTab('routes')}
+ >
+ Routes
+
+ setActiveTab('contacts')}
+ >
+ Contacts
+
+
+ )}
+
+ {(!showContacts || activeTab === 'routes') ? routesContent :
}
+ >
+ )
+
+ const header = (
+
+
Navi
+
+ {theme === 'dark' ? : }
+
+
+ )
+
+ // Desktop: side panel (now 360px to accommodate PlaceCard)
+ if (!isMobile) {
+ return (
+
+ {header}
+ {content}
+
+ )
+ }
+
+ // Mobile: bottom sheet
+ const sheetHeights = {
+ collapsed: 'h-12',
+ half: 'h-[45vh]',
+ full: 'h-[85vh]',
+ }
+
+ return (
+
+ {/* Drag handle */}
+
{
+ if (sheetState === 'collapsed') setSheetState('half')
+ else if (sheetState === 'half') setSheetState('full')
+ else setSheetState('half')
+ }}
+ >
+
+
+
+ {sheetState !== 'collapsed' && (
+
+ {header}
+ {content}
+
+ )}
+
+ )
+}
diff --git a/src/components/Panel.jsx.bak.uxfix b/src/components/Panel.jsx.bak.uxfix
new file mode 100644
index 0000000..f46520f
--- /dev/null
+++ b/src/components/Panel.jsx.bak.uxfix
@@ -0,0 +1,283 @@
+import { useRef, useCallback, useEffect, useState } from 'react'
+import { Sun, Moon } from 'lucide-react'
+import { useStore, usePanelState } from '../store'
+import { hasFeature } from '../config'
+import SearchBar from './SearchBar'
+import StopList from './StopList'
+import ModeSelector from './ModeSelector'
+import ManeuverList from './ManeuverList'
+import ContactList from './ContactList'
+import { PlaceCard } from './PlaceCard'
+import { requestOptimizedRoute } from '../api'
+
+export default function Panel({ onManeuverClick }) {
+ const selectedPlace = useStore((s) => s.selectedPlace)
+ const clearSelectedPlace = useStore((s) => s.clearSelectedPlace)
+ const stops = useStore((s) => s.stops)
+ const mode = useStore((s) => s.mode)
+ const route = useStore((s) => s.route)
+ const routeLoading = useStore((s) => s.routeLoading)
+ const routeError = useStore((s) => s.routeError)
+ const setStops = useStore((s) => s.setStops)
+ const setRoute = useStore((s) => s.setRoute)
+ const setRouteError = useStore((s) => s.setRouteError)
+ const setRouteLoading = useStore((s) => s.setRouteLoading)
+ const sheetState = useStore((s) => s.sheetState)
+ const setSheetState = useStore((s) => s.setSheetState)
+ const theme = useStore((s) => s.theme)
+ const themeOverride = useStore((s) => s.themeOverride)
+ const setThemeOverride = useStore((s) => s.setThemeOverride)
+ const gpsOrigin = useStore((s) => s.gpsOrigin)
+ const geoPermission = useStore((s) => s.geoPermission)
+ const activeTab = useStore((s) => s.activeTab)
+ const setActiveTab = useStore((s) => s.setActiveTab)
+
+ const panelState = usePanelState()
+
+ const [isMobile, setIsMobile] = useState(false)
+ const [optimizing, setOptimizing] = useState(false)
+ const sheetRef = useRef(null)
+ const dragStartY = useRef(0)
+ const dragStartState = useRef('half')
+
+ const showContacts = hasFeature('has_contacts')
+
+ // Responsive detection
+ useEffect(() => {
+ const check = () => setIsMobile(window.innerWidth < 768)
+ check()
+ window.addEventListener('resize', check)
+ return () => window.removeEventListener('resize', check)
+ }, [])
+
+ // Theme toggle
+ const toggleTheme = () => {
+ const next = theme === 'dark' ? 'light' : 'dark'
+ setThemeOverride(next)
+ }
+
+ // Optimize stops
+ const hasGpsOrigin = gpsOrigin && geoPermission === 'granted'
+ const effectiveCount = stops.length + (hasGpsOrigin ? 1 : 0)
+
+ const handleOptimize = useCallback(async () => {
+ if (effectiveCount < 3 || optimizing) return
+ setOptimizing(true)
+ try {
+ const { userLocation } = useStore.getState()
+ let locations = stops.map((s) => ({ lat: s.lat, lon: s.lon }))
+ if (hasGpsOrigin && userLocation) {
+ locations = [{ lat: userLocation.lat, lon: userLocation.lon }, ...locations]
+ }
+ const data = await requestOptimizedRoute(locations, mode)
+ if (data.trip) {
+ const wpOrder = hasGpsOrigin && userLocation
+ ? (data.trip.locations || []).slice(1)
+ : data.trip.locations
+ if (wpOrder && wpOrder.length === stops.length) {
+ const reordered = wpOrder.map((wp) => {
+ let closest = stops[0]
+ let minDist = Infinity
+ for (const s of stops) {
+ const d = Math.abs(s.lat - wp.lat) + Math.abs(s.lon - wp.lon)
+ if (d < minDist) {
+ minDist = d
+ closest = s
+ }
+ }
+ return closest
+ })
+ const seen = new Set()
+ const unique = reordered.filter((s) => {
+ if (seen.has(s.id)) return false
+ seen.add(s.id)
+ return true
+ })
+ if (unique.length === stops.length) {
+ setStops(unique)
+ }
+ }
+ setRoute(data.trip)
+ }
+ } catch (e) {
+ setRouteError(e.message)
+ } finally {
+ setOptimizing(false)
+ }
+ }, [stops, mode, optimizing, effectiveCount, hasGpsOrigin, setStops, setRoute, setRouteError])
+
+ // Mobile sheet drag handling
+ const handleTouchStart = useCallback((e) => {
+ dragStartY.current = e.touches[0].clientY
+ dragStartState.current = sheetState
+ }, [sheetState])
+
+ const handleTouchEnd = useCallback((e) => {
+ const deltaY = e.changedTouches[0].clientY - dragStartY.current
+ if (Math.abs(deltaY) < 30) return
+ if (deltaY < 0) {
+ if (dragStartState.current === 'collapsed') setSheetState('half')
+ else if (dragStartState.current === 'half') setSheetState('full')
+ } else {
+ if (dragStartState.current === 'full') setSheetState('half')
+ else if (dragStartState.current === 'half') setSheetState('collapsed')
+ }
+ }, [setSheetState])
+
+ const showOptimize = effectiveCount >= 3
+
+ // Determine what to show based on panel state
+ const showPreviewCard = panelState === 'PREVIEW' || panelState === 'PREVIEW_ROUTING'
+ const showRouteSection = panelState === 'ROUTING' || panelState === 'PREVIEW_ROUTING' || panelState === 'ROUTE_CALCULATED'
+ const showManeuvers = panelState === 'ROUTE_CALCULATED'
+ const showEmptyState = panelState === 'IDLE'
+
+ // Routes tab content - now state-driven
+ const routesContent = (
+ <>
+
+
+ {/* Preview card when place is selected */}
+ {showPreviewCard && selectedPlace && (
+
+ )}
+
+ {/* Route section with stops */}
+ {showRouteSection && (
+ <>
+
+
+
+
+
+
+ {showOptimize && (
+
+ {optimizing ? 'Optimizing...' : 'Optimize stop order'}
+
+ )}
+
+ >
+ )}
+
+ {/* Maneuvers when route is calculated */}
+ {showManeuvers && (route || routeLoading || routeError) && (
+
+
+
+ )}
+
+ {/* Empty state */}
+ {showEmptyState && (
+
+
Search or tap the map to explore
+
+ )}
+ >
+ )
+
+ const content = (
+ <>
+ {showContacts && (
+
+ setActiveTab('routes')}
+ >
+ Routes
+
+ setActiveTab('contacts')}
+ >
+ Contacts
+
+
+ )}
+
+ {(!showContacts || activeTab === 'routes') ? routesContent :
}
+ >
+ )
+
+ const header = (
+
+
Navi
+
+ {theme === 'dark' ? : }
+
+
+ )
+
+ // Desktop: side panel (now 360px to accommodate PlaceCard)
+ if (!isMobile) {
+ return (
+
+ {header}
+ {content}
+
+ )
+ }
+
+ // Mobile: bottom sheet
+ const sheetHeights = {
+ collapsed: 'h-12',
+ half: 'h-[45vh]',
+ full: 'h-[85vh]',
+ }
+
+ return (
+
+ {/* Drag handle */}
+
{
+ if (sheetState === 'collapsed') setSheetState('half')
+ else if (sheetState === 'half') setSheetState('full')
+ else setSheetState('half')
+ }}
+ >
+
+
+
+ {sheetState !== 'collapsed' && (
+
+ {header}
+ {content}
+
+ )}
+
+ )
+}
diff --git a/src/components/Panel.jsx.bak.uxfix2 b/src/components/Panel.jsx.bak.uxfix2
new file mode 100644
index 0000000..f46520f
--- /dev/null
+++ b/src/components/Panel.jsx.bak.uxfix2
@@ -0,0 +1,283 @@
+import { useRef, useCallback, useEffect, useState } from 'react'
+import { Sun, Moon } from 'lucide-react'
+import { useStore, usePanelState } from '../store'
+import { hasFeature } from '../config'
+import SearchBar from './SearchBar'
+import StopList from './StopList'
+import ModeSelector from './ModeSelector'
+import ManeuverList from './ManeuverList'
+import ContactList from './ContactList'
+import { PlaceCard } from './PlaceCard'
+import { requestOptimizedRoute } from '../api'
+
+export default function Panel({ onManeuverClick }) {
+ const selectedPlace = useStore((s) => s.selectedPlace)
+ const clearSelectedPlace = useStore((s) => s.clearSelectedPlace)
+ const stops = useStore((s) => s.stops)
+ const mode = useStore((s) => s.mode)
+ const route = useStore((s) => s.route)
+ const routeLoading = useStore((s) => s.routeLoading)
+ const routeError = useStore((s) => s.routeError)
+ const setStops = useStore((s) => s.setStops)
+ const setRoute = useStore((s) => s.setRoute)
+ const setRouteError = useStore((s) => s.setRouteError)
+ const setRouteLoading = useStore((s) => s.setRouteLoading)
+ const sheetState = useStore((s) => s.sheetState)
+ const setSheetState = useStore((s) => s.setSheetState)
+ const theme = useStore((s) => s.theme)
+ const themeOverride = useStore((s) => s.themeOverride)
+ const setThemeOverride = useStore((s) => s.setThemeOverride)
+ const gpsOrigin = useStore((s) => s.gpsOrigin)
+ const geoPermission = useStore((s) => s.geoPermission)
+ const activeTab = useStore((s) => s.activeTab)
+ const setActiveTab = useStore((s) => s.setActiveTab)
+
+ const panelState = usePanelState()
+
+ const [isMobile, setIsMobile] = useState(false)
+ const [optimizing, setOptimizing] = useState(false)
+ const sheetRef = useRef(null)
+ const dragStartY = useRef(0)
+ const dragStartState = useRef('half')
+
+ const showContacts = hasFeature('has_contacts')
+
+ // Responsive detection
+ useEffect(() => {
+ const check = () => setIsMobile(window.innerWidth < 768)
+ check()
+ window.addEventListener('resize', check)
+ return () => window.removeEventListener('resize', check)
+ }, [])
+
+ // Theme toggle
+ const toggleTheme = () => {
+ const next = theme === 'dark' ? 'light' : 'dark'
+ setThemeOverride(next)
+ }
+
+ // Optimize stops
+ const hasGpsOrigin = gpsOrigin && geoPermission === 'granted'
+ const effectiveCount = stops.length + (hasGpsOrigin ? 1 : 0)
+
+ const handleOptimize = useCallback(async () => {
+ if (effectiveCount < 3 || optimizing) return
+ setOptimizing(true)
+ try {
+ const { userLocation } = useStore.getState()
+ let locations = stops.map((s) => ({ lat: s.lat, lon: s.lon }))
+ if (hasGpsOrigin && userLocation) {
+ locations = [{ lat: userLocation.lat, lon: userLocation.lon }, ...locations]
+ }
+ const data = await requestOptimizedRoute(locations, mode)
+ if (data.trip) {
+ const wpOrder = hasGpsOrigin && userLocation
+ ? (data.trip.locations || []).slice(1)
+ : data.trip.locations
+ if (wpOrder && wpOrder.length === stops.length) {
+ const reordered = wpOrder.map((wp) => {
+ let closest = stops[0]
+ let minDist = Infinity
+ for (const s of stops) {
+ const d = Math.abs(s.lat - wp.lat) + Math.abs(s.lon - wp.lon)
+ if (d < minDist) {
+ minDist = d
+ closest = s
+ }
+ }
+ return closest
+ })
+ const seen = new Set()
+ const unique = reordered.filter((s) => {
+ if (seen.has(s.id)) return false
+ seen.add(s.id)
+ return true
+ })
+ if (unique.length === stops.length) {
+ setStops(unique)
+ }
+ }
+ setRoute(data.trip)
+ }
+ } catch (e) {
+ setRouteError(e.message)
+ } finally {
+ setOptimizing(false)
+ }
+ }, [stops, mode, optimizing, effectiveCount, hasGpsOrigin, setStops, setRoute, setRouteError])
+
+ // Mobile sheet drag handling
+ const handleTouchStart = useCallback((e) => {
+ dragStartY.current = e.touches[0].clientY
+ dragStartState.current = sheetState
+ }, [sheetState])
+
+ const handleTouchEnd = useCallback((e) => {
+ const deltaY = e.changedTouches[0].clientY - dragStartY.current
+ if (Math.abs(deltaY) < 30) return
+ if (deltaY < 0) {
+ if (dragStartState.current === 'collapsed') setSheetState('half')
+ else if (dragStartState.current === 'half') setSheetState('full')
+ } else {
+ if (dragStartState.current === 'full') setSheetState('half')
+ else if (dragStartState.current === 'half') setSheetState('collapsed')
+ }
+ }, [setSheetState])
+
+ const showOptimize = effectiveCount >= 3
+
+ // Determine what to show based on panel state
+ const showPreviewCard = panelState === 'PREVIEW' || panelState === 'PREVIEW_ROUTING'
+ const showRouteSection = panelState === 'ROUTING' || panelState === 'PREVIEW_ROUTING' || panelState === 'ROUTE_CALCULATED'
+ const showManeuvers = panelState === 'ROUTE_CALCULATED'
+ const showEmptyState = panelState === 'IDLE'
+
+ // Routes tab content - now state-driven
+ const routesContent = (
+ <>
+
+
+ {/* Preview card when place is selected */}
+ {showPreviewCard && selectedPlace && (
+
+ )}
+
+ {/* Route section with stops */}
+ {showRouteSection && (
+ <>
+
+
+
+
+
+
+ {showOptimize && (
+
+ {optimizing ? 'Optimizing...' : 'Optimize stop order'}
+
+ )}
+
+ >
+ )}
+
+ {/* Maneuvers when route is calculated */}
+ {showManeuvers && (route || routeLoading || routeError) && (
+
+
+
+ )}
+
+ {/* Empty state */}
+ {showEmptyState && (
+
+
Search or tap the map to explore
+
+ )}
+ >
+ )
+
+ const content = (
+ <>
+ {showContacts && (
+
+ setActiveTab('routes')}
+ >
+ Routes
+
+ setActiveTab('contacts')}
+ >
+ Contacts
+
+
+ )}
+
+ {(!showContacts || activeTab === 'routes') ? routesContent :
}
+ >
+ )
+
+ const header = (
+
+
Navi
+
+ {theme === 'dark' ? : }
+
+
+ )
+
+ // Desktop: side panel (now 360px to accommodate PlaceCard)
+ if (!isMobile) {
+ return (
+
+ {header}
+ {content}
+
+ )
+ }
+
+ // Mobile: bottom sheet
+ const sheetHeights = {
+ collapsed: 'h-12',
+ half: 'h-[45vh]',
+ full: 'h-[85vh]',
+ }
+
+ return (
+
+ {/* Drag handle */}
+
{
+ if (sheetState === 'collapsed') setSheetState('half')
+ else if (sheetState === 'half') setSheetState('full')
+ else setSheetState('half')
+ }}
+ >
+
+
+
+ {sheetState !== 'collapsed' && (
+
+ {header}
+ {content}
+
+ )}
+
+ )
+}
diff --git a/src/components/Panel.jsx.bak.viewport b/src/components/Panel.jsx.bak.viewport
new file mode 100644
index 0000000..d6dd2a0
--- /dev/null
+++ b/src/components/Panel.jsx.bak.viewport
@@ -0,0 +1,283 @@
+import { useRef, useCallback, useEffect, useState } from 'react'
+import { Sun, Moon } from 'lucide-react'
+import { useStore, usePanelState } from '../store'
+import { hasFeature } from '../config'
+import SearchBar from './SearchBar'
+import StopList from './StopList'
+import ModeSelector from './ModeSelector'
+import ManeuverList from './ManeuverList'
+import ContactList from './ContactList'
+import { PlaceCard } from './PlaceCard'
+import { requestOptimizedRoute } from '../api'
+
+export default function Panel({ onManeuverClick }) {
+ const selectedPlace = useStore((s) => s.selectedPlace)
+ const clearSelectedPlace = useStore((s) => s.clearSelectedPlace)
+ const stops = useStore((s) => s.stops)
+ const mode = useStore((s) => s.mode)
+ const route = useStore((s) => s.route)
+ const routeLoading = useStore((s) => s.routeLoading)
+ const routeError = useStore((s) => s.routeError)
+ const setStops = useStore((s) => s.setStops)
+ const setRoute = useStore((s) => s.setRoute)
+ const setRouteError = useStore((s) => s.setRouteError)
+ const setRouteLoading = useStore((s) => s.setRouteLoading)
+ const sheetState = useStore((s) => s.sheetState)
+ const setSheetState = useStore((s) => s.setSheetState)
+ const theme = useStore((s) => s.theme)
+ const themeOverride = useStore((s) => s.themeOverride)
+ const setThemeOverride = useStore((s) => s.setThemeOverride)
+ const gpsOrigin = useStore((s) => s.gpsOrigin)
+ const geoPermission = useStore((s) => s.geoPermission)
+ const activeTab = useStore((s) => s.activeTab)
+ const setActiveTab = useStore((s) => s.setActiveTab)
+
+ const panelState = usePanelState()
+
+ const [isMobile, setIsMobile] = useState(false)
+ const [optimizing, setOptimizing] = useState(false)
+ const sheetRef = useRef(null)
+ const dragStartY = useRef(0)
+ const dragStartState = useRef('half')
+
+ const showContacts = hasFeature('has_contacts')
+
+ // Responsive detection
+ useEffect(() => {
+ const check = () => setIsMobile(window.innerWidth < 768)
+ check()
+ window.addEventListener('resize', check)
+ return () => window.removeEventListener('resize', check)
+ }, [])
+
+ // Theme toggle
+ const toggleTheme = () => {
+ const next = theme === 'dark' ? 'light' : 'dark'
+ setThemeOverride(next)
+ }
+
+ // Optimize stops
+ const hasGpsOrigin = gpsOrigin && geoPermission === 'granted'
+ const effectiveCount = stops.length + (hasGpsOrigin ? 1 : 0)
+
+ const handleOptimize = useCallback(async () => {
+ if (effectiveCount < 3 || optimizing) return
+ setOptimizing(true)
+ try {
+ const { userLocation } = useStore.getState()
+ let locations = stops.map((s) => ({ lat: s.lat, lon: s.lon }))
+ if (hasGpsOrigin && userLocation) {
+ locations = [{ lat: userLocation.lat, lon: userLocation.lon }, ...locations]
+ }
+ const data = await requestOptimizedRoute(locations, mode)
+ if (data.trip) {
+ const wpOrder = hasGpsOrigin && userLocation
+ ? (data.trip.locations || []).slice(1)
+ : data.trip.locations
+ if (wpOrder && wpOrder.length === stops.length) {
+ const reordered = wpOrder.map((wp) => {
+ let closest = stops[0]
+ let minDist = Infinity
+ for (const s of stops) {
+ const d = Math.abs(s.lat - wp.lat) + Math.abs(s.lon - wp.lon)
+ if (d < minDist) {
+ minDist = d
+ closest = s
+ }
+ }
+ return closest
+ })
+ const seen = new Set()
+ const unique = reordered.filter((s) => {
+ if (seen.has(s.id)) return false
+ seen.add(s.id)
+ return true
+ })
+ if (unique.length === stops.length) {
+ setStops(unique)
+ }
+ }
+ setRoute(data.trip)
+ }
+ } catch (e) {
+ setRouteError(e.message)
+ } finally {
+ setOptimizing(false)
+ }
+ }, [stops, mode, optimizing, effectiveCount, hasGpsOrigin, setStops, setRoute, setRouteError])
+
+ // Mobile sheet drag handling
+ const handleTouchStart = useCallback((e) => {
+ dragStartY.current = e.touches[0].clientY
+ dragStartState.current = sheetState
+ }, [sheetState])
+
+ const handleTouchEnd = useCallback((e) => {
+ const deltaY = e.changedTouches[0].clientY - dragStartY.current
+ if (Math.abs(deltaY) < 30) return
+ if (deltaY < 0) {
+ if (dragStartState.current === 'collapsed') setSheetState('half')
+ else if (dragStartState.current === 'half') setSheetState('full')
+ } else {
+ if (dragStartState.current === 'full') setSheetState('half')
+ else if (dragStartState.current === 'half') setSheetState('collapsed')
+ }
+ }, [setSheetState])
+
+ const showOptimize = effectiveCount >= 3
+
+ // Determine what to show based on panel state
+ const showPreviewCard = panelState.startsWith('PREVIEW')
+ const showRouteSection = ['ROUTING', 'ROUTE_CALCULATED', 'PREVIEW_ROUTING', 'PREVIEW_CALCULATED'].includes(panelState)
+ const showManeuvers = panelState === 'ROUTE_CALCULATED' || panelState === 'PREVIEW_CALCULATED'
+ const showEmptyState = panelState === 'IDLE'
+
+ // Routes tab content - now state-driven
+ const routesContent = (
+ <>
+
+
+ {/* Preview card when place is selected */}
+ {showPreviewCard && selectedPlace && (
+
+ )}
+
+ {/* Route section with stops */}
+ {showRouteSection && (
+ <>
+
+
+
+
+
+
+ {showOptimize && (
+
+ {optimizing ? 'Optimizing...' : 'Optimize stop order'}
+
+ )}
+
+ >
+ )}
+
+ {/* Maneuvers when route is calculated */}
+ {showManeuvers && (route || routeLoading || routeError) && (
+
+
+
+ )}
+
+ {/* Empty state */}
+ {showEmptyState && (
+
+
Search or tap the map to explore
+
+ )}
+ >
+ )
+
+ const content = (
+ <>
+ {showContacts && (
+
+ setActiveTab('routes')}
+ >
+ Routes
+
+ setActiveTab('contacts')}
+ >
+ Contacts
+
+
+ )}
+
+ {(!showContacts || activeTab === 'routes') ? routesContent :
}
+ >
+ )
+
+ const header = (
+
+
Navi
+
+ {theme === 'dark' ? : }
+
+
+ )
+
+ // Desktop: side panel (now 360px to accommodate PlaceCard)
+ if (!isMobile) {
+ return (
+
+ {header}
+ {content}
+
+ )
+ }
+
+ // Mobile: bottom sheet
+ const sheetHeights = {
+ collapsed: 'h-12',
+ half: 'h-[45vh]',
+ full: 'h-[85vh]',
+ }
+
+ return (
+
+ {/* Drag handle */}
+
{
+ if (sheetState === 'collapsed') setSheetState('half')
+ else if (sheetState === 'half') setSheetState('full')
+ else setSheetState('half')
+ }}
+ >
+
+
+
+ {sheetState !== 'collapsed' && (
+
+ {header}
+ {content}
+
+ )}
+
+ )
+}
diff --git a/src/components/PlaceCard.jsx b/src/components/PlaceCard.jsx
index 521d4f0..17a5131 100644
--- a/src/components/PlaceCard.jsx
+++ b/src/components/PlaceCard.jsx
@@ -1,6 +1,6 @@
import { useEffect, useState, useRef, useCallback } from "react"
import {
- X, Navigation, Plus, Bookmark, ChevronDown, ChevronUp, Copy,
+ X, Navigation, Plus, Bookmark, ChevronDown, ChevronUp, Copy, LogIn,
Clock, Phone, Globe, Mail, BookOpen, Info, Trees, GripVertical,
} from "lucide-react"
import OpeningHours from "opening_hours"
@@ -245,7 +245,15 @@ function CopyPopover({ address, place, onClose }) {
}
export function PlaceCard({ place, variant = "preview", expanded = true, onToggleExpand, onClose, onRemove, stopIndex, draggable = false, dragHandleProps = {} }) {
- const { contacts, userLocation, stops, geoPermission, addStop, startDirections, clearSelectedPlace, setEditingContact } = useStore()
+ const contacts = useStore((s) => s.contacts)
+ const userLocation = useStore((s) => s.userLocation)
+ const stops = useStore((s) => s.stops)
+ const geoPermission = useStore((s) => s.geoPermission)
+ const addStop = useStore((s) => s.addStop)
+ const startDirections = useStore((s) => s.startDirections)
+ const clearSelectedPlace = useStore((s) => s.clearSelectedPlace)
+ const setEditingContact = useStore((s) => s.setEditingContact)
+ const auth = useStore((s) => s.auth)
const [elevResult, setElevResult] = useState({ lat: null, lon: null, value: null })
const [placeDetails, setPlaceDetails] = useState(null)
const [driveTime, setDriveTime] = useState(null)
@@ -421,7 +429,11 @@ export function PlaceCard({ place, variant = "preview", expanded = true, onToggl
>
)}
{variant === "stop" && onRemove &&
Remove}
-
+ {auth.authenticated ? (
+
+ ) : (
+
{ window.location.href = "/api/auth/whoami" }} className="flex items-center gap-1 px-2 py-1.5 rounded-lg text-xs" style={{ background: "var(--accent-muted)", color: "var(--accent)", border: "1px solid var(--border)" }} title="Log in to save places">Save
+ )}
setCopyOpen((v) => !v)} className="p-2 rounded-lg flex items-center gap-0.5" style={{ background: "var(--tan-muted)", color: "var(--tan)", border: "1px solid var(--border)" }} aria-label="Copy">
{copyOpen &&
}
diff --git a/src/components/PlaceCard.jsx.bak.uxfix b/src/components/PlaceCard.jsx.bak.uxfix
new file mode 100644
index 0000000..83aae38
--- /dev/null
+++ b/src/components/PlaceCard.jsx.bak.uxfix
@@ -0,0 +1,434 @@
+import { useEffect, useState, useRef, useCallback } from "react"
+import {
+ X, Navigation, Plus, Bookmark, ChevronDown, ChevronUp, Copy,
+ Clock, Phone, Globe, Mail, BookOpen, Info, Trees, GripVertical,
+} from "lucide-react"
+import OpeningHours from "opening_hours"
+import toast from "react-hot-toast"
+import { useStore } from "../store"
+import { fetchElevation, fetchPlaceDetails, fetchPlaceByWikidata, fetchDriveTime, fetchNearbyContacts, fetchLandclass } from "../api"
+import { hasFeature } from "../config"
+import { buildAddress } from "../utils/place"
+
+const M_TO_FT = 3.28084
+
+function formatDriveTime(seconds) {
+ const mins = Math.round(seconds / 60)
+ if (mins < 2) return "< 2 min"
+ if (mins < 120) return `${mins} min`
+ const h = Math.floor(mins / 60)
+ const m = mins % 60
+ return m > 0 ? `${h}h ${m}m` : `${h}h`
+}
+
+const DAY_SHORT = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]
+
+function parseHours(hoursStr) {
+ try {
+ const oh = new OpeningHours(hoursStr, { address: { country_code: "us", state: "Idaho" } })
+ const now = new Date()
+ const isOpen = oh.getState(now)
+ const nextChange = oh.getNextChange(now)
+ let todayStr = ""
+ if (isOpen) {
+ todayStr = "Open now"
+ if (nextChange) {
+ const closeTime = nextChange.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" })
+ todayStr += " \u00b7 Closes " + closeTime
+ }
+ } else {
+ todayStr = "Closed"
+ if (nextChange) {
+ const openTime = nextChange.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" })
+ const isTodayOpen = nextChange.getDate() === now.getDate()
+ todayStr += " \u00b7 Opens " + (isTodayOpen ? "at " : "tomorrow ") + openTime
+ }
+ }
+ const week = []
+ for (let d = 0; d < 7; d++) {
+ const date = new Date(now)
+ const diff = (d - now.getDay() + 7) % 7
+ date.setDate(now.getDate() + diff)
+ date.setHours(0, 0, 0, 0)
+ const intervals = oh.getOpenIntervals(date, new Date(date.getTime() + 86400000))
+ if (intervals.length === 0) {
+ week.push({ day: DAY_SHORT[d], hours: "Closed", isTodayRow: d === now.getDay() })
+ } else {
+ const parts = intervals.map(([start, end]) => {
+ const s = start.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" })
+ const e = end.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" })
+ return s + " \u2013 " + e
+ })
+ week.push({ day: DAY_SHORT[d], hours: parts.join(", "), isTodayRow: d === now.getDay() })
+ }
+ }
+ return { isOpen, todayStr, week }
+ } catch {
+ return null
+ }
+}
+
+function formatPhone(phone) {
+ if (!phone) return null
+ const digits = phone.replace(/[^\d]/g, "")
+ if (digits.length === 11 && digits[0] === "1") {
+ return "(" + digits.slice(1, 4) + ") " + digits.slice(4, 7) + "-" + digits.slice(7)
+ }
+ if (digits.length === 10) {
+ return "(" + digits.slice(0, 3) + ") " + digits.slice(3, 6) + "-" + digits.slice(6)
+ }
+ return phone
+}
+
+function wheelchairLabel(val) {
+ if (!val) return null
+ const map = { yes: "Accessible", limited: "Limited access", no: "Not accessible" }
+ return map[val.toLowerCase()] || null
+}
+
+function wikiUrl(wp) {
+ if (!wp) return null
+ const [lang, ...rest] = wp.split(":")
+ const title = rest.join(":").replace(/ /g, "_")
+ return "https://" + lang + ".wikipedia.org/wiki/" + encodeURIComponent(title)
+}
+
+function wikiLabel(wp) {
+ if (!wp) return null
+ const [, ...rest] = wp.split(":")
+ return rest.join(":").replace(/_/g, " ")
+}
+
+function DetailSection({ label, icon: Icon, first, children }) {
+ return (
+
+
+
+ {label}
+
+ {children}
+
+ )
+}
+
+function HoursDisplay({ hoursStr, first }) {
+ const [expanded, setExpanded] = useState(false)
+ const parsed = parseHours(hoursStr)
+ if (!parsed) return null
+ const { isOpen, todayStr, week } = parsed
+ return (
+
+ setExpanded((v) => !v)} className="w-full flex items-center justify-between text-xs" style={{ color: "var(--text-primary)" }}>
+ {todayStr}
+ {expanded ? : }
+
+ {expanded && (
+
+ {week.map((w) => (
+
+ {w.day}
+ {w.hours}
+
+ ))}
+
+ )}
+
+ )
+}
+
+function LandclassSection({ data }) {
+ if (!data || !data.summary) return null
+ return (
+
+
+
+ {data.summary}
+ {data.unit_name && {data.unit_name} }
+
+
+ )
+}
+
+function PrivateLandIndicator({ data }) {
+ if (!data || data.gap_status !== "4") return null
+ return (
+
+ Private land — permission required
+
+ )
+}
+
+function EnrichmentSkeleton() {
+ return (
+
+ )
+}
+
+function EnrichmentSections({ details }) {
+ if (!details) return null
+ const { category, extratags } = details
+ const et = extratags || {}
+ const hasAbout = category
+ const hasHours = et.opening_hours
+ const hasContact = et.phone || et.website || et.email
+ const hasDetails = et.cuisine || et.operator || et.fee || et.wheelchair || et.takeaway
+ const hasLinks = et.wikipedia || et.wikidata
+ if (!hasAbout && !hasHours && !hasContact && !hasDetails && !hasLinks) return null
+ let idx = 0
+ return (
+
+ {hasAbout && (
+
+ {category}
+
+ )}
+ {hasHours &&
}
+ {hasContact && (
+
+
+
+ )}
+ {hasDetails && (
+
+
+ {et.cuisine && Cuisine: {et.cuisine.replace(/_/g, " ").replace(/;/g, ", ")} }
+ {et.operator && Operated by {et.operator} }
+ {et.fee && {et.fee === "no" ? "Free" : "Fee: " + et.fee} }
+ {et.wheelchair && wheelchairLabel(et.wheelchair) && {wheelchairLabel(et.wheelchair)} }
+ {et.takeaway === "yes" && Takeaway available }
+
+
+ )}
+ {hasLinks && (
+
+
+
+ )}
+
+ )
+}
+
+function CopyPopover({ address, place, onClose }) {
+ const ref = useRef(null)
+ useEffect(() => {
+ function handleClick(e) { if (ref.current && !ref.current.contains(e.target)) onClose() }
+ document.addEventListener("mousedown", handleClick)
+ return () => document.removeEventListener("mousedown", handleClick)
+ }, [onClose])
+ const copyAddress = () => {
+ const text = [place.name, address].filter(Boolean).join("\n")
+ navigator.clipboard.writeText(text).then(() => toast("Address copied"), () => toast.error("Failed to copy"))
+ onClose()
+ }
+ const copyCoords = () => {
+ const text = place.lat.toFixed(6) + ", " + place.lon.toFixed(6)
+ navigator.clipboard.writeText(text).then(() => toast("Coordinates copied"), () => toast.error("Failed to copy"))
+ onClose()
+ }
+ return (
+
+ Address
+ Coordinates
+
+ )
+}
+
+export function PlaceCard({ place, variant = "preview", expanded = true, onToggleExpand, onClose, onRemove, stopIndex, draggable = false, dragHandleProps = {} }) {
+ const { contacts, userLocation, stops, geoPermission, addStop, startDirections, clearSelectedPlace, setEditingContact } = useStore()
+ const [elevResult, setElevResult] = useState({ lat: null, lon: null, value: null })
+ const [placeDetails, setPlaceDetails] = useState(null)
+ const [driveTime, setDriveTime] = useState(null)
+ const [nearbyLabel, setNearbyLabel] = useState(null)
+ const [landclass, setLandclass] = useState(null)
+ const [copyOpen, setCopyOpen] = useState(false)
+
+ const placeLat = place?.lat
+ const placeLon = place?.lon
+ const osmType = place?.raw?.osm_type
+ const osmId = place?.raw?.osm_id
+ const wikidataId = place?.wikidata || place?.raw?.wikidata
+
+ useEffect(() => {
+ if (placeLat == null || placeLon == null) return
+ let cancelled = false
+ fetchElevation(placeLat, placeLon).then((h) => { if (!cancelled) setElevResult({ lat: placeLat, lon: placeLon, value: h }) })
+ return () => { cancelled = true }
+ }, [placeLat, placeLon])
+
+ useEffect(() => {
+ if (!hasFeature("has_nominatim_details") || !osmType || !osmId) { setPlaceDetails(null); return }
+ const controller = new AbortController()
+ setPlaceDetails("loading")
+ fetchPlaceDetails(osmType, osmId, controller.signal).then((data) => {
+ if (!controller.signal.aborted) {
+ setPlaceDetails(data || null)
+ if (data?.boundary) {
+ const current = useStore.getState().selectedPlace
+ if (current && current.lat === placeLat && current.lon === placeLon) {
+ useStore.getState().setSelectedPlace({ ...current, boundary: data.boundary })
+ }
+ }
+ }
+ })
+ return () => controller.abort()
+ }, [osmType, osmId, placeLat, placeLon])
+
+ useEffect(() => {
+ if (osmType && osmId) return
+ if (!wikidataId) return
+ const controller = new AbortController()
+ fetchPlaceByWikidata(wikidataId, controller.signal).then((data) => {
+ if (!controller.signal.aborted && data) {
+ setPlaceDetails((prev) => ({
+ ...(prev === "loading" ? {} : prev || {}),
+ description: data.description,
+ population: data.population,
+ osm_relation_id: data.osm_relation_id,
+ extratags: { ...(prev && prev !== "loading" ? prev.extratags : {}), ...data.extratags },
+ }))
+ if (data?.boundary) {
+ const current = useStore.getState().selectedPlace
+ if (current && current.lat === placeLat && current.lon === placeLon) {
+ useStore.getState().setSelectedPlace({ ...current, boundary: data.boundary })
+ }
+ }
+ }
+ })
+ return () => controller.abort()
+ }, [wikidataId, osmType, osmId, placeLat, placeLon])
+
+ useEffect(() => {
+ if (variant !== "preview" || !userLocation || placeLat == null || placeLon == null) { setDriveTime(null); return }
+ setDriveTime(null)
+ const controller = new AbortController()
+ const timeout = setTimeout(() => controller.abort(), 3000)
+ fetchDriveTime(userLocation.lat, userLocation.lon, placeLat, placeLon, controller.signal).then((time) => { if (!controller.signal.aborted) setDriveTime(time) })
+ return () => { controller.abort(); clearTimeout(timeout) }
+ }, [variant, userLocation?.lat, userLocation?.lon, placeLat, placeLon])
+
+ useEffect(() => {
+ if (!hasFeature("has_contacts") || placeLat == null || placeLon == null) { setNearbyLabel(null); return }
+ const controller = new AbortController()
+ fetchNearbyContacts(placeLat, placeLon, 75, controller.signal).then((nearby) => {
+ if (!controller.signal.aborted && nearby.length > 0) setNearbyLabel(nearby[0].label)
+ else if (!controller.signal.aborted) setNearbyLabel(null)
+ })
+ return () => controller.abort()
+ }, [placeLat, placeLon])
+
+ useEffect(() => {
+ if (!hasFeature("has_landclass") || placeLat == null || placeLon == null) { setLandclass(null); return }
+ const controller = new AbortController()
+ fetchLandclass(placeLat, placeLon, controller.signal).then((data) => {
+ if (!controller.signal.aborted && data) {
+ setLandclass(data)
+ if (data.summary && useStore.getState().selectedPlace?.name === "Dropped pin") {
+ const current = useStore.getState().selectedPlace
+ useStore.getState().setSelectedPlace({ ...current, name: data.summary })
+ }
+ } else if (!controller.signal.aborted) setLandclass(null)
+ })
+ return () => controller.abort()
+ }, [placeLat, placeLon])
+
+ if (!place) return null
+
+ const address = buildAddress(place)
+ const elevLoading = placeLat != null && (elevResult.lat !== placeLat || elevResult.lon !== placeLon)
+ const elevation = !elevLoading ? elevResult.value : null
+ const elevFeet = elevation != null ? Math.round(elevation * M_TO_FT) : null
+ const existingStopIndex = stops.findIndex((s) => s.lat === place.lat && s.lon === place.lon)
+ const savedContact = contacts.find((c) => c.lat === place.lat && c.lon === place.lon)
+
+ const handleDirections = () => {
+ startDirections(place)
+ if (geoPermission !== "granted" && stops.length === 0) toast("Set a starting point to get directions", { icon: "\u{1F4CD}" })
+ }
+ const handleAddStop = () => {
+ addStop({ lat: place.lat, lon: place.lon, name: place.name, source: place.source, matchCode: place.matchCode })
+ clearSelectedPlace()
+ }
+ const handleSave = () => {
+ if (!hasFeature("has_contacts")) { toast("Saved places coming soon"); return }
+ if (savedContact) setEditingContact(savedContact)
+ else setEditingContact({ label: "", lat: place.lat, lon: place.lon, osm_type: osmType || null, osm_id: osmId || null, address: address || "", name: place.type === "poi" && place.raw?.name ? place.raw.name : "" })
+ }
+ const closeCopy = useCallback(() => setCopyOpen(false), [])
+ const stopLetter = stopIndex != null ? String.fromCharCode(65 + stopIndex) : null
+
+ if (!expanded) {
+ return (
+
+ {draggable &&
}
+ {stopLetter &&
{stopLetter}
}
+
{place.name || "Unknown place"}
+
+ {onRemove &&
{ e.stopPropagation(); onRemove() }} className="p-1 rounded hover:opacity-80" style={{ color: "var(--text-tertiary)" }}> }
+
+ )
+ }
+
+ return (
+
+
+
+ {draggable &&
}
+ {stopLetter &&
{stopLetter}
}
+
+
{place.name || "Unknown place"}
+
+ {place.type && {place.type} }
+ {driveTime != null && <>{"\u00b7"} {formatDriveTime(driveTime)} drive >}
+ {nearbyLabel && <>{"\u00b7"} Near {nearbyLabel} >}
+
+
+
+
+ {onToggleExpand && variant === "stop" && }
+ {onClose && }
+
+
+ {address &&
{address}
}
+
+ {place.lat.toFixed(6)}, {place.lon.toFixed(6)}
+ {"\u00b7"}
+ {elevLoading ? "..." : elevFeet != null ? elevFeet.toLocaleString() + " ft" : "\u2014"}
+
+
+
+ {placeDetails === "loading" &&
}
+ {placeDetails && placeDetails !== "loading" &&
}
+
+ {variant === "preview" && (
+ <>
+
Directions
+ {existingStopIndex >= 0 ? (
+
Stop {String.fromCharCode(65 + existingStopIndex)}
+ ) : (
+
Add stop
+ )}
+ >
+ )}
+ {variant === "stop" && onRemove &&
Remove}
+
+
+ setCopyOpen((v) => !v)} className="p-2 rounded-lg flex items-center gap-0.5" style={{ background: "var(--tan-muted)", color: "var(--tan)", border: "1px solid var(--border)" }} aria-label="Copy">
+ {copyOpen && }
+
+
+
+ )
+}
+
+export default PlaceCard
diff --git a/src/components/PlaceCard.jsx.bak.uxfix2 b/src/components/PlaceCard.jsx.bak.uxfix2
new file mode 100644
index 0000000..868a644
--- /dev/null
+++ b/src/components/PlaceCard.jsx.bak.uxfix2
@@ -0,0 +1,434 @@
+import { useEffect, useState, useRef, useCallback } from "react"
+import {
+ X, Navigation, Plus, Bookmark, ChevronDown, ChevronUp, Copy,
+ Clock, Phone, Globe, Mail, BookOpen, Info, Trees, GripVertical,
+} from "lucide-react"
+import OpeningHours from "opening_hours"
+import toast from "react-hot-toast"
+import { useStore } from "../store"
+import { fetchElevation, fetchPlaceDetails, fetchPlaceByWikidata, fetchDriveTime, fetchNearbyContacts, fetchLandclass } from "../api"
+import { hasFeature } from "../config"
+import { buildAddress } from "../utils/place"
+
+const M_TO_FT = 3.28084
+
+function formatDriveTime(seconds) {
+ const mins = Math.round(seconds / 60)
+ if (mins < 2) return "< 2 min"
+ if (mins < 120) return `${mins} min`
+ const h = Math.floor(mins / 60)
+ const m = mins % 60
+ return m > 0 ? `${h}h ${m}m` : `${h}h`
+}
+
+const DAY_SHORT = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]
+
+function parseHours(hoursStr) {
+ try {
+ const oh = new OpeningHours(hoursStr, { address: { country_code: "us", state: "Idaho" } })
+ const now = new Date()
+ const isOpen = oh.getState(now)
+ const nextChange = oh.getNextChange(now)
+ let todayStr = ""
+ if (isOpen) {
+ todayStr = "Open now"
+ if (nextChange) {
+ const closeTime = nextChange.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" })
+ todayStr += " \u00b7 Closes " + closeTime
+ }
+ } else {
+ todayStr = "Closed"
+ if (nextChange) {
+ const openTime = nextChange.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" })
+ const isTodayOpen = nextChange.getDate() === now.getDate()
+ todayStr += " \u00b7 Opens " + (isTodayOpen ? "at " : "tomorrow ") + openTime
+ }
+ }
+ const week = []
+ for (let d = 0; d < 7; d++) {
+ const date = new Date(now)
+ const diff = (d - now.getDay() + 7) % 7
+ date.setDate(now.getDate() + diff)
+ date.setHours(0, 0, 0, 0)
+ const intervals = oh.getOpenIntervals(date, new Date(date.getTime() + 86400000))
+ if (intervals.length === 0) {
+ week.push({ day: DAY_SHORT[d], hours: "Closed", isTodayRow: d === now.getDay() })
+ } else {
+ const parts = intervals.map(([start, end]) => {
+ const s = start.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" })
+ const e = end.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" })
+ return s + " \u2013 " + e
+ })
+ week.push({ day: DAY_SHORT[d], hours: parts.join(", "), isTodayRow: d === now.getDay() })
+ }
+ }
+ return { isOpen, todayStr, week }
+ } catch {
+ return null
+ }
+}
+
+function formatPhone(phone) {
+ if (!phone) return null
+ const digits = phone.replace(/[^\d]/g, "")
+ if (digits.length === 11 && digits[0] === "1") {
+ return "(" + digits.slice(1, 4) + ") " + digits.slice(4, 7) + "-" + digits.slice(7)
+ }
+ if (digits.length === 10) {
+ return "(" + digits.slice(0, 3) + ") " + digits.slice(3, 6) + "-" + digits.slice(6)
+ }
+ return phone
+}
+
+function wheelchairLabel(val) {
+ if (!val) return null
+ const map = { yes: "Accessible", limited: "Limited access", no: "Not accessible" }
+ return map[val.toLowerCase()] || null
+}
+
+function wikiUrl(wp) {
+ if (!wp) return null
+ const [lang, ...rest] = wp.split(":")
+ const title = rest.join(":").replace(/ /g, "_")
+ return "https://" + lang + ".wikipedia.org/wiki/" + encodeURIComponent(title)
+}
+
+function wikiLabel(wp) {
+ if (!wp) return null
+ const [, ...rest] = wp.split(":")
+ return rest.join(":").replace(/_/g, " ")
+}
+
+function DetailSection({ label, icon: Icon, first, children }) {
+ return (
+
+
+
+ {label}
+
+ {children}
+
+ )
+}
+
+function HoursDisplay({ hoursStr, first }) {
+ const [expanded, setExpanded] = useState(false)
+ const parsed = parseHours(hoursStr)
+ if (!parsed) return null
+ const { isOpen, todayStr, week } = parsed
+ return (
+
+ setExpanded((v) => !v)} className="w-full flex items-center justify-between text-xs" style={{ color: "var(--text-primary)" }}>
+ {todayStr}
+ {expanded ? : }
+
+ {expanded && (
+
+ {week.map((w) => (
+
+ {w.day}
+ {w.hours}
+
+ ))}
+
+ )}
+
+ )
+}
+
+function LandclassSection({ data }) {
+ if (!data || !data.summary) return null
+ return (
+
+
+
+ {data.summary}
+ {data.unit_name && {data.unit_name} }
+
+
+ )
+}
+
+function PrivateLandIndicator({ data }) {
+ if (!data || data.gap_status !== "4") return null
+ return (
+
+ Private land — permission required
+
+ )
+}
+
+function EnrichmentSkeleton() {
+ return (
+
+ )
+}
+
+function EnrichmentSections({ details }) {
+ if (!details) return null
+ const { category, extratags } = details
+ const et = extratags || {}
+ const hasAbout = category
+ const hasHours = et.opening_hours
+ const hasContact = et.phone || et.website || et.email
+ const hasDetails = et.cuisine || et.operator || et.fee || et.wheelchair || et.takeaway
+ const hasLinks = et.wikipedia || et.wikidata
+ if (!hasAbout && !hasHours && !hasContact && !hasDetails && !hasLinks) return null
+ let idx = 0
+ return (
+
+ {hasAbout && (
+
+ {category}
+
+ )}
+ {hasHours &&
}
+ {hasContact && (
+
+
+
+ )}
+ {hasDetails && (
+
+
+ {et.cuisine && Cuisine: {et.cuisine.replace(/_/g, " ").replace(/;/g, ", ")} }
+ {et.operator && Operated by {et.operator} }
+ {et.fee && {et.fee === "no" ? "Free" : "Fee: " + et.fee} }
+ {et.wheelchair && wheelchairLabel(et.wheelchair) && {wheelchairLabel(et.wheelchair)} }
+ {et.takeaway === "yes" && Takeaway available }
+
+
+ )}
+ {hasLinks && (
+
+
+
+ )}
+
+ )
+}
+
+function CopyPopover({ address, place, onClose }) {
+ const ref = useRef(null)
+ useEffect(() => {
+ function handleClick(e) { if (ref.current && !ref.current.contains(e.target)) onClose() }
+ document.addEventListener("mousedown", handleClick)
+ return () => document.removeEventListener("mousedown", handleClick)
+ }, [onClose])
+ const copyAddress = () => {
+ const text = [place.name, address].filter(Boolean).join("\n")
+ navigator.clipboard.writeText(text).then(() => toast("Address copied"), () => toast.error("Failed to copy"))
+ onClose()
+ }
+ const copyCoords = () => {
+ const text = place.lat.toFixed(6) + ", " + place.lon.toFixed(6)
+ navigator.clipboard.writeText(text).then(() => toast("Coordinates copied"), () => toast.error("Failed to copy"))
+ onClose()
+ }
+ return (
+
+ Address
+ Coordinates
+
+ )
+}
+
+export function PlaceCard({ place, variant = "preview", expanded = true, onToggleExpand, onClose, onRemove, stopIndex, draggable = false, dragHandleProps = {} }) {
+ const { contacts, userLocation, stops, geoPermission, addStop, startDirections, clearSelectedPlace, setEditingContact } = useStore()
+ const [elevResult, setElevResult] = useState({ lat: null, lon: null, value: null })
+ const [placeDetails, setPlaceDetails] = useState(null)
+ const [driveTime, setDriveTime] = useState(null)
+ const [nearbyLabel, setNearbyLabel] = useState(null)
+ const [landclass, setLandclass] = useState(null)
+ const [copyOpen, setCopyOpen] = useState(false)
+
+ const placeLat = place?.lat
+ const placeLon = place?.lon
+ const osmType = place?.raw?.osm_type
+ const osmId = place?.raw?.osm_id
+ const wikidataId = place?.wikidata || place?.raw?.wikidata
+
+ useEffect(() => {
+ if (placeLat == null || placeLon == null) return
+ let cancelled = false
+ fetchElevation(placeLat, placeLon).then((h) => { if (!cancelled) setElevResult({ lat: placeLat, lon: placeLon, value: h }) })
+ return () => { cancelled = true }
+ }, [placeLat, placeLon])
+
+ useEffect(() => {
+ if (!hasFeature("has_nominatim_details") || !osmType || !osmId) { setPlaceDetails(null); return }
+ const controller = new AbortController()
+ setPlaceDetails("loading")
+ fetchPlaceDetails(osmType, osmId, controller.signal).then((data) => {
+ if (!controller.signal.aborted) {
+ setPlaceDetails(data || null)
+ if (data?.boundary) {
+ const current = useStore.getState().selectedPlace
+ if (current && current.lat === placeLat && current.lon === placeLon) {
+ useStore.getState().setSelectedPlace({ ...current, boundary: data.boundary })
+ }
+ }
+ }
+ })
+ return () => controller.abort()
+ }, [osmType, osmId, placeLat, placeLon])
+
+ useEffect(() => {
+ if (osmType && osmId) return
+ if (!wikidataId) return
+ const controller = new AbortController()
+ fetchPlaceByWikidata(wikidataId, controller.signal).then((data) => {
+ if (!controller.signal.aborted && data) {
+ setPlaceDetails((prev) => ({
+ ...(prev === "loading" ? {} : prev || {}),
+ description: data.description,
+ population: data.population,
+ osm_relation_id: data.osm_relation_id,
+ extratags: { ...(prev && prev !== "loading" ? prev.extratags : {}), ...data.extratags },
+ }))
+ if (data?.boundary) {
+ const current = useStore.getState().selectedPlace
+ if (current && current.lat === placeLat && current.lon === placeLon) {
+ useStore.getState().setSelectedPlace({ ...current, boundary: data.boundary })
+ }
+ }
+ }
+ })
+ return () => controller.abort()
+ }, [wikidataId, osmType, osmId, placeLat, placeLon])
+
+ useEffect(() => {
+ if (variant !== "preview" || !userLocation || placeLat == null || placeLon == null) { setDriveTime(null); return }
+ setDriveTime(null)
+ const controller = new AbortController()
+ const timeout = setTimeout(() => controller.abort(), 3000)
+ fetchDriveTime(userLocation.lat, userLocation.lon, placeLat, placeLon, controller.signal).then((time) => { if (!controller.signal.aborted) setDriveTime(time) })
+ return () => { controller.abort(); clearTimeout(timeout) }
+ }, [variant, userLocation?.lat, userLocation?.lon, placeLat, placeLon])
+
+ useEffect(() => {
+ if (!hasFeature("has_contacts") || placeLat == null || placeLon == null) { setNearbyLabel(null); return }
+ const controller = new AbortController()
+ fetchNearbyContacts(placeLat, placeLon, 75, controller.signal).then((nearby) => {
+ if (!controller.signal.aborted && nearby.length > 0) setNearbyLabel(nearby[0].label)
+ else if (!controller.signal.aborted) setNearbyLabel(null)
+ })
+ return () => controller.abort()
+ }, [placeLat, placeLon])
+
+ useEffect(() => {
+ if (!hasFeature("has_landclass") || placeLat == null || placeLon == null) { setLandclass(null); return }
+ const controller = new AbortController()
+ fetchLandclass(placeLat, placeLon, controller.signal).then((data) => {
+ if (!controller.signal.aborted && data) {
+ setLandclass(data)
+ if (data.summary && useStore.getState().selectedPlace?.name === "Dropped pin") {
+ const current = useStore.getState().selectedPlace
+ useStore.getState().setSelectedPlace({ ...current, name: data.summary })
+ }
+ } else if (!controller.signal.aborted) setLandclass(null)
+ })
+ return () => controller.abort()
+ }, [placeLat, placeLon])
+
+ if (!place) return null
+
+ const address = buildAddress(place)
+ const elevLoading = placeLat != null && (elevResult.lat !== placeLat || elevResult.lon !== placeLon)
+ const elevation = !elevLoading ? elevResult.value : null
+ const elevFeet = elevation != null ? Math.round(elevation * M_TO_FT) : null
+ const existingStopIndex = stops.findIndex((s) => s.lat === place.lat && s.lon === place.lon)
+ const savedContact = contacts.find((c) => c.lat === place.lat && c.lon === place.lon)
+
+ const handleDirections = () => {
+ // No toast - empty origin slot is the visual prompt
+ startDirections(place)
+ }
+ const handleAddStop = () => {
+ addStop({ lat: place.lat, lon: place.lon, name: place.name, source: place.source, matchCode: place.matchCode })
+ clearSelectedPlace()
+ }
+ const handleSave = () => {
+ if (!hasFeature("has_contacts")) { toast("Saved places coming soon"); return }
+ if (savedContact) setEditingContact(savedContact)
+ else setEditingContact({ label: "", lat: place.lat, lon: place.lon, osm_type: osmType || null, osm_id: osmId || null, address: address || "", name: place.type === "poi" && place.raw?.name ? place.raw.name : "" })
+ }
+ const closeCopy = useCallback(() => setCopyOpen(false), [])
+ const stopLetter = stopIndex != null ? String.fromCharCode(65 + stopIndex) : null
+
+ if (!expanded) {
+ return (
+
+ {draggable &&
}
+ {stopLetter &&
{stopLetter}
}
+
{place.name || "Unknown place"}
+
+ {onRemove &&
{ e.stopPropagation(); onRemove() }} className="p-1 rounded hover:opacity-80" style={{ color: "var(--text-tertiary)" }}> }
+
+ )
+ }
+
+ return (
+
+
+
+ {draggable &&
}
+ {stopLetter &&
{stopLetter}
}
+
+
{place.name || "Unknown place"}
+
+ {place.type && {place.type} }
+ {driveTime != null && <>{"\u00b7"} {formatDriveTime(driveTime)} drive >}
+ {nearbyLabel && <>{"\u00b7"} Near {nearbyLabel} >}
+
+
+
+
+ {onToggleExpand && variant === "stop" && }
+ {onClose && }
+
+
+ {address &&
{address}
}
+
+ {place.lat.toFixed(6)}, {place.lon.toFixed(6)}
+ {"\u00b7"}
+ {elevLoading ? "..." : elevFeet != null ? elevFeet.toLocaleString() + " ft" : "\u2014"}
+
+
+
+ {placeDetails === "loading" &&
}
+ {placeDetails && placeDetails !== "loading" &&
}
+
+ {variant === "preview" && (
+ <>
+
Get Directions
+ {existingStopIndex >= 0 ? (
+
Stop {String.fromCharCode(65 + existingStopIndex)}
+ ) : (
+
Add stop
+ )}
+ >
+ )}
+ {variant === "stop" && onRemove &&
Remove}
+
+
+ setCopyOpen((v) => !v)} className="p-2 rounded-lg flex items-center gap-0.5" style={{ background: "var(--tan-muted)", color: "var(--tan)", border: "1px solid var(--border)" }} aria-label="Copy">
+ {copyOpen && }
+
+
+
+ )
+}
+
+export default PlaceCard
diff --git a/src/components/PlaceDetail.jsx b/src/components/PlaceDetail.jsx
index 1ec4270..7e4fc7d 100644
--- a/src/components/PlaceDetail.jsx
+++ b/src/components/PlaceDetail.jsx
@@ -1,6 +1,6 @@
import { useEffect, useState, useRef, useCallback } from 'react'
import {
- X, Navigation, Plus, Bookmark, ChevronDown, ChevronUp, Copy,
+ X, Navigation, Plus, Bookmark, ChevronDown, ChevronUp, Copy, LogIn,
Clock, Phone, Globe, Mail, BookOpen, Info, Trees,
} from 'lucide-react'
import OpeningHours from 'opening_hours'
@@ -416,6 +416,7 @@ export default function PlaceDetail() {
const userLocation = useStore((s) => s.userLocation)
const contacts = useStore((s) => s.contacts)
const setEditingContact = useStore((s) => s.setEditingContact)
+ const auth = useStore((s) => s.auth)
const [elevResult, setElevResult] = useState({ lat: null, lon: null, value: null })
const [isMobile, setIsMobile] = useState(false)
@@ -742,18 +743,30 @@ export default function PlaceDetail() {
)}
-
-
-
+ {auth.authenticated ? (
+
+
+
+ ) : (
+
{ window.location.href = '/api/auth/whoami' }}
+ className="flex items-center gap-1 px-2 py-1.5 rounded-lg text-xs"
+ style={{ background: 'var(--accent-muted)', color: 'var(--accent)', border: '1px solid var(--border)' }}
+ title="Log in to save places"
+ >
+
+ Save
+
+ )}
{/* Copy dropdown */}
diff --git a/src/components/PlaceDetail.jsx.bak.boundary b/src/components/PlaceDetail.jsx.bak.boundary
new file mode 100644
index 0000000..a5c7a9f
--- /dev/null
+++ b/src/components/PlaceDetail.jsx.bak.boundary
@@ -0,0 +1,798 @@
+import { useEffect, useState, useRef, useCallback } from 'react'
+import {
+ X, Navigation, Plus, Bookmark, ChevronDown, ChevronUp, Copy,
+ Clock, Phone, Globe, Mail, BookOpen, Info, Trees,
+} from 'lucide-react'
+import OpeningHours from 'opening_hours'
+import toast from 'react-hot-toast'
+import { useStore } from '../store'
+import { fetchElevation, fetchPlaceDetails, fetchPlaceByWikidata, fetchDriveTime, fetchNearbyContacts, fetchLandclass } from '../api'
+import { hasFeature } from '../config'
+import { buildAddress } from '../utils/place'
+
+/** Meters to feet */
+const M_TO_FT = 3.28084
+
+/** Format drive time (seconds) to human-readable string */
+function formatDriveTime(seconds) {
+ const mins = Math.round(seconds / 60)
+ if (mins < 2) return '< 2 min drive'
+ if (mins < 120) return `${mins} min drive`
+ const h = Math.floor(mins / 60)
+ const m = mins % 60
+ return m > 0 ? `${h}h ${m}m drive` : `${h}h drive`
+}
+
+// ── Opening hours helpers ──────────────────────────────────────────────
+
+const DAY_SHORT = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
+
+function parseHours(hoursStr) {
+ try {
+ const oh = new OpeningHours(hoursStr, { address: { country_code: 'us', state: 'Idaho' } })
+ const now = new Date()
+ const isOpen = oh.getState(now)
+ const nextChange = oh.getNextChange(now)
+
+ let todayStr = ''
+ if (isOpen) {
+ todayStr = 'Open now'
+ if (nextChange) {
+ const closeTime = nextChange.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })
+ todayStr += ` \u00b7 Closes at ${closeTime}`
+ }
+ } else {
+ todayStr = 'Closed'
+ if (nextChange) {
+ const openTime = nextChange.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })
+ const isToday = nextChange.getDate() === now.getDate()
+ todayStr += ` \u00b7 Opens ${isToday ? 'at' : 'tomorrow at'} ${openTime}`
+ }
+ }
+
+ const week = []
+ for (let d = 0; d < 7; d++) {
+ const date = new Date(now)
+ const diff = (d - now.getDay() + 7) % 7
+ date.setDate(now.getDate() + diff)
+ date.setHours(0, 0, 0, 0)
+
+ const intervals = oh.getOpenIntervals(date, new Date(date.getTime() + 86400000))
+ if (intervals.length === 0) {
+ week.push({ day: DAY_SHORT[d], hours: 'Closed', isToday: d === now.getDay() })
+ } else {
+ const parts = intervals.map(([start, end]) => {
+ const s = start.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })
+ const e = end.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })
+ return `${s} \u2013 ${e}`
+ })
+ week.push({ day: DAY_SHORT[d], hours: parts.join(', '), isToday: d === now.getDay() })
+ }
+ }
+
+ return { isOpen, todayStr, week }
+ } catch {
+ return null
+ }
+}
+
+// ── Formatting helpers ─────────────────────────────────────────────────
+
+function formatPhone(phone) {
+ if (!phone) return null
+ const digits = phone.replace(/[^\d]/g, '')
+ if (digits.length === 11 && digits[0] === '1') {
+ return `(${digits.slice(1, 4)}) ${digits.slice(4, 7)}-${digits.slice(7)}`
+ }
+ if (digits.length === 10) {
+ return `(${digits.slice(0, 3)}) ${digits.slice(3, 6)}-${digits.slice(6)}`
+ }
+ return phone
+}
+
+function wheelchairLabel(val) {
+ if (!val) return null
+ const map = { yes: 'Accessible', limited: 'Limited access', no: 'Not accessible' }
+ return map[val.toLowerCase()] || null
+}
+
+function wikiUrl(wp) {
+ if (!wp) return null
+ const match = wp.match(/^([a-z-]+):(.+)$/)
+ if (!match) return null
+ return `https://${match[1]}.wikipedia.org/wiki/${encodeURIComponent(match[2].replace(/ /g, '_'))}`
+}
+
+function wikiLabel(wp) {
+ if (!wp) return null
+ const match = wp.match(/^[a-z-]+:(.+)$/)
+ return match ? match[1].replace(/_/g, ' ') : wp
+}
+
+// ── Section wrapper ────────────────────────────────────────────────────
+
+function DetailSection({ label, icon: Icon, first, children }) {
+ return (
+
+
+ {Icon && }
+ {label}
+
+ {children}
+
+ )
+}
+
+// ── Hours display ──────────────────────────────────────────────────────
+
+function HoursDisplay({ hoursStr, first }) {
+ const [expanded, setExpanded] = useState(false)
+ const parsed = parseHours(hoursStr)
+
+ if (!parsed) {
+ return (
+
+ {hoursStr}
+
+ )
+ }
+
+ return (
+
+ setExpanded((v) => !v)}
+ className="w-full flex items-center justify-between text-xs"
+ style={{ color: 'var(--text-primary)' }}
+ >
+
+
+ {parsed.todayStr}
+
+ {expanded ? : }
+
+ {expanded && (
+
+ {parsed.week.map((d) => (
+
+ {d.day}
+ {d.hours}
+
+ ))}
+
+ )}
+
+ )
+}
+
+// ── Copy popover ───────────────────────────────────────────────────────
+
+function CopyPopover({ address, selectedPlace, onClose }) {
+ const ref = useRef(null)
+
+ useEffect(() => {
+ function handleClick(e) {
+ if (ref.current && !ref.current.contains(e.target)) onClose()
+ }
+ function handleKey(e) {
+ if (e.key === 'Escape') onClose()
+ }
+ document.addEventListener('mousedown', handleClick)
+ document.addEventListener('keydown', handleKey)
+ return () => {
+ document.removeEventListener('mousedown', handleClick)
+ document.removeEventListener('keydown', handleKey)
+ }
+ }, [onClose])
+
+ const copyAddress = () => {
+ const text = [selectedPlace.name, address].filter(Boolean).join('\n')
+ navigator.clipboard.writeText(text).then(
+ () => toast('Address copied'),
+ () => toast.error('Failed to copy')
+ )
+ onClose()
+ }
+
+ const copyCoords = () => {
+ const text = `${selectedPlace.lat.toFixed(6)}, ${selectedPlace.lon.toFixed(6)}`
+ navigator.clipboard.writeText(text).then(
+ () => toast('Coordinates copied'),
+ () => toast.error('Failed to copy')
+ )
+ onClose()
+ }
+
+ return (
+
+
+ Address
+
+
+ Coordinates
+
+
+ )
+}
+
+// ── Enrichment sections ────────────────────────────────────────────────
+
+function EnrichmentSections({ details }) {
+ if (!details) return null
+
+ const { category, extratags } = details
+ const et = extratags || {}
+
+ const hasAbout = category
+ const hasHours = et.opening_hours
+ const hasContact = et.phone || et.website || et.email
+ const hasDetails = et.cuisine || et.operator || et.fee || et.wheelchair || et.takeaway
+ const hasLinks = et.wikipedia || et.wikidata
+
+ if (!hasAbout && !hasHours && !hasContact && !hasDetails && !hasLinks) return null
+
+ let idx = 0
+
+ return (
+
+ {hasAbout && (
+
+ {category}
+
+ )}
+
+ {hasHours &&
}
+
+ {hasContact && (
+
+
+
+ )}
+
+ {hasDetails && (
+
+
+ {et.cuisine && Cuisine: {et.cuisine.replace(/_/g, ' ').replace(/;/g, ', ')} }
+ {et.operator && Operated by {et.operator} }
+ {et.fee && {et.fee === 'no' ? 'Free' : `Fee: ${et.fee}`} }
+ {et.wheelchair && wheelchairLabel(et.wheelchair) && {wheelchairLabel(et.wheelchair)} }
+ {et.takeaway === 'yes' && Takeaway available }
+
+
+ )}
+
+ {hasLinks && (
+
+
+
+ )}
+
+ )
+}
+
+// ── Skeleton loader ────────────────────────────────────────────────────
+
+
+// ── Land classification display ──────────────────────────────────────────────────────────────────────
+
+function LandclassSection({ data }) {
+ if (!data || data.is_public !== true || !data.classifications?.length) return null
+
+ return (
+
+
+ {data.classifications.map((c, i) => (
+
+
+ {c.unit_name}
+
+ {(c.owner_type || c.manager_name || c.designation_type) && (
+
+ {[c.owner_type, c.manager_name, c.designation_type].filter(Boolean).join(' \u203a ')}
+
+ )}
+ {c.public_access && c.public_access !== 'Unknown' && (
+
+ {c.public_access}
+
+ )}
+
+ ))}
+
+
+ )
+}
+
+function PrivateLandIndicator({ data }) {
+ if (!data || data.is_private !== true) return null
+ return (
+
+ Private land
+
+ )
+}
+
+function EnrichmentSkeleton() {
+ return (
+
+ )
+}
+
+// ── Main component ─────────────────────────────────────────────────────
+
+export default function PlaceDetail() {
+ const selectedPlace = useStore((s) => s.selectedPlace)
+ const clearSelectedPlace = useStore((s) => s.clearSelectedPlace)
+ const startDirections = useStore((s) => s.startDirections)
+ const addStop = useStore((s) => s.addStop)
+ const stops = useStore((s) => s.stops)
+ const geoPermission = useStore((s) => s.geoPermission)
+ const userLocation = useStore((s) => s.userLocation)
+ const contacts = useStore((s) => s.contacts)
+ const setEditingContact = useStore((s) => s.setEditingContact)
+
+ const [elevResult, setElevResult] = useState({ lat: null, lon: null, value: null })
+ const [isMobile, setIsMobile] = useState(false)
+ const [copyOpen, setCopyOpen] = useState(false)
+ const [placeDetails, setPlaceDetails] = useState(null)
+ const [driveTime, setDriveTime] = useState(null)
+ const [nearbyLabel, setNearbyLabel] = useState(null)
+ const [landclass, setLandclass] = useState(null)
+
+ const closeCopy = useCallback(() => setCopyOpen(false), [])
+
+ useEffect(() => {
+ const check = () => setIsMobile(window.innerWidth < 768)
+ check()
+ window.addEventListener('resize', check)
+ return () => window.removeEventListener('resize', check)
+ }, [])
+
+ // Close copy popover when place changes
+ useEffect(() => { setCopyOpen(false) }, [selectedPlace])
+
+ // Escape key closes panel
+ useEffect(() => {
+ if (!selectedPlace) return
+ function handleKey(e) {
+ if (e.key === 'Escape') clearSelectedPlace()
+ }
+ document.addEventListener('keydown', handleKey)
+ return () => document.removeEventListener('keydown', handleKey)
+ }, [selectedPlace, clearSelectedPlace])
+
+ // Fetch elevation when place changes
+ const placeLat = selectedPlace?.lat
+ const placeLon = selectedPlace?.lon
+ useEffect(() => {
+ if (placeLat == null || placeLon == null) return
+ let cancelled = false
+ fetchElevation(placeLat, placeLon).then((h) => {
+ if (!cancelled) setElevResult({ lat: placeLat, lon: placeLon, value: h })
+ })
+ return () => { cancelled = true }
+ }, [placeLat, placeLon])
+
+ // Fetch place details when place changes (if feature enabled)
+ const osmType = selectedPlace?.raw?.osm_type
+ const osmId = selectedPlace?.raw?.osm_id
+ useEffect(() => {
+ if (!hasFeature('has_nominatim_details') || !osmType || !osmId) {
+ setPlaceDetails(null)
+ return
+ }
+
+ const controller = new AbortController()
+ setPlaceDetails('loading')
+
+ fetchPlaceDetails(osmType, osmId, controller.signal).then((data) => {
+ if (!controller.signal.aborted) {
+ setPlaceDetails(data || null)
+ }
+ })
+
+ return () => controller.abort()
+ }, [osmType, osmId])
+
+ // Fetch wikidata enrichment when place has wikidata but no OSM details
+ const wikidataId = selectedPlace?.wikidata || selectedPlace?.raw?.wikidata
+ useEffect(() => {
+ // Skip if OSM details are available (they provide richer data)
+ if (osmType && osmId) return
+ // Skip if no wikidata ID
+ if (!wikidataId) return
+
+ const controller = new AbortController()
+
+ fetchPlaceByWikidata(wikidataId, controller.signal).then((data) => {
+ if (!controller.signal.aborted && data) {
+ // Merge wikidata info into placeDetails (description, population, etc.)
+ setPlaceDetails((prev) => ({
+ ...(prev === 'loading' ? {} : prev || {}),
+ description: data.description,
+ population: data.population,
+ osm_relation_id: data.osm_relation_id,
+ extratags: {
+ ...(prev && prev !== 'loading' ? prev.extratags : {}),
+ ...data.extratags,
+ },
+ }))
+ }
+ })
+
+ return () => controller.abort()
+ }, [wikidataId, osmType, osmId])
+
+ // Fetch drive time when place or user location changes
+ useEffect(() => {
+ if (!userLocation || placeLat == null || placeLon == null) {
+ setDriveTime(null)
+ return
+ }
+
+ setDriveTime(null)
+ const controller = new AbortController()
+ const timeout = setTimeout(() => controller.abort(), 3000)
+
+ fetchDriveTime(
+ userLocation.lat, userLocation.lon,
+ placeLat, placeLon,
+ controller.signal
+ ).then((time) => {
+ if (!controller.signal.aborted) setDriveTime(time)
+ })
+
+ return () => {
+ controller.abort()
+ clearTimeout(timeout)
+ }
+ }, [userLocation?.lat, userLocation?.lon, placeLat, placeLon])
+
+ // Fetch nearby contacts for proximity annotation
+ useEffect(() => {
+ if (!hasFeature('has_contacts') || placeLat == null || placeLon == null) {
+ setNearbyLabel(null)
+ return
+ }
+ const controller = new AbortController()
+ fetchNearbyContacts(placeLat, placeLon, 75, controller.signal).then((nearby) => {
+ if (!controller.signal.aborted && nearby.length > 0) {
+ setNearbyLabel(nearby[0].label)
+ } else if (!controller.signal.aborted) {
+ setNearbyLabel(null)
+ }
+ })
+ return () => controller.abort()
+ }, [placeLat, placeLon])
+
+ // Fetch land classification when place changes (if feature enabled)
+ useEffect(() => {
+ if (!hasFeature('has_landclass') || placeLat == null || placeLon == null) {
+ setLandclass(null)
+ return
+ }
+ const controller = new AbortController()
+ fetchLandclass(placeLat, placeLon, controller.signal).then((data) => {
+ if (!controller.signal.aborted && data) {
+ setLandclass(data)
+ // Upgrade "Dropped pin" name to land summary if reverse geocode didn't resolve
+ if (data.summary && useStore.getState().selectedPlace?.name === 'Dropped pin') {
+ const current = useStore.getState().selectedPlace
+ useStore.getState().setSelectedPlace({ ...current, name: data.summary })
+ }
+ } else if (!controller.signal.aborted) {
+ setLandclass(null)
+ }
+ })
+ return () => controller.abort()
+ }, [placeLat, placeLon])
+
+ // Derive elevation/loading from comparing result to current place
+ const elevLoading = placeLat != null && (elevResult.lat !== placeLat || elevResult.lon !== placeLon)
+ const elevation = !elevLoading ? elevResult.value : null
+
+ if (!selectedPlace) return null
+
+ const address = buildAddress(selectedPlace)
+ const elevFeet = elevation != null ? Math.round(elevation * M_TO_FT) : null
+
+ // Check if place is already in stops
+ const existingStopIndex = stops.findIndex(
+ (s) => Math.abs(s.lat - selectedPlace.lat) < 0.00001 && Math.abs(s.lon - selectedPlace.lon) < 0.00001
+ )
+
+ // Check if place is already saved as a contact
+ const savedContact = hasFeature('has_contacts')
+ ? contacts.find((c) => {
+ if (c.osm_type && c.osm_id && osmType && osmId) {
+ return c.osm_type === osmType && c.osm_id === osmId
+ }
+ if (c.lat != null && c.lon != null) {
+ return Math.abs(c.lat - selectedPlace.lat) < 0.0001 && Math.abs(c.lon - selectedPlace.lon) < 0.0001
+ }
+ return false
+ })
+ : null
+
+ const handleDirections = () => {
+ startDirections(selectedPlace)
+ if (geoPermission !== 'granted' && stops.length === 0) {
+ toast('Set a starting point to get directions', { icon: '\u{1F4CD}' })
+ }
+ }
+
+ const handleAddStop = () => {
+ addStop({
+ lat: selectedPlace.lat,
+ lon: selectedPlace.lon,
+ name: selectedPlace.name,
+ source: selectedPlace.source,
+ matchCode: selectedPlace.matchCode,
+ })
+ clearSelectedPlace()
+ }
+
+ const handleSave = () => {
+ if (!hasFeature('has_contacts')) {
+ toast('Saved places coming soon')
+ return
+ }
+ if (savedContact) {
+ // Edit existing contact
+ setEditingContact(savedContact)
+ } else {
+ // New contact pre-populated from place
+ setEditingContact({
+ label: '',
+ lat: selectedPlace.lat,
+ lon: selectedPlace.lon,
+ osm_type: osmType || null,
+ osm_id: osmId || null,
+ address: address || '',
+ name: selectedPlace.type === 'poi' && selectedPlace.raw?.name ? selectedPlace.raw.name : '',
+ })
+ }
+ }
+
+ const panelContent = (
+ <>
+ {/* Close button */}
+
+
+
+
+ {/* Place name */}
+
+
+ {selectedPlace.type === 'poi' && selectedPlace.raw?.name
+ ? selectedPlace.raw.name
+ : selectedPlace.name}
+
+ {(() => {
+ const cat = placeDetails && placeDetails !== 'loading' ? placeDetails.category : null
+ const parts = []
+ if (cat) parts.push(cat)
+ if (nearbyLabel) parts.push(`near ${nearbyLabel}`)
+ if (driveTime != null) parts.push(formatDriveTime(driveTime))
+ if (parts.length === 0) return null
+ return (
+
+ {parts.join(' \u00b7 ')}
+
+ )
+ })()}
+
+
+ {/* Address */}
+ {address && (
+
+ {address}
+
+ )}
+
+ {/* Coordinates + elevation */}
+
+ {selectedPlace.lat.toFixed(6)}, {selectedPlace.lon.toFixed(6)}
+ ·
+
+ {elevLoading ? '...' : elevFeet != null ? `${elevFeet.toLocaleString()} ft` : '\u2014'}
+
+
+
+ {/* OSM enrichment sections */}
+ {/* Land classification (PAD-US) */}
+
+
+
+ {/* OSM enrichment sections */}
+ {placeDetails === 'loading' &&
}
+ {placeDetails && placeDetails !== 'loading' &&
}
+
+ {/* Action buttons */}
+
+
+
+ Directions
+
+
+ {existingStopIndex >= 0 ? (
+
+ Added as stop {String.fromCharCode(65 + existingStopIndex)}
+
+ ) : (
+
+
+ Add stop
+
+ )}
+
+
+
+
+
+ {/* Copy dropdown */}
+
+ setCopyOpen((v) => !v)}
+ className="p-2 rounded-lg flex items-center gap-0.5"
+ style={{ background: 'var(--tan-muted)', color: 'var(--tan)', border: '1px solid var(--border)' }}
+ aria-label="Copy"
+ >
+
+
+
+ {copyOpen && (
+
+ )}
+
+
+ >
+ )
+
+ // Mobile: bottom overlay
+ if (isMobile) {
+ return (
+
+ {panelContent}
+
+ )
+ }
+
+ // Desktop: side panel
+ return (
+
+ {panelContent}
+
+ )
+}
diff --git a/src/components/SearchBar.jsx.bak.twoclick b/src/components/SearchBar.jsx.bak.twoclick
new file mode 100644
index 0000000..e1fd1af
--- /dev/null
+++ b/src/components/SearchBar.jsx.bak.twoclick
@@ -0,0 +1,320 @@
+import { useRef, useEffect, useCallback, useState, forwardRef, useImperativeHandle } from 'react'
+import { MapPin, Building2, Star, Crosshair, Coffee, Fuel, ShoppingBag, Hotel, X, User } from 'lucide-react'
+import toast from 'react-hot-toast'
+import { useStore } from '../store'
+import { buildAddress } from '../utils/place'
+import { searchGeocode } from '../api'
+import { hasFeature } from '../config'
+
+/** Get category icon based on result type/source */
+function CategoryIcon({ result }) {
+ const type = result.type || ''
+ const source = result.source || ''
+ const size = 14
+
+ if (result._isContact) return
+ if (source === 'nickname') return
+ if (type === 'coordinates') return
+ if (type === 'locality' || type === 'city') return
+
+ // POI subcategories from osm_value if available
+ const osmVal = result.raw?.osm_value || ''
+ if (osmVal.includes('cafe') || osmVal.includes('coffee')) return
+ if (osmVal.includes('fuel') || osmVal.includes('gas')) return
+ if (osmVal.includes('shop') || osmVal.includes('supermarket')) return
+ if (osmVal.includes('hotel') || osmVal.includes('motel')) return
+
+ return
+}
+
+const SearchBar = forwardRef(function SearchBar(_, ref) {
+ const inputRef = useRef(null)
+ const [activeIndex, setActiveIndex] = useState(-1)
+ const debounceRef = useRef(null)
+
+ useImperativeHandle(ref, () => ({
+ focus: () => inputRef.current?.focus(),
+ }))
+
+ const query = useStore((s) => s.query)
+ const results = useStore((s) => s.results)
+ const searchLoading = useStore((s) => s.searchLoading)
+ const autocompleteOpen = useStore((s) => s.autocompleteOpen)
+ const stops = useStore((s) => s.stops)
+ const pendingDestination = useStore((s) => s.pendingDestination)
+ const contacts = useStore((s) => s.contacts)
+ const setQuery = useStore((s) => s.setQuery)
+ const setResults = useStore((s) => s.setResults)
+ const setSearchLoading = useStore((s) => s.setSearchLoading)
+ const setAbortController = useStore((s) => s.setAbortController)
+ const setAutocompleteOpen = useStore((s) => s.setAutocompleteOpen)
+ const addStop = useStore((s) => s.addStop)
+ const setSelectedPlace = useStore((s) => s.setSelectedPlace)
+ const setEditingContact = useStore((s) => s.setEditingContact)
+ const clearPendingDestination = useStore((s) => s.clearPendingDestination)
+ const mapCenter = useStore((s) => s.mapCenter)
+
+ useEffect(() => {
+ inputRef.current?.focus()
+ }, [])
+
+ const doSearch = useCallback(
+ async (q) => {
+ const prev = useStore.getState().abortController
+ if (prev) prev.abort()
+
+ if (!q.trim()) {
+ setResults([])
+ setAutocompleteOpen(false)
+ setSearchLoading(false)
+ return
+ }
+
+ // Prepend matching contacts
+ let contactResults = []
+ if (hasFeature('has_contacts') && contacts.length > 0) {
+ const lower = q.trim().toLowerCase()
+ contactResults = contacts
+ .filter((c) =>
+ (c.label || '').toLowerCase().startsWith(lower) ||
+ (c.name || '').toLowerCase().startsWith(lower) ||
+ (c.call_sign || '').toLowerCase().startsWith(lower)
+ )
+ .slice(0, 3)
+ .map((c) => ({
+ lat: c.lat,
+ lon: c.lon,
+ name: c.label,
+ address: c.address || c.name || '',
+ type: 'contact',
+ source: 'contacts',
+ match_code: null,
+ raw: { osm_type: c.osm_type, osm_id: c.osm_id, contact: c },
+ _isContact: true,
+ }))
+ }
+
+ const ctrl = new AbortController()
+ setAbortController(ctrl)
+ setSearchLoading(true)
+
+ try {
+ const data = await searchGeocode(q.trim(), 6, ctrl.signal, mapCenter)
+ const combined = [...contactResults, ...(data.results || [])]
+ setResults(combined)
+ setAutocompleteOpen(combined.length > 0)
+ setActiveIndex(-1)
+ } catch (e) {
+ if (e.name !== 'AbortError') {
+ // Still show contacts even if geocode fails
+ if (contactResults.length > 0) {
+ setResults(contactResults)
+ setAutocompleteOpen(true)
+ } else {
+ setResults([])
+ setAutocompleteOpen(false)
+ }
+ }
+ } finally {
+ setSearchLoading(false)
+ }
+ },
+ [setResults, setAutocompleteOpen, setSearchLoading, setAbortController, contacts]
+ )
+
+ const handleChange = (e) => {
+ const val = e.target.value
+ setQuery(val)
+ if (debounceRef.current) clearTimeout(debounceRef.current)
+ debounceRef.current = setTimeout(() => doSearch(val), 150)
+ }
+
+ const handleClear = () => {
+ setQuery('')
+ setResults([])
+ setAutocompleteOpen(false)
+ inputRef.current?.focus()
+ }
+
+ const selectResult = (result) => {
+ const { pendingDestination: pending } = useStore.getState()
+
+ // Pure contact (no geo) → open edit modal
+ if (result._isContact && result.lat == null) {
+ setEditingContact(result.raw.contact)
+ setQuery('')
+ setResults([])
+ setAutocompleteOpen(false)
+ setActiveIndex(-1)
+ return
+ }
+
+ if (pending) {
+ addStop({ lat: result.lat, lon: result.lon, name: result.name, source: result.source, matchCode: result.match_code })
+ addStop({ lat: pending.lat, lon: pending.lon, name: pending.name, source: pending.source, matchCode: pending.matchCode })
+ clearPendingDestination()
+ toast(`Routing from ${result.name} to ${pending.name}`, { icon: '\u{1F9ED}' })
+ } else {
+ setSelectedPlace({
+ lat: result.lat,
+ lon: result.lon,
+ name: result.name,
+ address: result.address || null,
+ type: result.type,
+ source: result.source,
+ matchCode: result.match_code,
+ raw: result.raw || {},
+ })
+ }
+
+ setQuery('')
+ setResults([])
+ setAutocompleteOpen(false)
+ setActiveIndex(-1)
+ inputRef.current?.focus()
+ }
+
+ const handleKeyDown = (e) => {
+ if (!autocompleteOpen || results.length === 0) {
+ if (e.key === 'Escape') setAutocompleteOpen(false)
+ return
+ }
+
+ switch (e.key) {
+ case 'ArrowDown':
+ e.preventDefault()
+ setActiveIndex((prev) => Math.min(prev + 1, results.length - 1))
+ break
+ case 'ArrowUp':
+ e.preventDefault()
+ setActiveIndex((prev) => Math.max(prev - 1, -1))
+ break
+ case 'Enter':
+ e.preventDefault()
+ if (activeIndex >= 0 && activeIndex < results.length) {
+ selectResult(results[activeIndex])
+ }
+ break
+ case 'Escape':
+ e.preventDefault()
+ setAutocompleteOpen(false)
+ setActiveIndex(-1)
+ break
+ }
+ }
+
+ const atCap = stops.length >= 10
+
+ return (
+
+
+
results.length > 0 && setAutocompleteOpen(true)}
+ placeholder={atCap ? 'Max 10 stops reached' : pendingDestination ? 'Starting point...' : 'Search for a place...'}
+ disabled={atCap}
+ className="navi-input w-full pr-8"
+ aria-label="Search places"
+ aria-expanded={autocompleteOpen}
+ aria-autocomplete="list"
+ role="combobox"
+ />
+ {/* Clear / Loading indicator */}
+
+ {searchLoading ? (
+
+ ) : query ? (
+
+
+
+ ) : null}
+
+
+
+ {/* Autocomplete dropdown */}
+ {autocompleteOpen && results.length > 0 && (
+
+ )}
+
+ )
+})
+
+export default SearchBar
diff --git a/src/components/StopList.jsx.bak.pending b/src/components/StopList.jsx.bak.pending
new file mode 100644
index 0000000..80cb538
--- /dev/null
+++ b/src/components/StopList.jsx.bak.pending
@@ -0,0 +1,117 @@
+import { useState } from 'react'
+import {
+ DndContext,
+ closestCenter,
+ KeyboardSensor,
+ PointerSensor,
+ useSensor,
+ useSensors,
+} from '@dnd-kit/core'
+import {
+ arrayMove,
+ SortableContext,
+ sortableKeyboardCoordinates,
+ verticalListSortingStrategy,
+ useSortable,
+} from '@dnd-kit/sortable'
+import { CSS } from '@dnd-kit/utilities'
+import { useStore } from '../store'
+import { PlaceCard } from './PlaceCard'
+import GpsOriginItem from './GpsOriginItem'
+
+// Wrapper to make PlaceCard sortable
+function SortableStopCard({ stop, index, indexOffset }) {
+ const removeStop = useStore((s) => s.removeStop)
+ const [expanded, setExpanded] = useState(false)
+
+ const { attributes, listeners, setNodeRef, transform, transition, isDragging } =
+ useSortable({ id: stop.id })
+
+ const style = {
+ transform: CSS.Transform.toString(transform),
+ transition,
+ opacity: isDragging ? 0.5 : 1,
+ }
+
+ // Convert stop to place format for PlaceCard
+ const place = {
+ lat: stop.lat,
+ lon: stop.lon,
+ name: stop.name,
+ source: stop.source,
+ matchCode: stop.matchCode,
+ type: stop.type || null,
+ raw: stop.raw || null,
+ wikidata: stop.wikidata || null,
+ }
+
+ return (
+
+
setExpanded(!expanded)}
+ onRemove={() => removeStop(stop.id)}
+ stopIndex={index + indexOffset}
+ draggable={true}
+ dragHandleProps={{ ...attributes, ...listeners }}
+ />
+
+ )
+}
+
+export default function StopList() {
+ const stops = useStore((s) => s.stops)
+ const reorderStops = useStore((s) => s.reorderStops)
+ const geoPermission = useStore((s) => s.geoPermission)
+ const gpsOrigin = useStore((s) => s.gpsOrigin)
+ const pendingDestination = useStore((s) => s.pendingDestination)
+
+ const hasGpsOrigin = gpsOrigin && geoPermission === 'granted'
+ const indexOffset = hasGpsOrigin ? 1 : 0
+
+ const sensors = useSensors(
+ useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
+ useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
+ )
+
+ function handleDragEnd(event) {
+ const { active, over } = event
+ if (!over || active.id === over.id) return
+
+ const oldIndex = stops.findIndex((s) => s.id === active.id)
+ const newIndex = stops.findIndex((s) => s.id === over.id)
+ reorderStops(arrayMove(stops, oldIndex, newIndex))
+ }
+
+ if (stops.length === 0 && !hasGpsOrigin) {
+ return (
+
+ {pendingDestination
+ ? 'Search for a starting point above'
+ : geoPermission === 'denied'
+ ? 'Add a starting point and destination above'
+ : 'Search and add stops to build your route'}
+
+ )
+ }
+
+ return (
+
+ {hasGpsOrigin && }
+
+ s.id)} strategy={verticalListSortingStrategy}>
+ {stops.map((stop, i) => (
+
+ ))}
+
+
+
+ )
+}
diff --git a/src/index.css.bak.twoclick b/src/index.css.bak.twoclick
new file mode 100644
index 0000000..8fca9fe
--- /dev/null
+++ b/src/index.css.bak.twoclick
@@ -0,0 +1,537 @@
+@import "tailwindcss";
+
+/* ═══════════════════════════════════════════════════════
+ NAVI DESIGN TOKENS
+ Warm grays, sage greens, khaki tans, deep blacks.
+ No blue in UI chrome.
+ ═══════════════════════════════════════════════════════ */
+
+:root {
+ /* ── Typography ── */
+ --font-sans: 'Inter', system-ui, -apple-system, sans-serif;
+ --font-mono: 'JetBrains Mono', ui-monospace, monospace;
+
+ /* ── Type scale ── */
+ --text-xs: 0.6875rem; /* 11px */
+ --text-sm: 0.8125rem; /* 13px */
+ --text-base: 0.875rem; /* 14px */
+ --text-md: 1rem; /* 16px */
+ --text-lg: 1.125rem; /* 18px */
+}
+
+/* ═══ DARK MODE (default) ═══ */
+[data-theme="dark"] {
+ --bg-base: #1c1917; /* warm off-black (was #0f1210) */
+ --bg-raised: #252220; /* raised surface (was #181d1a) */
+ --bg-overlay: #2e2a27; /* overlay/dropdown (was #1e2522) */
+ --bg-input: #201d1a; /* input fields (was #141a16) */
+
+ --text-primary: #dde3dc;
+ --text-secondary: #8f9a8e;
+ --text-tertiary: #5e6b5d;
+ --text-inverse: #1c1917;
+
+ --border: #3a3530; /* warm brown-gray (was #2a3329) */
+ --border-subtle: #2a2624; /* (was #1f261e) */
+
+ --accent: #7a9a6b; /* sage green — interactive states */
+ --accent-hover: #8fad7f;
+ --accent-muted: #3d4d36;
+
+ --tan: #b8a88a; /* khaki — secondary highlights */
+ --tan-muted: #4a4235;
+
+ --pin-origin: #6b8f5e; /* sage */
+ --pin-destination: #a67c52; /* rust/tan */
+ --pin-intermediate: #6b7268; /* warm gray */
+ --pin-stroke: #1c1917;
+
+ --status-success: #6b8f5e;
+ --status-warning: #b89a4a;
+ --status-danger: #a65c52;
+
+ --route-line: #7a9a6b;
+
+ --shadow: 0 2px 8px rgba(0, 0, 0, 0.4);
+ --shadow-lg: 0 4px 16px rgba(0, 0, 0, 0.5);
+}
+
+/* ═══ LIGHT MODE ═══ */
+[data-theme="light"] {
+ --bg-base: #ddd2b9; /* warm khaki-tan (was #ece8e1) */
+ --bg-raised: #e8dec8; /* raised surface (was #f5f2ec) */
+ --bg-overlay: #e3d9c1; /* overlay/dropdown (was #f0ece5) */
+ --bg-input: #e8dec8; /* input fields (was #f5f2ec) */
+
+ --text-primary: #1a1d1a;
+ --text-secondary: #4f5a49; /* darkened for WCAG AA on new base (was #5c6558) */
+ --text-tertiary: #7a8674; /* darkened proportionally (was #8a9486) */
+ --text-inverse: #f5f2ed;
+
+ --border: #c4b89e; /* warmer border (was #d4cfc5) */
+ --border-subtle: #d5cab2; /* warmer subtle border (was #e8e3db) */
+
+ --accent: #4a7040;
+ --accent-hover: #3d5e35;
+ --accent-muted: #dce8d6;
+
+ --tan: #8a7556;
+ --tan-muted: #f0e8d8;
+
+ --pin-origin: #4a7040;
+ --pin-destination: #8a5c35;
+ --pin-intermediate: #6b6960;
+ --pin-stroke: #1a1d1a;
+
+ --status-success: #4a7040;
+ --status-warning: #8a7040;
+ --status-danger: #8a4040;
+
+ --route-line: #4a7040;
+
+ --shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
+ --shadow-lg: 0 4px 16px rgba(0, 0, 0, 0.12);
+}
+
+/* ═══ BASE STYLES ═══ */
+html, body, #root {
+ margin: 0;
+ padding: 0;
+ height: 100%;
+ overflow: hidden;
+ font-family: var(--font-sans);
+}
+
+body {
+ background: var(--bg-base);
+ color: var(--text-primary);
+}
+
+/* Mono class utility */
+.font-mono {
+ font-family: var(--font-mono);
+}
+
+/* ═══ FOCUS RING — accent, never blue ═══ */
+*:focus-visible {
+ outline: 2px solid var(--accent);
+ outline-offset: 2px;
+}
+
+/* ═══ TRANSITIONS — respect reduced motion ═══ */
+@media (prefers-reduced-motion: reduce) {
+ *, *::before, *::after {
+ animation-duration: 0.01ms !important;
+ transition-duration: 0.01ms !important;
+ }
+}
+
+/* ═══ MAPLIBRE POPUP ═══ */
+.maplibregl-popup-content {
+ background: var(--bg-raised) !important;
+ border: 1px solid var(--border) !important;
+ border-radius: 8px !important;
+ padding: 8px 12px !important;
+ box-shadow: var(--shadow-lg) !important;
+ color: var(--text-primary) !important;
+}
+
+.maplibregl-popup-tip {
+ border-top-color: var(--bg-raised) !important;
+ border-bottom-color: var(--bg-raised) !important;
+}
+
+.maplibregl-popup-close-button {
+ color: var(--text-secondary) !important;
+ font-size: 16px !important;
+ padding: 2px 6px !important;
+}
+
+.maplibregl-popup-close-button:hover {
+ color: var(--text-primary) !important;
+ background: transparent !important;
+}
+
+/* ═══ SCROLLBAR ═══ */
+::-webkit-scrollbar {
+ width: 6px;
+}
+
+::-webkit-scrollbar-track {
+ background: transparent;
+}
+
+::-webkit-scrollbar-thumb {
+ background: var(--border);
+ border-radius: 3px;
+}
+
+::-webkit-scrollbar-thumb:hover {
+ background: var(--text-tertiary);
+}
+
+/* ═══ GPS CHEVRON MARKER ═══ */
+.navi-chevron {
+ width: 16px;
+ height: 16px;
+ transition: transform 0.3s ease;
+}
+
+.navi-gps-dot {
+ width: 12px;
+ height: 12px;
+ border-radius: 50%;
+ background: var(--accent);
+ border: 2px solid var(--bg-raised);
+ box-shadow: 0 0 0 2px var(--accent);
+}
+
+/* ═══ STOP PIN MARKERS (map) ═══ */
+.navi-pin {
+ width: 26px;
+ height: 26px;
+ border-radius: 50%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-family: var(--font-sans);
+ font-size: 11px;
+ font-weight: 600;
+ color: #fff;
+ border: 2px solid var(--pin-stroke);
+ cursor: pointer;
+ box-shadow: var(--shadow);
+}
+
+.navi-pin--origin { background: var(--pin-origin); }
+.navi-pin--destination { background: var(--pin-destination); }
+.navi-pin--intermediate { background: var(--pin-intermediate); }
+
+/* ═══ FORM ELEMENTS ═══ */
+.navi-input {
+ padding: 0.5rem 0.75rem;
+ font-size: var(--text-sm);
+ font-family: var(--font-sans);
+ background: var(--bg-input);
+ border: 1px solid var(--border);
+ border-radius: 0.5rem;
+ color: var(--text-primary);
+ transition: border-color 0.1s;
+}
+
+.navi-input::placeholder {
+ color: var(--text-tertiary);
+}
+
+.navi-input:focus {
+ outline: none;
+ border-color: var(--accent);
+ box-shadow: 0 0 0 2px var(--accent-muted);
+}
+
+.navi-input:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+.navi-btn-secondary {
+ padding: 0.375rem 0.75rem;
+ font-size: var(--text-xs);
+ font-family: var(--font-sans);
+ font-weight: 500;
+ background: var(--tan-muted);
+ color: var(--tan);
+ border: 1px solid var(--border);
+ border-radius: 0.5rem;
+ cursor: pointer;
+ transition: background 0.1s;
+}
+
+.navi-btn-secondary:hover:not(:disabled) {
+ background: var(--accent-muted);
+ color: var(--accent);
+}
+
+.navi-btn-secondary:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+/* ═══ PREVIEW PIN (selected but not committed) ═══ */
+.navi-pin-preview {
+ width: 28px;
+ height: 28px;
+ border-radius: 50%;
+ border: 3px solid var(--accent);
+ background: transparent;
+ box-shadow: 0 0 0 3px var(--accent-muted), var(--shadow);
+ pointer-events: none;
+}
+
+/* ═══ PLACE DETAIL PANEL ═══ */
+.navi-place-detail {
+ transition: transform 150ms ease, opacity 150ms ease;
+}
+
+.navi-place-detail-enter {
+ transform: translateX(-10px);
+ opacity: 0;
+}
+
+.navi-place-detail-active {
+ transform: translateX(0);
+ opacity: 1;
+}
+
+/* ═══ LAYER CONTROL ═══ */
+.layer-control {
+ position: absolute;
+ bottom: 32px;
+ right: 10px;
+ z-index: 10;
+}
+
+.layer-control-btn {
+ width: 36px;
+ height: 36px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: var(--bg-raised);
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ color: var(--text-secondary);
+ cursor: pointer;
+ box-shadow: var(--shadow);
+ transition: color 0.1s, border-color 0.1s;
+}
+
+.layer-control-btn:hover {
+ color: var(--text-primary);
+ border-color: var(--accent);
+}
+
+.layer-control-popover {
+ position: absolute;
+ bottom: 44px;
+ right: 0;
+ min-width: 160px;
+ background: var(--bg-raised);
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ padding: 8px 0;
+ box-shadow: var(--shadow-lg);
+}
+
+.layer-control-header {
+ padding: 4px 12px 6px;
+ font-size: var(--text-xs);
+ font-weight: 600;
+ color: var(--text-tertiary);
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+}
+
+.layer-control-item {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 6px 12px;
+ cursor: pointer;
+ transition: background 0.1s;
+}
+
+.layer-control-item:hover {
+ background: var(--bg-overlay);
+}
+
+.layer-control-label {
+ font-size: var(--text-sm);
+ color: var(--text-primary);
+}
+
+.layer-control-toggle {
+ appearance: none;
+ width: 32px;
+ height: 18px;
+ background: var(--border);
+ border-radius: 9px;
+ position: relative;
+ cursor: pointer;
+ transition: background 0.15s;
+ flex-shrink: 0;
+ margin-left: 12px;
+}
+
+.layer-control-toggle::after {
+ content: '';
+ position: absolute;
+ top: 2px;
+ left: 2px;
+ width: 14px;
+ height: 14px;
+ background: var(--text-primary);
+ border-radius: 50%;
+ transition: transform 0.15s;
+}
+
+.layer-control-toggle:checked {
+ background: var(--accent);
+}
+
+.layer-control-toggle:checked::after {
+ transform: translateX(14px);
+}
+
+
+/* ═══ PLACE DETAIL ENRICHMENT ═══ */
+.place-detail-section {
+ margin-top: 2px;
+}
+
+.place-detail-section-header {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ padding-bottom: 6px;
+ font-size: 10px;
+ font-weight: 600;
+ color: var(--text-tertiary);
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+}
+
+.category-badge {
+ display: inline-block;
+ padding: 2px 8px;
+ font-size: 11px;
+ font-weight: 500;
+ color: var(--accent);
+ background: var(--accent-muted);
+ border-radius: 10px;
+}
+
+
+/* ═══ LOCATE BUTTON ═══ */
+.locate-btn {
+ position: absolute;
+ bottom: 80px;
+ right: 10px;
+ z-index: 10;
+ width: 36px;
+ height: 36px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: var(--bg-raised);
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ color: var(--text-secondary);
+ cursor: pointer;
+ box-shadow: var(--shadow);
+ transition: color 0.1s, border-color 0.1s;
+}
+
+.locate-btn:hover {
+ color: var(--text-primary);
+ border-color: var(--accent);
+}
+
+/* ═══ STOP REMOVE BUTTON (touch-friendly) ═══ */
+.stop-remove-btn {
+ opacity: 0;
+ transition: opacity 0.15s;
+}
+
+.group:hover .stop-remove-btn {
+ opacity: 1;
+}
+
+/* ═══ MOBILE OVERRIDES ═══ */
+@media (max-width: 767px) {
+ body {
+ overflow-x: hidden;
+ }
+
+ .layer-control {
+ bottom: auto;
+ top: 120px;
+ right: 10px;
+ }
+
+ .locate-btn {
+ bottom: auto;
+ top: 166px;
+ right: 10px;
+ }
+
+ .stop-remove-btn {
+ opacity: 0.6;
+ }
+}
+
+/* ═══ CONTACT MODAL ═══ */
+.contact-modal-overlay {
+ position: fixed;
+ inset: 0;
+ z-index: 50;
+ background: rgba(0, 0, 0, 0.5);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.contact-modal {
+ background: var(--bg-raised);
+ border: 1px solid var(--border);
+ border-radius: 12px;
+ width: 90%;
+ max-width: 420px;
+ max-height: 85vh;
+ overflow-y: auto;
+ padding: 20px;
+ box-shadow: var(--shadow-lg);
+}
+
+/* ═══ PANEL TAB BAR ═══ */
+.navi-tab-bar {
+ display: flex;
+ gap: 4px;
+ border-bottom: 1px solid var(--border-subtle);
+}
+
+.navi-tab {
+ padding: 6px 12px;
+ font-size: var(--text-xs);
+ font-weight: 500;
+ color: var(--text-tertiary);
+ background: transparent;
+ border: none;
+ border-bottom: 2px solid transparent;
+ cursor: pointer;
+ transition: color 0.1s, border-color 0.1s;
+}
+
+.navi-tab:hover {
+ color: var(--text-secondary);
+}
+
+.navi-tab-active {
+ color: var(--accent);
+ border-bottom-color: var(--accent);
+}
+
+/* ═══ CONTACT LIST ITEMS ═══ */
+.contact-item {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 8px;
+ border-radius: 8px;
+ cursor: pointer;
+ transition: background 0.1s;
+}
+
+.contact-item:hover {
+ background: var(--bg-overlay);
+}
diff --git a/src/store.js b/src/store.js
index 42f90c3..86a78d7 100644
--- a/src/store.js
+++ b/src/store.js
@@ -109,6 +109,10 @@ export const useStore = create((set, get) => ({
localStorage.removeItem('navi-theme-override')
}
},
+ // ── Auth state ──
+ auth: { authenticated: false, username: null, loaded: false },
+ setAuth: (auth) => set({ auth: { ...auth, loaded: true } }),
+
// ── Contacts ──
contacts: [],
contactsLoaded: false,
diff --git a/src/store.js.bak.dupstop b/src/store.js.bak.dupstop
new file mode 100644
index 0000000..7949d95
--- /dev/null
+++ b/src/store.js.bak.dupstop
@@ -0,0 +1,141 @@
+import { create } from 'zustand'
+
+export const useStore = create((set, get) => ({
+ // ── Search state ──
+ query: '',
+ results: [],
+ searchLoading: false,
+ abortController: null,
+
+ setQuery: (query) => set({ query }),
+ setResults: (results) => set({ results }),
+ setSearchLoading: (loading) => set({ searchLoading: loading }),
+ setAbortController: (ctrl) => set({ abortController: ctrl }),
+
+ // ── Stop list ──
+ stops: [],
+ // Each stop: { id, lat, lon, name, source, matchCode, isOrigin }
+
+ addStop: (stop) => {
+ const { stops } = get()
+ if (stops.length >= 10) return false
+ set({ stops: [...stops, { ...stop, id: crypto.randomUUID() }] })
+ return true
+ },
+
+ removeStop: (id) => {
+ set({ stops: get().stops.filter((s) => s.id !== id) })
+ },
+
+ reorderStops: (newStops) => set({ stops: newStops }),
+
+ clearStops: () => set({ stops: [] }),
+
+ setStops: (stops) => set({ stops }),
+
+ // ── Geolocation ──
+ userLocation: null, // { lat, lon }
+ geoPermission: 'prompt', // 'prompt' | 'granted' | 'denied'
+
+ setUserLocation: (loc) => set({ userLocation: loc }),
+ setGeoPermission: (p) => set({ geoPermission: p }),
+
+ // ── Map viewport (for search bias) ──
+ mapCenter: null, // { lat, lon, zoom }
+ setMapCenter: (center) => set({ mapCenter: center }),
+
+ // ── Mode ──
+ mode: 'auto', // 'auto' | 'pedestrian' | 'bicycle'
+ setMode: (mode) => set({ mode }),
+
+ // ── Route ──
+ route: null, // Valhalla response (trip object)
+ routeLoading: false,
+ routeError: null,
+
+ setRoute: (route) => set({ route, routeError: null }),
+ setRouteLoading: (loading) => set({ routeLoading: loading }),
+ setRouteError: (err) => set({ routeError: err, route: null }),
+ clearRoute: () => set({ route: null, routeError: null }),
+
+ // ── Place detail ──
+ selectedPlace: null, // { lat, lon, name, address, type, source, matchCode, raw, mode?, featureId?, featureLayer?, wikidata? }
+ clickMarker: null, // { lat, lon, circleRadiusPx } — visual marker for two-click selection
+ gpsOrigin: true, // whether GPS should be used as origin when available
+ pendingDestination: null, // place waiting for a starting point (GPS-denied Directions flow)
+
+ setSelectedPlace: (place) => set({ selectedPlace: place }),
+ clearSelectedPlace: () => set({ selectedPlace: null, clickMarker: null }),
+ setClickMarker: (marker) => set({ clickMarker: marker }),
+ clearClickMarker: () => set({ clickMarker: null }),
+ setGpsOrigin: (val) => set({ gpsOrigin: val }),
+ setPendingDestination: (place) => set({ pendingDestination: place }),
+ clearPendingDestination: () => set({ pendingDestination: null }),
+
+ startDirections: (place) => {
+ const { geoPermission, stops, addStop, clearStops } = get()
+ if (geoPermission === 'granted') {
+ clearStops()
+ addStop({ lat: place.lat, lon: place.lon, name: place.name, source: place.source, matchCode: place.matchCode })
+ set({ gpsOrigin: true, selectedPlace: null })
+ } else if (stops.length > 0) {
+ const origin = stops[0]
+ clearStops()
+ addStop({ lat: origin.lat, lon: origin.lon, name: origin.name, source: origin.source, matchCode: origin.matchCode })
+ addStop({ lat: place.lat, lon: place.lon, name: place.name, source: place.source, matchCode: place.matchCode })
+ set({ selectedPlace: null })
+ } else {
+ // GPS denied, no stops: add destination, show empty origin slot
+ clearStops()
+ addStop({ lat: place.lat, lon: place.lon, name: place.name, source: place.source, matchCode: place.matchCode })
+ set({ pendingDestination: place, selectedPlace: null })
+ }
+ },
+
+ // ── UI state ──
+ sheetState: 'half', // 'collapsed' | 'half' | 'full'
+ panelOpen: true,
+ autocompleteOpen: false,
+ theme: 'dark', // 'dark' | 'light' (resolved value — what's actually applied)
+ themeOverride: null, // null | 'dark' | 'light' (manual override, persisted)
+
+ setSheetState: (s) => set({ sheetState: s }),
+ setPanelOpen: (open) => set({ panelOpen: open }),
+ setAutocompleteOpen: (open) => set({ autocompleteOpen: open }),
+ setTheme: (theme) => set({ theme }),
+ setThemeOverride: (override) => {
+ set({ themeOverride: override })
+ if (override) {
+ localStorage.setItem('navi-theme-override', override)
+ } else {
+ localStorage.removeItem('navi-theme-override')
+ }
+ },
+ // ── Contacts ──
+ contacts: [],
+ contactsLoaded: false,
+ activeTab: 'routes', // 'routes' | 'contacts'
+ editingContact: null, // null=closed, {}=new, {id:N}=edit
+
+ setContacts: (c) => set({ contacts: c, contactsLoaded: true }),
+ setActiveTab: (tab) => set({ activeTab: tab }),
+ setEditingContact: (c) => set({ editingContact: c }),
+ clearEditingContact: () => set({ editingContact: null }),
+}))
+
+// ── Panel state selector ──
+// Returns string state, prioritizing preview to allow it alongside any route state
+export const usePanelState = () => {
+ return useStore((s) => {
+ const hasPreview = !!s.selectedPlace
+ const hasRoute = !!s.route
+ const hasStops = s.stops.length >= 1
+
+ if (hasPreview && hasRoute) return "PREVIEW_CALCULATED"
+ if (hasPreview && hasStops) return "PREVIEW_ROUTING"
+ if (hasPreview) return "PREVIEW"
+ if (hasRoute) return "ROUTE_CALCULATED"
+ if (hasStops) return "ROUTING"
+ return "IDLE"
+ })
+}
diff --git a/src/store.js.bak.regressions b/src/store.js.bak.regressions
new file mode 100644
index 0000000..8ce6fcc
--- /dev/null
+++ b/src/store.js.bak.regressions
@@ -0,0 +1,139 @@
+import { create } from 'zustand'
+
+export const useStore = create((set, get) => ({
+ // ── Search state ──
+ query: '',
+ results: [],
+ searchLoading: false,
+ abortController: null,
+
+ setQuery: (query) => set({ query }),
+ setResults: (results) => set({ results }),
+ setSearchLoading: (loading) => set({ searchLoading: loading }),
+ setAbortController: (ctrl) => set({ abortController: ctrl }),
+
+ // ── Stop list ──
+ stops: [],
+ // Each stop: { id, lat, lon, name, source, matchCode, isOrigin }
+
+ addStop: (stop) => {
+ const { stops } = get()
+ if (stops.length >= 10) return false
+ set({ stops: [...stops, { ...stop, id: crypto.randomUUID() }] })
+ return true
+ },
+
+ removeStop: (id) => {
+ set({ stops: get().stops.filter((s) => s.id !== id) })
+ },
+
+ reorderStops: (newStops) => set({ stops: newStops }),
+
+ clearStops: () => set({ stops: [] }),
+
+ setStops: (stops) => set({ stops }),
+
+ // ── Geolocation ──
+ userLocation: null, // { lat, lon }
+ geoPermission: 'prompt', // 'prompt' | 'granted' | 'denied'
+
+ setUserLocation: (loc) => set({ userLocation: loc }),
+ setGeoPermission: (p) => set({ geoPermission: p }),
+
+ // ── Map viewport (for search bias) ──
+ mapCenter: null, // { lat, lon, zoom }
+ setMapCenter: (center) => set({ mapCenter: center }),
+
+ // ── Mode ──
+ mode: 'auto', // 'auto' | 'pedestrian' | 'bicycle'
+ setMode: (mode) => set({ mode }),
+
+ // ── Route ──
+ route: null, // Valhalla response (trip object)
+ routeLoading: false,
+ routeError: null,
+
+ setRoute: (route) => set({ route, routeError: null }),
+ setRouteLoading: (loading) => set({ routeLoading: loading }),
+ setRouteError: (err) => set({ routeError: err, route: null }),
+ clearRoute: () => set({ route: null, routeError: null }),
+
+ // ── Place detail ──
+ selectedPlace: null, // { lat, lon, name, address, type, source, matchCode, raw, mode?, featureId?, featureLayer?, wikidata? }
+ clickMarker: null, // { lat, lon, circleRadiusPx } — visual marker for two-click selection
+ gpsOrigin: true, // whether GPS should be used as origin when available
+ pendingDestination: null, // place waiting for a starting point (GPS-denied Directions flow)
+
+ setSelectedPlace: (place) => set({ selectedPlace: place }),
+ clearSelectedPlace: () => set({ selectedPlace: null, clickMarker: null }),
+ setClickMarker: (marker) => set({ clickMarker: marker }),
+ clearClickMarker: () => set({ clickMarker: null }),
+ setGpsOrigin: (val) => set({ gpsOrigin: val }),
+ setPendingDestination: (place) => set({ pendingDestination: place }),
+ clearPendingDestination: () => set({ pendingDestination: null }),
+
+ startDirections: (place) => {
+ const { geoPermission, stops, addStop, clearStops } = get()
+ if (geoPermission === 'granted') {
+ clearStops()
+ addStop({ lat: place.lat, lon: place.lon, name: place.name, source: place.source, matchCode: place.matchCode })
+ set({ gpsOrigin: true, selectedPlace: null })
+ } else if (stops.length > 0) {
+ const origin = stops[0]
+ clearStops()
+ addStop({ lat: origin.lat, lon: origin.lon, name: origin.name, source: origin.source, matchCode: origin.matchCode })
+ addStop({ lat: place.lat, lon: place.lon, name: place.name, source: place.source, matchCode: place.matchCode })
+ set({ selectedPlace: null })
+ } else {
+ // GPS denied, no stops: add destination, show empty origin slot
+ clearStops()
+ addStop({ lat: place.lat, lon: place.lon, name: place.name, source: place.source, matchCode: place.matchCode })
+ set({ pendingDestination: place, selectedPlace: null })
+ }
+ },
+
+ // ── UI state ──
+ sheetState: 'half', // 'collapsed' | 'half' | 'full'
+ panelOpen: true,
+ autocompleteOpen: false,
+ theme: 'dark', // 'dark' | 'light' (resolved value — what's actually applied)
+ themeOverride: null, // null | 'dark' | 'light' (manual override, persisted)
+
+ setSheetState: (s) => set({ sheetState: s }),
+ setPanelOpen: (open) => set({ panelOpen: open }),
+ setAutocompleteOpen: (open) => set({ autocompleteOpen: open }),
+ setTheme: (theme) => set({ theme }),
+ setThemeOverride: (override) => {
+ set({ themeOverride: override })
+ if (override) {
+ localStorage.setItem('navi-theme-override', override)
+ } else {
+ // GPS denied, no stops: add destination, show empty origin slot
+ clearStops()
+ addStop({ lat: place.lat, lon: place.lon, name: place.name, source: place.source, matchCode: place.matchCode })
+ localStorage.removeItem('navi-theme-override')
+ }
+ },
+ // ── Contacts ──
+ contacts: [],
+ contactsLoaded: false,
+ activeTab: 'routes', // 'routes' | 'contacts'
+ editingContact: null, // null=closed, {}=new, {id:N}=edit
+
+ setContacts: (c) => set({ contacts: c, contactsLoaded: true }),
+ setActiveTab: (tab) => set({ activeTab: tab }),
+ setEditingContact: (c) => set({ editingContact: c }),
+ clearEditingContact: () => set({ editingContact: null }),
+}))
+
+// ── Panel state selector ──
+// IDLE | PREVIEW | ROUTING | PREVIEW_ROUTING | ROUTE_CALCULATED
+export const usePanelState = () => {
+ return useStore((s) => {
+ if (s.route) return "ROUTE_CALCULATED"
+ if (s.selectedPlace && s.stops.length >= 1) return "PREVIEW_ROUTING"
+ if (s.selectedPlace) return "PREVIEW"
+ if (s.stops.length >= 1) return "ROUTING"
+ return "IDLE"
+ })
+}
diff --git a/src/store.js.bak.twoclick b/src/store.js.bak.twoclick
new file mode 100644
index 0000000..8a1097d
--- /dev/null
+++ b/src/store.js.bak.twoclick
@@ -0,0 +1,118 @@
+import { create } from 'zustand'
+
+export const useStore = create((set, get) => ({
+ // ── Search state ──
+ query: '',
+ results: [],
+ searchLoading: false,
+ abortController: null,
+
+ setQuery: (query) => set({ query }),
+ setResults: (results) => set({ results }),
+ setSearchLoading: (loading) => set({ searchLoading: loading }),
+ setAbortController: (ctrl) => set({ abortController: ctrl }),
+
+ // ── Stop list ──
+ stops: [],
+ // Each stop: { id, lat, lon, name, source, matchCode, isOrigin }
+
+ addStop: (stop) => {
+ const { stops } = get()
+ if (stops.length >= 10) return false
+ set({ stops: [...stops, { ...stop, id: crypto.randomUUID() }] })
+ return true
+ },
+
+ removeStop: (id) => {
+ set({ stops: get().stops.filter((s) => s.id !== id) })
+ },
+
+ reorderStops: (newStops) => set({ stops: newStops }),
+
+ clearStops: () => set({ stops: [] }),
+
+ setStops: (stops) => set({ stops }),
+
+ // ── Geolocation ──
+ userLocation: null, // { lat, lon }
+ geoPermission: 'prompt', // 'prompt' | 'granted' | 'denied'
+
+ setUserLocation: (loc) => set({ userLocation: loc }),
+ setGeoPermission: (p) => set({ geoPermission: p }),
+
+ // ── Map viewport (for search bias) ──
+ mapCenter: null, // { lat, lon, zoom }
+ setMapCenter: (center) => set({ mapCenter: center }),
+
+ // ── Mode ──
+ mode: 'auto', // 'auto' | 'pedestrian' | 'bicycle'
+ setMode: (mode) => set({ mode }),
+
+ // ── Route ──
+ route: null, // Valhalla response (trip object)
+ routeLoading: false,
+ routeError: null,
+
+ setRoute: (route) => set({ route, routeError: null }),
+ setRouteLoading: (loading) => set({ routeLoading: loading }),
+ setRouteError: (err) => set({ routeError: err, route: null }),
+ clearRoute: () => set({ route: null, routeError: null }),
+
+ // ── Place detail ──
+ selectedPlace: null, // { lat, lon, name, address, type, source, matchCode, raw }
+ gpsOrigin: true, // whether GPS should be used as origin when available
+ pendingDestination: null, // place waiting for a starting point (GPS-denied Directions flow)
+
+ setSelectedPlace: (place) => set({ selectedPlace: place }),
+ clearSelectedPlace: () => set({ selectedPlace: null }),
+ setGpsOrigin: (val) => set({ gpsOrigin: val }),
+ setPendingDestination: (place) => set({ pendingDestination: place }),
+ clearPendingDestination: () => set({ pendingDestination: null }),
+
+ startDirections: (place) => {
+ const { geoPermission, stops, addStop, clearStops } = get()
+ if (geoPermission === 'granted') {
+ clearStops()
+ addStop({ lat: place.lat, lon: place.lon, name: place.name, source: place.source, matchCode: place.matchCode })
+ set({ gpsOrigin: true, selectedPlace: null })
+ } else if (stops.length > 0) {
+ const origin = stops[0]
+ clearStops()
+ addStop({ lat: origin.lat, lon: origin.lon, name: origin.name, source: origin.source, matchCode: origin.matchCode })
+ addStop({ lat: place.lat, lon: place.lon, name: place.name, source: place.source, matchCode: place.matchCode })
+ set({ selectedPlace: null })
+ } else {
+ set({ pendingDestination: place, selectedPlace: null })
+ }
+ },
+
+ // ── UI state ──
+ sheetState: 'half', // 'collapsed' | 'half' | 'full'
+ panelOpen: true,
+ autocompleteOpen: false,
+ theme: 'dark', // 'dark' | 'light' (resolved value — what's actually applied)
+ themeOverride: null, // null | 'dark' | 'light' (manual override, persisted)
+
+ setSheetState: (s) => set({ sheetState: s }),
+ setPanelOpen: (open) => set({ panelOpen: open }),
+ setAutocompleteOpen: (open) => set({ autocompleteOpen: open }),
+ setTheme: (theme) => set({ theme }),
+ setThemeOverride: (override) => {
+ set({ themeOverride: override })
+ if (override) {
+ localStorage.setItem('navi-theme-override', override)
+ } else {
+ localStorage.removeItem('navi-theme-override')
+ }
+ },
+ // ── Contacts ──
+ contacts: [],
+ contactsLoaded: false,
+ activeTab: 'routes', // 'routes' | 'contacts'
+ editingContact: null, // null=closed, {}=new, {id:N}=edit
+
+ setContacts: (c) => set({ contacts: c, contactsLoaded: true }),
+ setActiveTab: (tab) => set({ activeTab: tab }),
+ setEditingContact: (c) => set({ editingContact: c }),
+ clearEditingContact: () => set({ editingContact: null }),
+}))
diff --git a/src/store.js.bak.uxfix b/src/store.js.bak.uxfix
new file mode 100644
index 0000000..aa5e45f
--- /dev/null
+++ b/src/store.js.bak.uxfix
@@ -0,0 +1,133 @@
+import { create } from 'zustand'
+
+export const useStore = create((set, get) => ({
+ // ── Search state ──
+ query: '',
+ results: [],
+ searchLoading: false,
+ abortController: null,
+
+ setQuery: (query) => set({ query }),
+ setResults: (results) => set({ results }),
+ setSearchLoading: (loading) => set({ searchLoading: loading }),
+ setAbortController: (ctrl) => set({ abortController: ctrl }),
+
+ // ── Stop list ──
+ stops: [],
+ // Each stop: { id, lat, lon, name, source, matchCode, isOrigin }
+
+ addStop: (stop) => {
+ const { stops } = get()
+ if (stops.length >= 10) return false
+ set({ stops: [...stops, { ...stop, id: crypto.randomUUID() }] })
+ return true
+ },
+
+ removeStop: (id) => {
+ set({ stops: get().stops.filter((s) => s.id !== id) })
+ },
+
+ reorderStops: (newStops) => set({ stops: newStops }),
+
+ clearStops: () => set({ stops: [] }),
+
+ setStops: (stops) => set({ stops }),
+
+ // ── Geolocation ──
+ userLocation: null, // { lat, lon }
+ geoPermission: 'prompt', // 'prompt' | 'granted' | 'denied'
+
+ setUserLocation: (loc) => set({ userLocation: loc }),
+ setGeoPermission: (p) => set({ geoPermission: p }),
+
+ // ── Map viewport (for search bias) ──
+ mapCenter: null, // { lat, lon, zoom }
+ setMapCenter: (center) => set({ mapCenter: center }),
+
+ // ── Mode ──
+ mode: 'auto', // 'auto' | 'pedestrian' | 'bicycle'
+ setMode: (mode) => set({ mode }),
+
+ // ── Route ──
+ route: null, // Valhalla response (trip object)
+ routeLoading: false,
+ routeError: null,
+
+ setRoute: (route) => set({ route, routeError: null }),
+ setRouteLoading: (loading) => set({ routeLoading: loading }),
+ setRouteError: (err) => set({ routeError: err, route: null }),
+ clearRoute: () => set({ route: null, routeError: null }),
+
+ // ── Place detail ──
+ selectedPlace: null, // { lat, lon, name, address, type, source, matchCode, raw, mode?, featureId?, featureLayer?, wikidata? }
+ clickMarker: null, // { lat, lon, circleRadiusPx } — visual marker for two-click selection
+ gpsOrigin: true, // whether GPS should be used as origin when available
+ pendingDestination: null, // place waiting for a starting point (GPS-denied Directions flow)
+
+ setSelectedPlace: (place) => set({ selectedPlace: place }),
+ clearSelectedPlace: () => set({ selectedPlace: null, clickMarker: null }),
+ setClickMarker: (marker) => set({ clickMarker: marker }),
+ clearClickMarker: () => set({ clickMarker: null }),
+ setGpsOrigin: (val) => set({ gpsOrigin: val }),
+ setPendingDestination: (place) => set({ pendingDestination: place }),
+ clearPendingDestination: () => set({ pendingDestination: null }),
+
+ startDirections: (place) => {
+ const { geoPermission, stops, addStop, clearStops } = get()
+ if (geoPermission === 'granted') {
+ clearStops()
+ addStop({ lat: place.lat, lon: place.lon, name: place.name, source: place.source, matchCode: place.matchCode })
+ set({ gpsOrigin: true, selectedPlace: null })
+ } else if (stops.length > 0) {
+ const origin = stops[0]
+ clearStops()
+ addStop({ lat: origin.lat, lon: origin.lon, name: origin.name, source: origin.source, matchCode: origin.matchCode })
+ addStop({ lat: place.lat, lon: place.lon, name: place.name, source: place.source, matchCode: place.matchCode })
+ set({ selectedPlace: null })
+ } else {
+ set({ pendingDestination: place, selectedPlace: null })
+ }
+ },
+
+ // ── UI state ──
+ sheetState: 'half', // 'collapsed' | 'half' | 'full'
+ panelOpen: true,
+ autocompleteOpen: false,
+ theme: 'dark', // 'dark' | 'light' (resolved value — what's actually applied)
+ themeOverride: null, // null | 'dark' | 'light' (manual override, persisted)
+
+ setSheetState: (s) => set({ sheetState: s }),
+ setPanelOpen: (open) => set({ panelOpen: open }),
+ setAutocompleteOpen: (open) => set({ autocompleteOpen: open }),
+ setTheme: (theme) => set({ theme }),
+ setThemeOverride: (override) => {
+ set({ themeOverride: override })
+ if (override) {
+ localStorage.setItem('navi-theme-override', override)
+ } else {
+ localStorage.removeItem('navi-theme-override')
+ }
+ },
+ // ── Contacts ──
+ contacts: [],
+ contactsLoaded: false,
+ activeTab: 'routes', // 'routes' | 'contacts'
+ editingContact: null, // null=closed, {}=new, {id:N}=edit
+
+ setContacts: (c) => set({ contacts: c, contactsLoaded: true }),
+ setActiveTab: (tab) => set({ activeTab: tab }),
+ setEditingContact: (c) => set({ editingContact: c }),
+ clearEditingContact: () => set({ editingContact: null }),
+}))
+
+// ── Panel state selector ──
+// IDLE | PREVIEW | ROUTING | PREVIEW_ROUTING | ROUTE_CALCULATED
+export const usePanelState = () => {
+ return useStore((s) => {
+ if (s.route) return "ROUTE_CALCULATED"
+ if (s.selectedPlace && s.stops.length >= 1) return "PREVIEW_ROUTING"
+ if (s.selectedPlace) return "PREVIEW"
+ if (s.stops.length >= 1) return "ROUTING"
+ return "IDLE"
+ })
+}
diff --git a/src/store.js.bak.viewport b/src/store.js.bak.viewport
index 1dec344..42f90c3 100644
--- a/src/store.js.bak.viewport
+++ b/src/store.js.bak.viewport
@@ -40,6 +40,10 @@ export const useStore = create((set, get) => ({
setUserLocation: (loc) => set({ userLocation: loc }),
setGeoPermission: (p) => set({ geoPermission: p }),
+ // ── Map viewport (for search bias) ──
+ mapCenter: null, // { lat, lon, zoom }
+ setMapCenter: (center) => set({ mapCenter: center }),
+
// ── Mode ──
mode: 'auto', // 'auto' | 'pedestrian' | 'bicycle'
setMode: (mode) => set({ mode }),
@@ -55,12 +59,15 @@ export const useStore = create((set, get) => ({
clearRoute: () => set({ route: null, routeError: null }),
// ── Place detail ──
- selectedPlace: null, // { lat, lon, name, address, type, source, matchCode, raw }
+ selectedPlace: null, // { lat, lon, name, address, type, source, matchCode, raw, mode?, featureId?, featureLayer?, wikidata? }
+ clickMarker: null, // { lat, lon, circleRadiusPx } — visual marker for two-click selection
gpsOrigin: true, // whether GPS should be used as origin when available
pendingDestination: null, // place waiting for a starting point (GPS-denied Directions flow)
setSelectedPlace: (place) => set({ selectedPlace: place }),
- clearSelectedPlace: () => set({ selectedPlace: null }),
+ clearSelectedPlace: () => set({ selectedPlace: null, clickMarker: null }),
+ setClickMarker: (marker) => set({ clickMarker: marker }),
+ clearClickMarker: () => set({ clickMarker: null }),
setGpsOrigin: (val) => set({ gpsOrigin: val }),
setPendingDestination: (place) => set({ pendingDestination: place }),
clearPendingDestination: () => set({ pendingDestination: null }),
@@ -78,6 +85,7 @@ export const useStore = create((set, get) => ({
addStop({ lat: place.lat, lon: place.lon, name: place.name, source: place.source, matchCode: place.matchCode })
set({ selectedPlace: null })
} else {
+ // GPS denied, no stops: set pendingDestination only; origin-picker will add both
set({ pendingDestination: place, selectedPlace: null })
}
},
@@ -112,3 +120,20 @@ export const useStore = create((set, get) => ({
setEditingContact: (c) => set({ editingContact: c }),
clearEditingContact: () => set({ editingContact: null }),
}))
+
+// ── Panel state selector ──
+// Returns string state, prioritizing preview to allow it alongside any route state
+export const usePanelState = () => {
+ return useStore((s) => {
+ const hasPreview = !!s.selectedPlace
+ const hasRoute = !!s.route
+ const hasStops = s.stops.length >= 1
+
+ if (hasPreview && hasRoute) return "PREVIEW_CALCULATED"
+ if (hasPreview && hasStops) return "PREVIEW_ROUTING"
+ if (hasPreview) return "PREVIEW"
+ if (hasRoute) return "ROUTE_CALCULATED"
+ if (hasStops) return "ROUTING"
+ return "IDLE"
+ })
+}