mirror of
https://github.com/zvx-echo6/navi.git
synced 2026-05-20 14:44:51 +02:00
style(radial): match Navi color palette in light and dark themes
This commit is contained in:
parent
741d760760
commit
15e6267022
17 changed files with 6318 additions and 47 deletions
59
package-lock.json
generated
59
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
262
src/api.js.bak.viewport
Normal file
262
src/api.js.bak.viewport
Normal file
|
|
@ -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<object>} 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<object>} 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<number|null>} 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<number|null>} 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<object|null>} 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<object|null>} 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<div ref={panelRef} className="layer-control">
|
||||
|
|
@ -222,6 +275,30 @@ export default function LayerControl({ mapRef }) {
|
|||
/>
|
||||
</label>
|
||||
)}
|
||||
|
||||
{showContoursTest && (
|
||||
<label className="layer-control-item">
|
||||
<span className="layer-control-label">Contours (Test)</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="layer-control-toggle"
|
||||
checked={contoursTest}
|
||||
onChange={(e) => setContoursTest(e.target.checked)}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
|
||||
{showContoursTest10ft && (
|
||||
<label className="layer-control-item">
|
||||
<span className="layer-control-label">Contours (Test 10ft)</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="layer-control-toggle"
|
||||
checked={contoursTest10ft}
|
||||
onChange={(e) => setContoursTest10ft(e.target.checked)}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
189
src/components/LayerControl.jsx.bak
Normal file
189
src/components/LayerControl.jsx.bak
Normal file
|
|
@ -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 (
|
||||
<div ref={panelRef} className="layer-control">
|
||||
<button
|
||||
className="layer-control-btn"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
title="Map layers"
|
||||
aria-label="Toggle map layers"
|
||||
>
|
||||
<Layers size={18} />
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="layer-control-popover">
|
||||
<div className="layer-control-header">Layers</div>
|
||||
|
||||
{showHillshade && (
|
||||
<label className="layer-control-item">
|
||||
<span className="layer-control-label">Hillshade</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="layer-control-toggle"
|
||||
checked={hillshade}
|
||||
onChange={(e) => setHillshade(e.target.checked)}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
|
||||
{showTraffic && (
|
||||
<label className="layer-control-item">
|
||||
<span className="layer-control-label">Traffic</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="layer-control-toggle"
|
||||
checked={traffic}
|
||||
onChange={(e) => setTraffic(e.target.checked)}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
|
||||
{showPublicLands && (
|
||||
<label className="layer-control-item">
|
||||
<span className="layer-control-label">Public Lands</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="layer-control-toggle"
|
||||
checked={publicLands}
|
||||
onChange={(e) => setPublicLands(e.target.checked)}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
229
src/components/LayerControl.jsx.bak.contour-test
Normal file
229
src/components/LayerControl.jsx.bak.contour-test
Normal file
|
|
@ -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 (
|
||||
<div ref={panelRef} className="layer-control">
|
||||
<button
|
||||
className="layer-control-btn"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
title="Map layers"
|
||||
aria-label="Toggle map layers"
|
||||
>
|
||||
<Layers size={18} />
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="layer-control-popover">
|
||||
<div className="layer-control-header">Layers</div>
|
||||
|
||||
{showHillshade && (
|
||||
<label className="layer-control-item">
|
||||
<span className="layer-control-label">Hillshade</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="layer-control-toggle"
|
||||
checked={hillshade}
|
||||
onChange={(e) => setHillshade(e.target.checked)}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
|
||||
{showTraffic && (
|
||||
<label className="layer-control-item">
|
||||
<span className="layer-control-label">Traffic</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="layer-control-toggle"
|
||||
checked={traffic}
|
||||
onChange={(e) => setTraffic(e.target.checked)}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
|
||||
{showPublicLands && (
|
||||
<label className="layer-control-item">
|
||||
<span className="layer-control-label">Public Lands</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="layer-control-toggle"
|
||||
checked={publicLands}
|
||||
onChange={(e) => setPublicLands(e.target.checked)}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
|
||||
{showContours && (
|
||||
<label className="layer-control-item">
|
||||
<span className="layer-control-label">Contours</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="layer-control-toggle"
|
||||
checked={contours}
|
||||
onChange={(e) => setContours(e.target.checked)}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
729
src/components/MapView.jsx.bak
Normal file
729
src/components/MapView.jsx.bak
Normal file
|
|
@ -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 = `<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M8 1 L14 13 L8 10 L2 13 Z" fill="var(--accent)" stroke="var(--bg-raised)" stroke-width="1.5" stroke-linejoin="round"/>
|
||||
</svg>`
|
||||
|
||||
/** Add hillshade raster-dem source + layer to the map */
|
||||
function addHillshade(map) {
|
||||
if (!map || map.getSource(HILLSHADE_SOURCE)) return
|
||||
const config = getConfig()
|
||||
const hs = config?.tileset_hillshade
|
||||
if (!hs?.url) return
|
||||
|
||||
map.addSource(HILLSHADE_SOURCE, {
|
||||
type: 'raster-dem',
|
||||
url: `pmtiles://${hs.url}`,
|
||||
encoding: hs.encoding || 'terrarium',
|
||||
tileSize: 256,
|
||||
maxzoom: hs.max_zoom || 12,
|
||||
})
|
||||
|
||||
// Insert below the first symbol/label layer for proper z-ordering
|
||||
let beforeId = undefined
|
||||
for (const layer of map.getStyle().layers) {
|
||||
if (layer.type === 'symbol') {
|
||||
beforeId = layer.id
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
map.addLayer({
|
||||
id: HILLSHADE_LAYER,
|
||||
type: 'hillshade',
|
||||
source: HILLSHADE_SOURCE,
|
||||
paint: {
|
||||
'hillshade-exaggeration': 0.5,
|
||||
'hillshade-illumination-direction': 315,
|
||||
'hillshade-shadow-color': '#000000',
|
||||
'hillshade-highlight-color': '#ffffff',
|
||||
},
|
||||
}, beforeId)
|
||||
}
|
||||
|
||||
/** Remove hillshade layer + source */
|
||||
function removeHillshade(map) {
|
||||
if (!map) return
|
||||
if (map.getLayer(HILLSHADE_LAYER)) map.removeLayer(HILLSHADE_LAYER)
|
||||
if (map.getSource(HILLSHADE_SOURCE)) map.removeSource(HILLSHADE_SOURCE)
|
||||
}
|
||||
|
||||
/** Add traffic raster tile source + layer */
|
||||
function addTraffic(map) {
|
||||
if (!map || map.getSource(TRAFFIC_SOURCE)) return
|
||||
const config = getConfig()
|
||||
const tr = config?.traffic
|
||||
if (!tr?.proxy_url) return
|
||||
|
||||
const tileUrl = tr.proxy_url.replace('{z}', '{z}').replace('{x}', '{x}').replace('{y}', '{y}')
|
||||
|
||||
map.addSource(TRAFFIC_SOURCE, {
|
||||
type: 'raster',
|
||||
tiles: [tileUrl],
|
||||
tileSize: 256,
|
||||
maxzoom: 18,
|
||||
})
|
||||
|
||||
map.addLayer({
|
||||
id: TRAFFIC_LAYER,
|
||||
type: 'raster',
|
||||
source: TRAFFIC_SOURCE,
|
||||
paint: {
|
||||
'raster-opacity': 0.6,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/** Remove traffic layer + source */
|
||||
function removeTraffic(map) {
|
||||
if (!map) return
|
||||
if (map.getLayer(TRAFFIC_LAYER)) map.removeLayer(TRAFFIC_LAYER)
|
||||
if (map.getSource(TRAFFIC_SOURCE)) map.removeSource(TRAFFIC_SOURCE)
|
||||
}
|
||||
|
||||
/** Add public lands vector tile overlay (PAD-US) */
|
||||
function addPublicLands(map) {
|
||||
if (!map || map.getSource(PUBLIC_LANDS_SOURCE)) return
|
||||
|
||||
map.addSource(PUBLIC_LANDS_SOURCE, {
|
||||
type: 'vector',
|
||||
url: 'pmtiles:///tiles/public-lands.pmtiles',
|
||||
})
|
||||
|
||||
// Insert below symbol layers for proper z-ordering
|
||||
let beforeId = undefined
|
||||
for (const layer of map.getStyle().layers) {
|
||||
if (layer.type === 'symbol') {
|
||||
beforeId = layer.id
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const isDark = document.documentElement.getAttribute('data-theme') === 'dark'
|
||||
const opacityMod = isDark ? 0.7 : 1.0
|
||||
|
||||
// Fill layer — data-driven color by agency + designation
|
||||
map.addLayer({
|
||||
id: PUBLIC_LANDS_FILL,
|
||||
type: 'fill',
|
||||
source: PUBLIC_LANDS_SOURCE,
|
||||
'source-layer': 'public_lands',
|
||||
paint: {
|
||||
'fill-color': [
|
||||
'case',
|
||||
['==', ['get', 'designation'], 'WA'], '#7c6b2f',
|
||||
['==', ['get', 'designation'], 'WSA'], '#7c6b2f',
|
||||
['==', ['get', 'agency'], 'NPS'], '#3d6b1f',
|
||||
['==', ['get', 'agency'], 'USFS'], '#5a7c2f',
|
||||
['==', ['get', 'agency'], 'BLM'], '#c4a672',
|
||||
['==', ['get', 'agency'], 'FWS'], '#4a7a5a',
|
||||
['any',
|
||||
['==', ['get', 'manager_type'], 'STAT'],
|
||||
['==', ['get', 'agency'], 'SPR'],
|
||||
['==', ['get', 'agency'], 'SDC'],
|
||||
['==', ['get', 'agency'], 'SLB']
|
||||
], '#5a8c7c',
|
||||
['any',
|
||||
['==', ['get', 'manager_type'], 'LOC'],
|
||||
['==', ['get', 'manager_type'], 'DIST']
|
||||
], '#8ca694',
|
||||
'#a0a0a0'
|
||||
],
|
||||
'fill-opacity': [
|
||||
'case',
|
||||
['==', ['get', 'designation'], 'WA'], 0.30 * opacityMod,
|
||||
['==', ['get', 'designation'], 'WSA'], 0.30 * opacityMod,
|
||||
['==', ['get', 'agency'], 'NPS'], 0.30 * opacityMod,
|
||||
['==', ['get', 'agency'], 'USFS'], 0.25 * opacityMod,
|
||||
['==', ['get', 'agency'], 'BLM'], 0.20 * opacityMod,
|
||||
['any',
|
||||
['==', ['get', 'manager_type'], 'STAT'],
|
||||
['==', ['get', 'agency'], 'SPR']
|
||||
], 0.25 * opacityMod,
|
||||
['any',
|
||||
['==', ['get', 'manager_type'], 'LOC'],
|
||||
['==', ['get', 'manager_type'], 'DIST']
|
||||
], 0.20 * opacityMod,
|
||||
0.15 * opacityMod
|
||||
],
|
||||
},
|
||||
}, beforeId)
|
||||
|
||||
// Outline layer
|
||||
map.addLayer({
|
||||
id: PUBLIC_LANDS_LINE,
|
||||
type: 'line',
|
||||
source: PUBLIC_LANDS_SOURCE,
|
||||
'source-layer': 'public_lands',
|
||||
paint: {
|
||||
'line-color': [
|
||||
'case',
|
||||
['==', ['get', 'designation'], 'WA'], '#5a4d20',
|
||||
['==', ['get', 'designation'], 'WSA'], '#5a4d20',
|
||||
['==', ['get', 'agency'], 'NPS'], '#2a4a15',
|
||||
['==', ['get', 'agency'], 'USFS'], '#3d5520',
|
||||
['==', ['get', 'agency'], 'BLM'], '#8a7343',
|
||||
['==', ['get', 'agency'], 'FWS'], '#2d5a3a',
|
||||
['any',
|
||||
['==', ['get', 'manager_type'], 'STAT'],
|
||||
['==', ['get', 'agency'], 'SPR']
|
||||
], '#3d6055',
|
||||
['any',
|
||||
['==', ['get', 'manager_type'], 'LOC'],
|
||||
['==', ['get', 'manager_type'], 'DIST']
|
||||
], '#5c6e66',
|
||||
'#707070'
|
||||
],
|
||||
'line-opacity': [
|
||||
'case',
|
||||
['==', ['get', 'agency'], 'NPS'], 0.7,
|
||||
['==', ['get', 'agency'], 'USFS'], 0.6,
|
||||
['==', ['get', 'agency'], 'BLM'], 0.5,
|
||||
0.5
|
||||
],
|
||||
'line-width': [
|
||||
'interpolate', ['linear'], ['zoom'],
|
||||
4, 0.3,
|
||||
8, 0.8,
|
||||
12, 1.2
|
||||
],
|
||||
},
|
||||
}, beforeId)
|
||||
|
||||
// Label layer — unit names at zoom 10+
|
||||
map.addLayer({
|
||||
id: PUBLIC_LANDS_LABEL,
|
||||
type: 'symbol',
|
||||
source: PUBLIC_LANDS_SOURCE,
|
||||
'source-layer': 'public_lands',
|
||||
minzoom: 10,
|
||||
layout: {
|
||||
'text-field': ['get', 'name'],
|
||||
'text-size': ['interpolate', ['linear'], ['zoom'], 10, 10, 14, 13],
|
||||
'text-font': ['Noto Sans Regular'],
|
||||
'symbol-placement': 'point',
|
||||
'text-anchor': 'center',
|
||||
'text-max-width': 8,
|
||||
'text-allow-overlap': false,
|
||||
'text-ignore-placement': false,
|
||||
},
|
||||
paint: {
|
||||
'text-color': isDark ? '#c0c8b8' : '#3a4a30',
|
||||
'text-halo-color': isDark ? '#1a1a1a' : '#ffffff',
|
||||
'text-halo-width': 1.5,
|
||||
'text-opacity': 0.85,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/** Remove public lands layers + source */
|
||||
function removePublicLands(map) {
|
||||
if (!map) return
|
||||
if (map.getLayer(PUBLIC_LANDS_LABEL)) map.removeLayer(PUBLIC_LANDS_LABEL)
|
||||
if (map.getLayer(PUBLIC_LANDS_LINE)) map.removeLayer(PUBLIC_LANDS_LINE)
|
||||
if (map.getLayer(PUBLIC_LANDS_FILL)) map.removeLayer(PUBLIC_LANDS_FILL)
|
||||
if (map.getSource(PUBLIC_LANDS_SOURCE)) map.removeSource(PUBLIC_LANDS_SOURCE)
|
||||
}
|
||||
|
||||
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(
|
||||
`<div style="font-size:12px;max-width:200px">
|
||||
<strong>${stop.name}</strong>
|
||||
<br/><button id="remove-stop-${stop.id}" style="margin-top:4px;padding:2px 8px;background:var(--status-danger);border:none;border-radius:4px;color:white;cursor:pointer;font-size:11px">Remove</button>
|
||||
</div>`
|
||||
)
|
||||
.addTo(map)
|
||||
|
||||
popup.getElement().querySelector(`#remove-stop-${stop.id}`)?.addEventListener('click', () => {
|
||||
useStore.getState().removeStop(stop.id)
|
||||
popup.remove()
|
||||
})
|
||||
popupRef.current = popup
|
||||
})
|
||||
|
||||
const marker = new maplibregl.Marker({ element: el })
|
||||
.setLngLat([stop.lon, stop.lat])
|
||||
.addTo(map)
|
||||
|
||||
markersRef.current.push(marker)
|
||||
})
|
||||
|
||||
// If stops but no route yet, fit to stops
|
||||
if (stops.length > 0 && !route) {
|
||||
if (stops.length === 1) {
|
||||
map.flyTo({ center: [stops[0].lon, stops[0].lat], zoom: 13 })
|
||||
} else {
|
||||
const bounds = stops.reduce(
|
||||
(b, s) => b.extend([s.lon, s.lat]),
|
||||
new maplibregl.LngLatBounds([stops[0].lon, stops[0].lat], [stops[0].lon, stops[0].lat])
|
||||
)
|
||||
map.fitBounds(bounds, { padding: { top: 60, bottom: 60, left: 340, right: 60 } })
|
||||
}
|
||||
}
|
||||
}, [stops, route, gpsOrigin, geoPermission])
|
||||
|
||||
return <div ref={mapRef} className="w-full h-full" />
|
||||
})
|
||||
|
||||
export default MapView
|
||||
854
src/components/MapView.jsx.bak.contour-test
Normal file
854
src/components/MapView.jsx.bak.contour-test
Normal file
|
|
@ -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 = `<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M8 1 L14 13 L8 10 L2 13 Z" fill="var(--accent)" stroke="var(--bg-raised)" stroke-width="1.5" stroke-linejoin="round"/>
|
||||
</svg>`
|
||||
|
||||
/** Add hillshade raster-dem source + layer to the map */
|
||||
function addHillshade(map) {
|
||||
if (!map || map.getSource(HILLSHADE_SOURCE)) return
|
||||
const config = getConfig()
|
||||
const hs = config?.tileset_hillshade
|
||||
if (!hs?.url) return
|
||||
|
||||
map.addSource(HILLSHADE_SOURCE, {
|
||||
type: 'raster-dem',
|
||||
url: `pmtiles://${hs.url}`,
|
||||
encoding: hs.encoding || 'terrarium',
|
||||
tileSize: 256,
|
||||
maxzoom: hs.max_zoom || 12,
|
||||
})
|
||||
|
||||
// Insert below the first symbol/label layer for proper z-ordering
|
||||
let beforeId = undefined
|
||||
for (const layer of map.getStyle().layers) {
|
||||
if (layer.type === 'symbol') {
|
||||
beforeId = layer.id
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
map.addLayer({
|
||||
id: HILLSHADE_LAYER,
|
||||
type: 'hillshade',
|
||||
source: HILLSHADE_SOURCE,
|
||||
paint: {
|
||||
'hillshade-exaggeration': 0.5,
|
||||
'hillshade-illumination-direction': 315,
|
||||
'hillshade-shadow-color': '#000000',
|
||||
'hillshade-highlight-color': '#ffffff',
|
||||
},
|
||||
}, beforeId)
|
||||
}
|
||||
|
||||
/** Remove hillshade layer + source */
|
||||
function removeHillshade(map) {
|
||||
if (!map) return
|
||||
if (map.getLayer(HILLSHADE_LAYER)) map.removeLayer(HILLSHADE_LAYER)
|
||||
if (map.getSource(HILLSHADE_SOURCE)) map.removeSource(HILLSHADE_SOURCE)
|
||||
}
|
||||
|
||||
/** Add traffic raster tile source + layer */
|
||||
function addTraffic(map) {
|
||||
if (!map || map.getSource(TRAFFIC_SOURCE)) return
|
||||
const config = getConfig()
|
||||
const tr = config?.traffic
|
||||
if (!tr?.proxy_url) return
|
||||
|
||||
const tileUrl = tr.proxy_url.replace('{z}', '{z}').replace('{x}', '{x}').replace('{y}', '{y}')
|
||||
|
||||
map.addSource(TRAFFIC_SOURCE, {
|
||||
type: 'raster',
|
||||
tiles: [tileUrl],
|
||||
tileSize: 256,
|
||||
maxzoom: 18,
|
||||
})
|
||||
|
||||
map.addLayer({
|
||||
id: TRAFFIC_LAYER,
|
||||
type: 'raster',
|
||||
source: TRAFFIC_SOURCE,
|
||||
paint: {
|
||||
'raster-opacity': 0.6,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/** Remove traffic layer + source */
|
||||
function removeTraffic(map) {
|
||||
if (!map) return
|
||||
if (map.getLayer(TRAFFIC_LAYER)) map.removeLayer(TRAFFIC_LAYER)
|
||||
if (map.getSource(TRAFFIC_SOURCE)) map.removeSource(TRAFFIC_SOURCE)
|
||||
}
|
||||
|
||||
/** Add public lands vector tile overlay (PAD-US) */
|
||||
function addPublicLands(map) {
|
||||
if (!map || map.getSource(PUBLIC_LANDS_SOURCE)) return
|
||||
|
||||
map.addSource(PUBLIC_LANDS_SOURCE, {
|
||||
type: 'vector',
|
||||
url: 'pmtiles:///tiles/public-lands.pmtiles',
|
||||
})
|
||||
|
||||
// Insert below symbol layers for proper z-ordering
|
||||
let beforeId = undefined
|
||||
for (const layer of map.getStyle().layers) {
|
||||
if (layer.type === 'symbol') {
|
||||
beforeId = layer.id
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const isDark = document.documentElement.getAttribute('data-theme') === 'dark'
|
||||
const opacityMod = isDark ? 0.7 : 1.0
|
||||
|
||||
// Fill layer — data-driven color by agency + designation
|
||||
map.addLayer({
|
||||
id: PUBLIC_LANDS_FILL,
|
||||
type: 'fill',
|
||||
source: PUBLIC_LANDS_SOURCE,
|
||||
'source-layer': 'public_lands',
|
||||
paint: {
|
||||
'fill-color': [
|
||||
'case',
|
||||
['==', ['get', 'designation'], 'WA'], '#7c6b2f',
|
||||
['==', ['get', 'designation'], 'WSA'], '#7c6b2f',
|
||||
['==', ['get', 'agency'], 'NPS'], '#3d6b1f',
|
||||
['==', ['get', 'agency'], 'USFS'], '#5a7c2f',
|
||||
['==', ['get', 'agency'], 'BLM'], '#c4a672',
|
||||
['==', ['get', 'agency'], 'FWS'], '#4a7a5a',
|
||||
['any',
|
||||
['==', ['get', 'manager_type'], 'STAT'],
|
||||
['==', ['get', 'agency'], 'SPR'],
|
||||
['==', ['get', 'agency'], 'SDC'],
|
||||
['==', ['get', 'agency'], 'SLB']
|
||||
], '#5a8c7c',
|
||||
['any',
|
||||
['==', ['get', 'manager_type'], 'LOC'],
|
||||
['==', ['get', 'manager_type'], 'DIST']
|
||||
], '#8ca694',
|
||||
'#a0a0a0'
|
||||
],
|
||||
'fill-opacity': [
|
||||
'case',
|
||||
['==', ['get', 'designation'], 'WA'], 0.30 * opacityMod,
|
||||
['==', ['get', 'designation'], 'WSA'], 0.30 * opacityMod,
|
||||
['==', ['get', 'agency'], 'NPS'], 0.30 * opacityMod,
|
||||
['==', ['get', 'agency'], 'USFS'], 0.25 * opacityMod,
|
||||
['==', ['get', 'agency'], 'BLM'], 0.20 * opacityMod,
|
||||
['any',
|
||||
['==', ['get', 'manager_type'], 'STAT'],
|
||||
['==', ['get', 'agency'], 'SPR']
|
||||
], 0.25 * opacityMod,
|
||||
['any',
|
||||
['==', ['get', 'manager_type'], 'LOC'],
|
||||
['==', ['get', 'manager_type'], 'DIST']
|
||||
], 0.20 * opacityMod,
|
||||
0.15 * opacityMod
|
||||
],
|
||||
},
|
||||
}, beforeId)
|
||||
|
||||
// Outline layer
|
||||
map.addLayer({
|
||||
id: PUBLIC_LANDS_LINE,
|
||||
type: 'line',
|
||||
source: PUBLIC_LANDS_SOURCE,
|
||||
'source-layer': 'public_lands',
|
||||
paint: {
|
||||
'line-color': [
|
||||
'case',
|
||||
['==', ['get', 'designation'], 'WA'], '#5a4d20',
|
||||
['==', ['get', 'designation'], 'WSA'], '#5a4d20',
|
||||
['==', ['get', 'agency'], 'NPS'], '#2a4a15',
|
||||
['==', ['get', 'agency'], 'USFS'], '#3d5520',
|
||||
['==', ['get', 'agency'], 'BLM'], '#8a7343',
|
||||
['==', ['get', 'agency'], 'FWS'], '#2d5a3a',
|
||||
['any',
|
||||
['==', ['get', 'manager_type'], 'STAT'],
|
||||
['==', ['get', 'agency'], 'SPR']
|
||||
], '#3d6055',
|
||||
['any',
|
||||
['==', ['get', 'manager_type'], 'LOC'],
|
||||
['==', ['get', 'manager_type'], 'DIST']
|
||||
], '#5c6e66',
|
||||
'#707070'
|
||||
],
|
||||
'line-opacity': [
|
||||
'case',
|
||||
['==', ['get', 'agency'], 'NPS'], 0.7,
|
||||
['==', ['get', 'agency'], 'USFS'], 0.6,
|
||||
['==', ['get', 'agency'], 'BLM'], 0.5,
|
||||
0.5
|
||||
],
|
||||
'line-width': [
|
||||
'interpolate', ['linear'], ['zoom'],
|
||||
4, 0.3,
|
||||
8, 0.8,
|
||||
12, 1.2
|
||||
],
|
||||
},
|
||||
}, beforeId)
|
||||
|
||||
// Label layer — unit names at zoom 10+
|
||||
map.addLayer({
|
||||
id: PUBLIC_LANDS_LABEL,
|
||||
type: 'symbol',
|
||||
source: PUBLIC_LANDS_SOURCE,
|
||||
'source-layer': 'public_lands',
|
||||
minzoom: 10,
|
||||
layout: {
|
||||
'text-field': ['get', 'name'],
|
||||
'text-size': ['interpolate', ['linear'], ['zoom'], 10, 10, 14, 13],
|
||||
'text-font': ['Noto Sans Regular'],
|
||||
'symbol-placement': 'point',
|
||||
'text-anchor': 'center',
|
||||
'text-max-width': 8,
|
||||
'text-allow-overlap': false,
|
||||
'text-ignore-placement': false,
|
||||
},
|
||||
paint: {
|
||||
'text-color': isDark ? '#c0c8b8' : '#3a4a30',
|
||||
'text-halo-color': isDark ? '#1a1a1a' : '#ffffff',
|
||||
'text-halo-width': 1.5,
|
||||
'text-opacity': 0.85,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/** Remove public lands layers + source */
|
||||
function removePublicLands(map) {
|
||||
if (!map) return
|
||||
if (map.getLayer(PUBLIC_LANDS_LABEL)) map.removeLayer(PUBLIC_LANDS_LABEL)
|
||||
if (map.getLayer(PUBLIC_LANDS_LINE)) map.removeLayer(PUBLIC_LANDS_LINE)
|
||||
if (map.getLayer(PUBLIC_LANDS_FILL)) map.removeLayer(PUBLIC_LANDS_FILL)
|
||||
if (map.getSource(PUBLIC_LANDS_SOURCE)) map.removeSource(PUBLIC_LANDS_SOURCE)
|
||||
}
|
||||
|
||||
/** Add topographic contour vector tile overlay */
|
||||
function addContours(map) {
|
||||
if (!map || map.getSource(CONTOUR_SOURCE)) return
|
||||
|
||||
map.addSource(CONTOUR_SOURCE, {
|
||||
type: 'vector',
|
||||
url: 'pmtiles:///tiles/contours-na.pmtiles',
|
||||
})
|
||||
|
||||
// Insert below first symbol layer (above hillshade, below labels)
|
||||
let beforeId = undefined
|
||||
for (const layer of map.getStyle().layers) {
|
||||
if (layer.type === 'symbol') {
|
||||
beforeId = layer.id
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const isDark = document.documentElement.getAttribute('data-theme') === 'dark'
|
||||
const opMod = isDark ? 0.8 : 1.0
|
||||
|
||||
// Minor contours (40ft) — visible z11+
|
||||
map.addLayer({
|
||||
id: CONTOUR_MINOR,
|
||||
type: 'line',
|
||||
source: CONTOUR_SOURCE,
|
||||
'source-layer': 'contours',
|
||||
minzoom: 11,
|
||||
filter: ['==', ['get', 'tier'], 'minor'],
|
||||
paint: {
|
||||
'line-color': '#8b6f47',
|
||||
'line-opacity': 0.4 * opMod,
|
||||
'line-width': ['interpolate', ['linear'], ['zoom'], 11, 0.5, 14, 1.0],
|
||||
},
|
||||
}, beforeId)
|
||||
|
||||
// Intermediate contours (200ft) — visible z8+
|
||||
map.addLayer({
|
||||
id: CONTOUR_INTERMEDIATE,
|
||||
type: 'line',
|
||||
source: CONTOUR_SOURCE,
|
||||
'source-layer': 'contours',
|
||||
minzoom: 8,
|
||||
filter: ['==', ['get', 'tier'], 'intermediate'],
|
||||
paint: {
|
||||
'line-color': '#8b6f47',
|
||||
'line-opacity': 0.7 * opMod,
|
||||
'line-width': ['interpolate', ['linear'], ['zoom'], 8, 0.8, 14, 1.2],
|
||||
},
|
||||
}, beforeId)
|
||||
|
||||
// Index contours (1000ft) — visible z4+
|
||||
map.addLayer({
|
||||
id: CONTOUR_INDEX,
|
||||
type: 'line',
|
||||
source: CONTOUR_SOURCE,
|
||||
'source-layer': 'contours',
|
||||
minzoom: 4,
|
||||
filter: ['==', ['get', 'tier'], 'index'],
|
||||
paint: {
|
||||
'line-color': '#6b4f2a',
|
||||
'line-opacity': 0.9 * opMod,
|
||||
'line-width': ['interpolate', ['linear'], ['zoom'], 4, 1.2, 14, 1.8],
|
||||
},
|
||||
}, beforeId)
|
||||
|
||||
// Elevation labels on index contours (z12+)
|
||||
map.addLayer({
|
||||
id: CONTOUR_LABEL,
|
||||
type: 'symbol',
|
||||
source: CONTOUR_SOURCE,
|
||||
'source-layer': 'contours',
|
||||
minzoom: 12,
|
||||
filter: ['==', ['get', 'tier'], 'index'],
|
||||
layout: {
|
||||
'text-field': ['concat', ['to-string', ['get', 'elevation_ft']], "'"],
|
||||
'text-size': 10,
|
||||
'text-font': ['Noto Sans Regular'],
|
||||
'symbol-placement': 'line',
|
||||
'text-anchor': 'center',
|
||||
'symbol-spacing': 400,
|
||||
'text-max-angle': 30,
|
||||
'text-allow-overlap': false,
|
||||
},
|
||||
paint: {
|
||||
'text-color': isDark ? '#c0b898' : '#5a4020',
|
||||
'text-halo-color': isDark ? '#1a1a1a' : '#ffffff',
|
||||
'text-halo-width': 1.5,
|
||||
'text-opacity': 0.85,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/** Remove contour layers + source */
|
||||
function removeContours(map) {
|
||||
if (!map) return
|
||||
if (map.getLayer(CONTOUR_LABEL)) map.removeLayer(CONTOUR_LABEL)
|
||||
if (map.getLayer(CONTOUR_INDEX)) map.removeLayer(CONTOUR_INDEX)
|
||||
if (map.getLayer(CONTOUR_INTERMEDIATE)) map.removeLayer(CONTOUR_INTERMEDIATE)
|
||||
if (map.getLayer(CONTOUR_MINOR)) map.removeLayer(CONTOUR_MINOR)
|
||||
if (map.getSource(CONTOUR_SOURCE)) map.removeSource(CONTOUR_SOURCE)
|
||||
}
|
||||
|
||||
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(
|
||||
`<div style="font-size:12px;max-width:200px">
|
||||
<strong>${stop.name}</strong>
|
||||
<br/><button id="remove-stop-${stop.id}" style="margin-top:4px;padding:2px 8px;background:var(--status-danger);border:none;border-radius:4px;color:white;cursor:pointer;font-size:11px">Remove</button>
|
||||
</div>`
|
||||
)
|
||||
.addTo(map)
|
||||
|
||||
popup.getElement().querySelector(`#remove-stop-${stop.id}`)?.addEventListener('click', () => {
|
||||
useStore.getState().removeStop(stop.id)
|
||||
popup.remove()
|
||||
})
|
||||
popupRef.current = popup
|
||||
})
|
||||
|
||||
const marker = new maplibregl.Marker({ element: el })
|
||||
.setLngLat([stop.lon, stop.lat])
|
||||
.addTo(map)
|
||||
|
||||
markersRef.current.push(marker)
|
||||
})
|
||||
|
||||
// If stops but no route yet, fit to stops
|
||||
if (stops.length > 0 && !route) {
|
||||
if (stops.length === 1) {
|
||||
map.flyTo({ center: [stops[0].lon, stops[0].lat], zoom: 13 })
|
||||
} else {
|
||||
const bounds = stops.reduce(
|
||||
(b, s) => b.extend([s.lon, s.lat]),
|
||||
new maplibregl.LngLatBounds([stops[0].lon, stops[0].lat], [stops[0].lon, stops[0].lat])
|
||||
)
|
||||
map.fitBounds(bounds, { padding: { top: 60, bottom: 60, left: 340, right: 60 } })
|
||||
}
|
||||
}
|
||||
}, [stops, route, gpsOrigin, geoPermission])
|
||||
|
||||
return <div ref={mapRef} className="w-full h-full" />
|
||||
})
|
||||
|
||||
export default MapView
|
||||
1162
src/components/MapView.jsx.bak.radial
Normal file
1162
src/components/MapView.jsx.bak.radial
Normal file
File diff suppressed because it is too large
Load diff
1134
src/components/MapView.jsx.bak.viewport
Normal file
1134
src/components/MapView.jsx.bak.viewport
Normal file
File diff suppressed because it is too large
Load diff
973
src/components/MapView.jsx.bak.zoom
Normal file
973
src/components/MapView.jsx.bak.zoom
Normal file
|
|
@ -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 = `<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M8 1 L14 13 L8 10 L2 13 Z" fill="var(--accent)" stroke="var(--bg-raised)" stroke-width="1.5" stroke-linejoin="round"/>
|
||||
</svg>`
|
||||
|
||||
/** Add hillshade raster-dem source + layer to the map */
|
||||
function addHillshade(map) {
|
||||
if (!map || map.getSource(HILLSHADE_SOURCE)) return
|
||||
const config = getConfig()
|
||||
const hs = config?.tileset_hillshade
|
||||
if (!hs?.url) return
|
||||
|
||||
map.addSource(HILLSHADE_SOURCE, {
|
||||
type: 'raster-dem',
|
||||
url: `pmtiles://${hs.url}`,
|
||||
encoding: hs.encoding || 'terrarium',
|
||||
tileSize: 256,
|
||||
maxzoom: hs.max_zoom || 12,
|
||||
})
|
||||
|
||||
// Insert below the first symbol/label layer for proper z-ordering
|
||||
let beforeId = undefined
|
||||
for (const layer of map.getStyle().layers) {
|
||||
if (layer.type === 'symbol') {
|
||||
beforeId = layer.id
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
map.addLayer({
|
||||
id: HILLSHADE_LAYER,
|
||||
type: 'hillshade',
|
||||
source: HILLSHADE_SOURCE,
|
||||
paint: {
|
||||
'hillshade-exaggeration': 0.5,
|
||||
'hillshade-illumination-direction': 315,
|
||||
'hillshade-shadow-color': '#000000',
|
||||
'hillshade-highlight-color': '#ffffff',
|
||||
},
|
||||
}, beforeId)
|
||||
}
|
||||
|
||||
/** Remove hillshade layer + source */
|
||||
function removeHillshade(map) {
|
||||
if (!map) return
|
||||
if (map.getLayer(HILLSHADE_LAYER)) map.removeLayer(HILLSHADE_LAYER)
|
||||
if (map.getSource(HILLSHADE_SOURCE)) map.removeSource(HILLSHADE_SOURCE)
|
||||
}
|
||||
|
||||
/** Add traffic raster tile source + layer */
|
||||
function addTraffic(map) {
|
||||
if (!map || map.getSource(TRAFFIC_SOURCE)) return
|
||||
const config = getConfig()
|
||||
const tr = config?.traffic
|
||||
if (!tr?.proxy_url) return
|
||||
|
||||
const tileUrl = tr.proxy_url.replace('{z}', '{z}').replace('{x}', '{x}').replace('{y}', '{y}')
|
||||
|
||||
map.addSource(TRAFFIC_SOURCE, {
|
||||
type: 'raster',
|
||||
tiles: [tileUrl],
|
||||
tileSize: 256,
|
||||
maxzoom: 18,
|
||||
})
|
||||
|
||||
map.addLayer({
|
||||
id: TRAFFIC_LAYER,
|
||||
type: 'raster',
|
||||
source: TRAFFIC_SOURCE,
|
||||
paint: {
|
||||
'raster-opacity': 0.6,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/** Remove traffic layer + source */
|
||||
function removeTraffic(map) {
|
||||
if (!map) return
|
||||
if (map.getLayer(TRAFFIC_LAYER)) map.removeLayer(TRAFFIC_LAYER)
|
||||
if (map.getSource(TRAFFIC_SOURCE)) map.removeSource(TRAFFIC_SOURCE)
|
||||
}
|
||||
|
||||
/** Add public lands vector tile overlay (PAD-US) */
|
||||
function addPublicLands(map) {
|
||||
if (!map || map.getSource(PUBLIC_LANDS_SOURCE)) return
|
||||
|
||||
map.addSource(PUBLIC_LANDS_SOURCE, {
|
||||
type: 'vector',
|
||||
url: 'pmtiles:///tiles/public-lands.pmtiles',
|
||||
})
|
||||
|
||||
// Insert below symbol layers for proper z-ordering
|
||||
let beforeId = undefined
|
||||
for (const layer of map.getStyle().layers) {
|
||||
if (layer.type === 'symbol') {
|
||||
beforeId = layer.id
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const isDark = document.documentElement.getAttribute('data-theme') === 'dark'
|
||||
const opacityMod = isDark ? 0.7 : 1.0
|
||||
|
||||
// Fill layer — data-driven color by agency + designation
|
||||
map.addLayer({
|
||||
id: PUBLIC_LANDS_FILL,
|
||||
type: 'fill',
|
||||
source: PUBLIC_LANDS_SOURCE,
|
||||
'source-layer': 'public_lands',
|
||||
paint: {
|
||||
'fill-color': [
|
||||
'case',
|
||||
['==', ['get', 'designation'], 'WA'], '#7c6b2f',
|
||||
['==', ['get', 'designation'], 'WSA'], '#7c6b2f',
|
||||
['==', ['get', 'agency'], 'NPS'], '#3d6b1f',
|
||||
['==', ['get', 'agency'], 'USFS'], '#5a7c2f',
|
||||
['==', ['get', 'agency'], 'BLM'], '#c4a672',
|
||||
['==', ['get', 'agency'], 'FWS'], '#4a7a5a',
|
||||
['any',
|
||||
['==', ['get', 'manager_type'], 'STAT'],
|
||||
['==', ['get', 'agency'], 'SPR'],
|
||||
['==', ['get', 'agency'], 'SDC'],
|
||||
['==', ['get', 'agency'], 'SLB']
|
||||
], '#5a8c7c',
|
||||
['any',
|
||||
['==', ['get', 'manager_type'], 'LOC'],
|
||||
['==', ['get', 'manager_type'], 'DIST']
|
||||
], '#8ca694',
|
||||
'#a0a0a0'
|
||||
],
|
||||
'fill-opacity': [
|
||||
'case',
|
||||
['==', ['get', 'designation'], 'WA'], 0.30 * opacityMod,
|
||||
['==', ['get', 'designation'], 'WSA'], 0.30 * opacityMod,
|
||||
['==', ['get', 'agency'], 'NPS'], 0.30 * opacityMod,
|
||||
['==', ['get', 'agency'], 'USFS'], 0.25 * opacityMod,
|
||||
['==', ['get', 'agency'], 'BLM'], 0.20 * opacityMod,
|
||||
['any',
|
||||
['==', ['get', 'manager_type'], 'STAT'],
|
||||
['==', ['get', 'agency'], 'SPR']
|
||||
], 0.25 * opacityMod,
|
||||
['any',
|
||||
['==', ['get', 'manager_type'], 'LOC'],
|
||||
['==', ['get', 'manager_type'], 'DIST']
|
||||
], 0.20 * opacityMod,
|
||||
0.15 * opacityMod
|
||||
],
|
||||
},
|
||||
}, beforeId)
|
||||
|
||||
// Outline layer
|
||||
map.addLayer({
|
||||
id: PUBLIC_LANDS_LINE,
|
||||
type: 'line',
|
||||
source: PUBLIC_LANDS_SOURCE,
|
||||
'source-layer': 'public_lands',
|
||||
paint: {
|
||||
'line-color': [
|
||||
'case',
|
||||
['==', ['get', 'designation'], 'WA'], '#5a4d20',
|
||||
['==', ['get', 'designation'], 'WSA'], '#5a4d20',
|
||||
['==', ['get', 'agency'], 'NPS'], '#2a4a15',
|
||||
['==', ['get', 'agency'], 'USFS'], '#3d5520',
|
||||
['==', ['get', 'agency'], 'BLM'], '#8a7343',
|
||||
['==', ['get', 'agency'], 'FWS'], '#2d5a3a',
|
||||
['any',
|
||||
['==', ['get', 'manager_type'], 'STAT'],
|
||||
['==', ['get', 'agency'], 'SPR']
|
||||
], '#3d6055',
|
||||
['any',
|
||||
['==', ['get', 'manager_type'], 'LOC'],
|
||||
['==', ['get', 'manager_type'], 'DIST']
|
||||
], '#5c6e66',
|
||||
'#707070'
|
||||
],
|
||||
'line-opacity': [
|
||||
'case',
|
||||
['==', ['get', 'agency'], 'NPS'], 0.7,
|
||||
['==', ['get', 'agency'], 'USFS'], 0.6,
|
||||
['==', ['get', 'agency'], 'BLM'], 0.5,
|
||||
0.5
|
||||
],
|
||||
'line-width': [
|
||||
'interpolate', ['linear'], ['zoom'],
|
||||
4, 0.3,
|
||||
8, 0.8,
|
||||
12, 1.2
|
||||
],
|
||||
},
|
||||
}, beforeId)
|
||||
|
||||
// Label layer — unit names at zoom 10+
|
||||
map.addLayer({
|
||||
id: PUBLIC_LANDS_LABEL,
|
||||
type: 'symbol',
|
||||
source: PUBLIC_LANDS_SOURCE,
|
||||
'source-layer': 'public_lands',
|
||||
minzoom: 10,
|
||||
layout: {
|
||||
'text-field': ['get', 'name'],
|
||||
'text-size': ['interpolate', ['linear'], ['zoom'], 10, 10, 14, 13],
|
||||
'text-font': ['Noto Sans Regular'],
|
||||
'symbol-placement': 'point',
|
||||
'text-anchor': 'center',
|
||||
'text-max-width': 8,
|
||||
'text-allow-overlap': false,
|
||||
'text-ignore-placement': false,
|
||||
},
|
||||
paint: {
|
||||
'text-color': isDark ? '#c0c8b8' : '#3a4a30',
|
||||
'text-halo-color': isDark ? '#1a1a1a' : '#ffffff',
|
||||
'text-halo-width': 1.5,
|
||||
'text-opacity': 0.85,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/** Remove public lands layers + source */
|
||||
function removePublicLands(map) {
|
||||
if (!map) return
|
||||
if (map.getLayer(PUBLIC_LANDS_LABEL)) map.removeLayer(PUBLIC_LANDS_LABEL)
|
||||
if (map.getLayer(PUBLIC_LANDS_LINE)) map.removeLayer(PUBLIC_LANDS_LINE)
|
||||
if (map.getLayer(PUBLIC_LANDS_FILL)) map.removeLayer(PUBLIC_LANDS_FILL)
|
||||
if (map.getSource(PUBLIC_LANDS_SOURCE)) map.removeSource(PUBLIC_LANDS_SOURCE)
|
||||
}
|
||||
|
||||
/** Add topographic contour vector tile overlay */
|
||||
function addContours(map) {
|
||||
if (!map || map.getSource(CONTOUR_SOURCE)) return
|
||||
|
||||
map.addSource(CONTOUR_SOURCE, {
|
||||
type: 'vector',
|
||||
url: 'pmtiles:///tiles/contours-na.pmtiles',
|
||||
})
|
||||
|
||||
// Insert below first symbol layer (above hillshade, below labels)
|
||||
let beforeId = undefined
|
||||
for (const layer of map.getStyle().layers) {
|
||||
if (layer.type === 'symbol') {
|
||||
beforeId = layer.id
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const isDark = document.documentElement.getAttribute('data-theme') === 'dark'
|
||||
const opMod = isDark ? 0.8 : 1.0
|
||||
|
||||
// Minor contours (40ft) — visible z11+
|
||||
map.addLayer({
|
||||
id: CONTOUR_MINOR,
|
||||
type: 'line',
|
||||
source: CONTOUR_SOURCE,
|
||||
'source-layer': 'contours',
|
||||
minzoom: 11,
|
||||
filter: ['==', ['get', 'tier'], 'minor'],
|
||||
paint: {
|
||||
'line-color': '#8b6f47',
|
||||
'line-opacity': 0.4 * opMod,
|
||||
'line-width': ['interpolate', ['linear'], ['zoom'], 11, 0.5, 14, 1.0],
|
||||
},
|
||||
}, beforeId)
|
||||
|
||||
// Intermediate contours (200ft) — visible z8+
|
||||
map.addLayer({
|
||||
id: CONTOUR_INTERMEDIATE,
|
||||
type: 'line',
|
||||
source: CONTOUR_SOURCE,
|
||||
'source-layer': 'contours',
|
||||
minzoom: 8,
|
||||
filter: ['==', ['get', 'tier'], 'intermediate'],
|
||||
paint: {
|
||||
'line-color': '#8b6f47',
|
||||
'line-opacity': 0.7 * opMod,
|
||||
'line-width': ['interpolate', ['linear'], ['zoom'], 8, 0.8, 14, 1.2],
|
||||
},
|
||||
}, beforeId)
|
||||
|
||||
// Index contours (1000ft) — visible z4+
|
||||
map.addLayer({
|
||||
id: CONTOUR_INDEX,
|
||||
type: 'line',
|
||||
source: CONTOUR_SOURCE,
|
||||
'source-layer': 'contours',
|
||||
minzoom: 4,
|
||||
filter: ['==', ['get', 'tier'], 'index'],
|
||||
paint: {
|
||||
'line-color': '#6b4f2a',
|
||||
'line-opacity': 0.9 * opMod,
|
||||
'line-width': ['interpolate', ['linear'], ['zoom'], 4, 1.2, 14, 1.8],
|
||||
},
|
||||
}, beforeId)
|
||||
|
||||
// Elevation labels on index contours (z12+)
|
||||
map.addLayer({
|
||||
id: CONTOUR_LABEL,
|
||||
type: 'symbol',
|
||||
source: CONTOUR_SOURCE,
|
||||
'source-layer': 'contours',
|
||||
minzoom: 12,
|
||||
filter: ['==', ['get', 'tier'], 'index'],
|
||||
layout: {
|
||||
'text-field': ['concat', ['to-string', ['get', 'elevation_ft']], "'"],
|
||||
'text-size': 10,
|
||||
'text-font': ['Noto Sans Regular'],
|
||||
'symbol-placement': 'line',
|
||||
'text-anchor': 'center',
|
||||
'symbol-spacing': 400,
|
||||
'text-max-angle': 30,
|
||||
'text-allow-overlap': false,
|
||||
},
|
||||
paint: {
|
||||
'text-color': isDark ? '#c0b898' : '#5a4020',
|
||||
'text-halo-color': isDark ? '#1a1a1a' : '#ffffff',
|
||||
'text-halo-width': 1.5,
|
||||
'text-opacity': 0.85,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/** Remove contour layers + source */
|
||||
function removeContours(map) {
|
||||
if (!map) return
|
||||
if (map.getLayer(CONTOUR_LABEL)) map.removeLayer(CONTOUR_LABEL)
|
||||
if (map.getLayer(CONTOUR_INDEX)) map.removeLayer(CONTOUR_INDEX)
|
||||
if (map.getLayer(CONTOUR_INTERMEDIATE)) map.removeLayer(CONTOUR_INTERMEDIATE)
|
||||
if (map.getLayer(CONTOUR_MINOR)) map.removeLayer(CONTOUR_MINOR)
|
||||
if (map.getSource(CONTOUR_SOURCE)) map.removeSource(CONTOUR_SOURCE)
|
||||
}
|
||||
|
||||
/** Add TEST topographic contour overlay (blue color scheme) */
|
||||
function addContoursTest(map) {
|
||||
if (!map || map.getSource(CONTOUR_TEST_SOURCE)) return
|
||||
|
||||
map.addSource(CONTOUR_TEST_SOURCE, {
|
||||
type: "vector",
|
||||
url: "pmtiles:///tiles/contours-test.pmtiles",
|
||||
})
|
||||
|
||||
let beforeId = undefined
|
||||
for (const layer of map.getStyle().layers) {
|
||||
if (layer.type === "symbol") {
|
||||
beforeId = layer.id
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const isDark = document.documentElement.getAttribute("data-theme") === "dark"
|
||||
const opMod = isDark ? 0.8 : 1.0
|
||||
|
||||
// Minor contours (40ft) — blue scheme
|
||||
map.addLayer({
|
||||
id: CONTOUR_TEST_MINOR,
|
||||
type: "line",
|
||||
source: CONTOUR_TEST_SOURCE,
|
||||
"source-layer": "contours",
|
||||
minzoom: 11,
|
||||
filter: ["==", ["get", "tier"], "minor"],
|
||||
paint: {
|
||||
"line-color": "#4a7c9b",
|
||||
"line-opacity": 0.4 * opMod,
|
||||
"line-width": ["interpolate", ["linear"], ["zoom"], 11, 0.5, 14, 1.0],
|
||||
},
|
||||
}, beforeId)
|
||||
|
||||
// Intermediate contours (200ft)
|
||||
map.addLayer({
|
||||
id: CONTOUR_TEST_INTERMEDIATE,
|
||||
type: "line",
|
||||
source: CONTOUR_TEST_SOURCE,
|
||||
"source-layer": "contours",
|
||||
minzoom: 8,
|
||||
filter: ["==", ["get", "tier"], "intermediate"],
|
||||
paint: {
|
||||
"line-color": "#4a7c9b",
|
||||
"line-opacity": 0.7 * opMod,
|
||||
"line-width": ["interpolate", ["linear"], ["zoom"], 8, 0.8, 14, 1.2],
|
||||
},
|
||||
}, beforeId)
|
||||
|
||||
// Index contours (1000ft)
|
||||
map.addLayer({
|
||||
id: CONTOUR_TEST_INDEX,
|
||||
type: "line",
|
||||
source: CONTOUR_TEST_SOURCE,
|
||||
"source-layer": "contours",
|
||||
minzoom: 4,
|
||||
filter: ["==", ["get", "tier"], "index"],
|
||||
paint: {
|
||||
"line-color": "#2a5a7c",
|
||||
"line-opacity": 0.9 * opMod,
|
||||
"line-width": ["interpolate", ["linear"], ["zoom"], 4, 1.2, 14, 1.8],
|
||||
},
|
||||
}, beforeId)
|
||||
|
||||
// Labels
|
||||
map.addLayer({
|
||||
id: CONTOUR_TEST_LABEL,
|
||||
type: "symbol",
|
||||
source: CONTOUR_TEST_SOURCE,
|
||||
"source-layer": "contours",
|
||||
minzoom: 12,
|
||||
filter: ["==", ["get", "tier"], "index"],
|
||||
layout: {
|
||||
"text-field": ["concat", ["to-string", ["get", "elevation_ft"]], ""],
|
||||
"text-size": 10,
|
||||
"text-font": ["Noto Sans Regular"],
|
||||
"symbol-placement": "line",
|
||||
"text-anchor": "center",
|
||||
"symbol-spacing": 400,
|
||||
"text-max-angle": 30,
|
||||
"text-allow-overlap": false,
|
||||
},
|
||||
paint: {
|
||||
"text-color": isDark ? "#98b8d0" : "#205080",
|
||||
"text-halo-color": isDark ? "#1a1a1a" : "#ffffff",
|
||||
"text-halo-width": 1.5,
|
||||
"text-opacity": 0.85,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/** Remove TEST contour layers + source */
|
||||
function removeContoursTest(map) {
|
||||
if (!map) return
|
||||
if (map.getLayer(CONTOUR_TEST_LABEL)) map.removeLayer(CONTOUR_TEST_LABEL)
|
||||
if (map.getLayer(CONTOUR_TEST_INDEX)) map.removeLayer(CONTOUR_TEST_INDEX)
|
||||
if (map.getLayer(CONTOUR_TEST_INTERMEDIATE)) map.removeLayer(CONTOUR_TEST_INTERMEDIATE)
|
||||
if (map.getLayer(CONTOUR_TEST_MINOR)) map.removeLayer(CONTOUR_TEST_MINOR)
|
||||
if (map.getSource(CONTOUR_TEST_SOURCE)) map.removeSource(CONTOUR_TEST_SOURCE)
|
||||
}
|
||||
|
||||
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(
|
||||
`<div style="font-size:12px;max-width:200px">
|
||||
<strong>${stop.name}</strong>
|
||||
<br/><button id="remove-stop-${stop.id}" style="margin-top:4px;padding:2px 8px;background:var(--status-danger);border:none;border-radius:4px;color:white;cursor:pointer;font-size:11px">Remove</button>
|
||||
</div>`
|
||||
)
|
||||
.addTo(map)
|
||||
|
||||
popup.getElement().querySelector(`#remove-stop-${stop.id}`)?.addEventListener('click', () => {
|
||||
useStore.getState().removeStop(stop.id)
|
||||
popup.remove()
|
||||
})
|
||||
popupRef.current = popup
|
||||
})
|
||||
|
||||
const marker = new maplibregl.Marker({ element: el })
|
||||
.setLngLat([stop.lon, stop.lat])
|
||||
.addTo(map)
|
||||
|
||||
markersRef.current.push(marker)
|
||||
})
|
||||
|
||||
// If stops but no route yet, fit to stops
|
||||
if (stops.length > 0 && !route) {
|
||||
if (stops.length === 1) {
|
||||
map.flyTo({ center: [stops[0].lon, stops[0].lat], zoom: 13 })
|
||||
} else {
|
||||
const bounds = stops.reduce(
|
||||
(b, s) => b.extend([s.lon, s.lat]),
|
||||
new maplibregl.LngLatBounds([stops[0].lon, stops[0].lat], [stops[0].lon, stops[0].lat])
|
||||
)
|
||||
map.fitBounds(bounds, { padding: { top: 60, bottom: 60, left: 340, right: 60 } })
|
||||
}
|
||||
}
|
||||
}, [stops, route, gpsOrigin, geoPermission])
|
||||
|
||||
return <div ref={mapRef} className="w-full h-full" />
|
||||
})
|
||||
|
||||
export default MapView
|
||||
|
|
@ -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 */}
|
||||
<div
|
||||
className="radial-backdrop"
|
||||
onClick={handleBackdropClick}
|
||||
onContextMenu={handleBackdropClick}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
zIndex: 9998,
|
||||
background: 'transparent',
|
||||
cursor: 'default',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 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({
|
|||
<g key={wedge.id} className="radial-wedge" data-wedge-id={wedge.id}>
|
||||
<path
|
||||
d={generateWedgePath(i)}
|
||||
fill="rgba(30, 28, 26, 0.85)"
|
||||
stroke="rgba(180, 160, 140, 0.3)"
|
||||
strokeWidth="1"
|
||||
style={{ transition: 'fill 100ms ease' }}
|
||||
className="wedge-path"
|
||||
/>
|
||||
<g transform={`translate(${iconPos.x}, ${iconPos.y})`}>
|
||||
{Icon && (
|
||||
<foreignObject
|
||||
x={-9}
|
||||
y={-12}
|
||||
width={18}
|
||||
height={18}
|
||||
style={{ overflow: 'visible' }}
|
||||
>
|
||||
<Icon
|
||||
size={18}
|
||||
stroke="rgba(230, 220, 210, 0.9)"
|
||||
className="wedge-icon"
|
||||
strokeWidth={1.5}
|
||||
style={{ transform: 'translate(-9px, -12px)' }}
|
||||
/>
|
||||
</foreignObject>
|
||||
)}
|
||||
{wedge.requiresAuth && (
|
||||
<foreignObject
|
||||
x={4}
|
||||
y={-14}
|
||||
width={10}
|
||||
height={10}
|
||||
style={{ overflow: 'visible' }}
|
||||
>
|
||||
<Lock
|
||||
size={10}
|
||||
stroke="rgba(230, 220, 210, 0.6)"
|
||||
className="wedge-lock"
|
||||
strokeWidth={1.5}
|
||||
style={{ transform: 'translate(4px, -14px)' }}
|
||||
/>
|
||||
</foreignObject>
|
||||
)}
|
||||
<text
|
||||
y={10}
|
||||
textAnchor="middle"
|
||||
fontSize="9"
|
||||
fill="rgba(230, 220, 210, 0.8)"
|
||||
style={{ pointerEvents: 'none', userSelect: 'none' }}
|
||||
className="wedge-label"
|
||||
>
|
||||
{wedge.label}
|
||||
</text>
|
||||
|
|
@ -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"
|
||||
/>
|
||||
<text
|
||||
y={-4}
|
||||
textAnchor="middle"
|
||||
fontSize="10"
|
||||
fontFamily="monospace"
|
||||
fill="rgba(230, 220, 210, 0.9)"
|
||||
className="center-coords"
|
||||
>
|
||||
{lat?.toFixed(4)}
|
||||
</text>
|
||||
<text
|
||||
y={8}
|
||||
textAnchor="middle"
|
||||
fontSize="10"
|
||||
fontFamily="monospace"
|
||||
fill="rgba(230, 220, 210, 0.9)"
|
||||
className="center-coords"
|
||||
>
|
||||
{lon?.toFixed(4)}
|
||||
</text>
|
||||
|
|
@ -251,9 +249,7 @@ export default function RadialMenu({
|
|||
<text
|
||||
y={20}
|
||||
textAnchor="middle"
|
||||
fontSize="9"
|
||||
fill="rgba(200, 180, 160, 0.9)"
|
||||
style={{ fontStyle: 'italic' }}
|
||||
className="center-label"
|
||||
>
|
||||
{centerLabel.length > 15 ? centerLabel.slice(0, 15) + '…' : centerLabel}
|
||||
</text>
|
||||
|
|
@ -261,12 +257,87 @@ export default function RadialMenu({
|
|||
</svg>
|
||||
|
||||
<style>{`
|
||||
.radial-wedge.active .wedge-path {
|
||||
fill: rgba(180, 160, 140, 0.4) !important;
|
||||
/* Backdrop — matches modal overlay */
|
||||
.radial-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 9998;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* Wedge paths — themed surface */
|
||||
.wedge-path {
|
||||
fill: var(--bg-overlay);
|
||||
fill-opacity: 0.92;
|
||||
stroke: var(--border);
|
||||
stroke-width: 1;
|
||||
transition: fill 100ms ease, fill-opacity 100ms ease;
|
||||
}
|
||||
|
||||
.radial-wedge:hover .wedge-path {
|
||||
fill: rgba(180, 160, 140, 0.3);
|
||||
fill: var(--accent-muted);
|
||||
fill-opacity: 1;
|
||||
}
|
||||
|
||||
.radial-wedge.active .wedge-path {
|
||||
fill: var(--accent-muted);
|
||||
fill-opacity: 1;
|
||||
}
|
||||
|
||||
/* Wedge icons — secondary text color */
|
||||
.wedge-icon {
|
||||
color: var(--text-secondary);
|
||||
transition: color 100ms ease;
|
||||
}
|
||||
|
||||
.radial-wedge:hover .wedge-icon,
|
||||
.radial-wedge.active .wedge-icon {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Lock icon — tertiary/muted */
|
||||
.wedge-lock {
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
/* Wedge labels — secondary text */
|
||||
.wedge-label {
|
||||
font-family: var(--font-sans);
|
||||
font-size: 9px;
|
||||
fill: var(--text-secondary);
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
transition: fill 100ms ease;
|
||||
}
|
||||
|
||||
.radial-wedge:hover .wedge-label,
|
||||
.radial-wedge.active .wedge-label {
|
||||
fill: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Center disc — raised surface */
|
||||
.center-disc {
|
||||
fill: var(--bg-raised);
|
||||
stroke: var(--border);
|
||||
stroke-width: 1;
|
||||
}
|
||||
|
||||
/* Center coordinates — monospace primary */
|
||||
.center-coords {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
fill: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Center label — secondary italic */
|
||||
.center-label {
|
||||
font-family: var(--font-sans);
|
||||
font-size: 9px;
|
||||
font-style: italic;
|
||||
fill: var(--text-secondary);
|
||||
}
|
||||
|
||||
@keyframes radialFadeIn {
|
||||
from { opacity: 0; transform: translate(-50%, -50%) scale(0.9); }
|
||||
to { opacity: 1; transform: translate(-50%, -50%) scale(1); }
|
||||
|
|
|
|||
319
src/components/SearchBar.jsx.bak.viewport
Normal file
319
src/components/SearchBar.jsx.bak.viewport
Normal file
|
|
@ -0,0 +1,319 @@
|
|||
import { useRef, useEffect, useCallback, useState, forwardRef, useImperativeHandle } from 'react'
|
||||
import { MapPin, Building2, Star, Crosshair, Coffee, Fuel, ShoppingBag, Hotel, X, User } from 'lucide-react'
|
||||
import toast from 'react-hot-toast'
|
||||
import { useStore } from '../store'
|
||||
import { buildAddress } from '../utils/place'
|
||||
import { searchGeocode } from '../api'
|
||||
import { hasFeature } from '../config'
|
||||
|
||||
/** Get category icon based on result type/source */
|
||||
function CategoryIcon({ result }) {
|
||||
const type = result.type || ''
|
||||
const source = result.source || ''
|
||||
const size = 14
|
||||
|
||||
if (result._isContact) return <User size={size} />
|
||||
if (source === 'nickname') return <Star size={size} />
|
||||
if (type === 'coordinates') return <Crosshair size={size} />
|
||||
if (type === 'locality' || type === 'city') return <Building2 size={size} />
|
||||
|
||||
// POI subcategories from osm_value if available
|
||||
const osmVal = result.raw?.osm_value || ''
|
||||
if (osmVal.includes('cafe') || osmVal.includes('coffee')) return <Coffee size={size} />
|
||||
if (osmVal.includes('fuel') || osmVal.includes('gas')) return <Fuel size={size} />
|
||||
if (osmVal.includes('shop') || osmVal.includes('supermarket')) return <ShoppingBag size={size} />
|
||||
if (osmVal.includes('hotel') || osmVal.includes('motel')) return <Hotel size={size} />
|
||||
|
||||
return <MapPin size={size} />
|
||||
}
|
||||
|
||||
const SearchBar = forwardRef(function SearchBar(_, ref) {
|
||||
const inputRef = useRef(null)
|
||||
const [activeIndex, setActiveIndex] = useState(-1)
|
||||
const debounceRef = useRef(null)
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
focus: () => inputRef.current?.focus(),
|
||||
}))
|
||||
|
||||
const query = useStore((s) => s.query)
|
||||
const results = useStore((s) => s.results)
|
||||
const searchLoading = useStore((s) => s.searchLoading)
|
||||
const autocompleteOpen = useStore((s) => s.autocompleteOpen)
|
||||
const stops = useStore((s) => s.stops)
|
||||
const pendingDestination = useStore((s) => s.pendingDestination)
|
||||
const contacts = useStore((s) => s.contacts)
|
||||
const setQuery = useStore((s) => s.setQuery)
|
||||
const setResults = useStore((s) => s.setResults)
|
||||
const setSearchLoading = useStore((s) => s.setSearchLoading)
|
||||
const setAbortController = useStore((s) => s.setAbortController)
|
||||
const setAutocompleteOpen = useStore((s) => s.setAutocompleteOpen)
|
||||
const addStop = useStore((s) => s.addStop)
|
||||
const setSelectedPlace = useStore((s) => s.setSelectedPlace)
|
||||
const setEditingContact = useStore((s) => s.setEditingContact)
|
||||
const clearPendingDestination = useStore((s) => s.clearPendingDestination)
|
||||
|
||||
useEffect(() => {
|
||||
inputRef.current?.focus()
|
||||
}, [])
|
||||
|
||||
const doSearch = useCallback(
|
||||
async (q) => {
|
||||
const prev = useStore.getState().abortController
|
||||
if (prev) prev.abort()
|
||||
|
||||
if (!q.trim()) {
|
||||
setResults([])
|
||||
setAutocompleteOpen(false)
|
||||
setSearchLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
// Prepend matching contacts
|
||||
let contactResults = []
|
||||
if (hasFeature('has_contacts') && contacts.length > 0) {
|
||||
const lower = q.trim().toLowerCase()
|
||||
contactResults = contacts
|
||||
.filter((c) =>
|
||||
(c.label || '').toLowerCase().startsWith(lower) ||
|
||||
(c.name || '').toLowerCase().startsWith(lower) ||
|
||||
(c.call_sign || '').toLowerCase().startsWith(lower)
|
||||
)
|
||||
.slice(0, 3)
|
||||
.map((c) => ({
|
||||
lat: c.lat,
|
||||
lon: c.lon,
|
||||
name: c.label,
|
||||
address: c.address || c.name || '',
|
||||
type: 'contact',
|
||||
source: 'contacts',
|
||||
match_code: null,
|
||||
raw: { osm_type: c.osm_type, osm_id: c.osm_id, contact: c },
|
||||
_isContact: true,
|
||||
}))
|
||||
}
|
||||
|
||||
const ctrl = new AbortController()
|
||||
setAbortController(ctrl)
|
||||
setSearchLoading(true)
|
||||
|
||||
try {
|
||||
const data = await searchGeocode(q.trim(), 6, ctrl.signal)
|
||||
const combined = [...contactResults, ...(data.results || [])]
|
||||
setResults(combined)
|
||||
setAutocompleteOpen(combined.length > 0)
|
||||
setActiveIndex(-1)
|
||||
} catch (e) {
|
||||
if (e.name !== 'AbortError') {
|
||||
// Still show contacts even if geocode fails
|
||||
if (contactResults.length > 0) {
|
||||
setResults(contactResults)
|
||||
setAutocompleteOpen(true)
|
||||
} else {
|
||||
setResults([])
|
||||
setAutocompleteOpen(false)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
setSearchLoading(false)
|
||||
}
|
||||
},
|
||||
[setResults, setAutocompleteOpen, setSearchLoading, setAbortController, contacts]
|
||||
)
|
||||
|
||||
const handleChange = (e) => {
|
||||
const val = e.target.value
|
||||
setQuery(val)
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current)
|
||||
debounceRef.current = setTimeout(() => doSearch(val), 150)
|
||||
}
|
||||
|
||||
const handleClear = () => {
|
||||
setQuery('')
|
||||
setResults([])
|
||||
setAutocompleteOpen(false)
|
||||
inputRef.current?.focus()
|
||||
}
|
||||
|
||||
const selectResult = (result) => {
|
||||
const { pendingDestination: pending } = useStore.getState()
|
||||
|
||||
// Pure contact (no geo) → open edit modal
|
||||
if (result._isContact && result.lat == null) {
|
||||
setEditingContact(result.raw.contact)
|
||||
setQuery('')
|
||||
setResults([])
|
||||
setAutocompleteOpen(false)
|
||||
setActiveIndex(-1)
|
||||
return
|
||||
}
|
||||
|
||||
if (pending) {
|
||||
addStop({ lat: result.lat, lon: result.lon, name: result.name, source: result.source, matchCode: result.match_code })
|
||||
addStop({ lat: pending.lat, lon: pending.lon, name: pending.name, source: pending.source, matchCode: pending.matchCode })
|
||||
clearPendingDestination()
|
||||
toast(`Routing from ${result.name} to ${pending.name}`, { icon: '\u{1F9ED}' })
|
||||
} else {
|
||||
setSelectedPlace({
|
||||
lat: result.lat,
|
||||
lon: result.lon,
|
||||
name: result.name,
|
||||
address: result.address || null,
|
||||
type: result.type,
|
||||
source: result.source,
|
||||
matchCode: result.match_code,
|
||||
raw: result.raw || {},
|
||||
})
|
||||
}
|
||||
|
||||
setQuery('')
|
||||
setResults([])
|
||||
setAutocompleteOpen(false)
|
||||
setActiveIndex(-1)
|
||||
inputRef.current?.focus()
|
||||
}
|
||||
|
||||
const handleKeyDown = (e) => {
|
||||
if (!autocompleteOpen || results.length === 0) {
|
||||
if (e.key === 'Escape') setAutocompleteOpen(false)
|
||||
return
|
||||
}
|
||||
|
||||
switch (e.key) {
|
||||
case 'ArrowDown':
|
||||
e.preventDefault()
|
||||
setActiveIndex((prev) => Math.min(prev + 1, results.length - 1))
|
||||
break
|
||||
case 'ArrowUp':
|
||||
e.preventDefault()
|
||||
setActiveIndex((prev) => Math.max(prev - 1, -1))
|
||||
break
|
||||
case 'Enter':
|
||||
e.preventDefault()
|
||||
if (activeIndex >= 0 && activeIndex < results.length) {
|
||||
selectResult(results[activeIndex])
|
||||
}
|
||||
break
|
||||
case 'Escape':
|
||||
e.preventDefault()
|
||||
setAutocompleteOpen(false)
|
||||
setActiveIndex(-1)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const atCap = stops.length >= 10
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<div className="relative">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={handleChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
onFocus={() => results.length > 0 && setAutocompleteOpen(true)}
|
||||
placeholder={atCap ? 'Max 10 stops reached' : pendingDestination ? 'Starting point...' : 'Search for a place...'}
|
||||
disabled={atCap}
|
||||
className="navi-input w-full pr-8"
|
||||
aria-label="Search places"
|
||||
aria-expanded={autocompleteOpen}
|
||||
aria-autocomplete="list"
|
||||
role="combobox"
|
||||
/>
|
||||
{/* Clear / Loading indicator */}
|
||||
<div className="absolute right-2.5 top-1/2 -translate-y-1/2">
|
||||
{searchLoading ? (
|
||||
<div
|
||||
className="w-4 h-4 border-2 border-t-transparent rounded-full animate-spin"
|
||||
style={{ borderColor: 'var(--accent)', borderTopColor: 'transparent' }}
|
||||
/>
|
||||
) : query ? (
|
||||
<button
|
||||
onClick={handleClear}
|
||||
className="p-0.5"
|
||||
style={{ color: 'var(--text-tertiary)' }}
|
||||
aria-label="Clear search"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Autocomplete dropdown */}
|
||||
{autocompleteOpen && results.length > 0 && (
|
||||
<ul
|
||||
className="absolute z-50 mt-1 w-full rounded-lg overflow-hidden max-h-72 overflow-y-auto"
|
||||
style={{
|
||||
background: 'var(--bg-overlay)',
|
||||
border: '1px solid var(--border)',
|
||||
boxShadow: 'var(--shadow-lg)',
|
||||
}}
|
||||
role="listbox"
|
||||
>
|
||||
{results.map((r, i) => {
|
||||
const isPoi = r.type === 'poi' && r.raw?.name
|
||||
const isContact = r._isContact
|
||||
const primary = isContact ? r.name : isPoi ? r.raw.name : r.name
|
||||
const secondary = isContact ? (r.address || '') : isPoi ? buildAddress(r) : null
|
||||
return (
|
||||
<li
|
||||
key={`${r.lat}-${r.lon}-${i}`}
|
||||
role="option"
|
||||
aria-selected={i === activeIndex}
|
||||
className="px-3 py-2 cursor-pointer text-sm"
|
||||
style={{
|
||||
background: i === activeIndex
|
||||
? 'var(--accent-muted)'
|
||||
: isContact
|
||||
? 'var(--accent-muted)'
|
||||
: 'transparent',
|
||||
borderBottom: i < results.length - 1 ? '1px solid var(--border-subtle)' : 'none',
|
||||
opacity: isContact && i !== activeIndex ? 0.85 : 1,
|
||||
}}
|
||||
onClick={() => selectResult(r)}
|
||||
onMouseEnter={() => setActiveIndex(i)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="shrink-0" style={{ color: isContact ? 'var(--accent)' : 'var(--text-tertiary)' }}>
|
||||
<CategoryIcon result={r} />
|
||||
</span>
|
||||
<span className="truncate flex-1" style={{ color: 'var(--text-primary)' }}>
|
||||
{primary}
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5 shrink-0">
|
||||
{isContact && (
|
||||
<span
|
||||
className="text-[10px] px-1.5 py-0.5 rounded font-medium"
|
||||
style={{ background: 'var(--accent-muted)', color: 'var(--accent)' }}
|
||||
>
|
||||
saved
|
||||
</span>
|
||||
)}
|
||||
{r.match_code?.housenumber === 'matched' && (
|
||||
<span
|
||||
className="text-[10px] px-1.5 py-0.5 rounded font-medium"
|
||||
style={{ background: 'var(--accent-muted)', color: 'var(--accent)' }}
|
||||
>
|
||||
exact
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
{secondary && (
|
||||
<div className="text-[11px] mt-0.5 ml-6 truncate" style={{ color: 'var(--text-tertiary)' }}>
|
||||
{secondary}
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
export default SearchBar
|
||||
|
|
@ -30,6 +30,8 @@ const FALLBACK_CONFIG = {
|
|||
has_landclass: false,
|
||||
has_public_lands_layer: false,
|
||||
has_contours: true,
|
||||
has_contours_test: true,
|
||||
has_contours_test_10ft: true,
|
||||
has_address_book_write: false,
|
||||
has_contacts: false,
|
||||
},
|
||||
|
|
|
|||
86
src/config.js.bak
Normal file
86
src/config.js.bak
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
/**
|
||||
* Deployment config loader.
|
||||
*
|
||||
* Fetches /api/config on startup and caches the result.
|
||||
* Falls back to hardcoded defaults matching the home profile if the
|
||||
* API is unavailable (backend restart, network issue).
|
||||
*/
|
||||
|
||||
const FALLBACK_CONFIG = {
|
||||
profile: 'home',
|
||||
region_name: 'North America',
|
||||
tileset: {
|
||||
url: '/tiles/na.pmtiles',
|
||||
bounds: [-168, 14, -52, 72],
|
||||
max_zoom: 15,
|
||||
attribution: 'Protomaps © OSM',
|
||||
},
|
||||
services: {
|
||||
geocode: '/api/geocode',
|
||||
reverse: '/api/reverse',
|
||||
address_book: '/api/address_book',
|
||||
valhalla: '/valhalla',
|
||||
},
|
||||
features: {
|
||||
has_nominatim_details: false,
|
||||
has_kiwix_wiki: false,
|
||||
has_hillshade: false,
|
||||
has_3d_terrain: false,
|
||||
has_traffic_overlay: false,
|
||||
has_landclass: false,
|
||||
has_public_lands_layer: false,
|
||||
has_address_book_write: false,
|
||||
has_contacts: false,
|
||||
},
|
||||
defaults: {
|
||||
center: [42.5736, -114.6066],
|
||||
zoom: 10,
|
||||
},
|
||||
}
|
||||
|
||||
let _config = null
|
||||
let _configPromise = null
|
||||
|
||||
/**
|
||||
* Fetch config from backend. Returns cached config on subsequent calls.
|
||||
* Falls back to FALLBACK_CONFIG if API fails.
|
||||
*/
|
||||
export function loadConfig() {
|
||||
if (_configPromise) return _configPromise
|
||||
|
||||
_configPromise = fetch('/api/config', { signal: AbortSignal.timeout(3000) })
|
||||
.then((resp) => {
|
||||
if (!resp.ok) throw new Error(`Config API returned ${resp.status}`)
|
||||
return resp.json()
|
||||
})
|
||||
.then((data) => {
|
||||
_config = data
|
||||
console.log('[navi] Config loaded:', data.profile, `(${data.region_name})`)
|
||||
console.log('[navi] Feature flags:', data.features)
|
||||
return data
|
||||
})
|
||||
.catch((err) => {
|
||||
console.warn('[navi] Config API unavailable, using fallback:', err.message)
|
||||
_config = FALLBACK_CONFIG
|
||||
return FALLBACK_CONFIG
|
||||
})
|
||||
|
||||
return _configPromise
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current config synchronously. Returns null if not yet loaded.
|
||||
*/
|
||||
export function getConfig() {
|
||||
return _config
|
||||
}
|
||||
|
||||
/**
|
||||
* Check a feature flag from the loaded config.
|
||||
* @param {string} flag - Feature flag name (e.g. 'has_hillshade')
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function hasFeature(flag) {
|
||||
if (!_config) return false
|
||||
return Boolean(_config.features?.[flag])
|
||||
}
|
||||
114
src/store.js.bak.viewport
Normal file
114
src/store.js.bak.viewport
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
import { create } from 'zustand'
|
||||
|
||||
export const useStore = create((set, get) => ({
|
||||
// ── Search state ──
|
||||
query: '',
|
||||
results: [],
|
||||
searchLoading: false,
|
||||
abortController: null,
|
||||
|
||||
setQuery: (query) => set({ query }),
|
||||
setResults: (results) => set({ results }),
|
||||
setSearchLoading: (loading) => set({ searchLoading: loading }),
|
||||
setAbortController: (ctrl) => set({ abortController: ctrl }),
|
||||
|
||||
// ── Stop list ──
|
||||
stops: [],
|
||||
// Each stop: { id, lat, lon, name, source, matchCode, isOrigin }
|
||||
|
||||
addStop: (stop) => {
|
||||
const { stops } = get()
|
||||
if (stops.length >= 10) return false
|
||||
set({ stops: [...stops, { ...stop, id: crypto.randomUUID() }] })
|
||||
return true
|
||||
},
|
||||
|
||||
removeStop: (id) => {
|
||||
set({ stops: get().stops.filter((s) => s.id !== id) })
|
||||
},
|
||||
|
||||
reorderStops: (newStops) => set({ stops: newStops }),
|
||||
|
||||
clearStops: () => set({ stops: [] }),
|
||||
|
||||
setStops: (stops) => set({ stops }),
|
||||
|
||||
// ── Geolocation ──
|
||||
userLocation: null, // { lat, lon }
|
||||
geoPermission: 'prompt', // 'prompt' | 'granted' | 'denied'
|
||||
|
||||
setUserLocation: (loc) => set({ userLocation: loc }),
|
||||
setGeoPermission: (p) => set({ geoPermission: p }),
|
||||
|
||||
// ── Mode ──
|
||||
mode: 'auto', // 'auto' | 'pedestrian' | 'bicycle'
|
||||
setMode: (mode) => set({ mode }),
|
||||
|
||||
// ── Route ──
|
||||
route: null, // Valhalla response (trip object)
|
||||
routeLoading: false,
|
||||
routeError: null,
|
||||
|
||||
setRoute: (route) => set({ route, routeError: null }),
|
||||
setRouteLoading: (loading) => set({ routeLoading: loading }),
|
||||
setRouteError: (err) => set({ routeError: err, route: null }),
|
||||
clearRoute: () => set({ route: null, routeError: null }),
|
||||
|
||||
// ── Place detail ──
|
||||
selectedPlace: null, // { lat, lon, name, address, type, source, matchCode, raw }
|
||||
gpsOrigin: true, // whether GPS should be used as origin when available
|
||||
pendingDestination: null, // place waiting for a starting point (GPS-denied Directions flow)
|
||||
|
||||
setSelectedPlace: (place) => set({ selectedPlace: place }),
|
||||
clearSelectedPlace: () => set({ selectedPlace: null }),
|
||||
setGpsOrigin: (val) => set({ gpsOrigin: val }),
|
||||
setPendingDestination: (place) => set({ pendingDestination: place }),
|
||||
clearPendingDestination: () => set({ pendingDestination: null }),
|
||||
|
||||
startDirections: (place) => {
|
||||
const { geoPermission, stops, addStop, clearStops } = get()
|
||||
if (geoPermission === 'granted') {
|
||||
clearStops()
|
||||
addStop({ lat: place.lat, lon: place.lon, name: place.name, source: place.source, matchCode: place.matchCode })
|
||||
set({ gpsOrigin: true, selectedPlace: null })
|
||||
} else if (stops.length > 0) {
|
||||
const origin = stops[0]
|
||||
clearStops()
|
||||
addStop({ lat: origin.lat, lon: origin.lon, name: origin.name, source: origin.source, matchCode: origin.matchCode })
|
||||
addStop({ lat: place.lat, lon: place.lon, name: place.name, source: place.source, matchCode: place.matchCode })
|
||||
set({ selectedPlace: null })
|
||||
} else {
|
||||
set({ pendingDestination: place, selectedPlace: null })
|
||||
}
|
||||
},
|
||||
|
||||
// ── UI state ──
|
||||
sheetState: 'half', // 'collapsed' | 'half' | 'full'
|
||||
panelOpen: true,
|
||||
autocompleteOpen: false,
|
||||
theme: 'dark', // 'dark' | 'light' (resolved value — what's actually applied)
|
||||
themeOverride: null, // null | 'dark' | 'light' (manual override, persisted)
|
||||
|
||||
setSheetState: (s) => set({ sheetState: s }),
|
||||
setPanelOpen: (open) => set({ panelOpen: open }),
|
||||
setAutocompleteOpen: (open) => set({ autocompleteOpen: open }),
|
||||
setTheme: (theme) => set({ theme }),
|
||||
setThemeOverride: (override) => {
|
||||
set({ themeOverride: override })
|
||||
if (override) {
|
||||
localStorage.setItem('navi-theme-override', override)
|
||||
} else {
|
||||
localStorage.removeItem('navi-theme-override')
|
||||
}
|
||||
},
|
||||
// ── Contacts ──
|
||||
contacts: [],
|
||||
contactsLoaded: false,
|
||||
activeTab: 'routes', // 'routes' | 'contacts'
|
||||
editingContact: null, // null=closed, {}=new, {id:N}=edit
|
||||
|
||||
setContacts: (c) => set({ contacts: c, contactsLoaded: true }),
|
||||
setActiveTab: (tab) => set({ activeTab: tab }),
|
||||
setEditingContact: (c) => set({ editingContact: c }),
|
||||
clearEditingContact: () => set({ editingContact: null }),
|
||||
}))
|
||||
10
src/utils/place.js
Normal file
10
src/utils/place.js
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
/** Build display address from raw result data */
|
||||
export function buildAddress(place) {
|
||||
if (place.address) return place.address
|
||||
const raw = place.raw || {}
|
||||
const street = raw.housenumber && raw.street
|
||||
? `${raw.housenumber} ${raw.street}`
|
||||
: raw.street
|
||||
const parts = [street, raw.city, raw.state, raw.postcode].filter(Boolean)
|
||||
return parts.join(', ') || null
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue