diff --git a/package-lock.json b/package-lock.json index fd4e1c8..682949c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "@dnd-kit/utilities": "^3.2.2", "lucide-react": "^1.8.0", "maplibre-gl": "^5.23.0", + "opening_hours": "^3.12.0", "pmtiles": "^4.4.1", "protomaps-themes-base": "^4.5.0", "react": "^19.2.5", @@ -241,6 +242,15 @@ "node": ">=6.0.0" } }, + "node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", @@ -2151,6 +2161,37 @@ "hermes-estree": "0.25.1" } }, + "node_modules/i18next": { + "version": "25.10.10", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-25.10.10.tgz", + "integrity": "sha512-cqUW2Z3EkRx7NqSyywjkgCLK7KLCL6IFVFcONG7nVYIJ3ekZ1/N5jUsihHV6Bq37NfhgtczxJcxduELtjTwkuQ==", + "funding": [ + { + "type": "individual", + "url": "https://www.locize.com/i18next" + }, + { + "type": "individual", + "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project" + }, + { + "type": "individual", + "url": "https://www.locize.com" + } + ], + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2" + }, + "peerDependencies": { + "typescript": "^5 || ^6" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -2746,6 +2787,19 @@ "dev": true, "license": "MIT" }, + "node_modules/opening_hours": { + "version": "3.12.0", + "resolved": "https://registry.npmjs.org/opening_hours/-/opening_hours-3.12.0.tgz", + "integrity": "sha512-8XVwJUyZVIDObb5OF2DoB+WShGL9RVZRKIevgHOqwPeu0acRx5akruOSmXHZZw2qUTqEjbneZG3REcyvr5yHiQ==", + "license": "LGPL-3.0-only", + "dependencies": { + "i18next": "^25.8.13", + "suncalc": "^1.9.0" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -3117,6 +3171,11 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/suncalc": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/suncalc/-/suncalc-1.9.0.tgz", + "integrity": "sha512-vMJ8Byp1uIPoj+wb9c1AdK4jpkSKVAywgHX0lqY7zt6+EWRRC3Z+0Ucfjy/0yxTVO1hwwchZe4uoFNqrIC24+A==" + }, "node_modules/supercluster": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/supercluster/-/supercluster-8.0.1.tgz", diff --git a/package.json b/package.json index b2ae015..9faab00 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "@dnd-kit/utilities": "^3.2.2", "lucide-react": "^1.8.0", "maplibre-gl": "^5.23.0", + "opening_hours": "^3.12.0", "pmtiles": "^4.4.1", "protomaps-themes-base": "^4.5.0", "react": "^19.2.5", diff --git a/src/api.js.bak.viewport b/src/api.js.bak.viewport new file mode 100644 index 0000000..35d724b --- /dev/null +++ b/src/api.js.bak.viewport @@ -0,0 +1,262 @@ +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) { + const params = new URLSearchParams({ q: query, limit: String(limit) }) + 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 + } +} + +// ── 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 b/src/components/LayerControl.jsx index 3030d0d..35b2313 100644 --- a/src/components/LayerControl.jsx +++ b/src/components/LayerControl.jsx @@ -22,6 +22,8 @@ export default function LayerControl({ mapRef }) { const [traffic, setTraffic] = useState(false) const [publicLands, setPublicLands] = useState(false) const [contours, setContours] = useState(false) + const [contoursTest, setContoursTest] = useState(false) + const [contoursTest10ft, setContoursTest10ft] = useState(false) const panelRef = useRef(null) // Initialize from localStorage or defaults on mount @@ -32,18 +34,24 @@ export default function LayerControl({ mapRef }) { const plAvailable = hasFeature('has_public_lands_layer') const ctAvailable = hasFeature('has_contours') + const ctTestAvailable = hasFeature('has_contours_test') + const ctTest10ftAvailable = hasFeature('has_contours_test_10ft') if (saved) { setHillshade(hsAvailable && (saved.hillshade ?? true)) setTraffic(trAvailable && (saved.traffic ?? false)) setPublicLands(plAvailable && (saved.publicLands ?? false)) setContours(ctAvailable && (saved.contours ?? false)) + setContoursTest(ctTestAvailable && (saved.contoursTest ?? false)) + setContoursTest10ft(ctTest10ftAvailable && (saved.contoursTest10ft ?? false)) } else { // Defaults: hillshade ON if available, others OFF setHillshade(hsAvailable) setTraffic(false) setPublicLands(false) setContours(false) + setContoursTest(false) + setContoursTest10ft(false) } }, []) @@ -67,7 +75,7 @@ export default function LayerControl({ mapRef }) { } else { map.once('style.load', apply) } - savePrefs({ hillshade, traffic, publicLands, contours }) + savePrefs({ hillshade, traffic, publicLands, contours, contoursTest, contoursTest10ft }) return () => map.off('style.load', apply) }, [hillshade, mapRef]) @@ -90,7 +98,7 @@ export default function LayerControl({ mapRef }) { } else { map.once('style.load', apply) } - savePrefs({ hillshade, traffic, publicLands, contours }) + savePrefs({ hillshade, traffic, publicLands, contours, contoursTest, contoursTest10ft }) return () => map.off('style.load', apply) }, [traffic, mapRef]) @@ -113,7 +121,7 @@ export default function LayerControl({ mapRef }) { } else { map.once('style.load', apply) } - savePrefs({ hillshade, traffic, publicLands, contours }) + savePrefs({ hillshade, traffic, publicLands, contours, contoursTest, contoursTest10ft }) return () => map.off('style.load', apply) }, [publicLands, mapRef]) @@ -136,10 +144,53 @@ export default function LayerControl({ mapRef }) { } else { map.once('style.load', apply) } - savePrefs({ hillshade, traffic, publicLands, contours }) + savePrefs({ hillshade, traffic, publicLands, contours, contoursTest, contoursTest10ft }) return () => map.off('style.load', apply) }, [contours, mapRef]) + useEffect(() => { + const mapView = mapRef?.current + if (!mapView) return + const map = mapView.getMap?.() + if (!map) return + + const apply = () => { + if (contoursTest && hasFeature('has_contours_test')) { + mapView.addContoursTestLayer?.() + } else { + mapView.removeContoursTestLayer?.() + } + } + + if (map.isStyleLoaded()) { + apply() + } else { + map.once('style.load', apply) + } + savePrefs({ hillshade, traffic, publicLands, contours, contoursTest, contoursTest10ft }) + return () => map.off('style.load', apply) + }, [contoursTest, mapRef]) + + // Apply contoursTest10ft layer + useEffect(() => { + const map = mapRef.current?.getMap?.() + if (!map) return + + const apply = () => { + if (contoursTest10ft && hasFeature('has_contours_test_10ft')) { + mapRef.current?.addContoursTest10ftLayer?.() + } else { + mapRef.current?.removeContoursTest10ftLayer?.() + } + } + + if (map.isStyleLoaded()) { + apply() + } else { + map.once('style.load', apply) + } + }, [contoursTest10ft, mapRef]) + // Close on outside click useEffect(() => { if (!open) return @@ -156,9 +207,11 @@ export default function LayerControl({ mapRef }) { const showTraffic = hasFeature('has_traffic_overlay') const showPublicLands = hasFeature('has_public_lands_layer') const showContours = hasFeature('has_contours') + const showContoursTest = hasFeature('has_contours_test') + const showContoursTest10ft = hasFeature('has_contours_test_10ft') // Don't render if no overlay features available - if (!showHillshade && !showTraffic && !showPublicLands && !showContours) return null + if (!showHillshade && !showTraffic && !showPublicLands && !showContours && !showContoursTest && !showContoursTest10ft) return null return (
@@ -222,6 +275,30 @@ export default function LayerControl({ mapRef }) { /> )} + + {showContoursTest && ( + + )} + + {showContoursTest10ft && ( + + )}
)} diff --git a/src/components/LayerControl.jsx.bak b/src/components/LayerControl.jsx.bak new file mode 100644 index 0000000..2e234cc --- /dev/null +++ b/src/components/LayerControl.jsx.bak @@ -0,0 +1,189 @@ +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 new file mode 100644 index 0000000..3030d0d --- /dev/null +++ b/src/components/LayerControl.jsx.bak.contour-test @@ -0,0 +1,229 @@ +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.bak b/src/components/MapView.jsx.bak new file mode 100644 index 0000000..7da3865 --- /dev/null +++ b/src/components/MapView.jsx.bak @@ -0,0 +1,729 @@ +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.contour-test b/src/components/MapView.jsx.bak.contour-test new file mode 100644 index 0000000..16694eb --- /dev/null +++ b/src/components/MapView.jsx.bak.contour-test @@ -0,0 +1,854 @@ +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.radial b/src/components/MapView.jsx.bak.radial new file mode 100644 index 0000000..e852306 --- /dev/null +++ b/src/components/MapView.jsx.bak.radial @@ -0,0 +1,1162 @@ +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.viewport b/src/components/MapView.jsx.bak.viewport new file mode 100644 index 0000000..ab59664 --- /dev/null +++ b/src/components/MapView.jsx.bak.viewport @@ -0,0 +1,1134 @@ +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 new file mode 100644 index 0000000..2d42640 --- /dev/null +++ b/src/components/MapView.jsx.bak.zoom @@ -0,0 +1,973 @@ +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/RadialMenu.jsx b/src/components/RadialMenu.jsx index fa411b3..ba5d72e 100644 --- a/src/components/RadialMenu.jsx +++ b/src/components/RadialMenu.jsx @@ -4,6 +4,7 @@ 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 @@ -138,17 +139,11 @@ export default function RadialMenu({ const content = ( <> - {/* Full-screen transparent backdrop for dismiss */} + {/* Full-screen backdrop for dismiss — matches modal overlay opacity */}
{/* Radial menu container */} @@ -163,6 +158,7 @@ export default function RadialMenu({ zIndex: 9999, transform: 'translate(-50%, -50%)', animation: 'radialFadeIn 100ms ease-out', + filter: 'drop-shadow(var(--shadow-lg))', }} onMouseMove={handlePointerMove} onMouseUp={handlePointerUp} @@ -183,35 +179,43 @@ export default function RadialMenu({ {Icon && ( - + + + )} {wedge.requiresAuth && ( - + + + )} {wedge.label} @@ -225,25 +229,19 @@ export default function RadialMenu({ cx={0} cy={0} r={innerRadius - 2} - fill="rgba(50, 45, 40, 0.95)" - stroke="rgba(180, 160, 140, 0.4)" - strokeWidth="1" + className="center-disc" /> {lat?.toFixed(4)} {lon?.toFixed(4)} @@ -251,9 +249,7 @@ export default function RadialMenu({ {centerLabel.length > 15 ? centerLabel.slice(0, 15) + '…' : centerLabel} @@ -261,12 +257,87 @@ export default function RadialMenu({