From a40f68fa26eeee9c07f841cf8c248dcf3c22e028 Mon Sep 17 00:00:00 2001 From: Matt Date: Mon, 27 Apr 2026 02:50:46 +0000 Subject: [PATCH] fix: resolve 5 confirmed bugs from code review - MapView.jsx: extract addBoundaryLayer function, use getComputedStyle for accent color (MapLibre rejects CSS vars in paint properties) - PlaceCard.jsx: gate fetchNearbyContacts on auth.authenticated - PlaceDetail.jsx: gate fetchNearbyContacts on auth.authenticated - api.js: replace invalid timeout option with AbortSignal.timeout() - RadialMenu.jsx: remove user-select from SVG style (Firefox rejects) - Panel.jsx: add Cancel button for pending directions state --- src/api.js | 52 +- src/api.js.bak.viewport | 278 --- src/components/LayerControl.jsx.bak | 189 -- .../LayerControl.jsx.bak.contour-test | 229 --- src/components/MapView.jsx | 58 +- src/components/MapView.jsx.bak | 729 -------- src/components/MapView.jsx.bak.5wedge | 1283 -------------- src/components/MapView.jsx.bak.boundary | 1403 --------------- src/components/MapView.jsx.bak.contour-test | 854 ---------- src/components/MapView.jsx.bak.labelclick | 1328 --------------- src/components/MapView.jsx.bak.poihover | 1315 -------------- src/components/MapView.jsx.bak.radial | 1162 ------------- src/components/MapView.jsx.bak.regressions | 1514 ----------------- src/components/MapView.jsx.bak.twoclick | 1256 -------------- src/components/MapView.jsx.bak.uxfix2 | 1514 ----------------- src/components/MapView.jsx.bak.viewport | 1134 ------------ src/components/MapView.jsx.bak.zoom | 973 ----------- src/components/Panel.jsx | 639 +++---- 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 | 2 +- src/components/PlaceCard.jsx.bak.uxfix | 434 ----- src/components/PlaceCard.jsx.bak.uxfix2 | 434 ----- src/components/PlaceDetail.jsx | 2 +- src/components/PlaceDetail.jsx.bak.boundary | 798 --------- src/components/RadialMenu.jsx | 701 ++++---- src/components/RadialMenu.jsx.bak.5wedge | 351 ---- src/components/SearchBar.jsx.bak.twoclick | 320 ---- src/components/SearchBar.jsx.bak.viewport | 319 ---- src/components/StopList.jsx.bak.pending | 117 -- src/config.js.bak | 86 - src/index.css.bak.twoclick | 537 ------ 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 | 139 -- 39 files changed, 728 insertions(+), 21085 deletions(-) delete mode 100644 src/api.js.bak.viewport delete mode 100644 src/components/LayerControl.jsx.bak delete mode 100644 src/components/LayerControl.jsx.bak.contour-test delete mode 100644 src/components/MapView.jsx.bak delete mode 100644 src/components/MapView.jsx.bak.5wedge delete mode 100644 src/components/MapView.jsx.bak.boundary delete mode 100644 src/components/MapView.jsx.bak.contour-test delete mode 100644 src/components/MapView.jsx.bak.labelclick delete mode 100644 src/components/MapView.jsx.bak.poihover delete mode 100644 src/components/MapView.jsx.bak.radial delete mode 100644 src/components/MapView.jsx.bak.regressions delete mode 100644 src/components/MapView.jsx.bak.twoclick delete mode 100644 src/components/MapView.jsx.bak.uxfix2 delete mode 100644 src/components/MapView.jsx.bak.viewport delete mode 100644 src/components/MapView.jsx.bak.zoom delete mode 100644 src/components/Panel.jsx.bak.regressions delete mode 100644 src/components/Panel.jsx.bak.uxfix delete mode 100644 src/components/Panel.jsx.bak.uxfix2 delete mode 100644 src/components/Panel.jsx.bak.viewport delete mode 100644 src/components/PlaceCard.jsx.bak.uxfix delete mode 100644 src/components/PlaceCard.jsx.bak.uxfix2 delete mode 100644 src/components/PlaceDetail.jsx.bak.boundary delete mode 100644 src/components/RadialMenu.jsx.bak.5wedge delete mode 100644 src/components/SearchBar.jsx.bak.twoclick delete mode 100644 src/components/SearchBar.jsx.bak.viewport delete mode 100644 src/components/StopList.jsx.bak.pending delete mode 100644 src/config.js.bak delete mode 100644 src/index.css.bak.twoclick delete mode 100644 src/store.js.bak.dupstop delete mode 100644 src/store.js.bak.regressions delete mode 100644 src/store.js.bak.twoclick delete mode 100644 src/store.js.bak.uxfix delete mode 100644 src/store.js.bak.viewport diff --git a/src/api.js b/src/api.js index d933c20..4548dce 100644 --- a/src/api.js +++ b/src/api.js @@ -25,7 +25,7 @@ export async function searchGeocode(query, limit = 6, signal) { if (mapCenter?.zoom != null && Number.isFinite(mapCenter.zoom)) { params.set('zoom', String(Math.round(mapCenter.zoom))) } - const resp = await fetch(`${GEOCODE_URL}?${params}`, { signal, timeout: 5000 }) + const resp = await fetch(`${GEOCODE_URL}?${params}`, { signal: signal ?? AbortSignal.timeout(5000) }) if (!resp.ok) throw new Error(`Geocode error: ${resp.status}`) return resp.json() } @@ -135,7 +135,7 @@ const REVERSE_URL = "/api/reverse" export async function fetchReverse(lat, lon) { try { const params = new URLSearchParams({ lat: String(lat), lon: String(lon) }) - const resp = await fetch(`${REVERSE_URL}?${params}`, { timeout: 5000 }) + const resp = await fetch(`${REVERSE_URL}?${params}`, { signal: AbortSignal.timeout(5000) }) if (!resp.ok) return null const data = await resp.json() if (!data.results || data.results.length === 0) return null @@ -286,27 +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 } - } -} + + +// ── 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 deleted file mode 100644 index f67a9d5..0000000 --- a/src/api.js.bak.viewport +++ /dev/null @@ -1,278 +0,0 @@ -const GEOCODE_URL = '/api/geocode' -const VALHALLA_URL = '/valhalla/route' -const VALHALLA_OPTIMIZED_URL = '/valhalla/optimized_route' -const VALHALLA_HEIGHT_URL = '/valhalla/height' - -/** - * Search geocode API with abort support. - * @param {string} query - * @param {number} limit - * @param {AbortSignal} signal - * @returns {Promise<{query, results, count}>} - */ -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() -} - -/** - * Request a route from Valhalla. - * @param {Array<{lat, lon}>} locations - * @param {string} costing - 'auto' | 'pedestrian' | 'bicycle' - * @returns {Promise} Valhalla trip response - */ -export async function requestRoute(locations, costing = 'auto') { - const body = { - locations: locations.map((l) => ({ lat: l.lat, lon: l.lon })), - costing, - units: 'miles', - directions_options: { units: 'miles' }, - } - - const controller = new AbortController() - const timeout = setTimeout(() => controller.abort(), 30000) - - try { - const resp = await fetch(VALHALLA_URL, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - signal: controller.signal, - }) - - if (!resp.ok) { - const errBody = await resp.json().catch(() => ({})) - throw new Error(errBody.error || errBody.status_message || `Route error: ${resp.status}`) - } - - return resp.json() - } finally { - clearTimeout(timeout) - } -} - -/** - * Request an optimized route from Valhalla. - * @param {Array<{lat, lon}>} locations - * @param {string} costing - * @returns {Promise} Valhalla optimized trip response - */ -export async function requestOptimizedRoute(locations, costing = 'auto') { - const body = { - locations: locations.map((l) => ({ lat: l.lat, lon: l.lon })), - costing, - units: 'miles', - directions_options: { units: 'miles' }, - } - - const controller = new AbortController() - const timeout = setTimeout(() => controller.abort(), 30000) - - try { - const resp = await fetch(VALHALLA_OPTIMIZED_URL, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - signal: controller.signal, - }) - - if (!resp.ok) { - const errBody = await resp.json().catch(() => ({})) - throw new Error(errBody.error || errBody.status_message || `Optimize error: ${resp.status}`) - } - - return resp.json() - } finally { - clearTimeout(timeout) - } -} - -/** - * Fetch elevation for a point via Valhalla height API. - * @param {number} lat - * @param {number} lon - * @returns {Promise} Height in meters, or null on error - */ -export async function fetchElevation(lat, lon) { - try { - const resp = await fetch(VALHALLA_HEIGHT_URL, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ shape: [{ lat, lon }], resample_distance: 100 }), - }) - if (!resp.ok) return null - const data = await resp.json() - if (data.height && data.height.length > 0) return data.height[0] - return null - } catch { - return null - } -} - -const REVERSE_URL = "/api/reverse" - -/** - * Reverse geocode a point. Returns a place object or null. - * @param {number} lat - * @param {number} lon - * @returns {Promise<{lat, lon, name, address, type, source, raw}|null>} - */ -export async function fetchReverse(lat, lon) { - try { - const params = new URLSearchParams({ lat: String(lat), lon: String(lon) }) - const resp = await fetch(`${REVERSE_URL}?${params}`, { timeout: 5000 }) - if (!resp.ok) return null - const data = await resp.json() - if (!data.results || data.results.length === 0) return null - const r = data.results[0] - return { - lat: r.lat, - lon: r.lon, - name: r.name, - address: null, - type: r.type, - source: r.source, - matchCode: null, - raw: r.raw || {}, - } - } catch { - return null - } -} - - -/** - * Fetch drive time between two points via Valhalla route. - * @param {number} oLat - Origin latitude - * @param {number} oLon - Origin longitude - * @param {number} dLat - Destination latitude - * @param {number} dLon - Destination longitude - * @param {AbortSignal} signal - AbortController signal - * @returns {Promise} Drive time in seconds, or null on error - */ -export async function fetchDriveTime(oLat, oLon, dLat, dLon, signal) { - try { - const resp = await fetch(VALHALLA_URL, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - locations: [{ lat: oLat, lon: oLon }, { lat: dLat, lon: dLon }], - costing: 'auto', - }), - signal, - }) - if (!resp.ok) return null - const data = await resp.json() - return data.trip?.summary?.time ?? null - } catch { - return null - } -} - -/** - * Fetch enriched place details from the place detail proxy. - * @param {string} osmType - N, W, or R - * @param {number} osmId - OSM element ID - * @param {AbortSignal} signal - AbortController signal for cancellation - * @returns {Promise} Cleaned place detail object, or null on error - */ -export async function fetchPlaceDetails(osmType, osmId, signal) { - try { - const resp = await fetch(`/api/place/${osmType}/${osmId}`, { - signal, - headers: { 'Accept': 'application/json' }, - }) - if (!resp.ok) return null - return resp.json() - } catch { - return null - } -} - -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) { - try { - const resp = await fetch('/api/contacts', { signal }) - if (resp.status === 401) return { auth: false } - if (!resp.ok) throw new Error(`Contacts error: ${resp.status}`) - return resp.json() - } catch (e) { - if (e.name === 'AbortError') throw e - return [] - } -} - -export async function createContact(data) { - const resp = await fetch('/api/contacts', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(data), - }) - if (resp.status === 401) return { auth: false } - return resp.json().then((d) => ({ ...d, _status: resp.status })) -} - -export async function updateContact(id, data) { - const resp = await fetch(`/api/contacts/${id}`, { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(data), - }) - if (resp.status === 401) return { auth: false } - return resp.json() -} - -export async function deleteContact(id) { - const resp = await fetch(`/api/contacts/${id}`, { method: 'DELETE' }) - if (resp.status === 401) return { auth: false } - return resp.json() -} - -export async function fetchNearbyContacts(lat, lon, radiusM, signal) { - try { - const params = new URLSearchParams({ lat: String(lat), lon: String(lon), radius_m: String(radiusM) }) - const resp = await fetch(`/api/contacts/nearby?${params}`, { signal }) - if (resp.status === 401) return [] - if (!resp.ok) return [] - return resp.json() - } catch { - return [] - } -} - -/** - * Fetch PAD-US land classification for a point. - * @param {number} lat - * @param {number} lon - * @param {AbortSignal} signal - * @returns {Promise} Classification data or null on error - */ -export async function fetchLandclass(lat, lon, signal) { - try { - const params = new URLSearchParams({ lat: String(lat), lon: String(lon) }) - const resp = await fetch(`/api/landclass?${params}`, { signal }) - if (!resp.ok) return null - return resp.json() - } catch { - return null - } -} diff --git a/src/components/LayerControl.jsx.bak b/src/components/LayerControl.jsx.bak deleted file mode 100644 index 2e234cc..0000000 --- a/src/components/LayerControl.jsx.bak +++ /dev/null @@ -1,189 +0,0 @@ -import { useState, useEffect, useRef } from 'react' -import { Layers, Trees } from 'lucide-react' -import { hasFeature, getConfig } from '../config' - -const STORAGE_KEY = 'navi-layer-prefs' - -function loadPrefs() { - try { - const raw = localStorage.getItem(STORAGE_KEY) - if (raw) return JSON.parse(raw) - } catch {} - return null -} - -function savePrefs(prefs) { - localStorage.setItem(STORAGE_KEY, JSON.stringify(prefs)) -} - -export default function LayerControl({ mapRef }) { - const [open, setOpen] = useState(false) - const [hillshade, setHillshade] = useState(false) - const [traffic, setTraffic] = useState(false) - const [publicLands, setPublicLands] = useState(false) - const panelRef = useRef(null) - - // Initialize from localStorage or defaults on mount - useEffect(() => { - const saved = loadPrefs() - const hsAvailable = hasFeature('has_hillshade') - const trAvailable = hasFeature('has_traffic_overlay') - - const plAvailable = hasFeature('has_public_lands_layer') - - if (saved) { - setHillshade(hsAvailable && (saved.hillshade ?? true)) - setTraffic(trAvailable && (saved.traffic ?? false)) - setPublicLands(plAvailable && (saved.publicLands ?? false)) - } else { - // Defaults: hillshade ON if available, traffic + publicLands OFF - setHillshade(hsAvailable) - setTraffic(false) - setPublicLands(false) - } - }, []) - - // Apply layers when prefs change - useEffect(() => { - const mapView = mapRef?.current - if (!mapView) return - const map = mapView.getMap?.() - if (!map) return - - const apply = () => { - if (hillshade && hasFeature('has_hillshade')) { - mapView.addHillshadeLayer?.() - } else { - mapView.removeHillshadeLayer?.() - } - } - - if (map.isStyleLoaded()) { - apply() - } else { - map.once('style.load', apply) - } - savePrefs({ hillshade, traffic, publicLands }) - return () => map.off('style.load', apply) - }, [hillshade, mapRef]) - - useEffect(() => { - const mapView = mapRef?.current - if (!mapView) return - const map = mapView.getMap?.() - if (!map) return - - const apply = () => { - if (traffic && hasFeature('has_traffic_overlay')) { - mapView.addTrafficLayer?.() - } else { - mapView.removeTrafficLayer?.() - } - } - - if (map.isStyleLoaded()) { - apply() - } else { - map.once('style.load', apply) - } - savePrefs({ hillshade, traffic, publicLands }) - return () => map.off('style.load', apply) - }, [traffic, mapRef]) - - useEffect(() => { - const mapView = mapRef?.current - if (!mapView) return - const map = mapView.getMap?.() - if (!map) return - - const apply = () => { - if (publicLands && hasFeature('has_public_lands_layer')) { - mapView.addPublicLandsLayer?.() - } else { - mapView.removePublicLandsLayer?.() - } - } - - if (map.isStyleLoaded()) { - apply() - } else { - map.once('style.load', apply) - } - savePrefs({ hillshade, traffic, publicLands }) - return () => map.off('style.load', apply) - }, [publicLands, mapRef]) - - // Close on outside click - useEffect(() => { - if (!open) return - function handleClick(e) { - if (panelRef.current && !panelRef.current.contains(e.target)) { - setOpen(false) - } - } - document.addEventListener('pointerdown', handleClick) - return () => document.removeEventListener('pointerdown', handleClick) - }, [open]) - - const showHillshade = hasFeature('has_hillshade') - const showTraffic = hasFeature('has_traffic_overlay') - const showPublicLands = hasFeature('has_public_lands_layer') - - // Don't render if no overlay features available - if (!showHillshade && !showTraffic && !showPublicLands) return null - - return ( -
- - - {open && ( -
-
Layers
- - {showHillshade && ( - - )} - - {showTraffic && ( - - )} - - {showPublicLands && ( - - )} -
- )} -
- ) -} diff --git a/src/components/LayerControl.jsx.bak.contour-test b/src/components/LayerControl.jsx.bak.contour-test deleted file mode 100644 index 3030d0d..0000000 --- a/src/components/LayerControl.jsx.bak.contour-test +++ /dev/null @@ -1,229 +0,0 @@ -import { useState, useEffect, useRef } from 'react' -import { Layers, Trees, Mountain } from 'lucide-react' -import { hasFeature, getConfig } from '../config' - -const STORAGE_KEY = 'navi-layer-prefs' - -function loadPrefs() { - try { - const raw = localStorage.getItem(STORAGE_KEY) - if (raw) return JSON.parse(raw) - } catch {} - return null -} - -function savePrefs(prefs) { - localStorage.setItem(STORAGE_KEY, JSON.stringify(prefs)) -} - -export default function LayerControl({ mapRef }) { - const [open, setOpen] = useState(false) - const [hillshade, setHillshade] = useState(false) - const [traffic, setTraffic] = useState(false) - const [publicLands, setPublicLands] = useState(false) - const [contours, setContours] = useState(false) - const panelRef = useRef(null) - - // Initialize from localStorage or defaults on mount - useEffect(() => { - const saved = loadPrefs() - const hsAvailable = hasFeature('has_hillshade') - const trAvailable = hasFeature('has_traffic_overlay') - - const plAvailable = hasFeature('has_public_lands_layer') - const ctAvailable = hasFeature('has_contours') - - if (saved) { - setHillshade(hsAvailable && (saved.hillshade ?? true)) - setTraffic(trAvailable && (saved.traffic ?? false)) - setPublicLands(plAvailable && (saved.publicLands ?? false)) - setContours(ctAvailable && (saved.contours ?? false)) - } else { - // Defaults: hillshade ON if available, others OFF - setHillshade(hsAvailable) - setTraffic(false) - setPublicLands(false) - setContours(false) - } - }, []) - - // Apply layers when prefs change - useEffect(() => { - const mapView = mapRef?.current - if (!mapView) return - const map = mapView.getMap?.() - if (!map) return - - const apply = () => { - if (hillshade && hasFeature('has_hillshade')) { - mapView.addHillshadeLayer?.() - } else { - mapView.removeHillshadeLayer?.() - } - } - - if (map.isStyleLoaded()) { - apply() - } else { - map.once('style.load', apply) - } - savePrefs({ hillshade, traffic, publicLands, contours }) - return () => map.off('style.load', apply) - }, [hillshade, mapRef]) - - useEffect(() => { - const mapView = mapRef?.current - if (!mapView) return - const map = mapView.getMap?.() - if (!map) return - - const apply = () => { - if (traffic && hasFeature('has_traffic_overlay')) { - mapView.addTrafficLayer?.() - } else { - mapView.removeTrafficLayer?.() - } - } - - if (map.isStyleLoaded()) { - apply() - } else { - map.once('style.load', apply) - } - savePrefs({ hillshade, traffic, publicLands, contours }) - return () => map.off('style.load', apply) - }, [traffic, mapRef]) - - useEffect(() => { - const mapView = mapRef?.current - if (!mapView) return - const map = mapView.getMap?.() - if (!map) return - - const apply = () => { - if (publicLands && hasFeature('has_public_lands_layer')) { - mapView.addPublicLandsLayer?.() - } else { - mapView.removePublicLandsLayer?.() - } - } - - if (map.isStyleLoaded()) { - apply() - } else { - map.once('style.load', apply) - } - savePrefs({ hillshade, traffic, publicLands, contours }) - return () => map.off('style.load', apply) - }, [publicLands, mapRef]) - - useEffect(() => { - const mapView = mapRef?.current - if (!mapView) return - const map = mapView.getMap?.() - if (!map) return - - const apply = () => { - if (contours && hasFeature('has_contours')) { - mapView.addContoursLayer?.() - } else { - mapView.removeContoursLayer?.() - } - } - - if (map.isStyleLoaded()) { - apply() - } else { - map.once('style.load', apply) - } - savePrefs({ hillshade, traffic, publicLands, contours }) - return () => map.off('style.load', apply) - }, [contours, mapRef]) - - // Close on outside click - useEffect(() => { - if (!open) return - function handleClick(e) { - if (panelRef.current && !panelRef.current.contains(e.target)) { - setOpen(false) - } - } - document.addEventListener('pointerdown', handleClick) - return () => document.removeEventListener('pointerdown', handleClick) - }, [open]) - - const showHillshade = hasFeature('has_hillshade') - const showTraffic = hasFeature('has_traffic_overlay') - const showPublicLands = hasFeature('has_public_lands_layer') - const showContours = hasFeature('has_contours') - - // Don't render if no overlay features available - if (!showHillshade && !showTraffic && !showPublicLands && !showContours) return null - - return ( -
- - - {open && ( -
-
Layers
- - {showHillshade && ( - - )} - - {showTraffic && ( - - )} - - {showPublicLands && ( - - )} - - {showContours && ( - - )} -
- )} -
- ) -} diff --git a/src/components/MapView.jsx b/src/components/MapView.jsx index 31eb6cb..2dee026 100644 --- a/src/components/MapView.jsx +++ b/src/components/MapView.jsx @@ -593,6 +593,28 @@ function removeContoursTest10ft(map) { if (map.getLayer(CONTOUR_TEST_10FT_MINOR)) map.removeLayer(CONTOUR_TEST_10FT_MINOR) if (map.getSource(CONTOUR_TEST_10FT_SOURCE)) map.removeSource(CONTOUR_TEST_10FT_SOURCE) } +/** Add boundary polygon layer with computed accent color (MapLibre rejects CSS vars in paint) */ +function addBoundaryLayer(map) { + if (!map || map.getLayer(BOUNDARY_LAYER)) return + if (!map.getSource(BOUNDARY_SOURCE)) { + map.addSource(BOUNDARY_SOURCE, { + type: "geojson", + data: { type: "FeatureCollection", features: [] }, + }) + } + const accentColor = getComputedStyle(document.documentElement).getPropertyValue("--accent").trim() || "#7a9a6b" + map.addLayer({ + id: BOUNDARY_LAYER, + type: "line", + source: BOUNDARY_SOURCE, + paint: { + "line-color": accentColor, + "line-width": 2, + "line-opacity": 0.7, + "line-dasharray": [3, 2], + }, + }) +} const MapView = forwardRef(function MapView(_, ref) { const mapRef = useRef(null) @@ -977,22 +999,8 @@ const MapView = forwardRef(function MapView(_, ref) { 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], - }, - }) + // Boundary polygon layer for selected places + addBoundaryLayer(map) // Restore overlay layers from localStorage prefs try { @@ -1125,22 +1133,8 @@ const MapView = forwardRef(function MapView(_, ref) { 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], - }, - }) + // Boundary polygon layer + addBoundaryLayer(map) // Re-add active overlay layers if (activeLayersRef.current.hillshade) addHillshade(map) diff --git a/src/components/MapView.jsx.bak b/src/components/MapView.jsx.bak deleted file mode 100644 index 7da3865..0000000 --- a/src/components/MapView.jsx.bak +++ /dev/null @@ -1,729 +0,0 @@ -import { useEffect, useRef, forwardRef, useImperativeHandle } 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' - -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' - -/** 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) -} - -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 }) - // 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) - - // Expose map methods to parent - 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 - }, - })) - - // 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 - } - } 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) - - // 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]) - - return
-}) - -export default MapView diff --git a/src/components/MapView.jsx.bak.5wedge b/src/components/MapView.jsx.bak.5wedge deleted file mode 100644 index ab4cdf0..0000000 --- a/src/components/MapView.jsx.bak.5wedge +++ /dev/null @@ -1,1283 +0,0 @@ -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, Info } 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: 'whats-here', - label: "What's here", - icon: Info, - onSelect: async ({ lat, lon }) => { - setRadialMenu((m) => ({ ...m, open: false })) - // Immediately show dropped pin - useStore.getState().setSelectedPlace({ - lat, - lon, - name: 'Dropped pin', - address: null, - type: null, - source: 'radial_menu', - matchCode: null, - raw: {}, - }) - // Reverse geocode in background - const place = await fetchReverse(lat, lon) - if (place) { - const current = useStore.getState().selectedPlace - if (current && Math.abs(current.lat - lat) < 0.00001 && Math.abs(current.lon - lon) < 0.00001) { - useStore.getState().setSelectedPlace({ ...place, lat, lon }) - } - } - }, - }, - { - 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.boundary b/src/components/MapView.jsx.bak.boundary deleted file mode 100644 index c717749..0000000 --- a/src/components/MapView.jsx.bak.boundary +++ /dev/null @@ -1,1403 +0,0 @@ -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.contour-test b/src/components/MapView.jsx.bak.contour-test deleted file mode 100644 index 16694eb..0000000 --- a/src/components/MapView.jsx.bak.contour-test +++ /dev/null @@ -1,854 +0,0 @@ -import { useEffect, useRef, forwardRef, useImperativeHandle } 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' - -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' - -/** 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) -} - -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 }) - // 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) - - // Expose map methods to parent - 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 - }, - })) - - // 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]) - - return
-}) - -export default MapView diff --git a/src/components/MapView.jsx.bak.labelclick b/src/components/MapView.jsx.bak.labelclick deleted file mode 100644 index da86ab9..0000000 --- a/src/components/MapView.jsx.bak.labelclick +++ /dev/null @@ -1,1328 +0,0 @@ -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 deleted file mode 100644 index 4116f1d..0000000 --- a/src/components/MapView.jsx.bak.poihover +++ /dev/null @@ -1,1315 +0,0 @@ -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.radial b/src/components/MapView.jsx.bak.radial deleted file mode 100644 index e852306..0000000 --- a/src/components/MapView.jsx.bak.radial +++ /dev/null @@ -1,1162 +0,0 @@ -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' - -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) - - // Expose map methods to parent - 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)} -
-
- ) -}) - -export default MapView diff --git a/src/components/MapView.jsx.bak.regressions b/src/components/MapView.jsx.bak.regressions deleted file mode 100644 index 9cc0498..0000000 --- a/src/components/MapView.jsx.bak.regressions +++ /dev/null @@ -1,1514 +0,0 @@ -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 deleted file mode 100644 index 9715c8d..0000000 --- a/src/components/MapView.jsx.bak.twoclick +++ /dev/null @@ -1,1256 +0,0 @@ -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 deleted file mode 100644 index ad7f8aa..0000000 --- a/src/components/MapView.jsx.bak.uxfix2 +++ /dev/null @@ -1,1514 +0,0 @@ -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/MapView.jsx.bak.viewport b/src/components/MapView.jsx.bak.viewport deleted file mode 100644 index ab59664..0000000 --- a/src/components/MapView.jsx.bak.viewport +++ /dev/null @@ -1,1134 +0,0 @@ -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' - -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) - - // Zoom level indicator state - const [zoomLevel, setZoomLevel] = useState(10) - - // Expose map methods to parent - 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) - } - }, []) - - return ( -
-
- {/* Zoom level indicator - bottom-left corner */} -
- Z {zoomLevel.toFixed(1)} -
-
- ) -}) - -export default MapView diff --git a/src/components/MapView.jsx.bak.zoom b/src/components/MapView.jsx.bak.zoom deleted file mode 100644 index 2d42640..0000000 --- a/src/components/MapView.jsx.bak.zoom +++ /dev/null @@ -1,973 +0,0 @@ -import { useEffect, useRef, forwardRef, useImperativeHandle } 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' - -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' - -/** 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) -} - -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 }) - // 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) - - // Expose map methods to parent - 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 - }, - })) - - // 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]) - - return
-}) - -export default MapView diff --git a/src/components/Panel.jsx b/src/components/Panel.jsx index cd2edb1..969b3fa 100644 --- a/src/components/Panel.jsx +++ b/src/components/Panel.jsx @@ -1,315 +1,324 @@ -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} -
- )} -
- ) -} +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 clearPendingDestination = useStore((s) => s.clearPendingDestination) + 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 && ( + + )} + {pendingDestination && stops.length === 0 && ( + + )} +
+ + )} + + {/* 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 deleted file mode 100644 index d06c1de..0000000 --- a/src/components/Panel.jsx.bak.regressions +++ /dev/null @@ -1,283 +0,0 @@ -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 deleted file mode 100644 index f46520f..0000000 --- a/src/components/Panel.jsx.bak.uxfix +++ /dev/null @@ -1,283 +0,0 @@ -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 deleted file mode 100644 index f46520f..0000000 --- a/src/components/Panel.jsx.bak.uxfix2 +++ /dev/null @@ -1,283 +0,0 @@ -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 deleted file mode 100644 index d6dd2a0..0000000 --- a/src/components/Panel.jsx.bak.viewport +++ /dev/null @@ -1,283 +0,0 @@ -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 17a5131..2e7a494 100644 --- a/src/components/PlaceCard.jsx +++ b/src/components/PlaceCard.jsx @@ -326,7 +326,7 @@ export function PlaceCard({ place, variant = "preview", expanded = true, onToggl }, [variant, userLocation?.lat, userLocation?.lon, placeLat, placeLon]) useEffect(() => { - if (!hasFeature("has_contacts") || placeLat == null || placeLon == null) { setNearbyLabel(null); return } + if (!hasFeature("has_contacts") || !auth.authenticated || 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) diff --git a/src/components/PlaceCard.jsx.bak.uxfix b/src/components/PlaceCard.jsx.bak.uxfix deleted file mode 100644 index 83aae38..0000000 --- a/src/components/PlaceCard.jsx.bak.uxfix +++ /dev/null @@ -1,434 +0,0 @@ -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 deleted file mode 100644 index 868a644..0000000 --- a/src/components/PlaceCard.jsx.bak.uxfix2 +++ /dev/null @@ -1,434 +0,0 @@ -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 7e4fc7d..85a497c 100644 --- a/src/components/PlaceDetail.jsx +++ b/src/components/PlaceDetail.jsx @@ -551,7 +551,7 @@ export default function PlaceDetail() { // Fetch nearby contacts for proximity annotation useEffect(() => { - if (!hasFeature('has_contacts') || placeLat == null || placeLon == null) { + if (!hasFeature('has_contacts') || !auth.authenticated || placeLat == null || placeLon == null) { setNearbyLabel(null) return } diff --git a/src/components/PlaceDetail.jsx.bak.boundary b/src/components/PlaceDetail.jsx.bak.boundary deleted file mode 100644 index a5c7a9f..0000000 --- a/src/components/PlaceDetail.jsx.bak.boundary +++ /dev/null @@ -1,798 +0,0 @@ -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/RadialMenu.jsx b/src/components/RadialMenu.jsx index 572c3a8..b0da0f4 100644 --- a/src/components/RadialMenu.jsx +++ b/src/components/RadialMenu.jsx @@ -1,310 +1,309 @@ -import { useEffect, useRef, useCallback } from 'react' -import { createPortal } from 'react-dom' - -/** - * RadialMenu - ATAK-style radial context menu - * Themed to match Navi light/dark palette using CSS custom properties. - * - * Props: - * - open: boolean - * - x, y: screen coordinates of trigger point - * - lat, lon: geographic coordinates - * - wedges: array of { id, label, icon: LucideIcon, onSelect, requiresAuth? } - * - centerLabel: string (coords by default, replaced by reverse-geocode async) - * - onDismiss: callback when menu should close - */ -export default function RadialMenu({ - open, - x, - y, - lat, - lon, - wedges = [], - centerLabel, - onDismiss, -}) { - const containerRef = useRef(null) - const activeWedgeRef = useRef(null) - - // Geometry constants - const outerRadius = 80 - const innerRadius = 40 - const wedgeCount = wedges.length || 6 - const wedgeAngle = 360 / wedgeCount - - // Handle escape key - useEffect(() => { - if (!open) return - const handleKey = (e) => { - if (e.key === 'Escape') { - onDismiss?.() - } - } - window.addEventListener('keydown', handleKey) - return () => window.removeEventListener('keydown', handleKey) - }, [open, onDismiss]) - - // Calculate which wedge the pointer is over - const getWedgeAtPoint = useCallback((clientX, clientY) => { - const dx = clientX - x - const dy = clientY - y - const dist = Math.sqrt(dx * dx + dy * dy) - - // Inside inner radius = center (no wedge) - if (dist < innerRadius) return null - // Outside outer radius = no wedge - if (dist > outerRadius + 20) return null - - // Calculate angle (0 = top, clockwise) - let angle = Math.atan2(dx, -dy) * (180 / Math.PI) - if (angle < 0) angle += 360 - - // Find which wedge - const wedgeIndex = Math.floor(angle / wedgeAngle) - return wedges[wedgeIndex] || null - }, [x, y, wedges, wedgeAngle]) - - // Handle mouse/touch move for highlighting - const handlePointerMove = useCallback((e) => { - const clientX = e.touches ? e.touches[0].clientX : e.clientX - const clientY = e.touches ? e.touches[0].clientY : e.clientY - activeWedgeRef.current = getWedgeAtPoint(clientX, clientY) - // Force re-render for highlight - containerRef.current?.querySelectorAll('.radial-wedge').forEach((el, i) => { - if (wedges[i] && wedges[i].id === activeWedgeRef.current?.id) { - el.classList.add('active') - } else { - el.classList.remove('active') - } - }) - }, [getWedgeAtPoint, wedges]) - - // Handle release - const handlePointerUp = useCallback((e) => { - const clientX = e.changedTouches ? e.changedTouches[0].clientX : e.clientX - const clientY = e.changedTouches ? e.changedTouches[0].clientY : e.clientY - const wedge = getWedgeAtPoint(clientX, clientY) - - if (wedge) { - wedge.onSelect?.({ lat, lon }) - } - onDismiss?.() - }, [getWedgeAtPoint, lat, lon, onDismiss]) - - // Handle backdrop click (dismiss menu) - const handleBackdropClick = useCallback((e) => { - e.stopPropagation() - onDismiss?.() - }, [onDismiss]) - - // Prevent menu container clicks from reaching backdrop - const handleContainerClick = useCallback((e) => { - e.stopPropagation() - }, []) - - // Generate wedge paths - const generateWedgePath = (index) => { - const startAngle = (index * wedgeAngle - 90) * (Math.PI / 180) - const endAngle = ((index + 1) * wedgeAngle - 90) * (Math.PI / 180) - - const x1 = innerRadius * Math.cos(startAngle) - const y1 = innerRadius * Math.sin(startAngle) - const x2 = outerRadius * Math.cos(startAngle) - const y2 = outerRadius * Math.sin(startAngle) - const x3 = outerRadius * Math.cos(endAngle) - const y3 = outerRadius * Math.sin(endAngle) - const x4 = innerRadius * Math.cos(endAngle) - const y4 = innerRadius * Math.sin(endAngle) - - return `M ${x1} ${y1} L ${x2} ${y2} A ${outerRadius} ${outerRadius} 0 0 1 ${x3} ${y3} L ${x4} ${y4} A ${innerRadius} ${innerRadius} 0 0 0 ${x1} ${y1} Z` - } - - // Calculate icon position for each wedge - const getIconPosition = (index) => { - const midAngle = ((index + 0.5) * wedgeAngle - 90) * (Math.PI / 180) - const r = (innerRadius + outerRadius) / 2 - return { - x: r * Math.cos(midAngle), - y: r * Math.sin(midAngle), - } - } - - if (!open) return null - - // Clamp position to viewport - const padding = outerRadius + 20 - const clampedX = Math.max(padding, Math.min(window.innerWidth - padding, x)) - const clampedY = Math.max(padding, Math.min(window.innerHeight - padding, y)) - - const content = ( - <> - {/* Full-screen backdrop for dismiss — matches modal overlay opacity */} -
- - {/* Radial menu container */} -
- - {/* Wedges */} - {wedges.map((wedge, i) => { - const iconPos = getIconPosition(i) - const Icon = wedge.icon - const wedgeClasses = `radial-wedge${wedge.requiresAuth ? ' auth-required' : ''}` - return ( - - - - {Icon && ( - - - - )} - - {wedge.label} - - - - ) - })} - - {/* Center disc */} - - - {lat?.toFixed(4)} - - - {lon?.toFixed(4)} - - {centerLabel && ( - - {centerLabel.length > 15 ? centerLabel.slice(0, 15) + '…' : centerLabel} - - )} - - - - - - ) - - return createPortal(content, document.body) -} + /* Auth-required hover — content stays muted */ + .radial-wedge.auth-required:hover .wedge-icon, + .radial-wedge.auth-required.active .wedge-icon { + color: var(--text-tertiary); + } + + .radial-wedge.auth-required:hover .wedge-label, + .radial-wedge.auth-required.active .wedge-label { + fill: var(--text-tertiary); + } + + /* Center disc — raised surface */ + .center-disc { + fill: var(--bg-raised); + stroke: var(--border); + stroke-width: 1; + } + + /* Center coordinates — monospace primary */ + .center-coords { + font-family: var(--font-mono); + font-size: 10px; + fill: var(--text-primary); + } + + /* Center label — secondary italic */ + .center-label { + font-family: var(--font-sans); + font-size: 9px; + font-style: italic; + fill: var(--text-secondary); + } + + @keyframes radialFadeIn { + from { opacity: 0; transform: translate(-50%, -50%) scale(0.9); } + to { opacity: 1; transform: translate(-50%, -50%) scale(1); } + } + `} + + + ) + + return createPortal(content, document.body) +} diff --git a/src/components/RadialMenu.jsx.bak.5wedge b/src/components/RadialMenu.jsx.bak.5wedge deleted file mode 100644 index ba5d72e..0000000 --- a/src/components/RadialMenu.jsx.bak.5wedge +++ /dev/null @@ -1,351 +0,0 @@ -import { useEffect, useRef, useCallback } from 'react' -import { createPortal } from 'react-dom' -import { Lock } from 'lucide-react' - -/** - * RadialMenu - ATAK-style radial context menu - * Themed to match Navi light/dark palette using CSS custom properties. - * - * Props: - * - open: boolean - * - x, y: screen coordinates of trigger point - * - lat, lon: geographic coordinates - * - wedges: array of { id, label, icon: LucideIcon, onSelect, requiresAuth? } - * - centerLabel: string (coords by default, replaced by reverse-geocode async) - * - onDismiss: callback when menu should close - */ -export default function RadialMenu({ - open, - x, - y, - lat, - lon, - wedges = [], - centerLabel, - onDismiss, -}) { - const containerRef = useRef(null) - const activeWedgeRef = useRef(null) - - // Geometry constants - const outerRadius = 80 - const innerRadius = 40 - const wedgeCount = wedges.length || 6 - const wedgeAngle = 360 / wedgeCount - - // Handle escape key - useEffect(() => { - if (!open) return - const handleKey = (e) => { - if (e.key === 'Escape') { - onDismiss?.() - } - } - window.addEventListener('keydown', handleKey) - return () => window.removeEventListener('keydown', handleKey) - }, [open, onDismiss]) - - // Calculate which wedge the pointer is over - const getWedgeAtPoint = useCallback((clientX, clientY) => { - const dx = clientX - x - const dy = clientY - y - const dist = Math.sqrt(dx * dx + dy * dy) - - // Inside inner radius = center (no wedge) - if (dist < innerRadius) return null - // Outside outer radius = no wedge - if (dist > outerRadius + 20) return null - - // Calculate angle (0 = top, clockwise) - let angle = Math.atan2(dx, -dy) * (180 / Math.PI) - if (angle < 0) angle += 360 - - // Find which wedge - const wedgeIndex = Math.floor(angle / wedgeAngle) - return wedges[wedgeIndex] || null - }, [x, y, wedges, wedgeAngle]) - - // Handle mouse/touch move for highlighting - const handlePointerMove = useCallback((e) => { - const clientX = e.touches ? e.touches[0].clientX : e.clientX - const clientY = e.touches ? e.touches[0].clientY : e.clientY - activeWedgeRef.current = getWedgeAtPoint(clientX, clientY) - // Force re-render for highlight - containerRef.current?.querySelectorAll('.radial-wedge').forEach((el, i) => { - if (wedges[i] && wedges[i].id === activeWedgeRef.current?.id) { - el.classList.add('active') - } else { - el.classList.remove('active') - } - }) - }, [getWedgeAtPoint, wedges]) - - // Handle release - const handlePointerUp = useCallback((e) => { - const clientX = e.changedTouches ? e.changedTouches[0].clientX : e.clientX - const clientY = e.changedTouches ? e.changedTouches[0].clientY : e.clientY - const wedge = getWedgeAtPoint(clientX, clientY) - - if (wedge) { - wedge.onSelect?.({ lat, lon }) - } - onDismiss?.() - }, [getWedgeAtPoint, lat, lon, onDismiss]) - - // Handle backdrop click (dismiss menu) - const handleBackdropClick = useCallback((e) => { - e.stopPropagation() - onDismiss?.() - }, [onDismiss]) - - // Prevent menu container clicks from reaching backdrop - const handleContainerClick = useCallback((e) => { - e.stopPropagation() - }, []) - - // Generate wedge paths - const generateWedgePath = (index) => { - const startAngle = (index * wedgeAngle - 90) * (Math.PI / 180) - const endAngle = ((index + 1) * wedgeAngle - 90) * (Math.PI / 180) - - const x1 = innerRadius * Math.cos(startAngle) - const y1 = innerRadius * Math.sin(startAngle) - const x2 = outerRadius * Math.cos(startAngle) - const y2 = outerRadius * Math.sin(startAngle) - const x3 = outerRadius * Math.cos(endAngle) - const y3 = outerRadius * Math.sin(endAngle) - const x4 = innerRadius * Math.cos(endAngle) - const y4 = innerRadius * Math.sin(endAngle) - - return `M ${x1} ${y1} L ${x2} ${y2} A ${outerRadius} ${outerRadius} 0 0 1 ${x3} ${y3} L ${x4} ${y4} A ${innerRadius} ${innerRadius} 0 0 0 ${x1} ${y1} Z` - } - - // Calculate icon position for each wedge - const getIconPosition = (index) => { - const midAngle = ((index + 0.5) * wedgeAngle - 90) * (Math.PI / 180) - const r = (innerRadius + outerRadius) / 2 - return { - x: r * Math.cos(midAngle), - y: r * Math.sin(midAngle), - } - } - - if (!open) return null - - // Clamp position to viewport - const padding = outerRadius + 20 - const clampedX = Math.max(padding, Math.min(window.innerWidth - padding, x)) - const clampedY = Math.max(padding, Math.min(window.innerHeight - padding, y)) - - const content = ( - <> - {/* Full-screen backdrop for dismiss — matches modal overlay opacity */} -
- - {/* Radial menu container */} -
- - {/* Wedges */} - {wedges.map((wedge, i) => { - const iconPos = getIconPosition(i) - const Icon = wedge.icon - return ( - - - - {Icon && ( - - - - )} - {wedge.requiresAuth && ( - - - - )} - - {wedge.label} - - - - ) - })} - - {/* Center disc */} - - - {lat?.toFixed(4)} - - - {lon?.toFixed(4)} - - {centerLabel && ( - - {centerLabel.length > 15 ? centerLabel.slice(0, 15) + '…' : centerLabel} - - )} - - - - - - ) - - return createPortal(content, document.body) -} diff --git a/src/components/SearchBar.jsx.bak.twoclick b/src/components/SearchBar.jsx.bak.twoclick deleted file mode 100644 index e1fd1af..0000000 --- a/src/components/SearchBar.jsx.bak.twoclick +++ /dev/null @@ -1,320 +0,0 @@ -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/SearchBar.jsx.bak.viewport b/src/components/SearchBar.jsx.bak.viewport deleted file mode 100644 index 732c865..0000000 --- a/src/components/SearchBar.jsx.bak.viewport +++ /dev/null @@ -1,319 +0,0 @@ -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) - - 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) - 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 deleted file mode 100644 index 80cb538..0000000 --- a/src/components/StopList.jsx.bak.pending +++ /dev/null @@ -1,117 +0,0 @@ -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/config.js.bak b/src/config.js.bak deleted file mode 100644 index 0d84fe4..0000000 --- a/src/config.js.bak +++ /dev/null @@ -1,86 +0,0 @@ -/** - * Deployment config loader. - * - * Fetches /api/config on startup and caches the result. - * Falls back to hardcoded defaults matching the home profile if the - * API is unavailable (backend restart, network issue). - */ - -const FALLBACK_CONFIG = { - profile: 'home', - region_name: 'North America', - tileset: { - url: '/tiles/na.pmtiles', - bounds: [-168, 14, -52, 72], - max_zoom: 15, - attribution: 'Protomaps © OSM', - }, - services: { - geocode: '/api/geocode', - reverse: '/api/reverse', - address_book: '/api/address_book', - valhalla: '/valhalla', - }, - features: { - has_nominatim_details: false, - has_kiwix_wiki: false, - has_hillshade: false, - has_3d_terrain: false, - has_traffic_overlay: false, - has_landclass: false, - has_public_lands_layer: false, - has_address_book_write: false, - has_contacts: false, - }, - defaults: { - center: [42.5736, -114.6066], - zoom: 10, - }, -} - -let _config = null -let _configPromise = null - -/** - * Fetch config from backend. Returns cached config on subsequent calls. - * Falls back to FALLBACK_CONFIG if API fails. - */ -export function loadConfig() { - if (_configPromise) return _configPromise - - _configPromise = fetch('/api/config', { signal: AbortSignal.timeout(3000) }) - .then((resp) => { - if (!resp.ok) throw new Error(`Config API returned ${resp.status}`) - return resp.json() - }) - .then((data) => { - _config = data - console.log('[navi] Config loaded:', data.profile, `(${data.region_name})`) - console.log('[navi] Feature flags:', data.features) - return data - }) - .catch((err) => { - console.warn('[navi] Config API unavailable, using fallback:', err.message) - _config = FALLBACK_CONFIG - return FALLBACK_CONFIG - }) - - return _configPromise -} - -/** - * Get the current config synchronously. Returns null if not yet loaded. - */ -export function getConfig() { - return _config -} - -/** - * Check a feature flag from the loaded config. - * @param {string} flag - Feature flag name (e.g. 'has_hillshade') - * @returns {boolean} - */ -export function hasFeature(flag) { - if (!_config) return false - return Boolean(_config.features?.[flag]) -} diff --git a/src/index.css.bak.twoclick b/src/index.css.bak.twoclick deleted file mode 100644 index 8fca9fe..0000000 --- a/src/index.css.bak.twoclick +++ /dev/null @@ -1,537 +0,0 @@ -@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.bak.dupstop b/src/store.js.bak.dupstop deleted file mode 100644 index 7949d95..0000000 --- a/src/store.js.bak.dupstop +++ /dev/null @@ -1,141 +0,0 @@ -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 deleted file mode 100644 index 8ce6fcc..0000000 --- a/src/store.js.bak.regressions +++ /dev/null @@ -1,139 +0,0 @@ -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 deleted file mode 100644 index 8a1097d..0000000 --- a/src/store.js.bak.twoclick +++ /dev/null @@ -1,118 +0,0 @@ -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 deleted file mode 100644 index aa5e45f..0000000 --- a/src/store.js.bak.uxfix +++ /dev/null @@ -1,133 +0,0 @@ -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 deleted file mode 100644 index 42f90c3..0000000 --- a/src/store.js.bak.viewport +++ /dev/null @@ -1,139 +0,0 @@ -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: set pendingDestination only; origin-picker will add both - 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" - }) -}