From 0d4a807a05063994aabbf23fd8ceec425b7b7589 Mon Sep 17 00:00:00 2001 From: Matt Date: Mon, 27 Apr 2026 01:26:05 +0000 Subject: [PATCH] feat: add auth-state awareness and graceful degradation - Add /api/auth/whoami endpoint check on app load - Store auth state in Zustand (authenticated, username, loaded) - Hide Contacts tab when unauthenticated - Gate fetchNearbyContacts calls on auth.authenticated - Replace Save button with Log in affordance when unauthenticated - Add Login/Logout buttons to panel header - Prevent any /api/contacts/* requests from firing when unauthenticated Public functionality (search, routing, place details) remains fully functional for unauthenticated users. --- src/App.jsx | 8 +- src/api.js | 24 + src/api.js.bak.viewport | 18 +- src/components/ContactList.jsx | 36 +- src/components/MapView.jsx.bak.boundary | 1403 +++++++++++++++++ src/components/MapView.jsx.bak.labelclick | 1328 ++++++++++++++++ src/components/MapView.jsx.bak.poihover | 1315 ++++++++++++++++ src/components/MapView.jsx.bak.regressions | 1514 +++++++++++++++++++ src/components/MapView.jsx.bak.twoclick | 1256 +++++++++++++++ src/components/MapView.jsx.bak.uxfix2 | 1514 +++++++++++++++++++ src/components/Panel.jsx | 599 ++++---- src/components/Panel.jsx.bak.regressions | 283 ++++ src/components/Panel.jsx.bak.uxfix | 283 ++++ src/components/Panel.jsx.bak.uxfix2 | 283 ++++ src/components/Panel.jsx.bak.viewport | 283 ++++ src/components/PlaceCard.jsx | 18 +- src/components/PlaceCard.jsx.bak.uxfix | 434 ++++++ src/components/PlaceCard.jsx.bak.uxfix2 | 434 ++++++ src/components/PlaceDetail.jsx | 39 +- src/components/PlaceDetail.jsx.bak.boundary | 798 ++++++++++ src/components/SearchBar.jsx.bak.twoclick | 320 ++++ src/components/StopList.jsx.bak.pending | 117 ++ src/index.css.bak.twoclick | 537 +++++++ src/store.js | 4 + src/store.js.bak.dupstop | 141 ++ src/store.js.bak.regressions | 139 ++ src/store.js.bak.twoclick | 118 ++ src/store.js.bak.uxfix | 133 ++ src/store.js.bak.viewport | 29 +- 29 files changed, 13091 insertions(+), 317 deletions(-) create mode 100644 src/components/MapView.jsx.bak.boundary create mode 100644 src/components/MapView.jsx.bak.labelclick create mode 100644 src/components/MapView.jsx.bak.poihover create mode 100644 src/components/MapView.jsx.bak.regressions create mode 100644 src/components/MapView.jsx.bak.twoclick create mode 100644 src/components/MapView.jsx.bak.uxfix2 create mode 100644 src/components/Panel.jsx.bak.regressions create mode 100644 src/components/Panel.jsx.bak.uxfix create mode 100644 src/components/Panel.jsx.bak.uxfix2 create mode 100644 src/components/Panel.jsx.bak.viewport create mode 100644 src/components/PlaceCard.jsx.bak.uxfix create mode 100644 src/components/PlaceCard.jsx.bak.uxfix2 create mode 100644 src/components/PlaceDetail.jsx.bak.boundary create mode 100644 src/components/SearchBar.jsx.bak.twoclick create mode 100644 src/components/StopList.jsx.bak.pending create mode 100644 src/index.css.bak.twoclick create mode 100644 src/store.js.bak.dupstop create mode 100644 src/store.js.bak.regressions create mode 100644 src/store.js.bak.twoclick create mode 100644 src/store.js.bak.uxfix 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 +

+
) } 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} +
+
` + ) + .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} +
+
` + ) + .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} +
+
` + ) + .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} +
+
` + ) + .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} +
+
` + ) + .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} +
+
` + ) + .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 && ( - - )} -
- - )} - - {/* Maneuvers when route is calculated */} - {showManeuvers && (route || routeLoading || routeError) && ( -
- -
- )} - - {/* Empty state */} - {showEmptyState && ( -
-

Search or tap the map to explore

-
- )} - - ) - - const content = ( - <> - {showContacts && ( -
- - -
- )} - - {(!showContacts || activeTab === 'routes') ? routesContent : } - - ) - - const header = ( -
-

Navi

- -
- ) - - // 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 && ( + + )} +
+ + )} + + {/* Maneuvers when route is calculated */} + {showManeuvers && (route || routeLoading || routeError) && ( +
+ +
+ )} + + {/* Empty state */} + {showEmptyState && ( +
+

Search or tap the map to explore

+
+ )} + + ) + + const content = ( + <> + {showContacts && ( +
+ + +
+ )} + + {(!showContacts || activeTab === 'routes') ? routesContent : } + + ) + + const header = ( +
+

Navi

+
+ {auth.loaded && ( + auth.authenticated ? ( + + ) : ( + + ) + )} + +
+
+ ) + + // 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 && ( + + )} +
+ + )} + + {/* Maneuvers when route is calculated */} + {showManeuvers && (route || routeLoading || routeError) && ( +
+ +
+ )} + + {/* Empty state */} + {showEmptyState && ( +
+

Search or tap the map to explore

+
+ )} + + ) + + const content = ( + <> + {showContacts && ( +
+ + +
+ )} + + {(!showContacts || activeTab === 'routes') ? routesContent : } + + ) + + const header = ( +
+

Navi

+ +
+ ) + + // 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 && ( + + )} +
+ + )} + + {/* Maneuvers when route is calculated */} + {showManeuvers && (route || routeLoading || routeError) && ( +
+ +
+ )} + + {/* Empty state */} + {showEmptyState && ( +
+

Search or tap the map to explore

+
+ )} + + ) + + const content = ( + <> + {showContacts && ( +
+ + +
+ )} + + {(!showContacts || activeTab === 'routes') ? routesContent : } + + ) + + const header = ( +
+

Navi

+ +
+ ) + + // 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 && ( + + )} +
+ + )} + + {/* Maneuvers when route is calculated */} + {showManeuvers && (route || routeLoading || routeError) && ( +
+ +
+ )} + + {/* Empty state */} + {showEmptyState && ( +
+

Search or tap the map to explore

+
+ )} + + ) + + const content = ( + <> + {showContacts && ( +
+ + +
+ )} + + {(!showContacts || activeTab === 'routes') ? routesContent : } + + ) + + const header = ( +
+

Navi

+ +
+ ) + + // 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 && ( + + )} +
+ + )} + + {/* Maneuvers when route is calculated */} + {showManeuvers && (route || routeLoading || routeError) && ( +
+ +
+ )} + + {/* Empty state */} + {showEmptyState && ( +
+

Search or tap the map to explore

+
+ )} + + ) + + const content = ( + <> + {showContacts && ( +
+ + +
+ )} + + {(!showContacts || activeTab === 'routes') ? routesContent : } + + ) + + const header = ( +
+

Navi

+ +
+ ) + + // 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 && } - + {auth.authenticated ? ( + + ) : ( + + )}
{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 ( + + + {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 && ( + +
+ {et.wikipedia && wikiUrl(et.wikipedia) && {wikiLabel(et.wikipedia)}} + {et.wikidata && View on Wikidata} +
+
+ )} +
+ ) +} + +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 ( +
+ + +
+ ) +} + +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 && } +
+ ) + } + + 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" && ( + <> + + {existingStopIndex >= 0 ? ( + Stop {String.fromCharCode(65 + existingStopIndex)} + ) : ( + + )} + + )} + {variant === "stop" && onRemove && } + +
+ + {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 ( + + + {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 && ( + +
+ {et.wikipedia && wikiUrl(et.wikipedia) && {wikiLabel(et.wikipedia)}} + {et.wikidata && View on Wikidata} +
+
+ )} +
+ ) +} + +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 ( +
+ + +
+ ) +} + +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 && } +
+ ) + } + + 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" && ( + <> + + {existingStopIndex >= 0 ? ( + Stop {String.fromCharCode(65 + existingStopIndex)} + ) : ( + + )} + + )} + {variant === "stop" && onRemove && } + +
+ + {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 ? ( + + ) : ( + + )} {/* 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 ( + + + {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 ( +
+ + +
+ ) +} + +// ── 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 && ( + +
+ {et.wikipedia && wikiUrl(et.wikipedia) && ( + + + {wikiLabel(et.wikipedia)} + + )} + {et.wikidata && ( + + Wikidata: {et.wikidata} + + )} +
+
+ )} +
+ ) +} + +// ── 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 */} +
+ + + {existingStopIndex >= 0 ? ( + + Added as stop {String.fromCharCode(65 + existingStopIndex)} + + ) : ( + + )} + + + + {/* Copy dropdown */} +
+ + {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 && ( +
    + {results.map((r, i) => { + const isPoi = r.type === 'poi' && r.raw?.name + const isContact = r._isContact + const primary = isContact ? r.name : isPoi ? r.raw.name : r.name + const secondary = isContact ? (r.address || '') : isPoi ? buildAddress(r) : null + return ( +
  • selectResult(r)} + onMouseEnter={() => setActiveIndex(i)} + > +
    + + + + + {primary} + + + {isContact && ( + + saved + + )} + {r.match_code?.housenumber === 'matched' && ( + + exact + + )} + +
    + {secondary && ( +
    + {secondary} +
    + )} +
  • + ) + })} +
+ )} +
+ ) +}) + +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" + }) +}