style(radial): match Navi color palette in light and dark themes

This commit is contained in:
Matt 2026-04-26 06:17:48 +00:00
commit 15e6267022
17 changed files with 6318 additions and 47 deletions

59
package-lock.json generated
View file

@ -13,6 +13,7 @@
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"lucide-react": "^1.8.0", "lucide-react": "^1.8.0",
"maplibre-gl": "^5.23.0", "maplibre-gl": "^5.23.0",
"opening_hours": "^3.12.0",
"pmtiles": "^4.4.1", "pmtiles": "^4.4.1",
"protomaps-themes-base": "^4.5.0", "protomaps-themes-base": "^4.5.0",
"react": "^19.2.5", "react": "^19.2.5",
@ -241,6 +242,15 @@
"node": ">=6.0.0" "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": { "node_modules/@babel/template": {
"version": "7.28.6", "version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
@ -2151,6 +2161,37 @@
"hermes-estree": "0.25.1" "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": { "node_modules/ignore": {
"version": "5.3.2", "version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@ -2746,6 +2787,19 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/optionator": {
"version": "0.9.4", "version": "0.9.4",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
@ -3117,6 +3171,11 @@
"url": "https://github.com/sponsors/sindresorhus" "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": { "node_modules/supercluster": {
"version": "8.0.1", "version": "8.0.1",
"resolved": "https://registry.npmjs.org/supercluster/-/supercluster-8.0.1.tgz", "resolved": "https://registry.npmjs.org/supercluster/-/supercluster-8.0.1.tgz",

View file

@ -15,6 +15,7 @@
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"lucide-react": "^1.8.0", "lucide-react": "^1.8.0",
"maplibre-gl": "^5.23.0", "maplibre-gl": "^5.23.0",
"opening_hours": "^3.12.0",
"pmtiles": "^4.4.1", "pmtiles": "^4.4.1",
"protomaps-themes-base": "^4.5.0", "protomaps-themes-base": "^4.5.0",
"react": "^19.2.5", "react": "^19.2.5",

262
src/api.js.bak.viewport Normal file
View 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
}
}

View file

@ -22,6 +22,8 @@ export default function LayerControl({ mapRef }) {
const [traffic, setTraffic] = useState(false) const [traffic, setTraffic] = useState(false)
const [publicLands, setPublicLands] = useState(false) const [publicLands, setPublicLands] = useState(false)
const [contours, setContours] = useState(false) const [contours, setContours] = useState(false)
const [contoursTest, setContoursTest] = useState(false)
const [contoursTest10ft, setContoursTest10ft] = useState(false)
const panelRef = useRef(null) const panelRef = useRef(null)
// Initialize from localStorage or defaults on mount // Initialize from localStorage or defaults on mount
@ -32,18 +34,24 @@ export default function LayerControl({ mapRef }) {
const plAvailable = hasFeature('has_public_lands_layer') const plAvailable = hasFeature('has_public_lands_layer')
const ctAvailable = hasFeature('has_contours') const ctAvailable = hasFeature('has_contours')
const ctTestAvailable = hasFeature('has_contours_test')
const ctTest10ftAvailable = hasFeature('has_contours_test_10ft')
if (saved) { if (saved) {
setHillshade(hsAvailable && (saved.hillshade ?? true)) setHillshade(hsAvailable && (saved.hillshade ?? true))
setTraffic(trAvailable && (saved.traffic ?? false)) setTraffic(trAvailable && (saved.traffic ?? false))
setPublicLands(plAvailable && (saved.publicLands ?? false)) setPublicLands(plAvailable && (saved.publicLands ?? false))
setContours(ctAvailable && (saved.contours ?? false)) setContours(ctAvailable && (saved.contours ?? false))
setContoursTest(ctTestAvailable && (saved.contoursTest ?? false))
setContoursTest10ft(ctTest10ftAvailable && (saved.contoursTest10ft ?? false))
} else { } else {
// Defaults: hillshade ON if available, others OFF // Defaults: hillshade ON if available, others OFF
setHillshade(hsAvailable) setHillshade(hsAvailable)
setTraffic(false) setTraffic(false)
setPublicLands(false) setPublicLands(false)
setContours(false) setContours(false)
setContoursTest(false)
setContoursTest10ft(false)
} }
}, []) }, [])
@ -67,7 +75,7 @@ export default function LayerControl({ mapRef }) {
} else { } else {
map.once('style.load', apply) map.once('style.load', apply)
} }
savePrefs({ hillshade, traffic, publicLands, contours }) savePrefs({ hillshade, traffic, publicLands, contours, contoursTest, contoursTest10ft })
return () => map.off('style.load', apply) return () => map.off('style.load', apply)
}, [hillshade, mapRef]) }, [hillshade, mapRef])
@ -90,7 +98,7 @@ export default function LayerControl({ mapRef }) {
} else { } else {
map.once('style.load', apply) map.once('style.load', apply)
} }
savePrefs({ hillshade, traffic, publicLands, contours }) savePrefs({ hillshade, traffic, publicLands, contours, contoursTest, contoursTest10ft })
return () => map.off('style.load', apply) return () => map.off('style.load', apply)
}, [traffic, mapRef]) }, [traffic, mapRef])
@ -113,7 +121,7 @@ export default function LayerControl({ mapRef }) {
} else { } else {
map.once('style.load', apply) map.once('style.load', apply)
} }
savePrefs({ hillshade, traffic, publicLands, contours }) savePrefs({ hillshade, traffic, publicLands, contours, contoursTest, contoursTest10ft })
return () => map.off('style.load', apply) return () => map.off('style.load', apply)
}, [publicLands, mapRef]) }, [publicLands, mapRef])
@ -136,10 +144,53 @@ export default function LayerControl({ mapRef }) {
} else { } else {
map.once('style.load', apply) map.once('style.load', apply)
} }
savePrefs({ hillshade, traffic, publicLands, contours }) savePrefs({ hillshade, traffic, publicLands, contours, contoursTest, contoursTest10ft })
return () => map.off('style.load', apply) return () => map.off('style.load', apply)
}, [contours, mapRef]) }, [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 // Close on outside click
useEffect(() => { useEffect(() => {
if (!open) return if (!open) return
@ -156,9 +207,11 @@ export default function LayerControl({ mapRef }) {
const showTraffic = hasFeature('has_traffic_overlay') const showTraffic = hasFeature('has_traffic_overlay')
const showPublicLands = hasFeature('has_public_lands_layer') const showPublicLands = hasFeature('has_public_lands_layer')
const showContours = hasFeature('has_contours') 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 // 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 ( return (
<div ref={panelRef} className="layer-control"> <div ref={panelRef} className="layer-control">
@ -222,6 +275,30 @@ export default function LayerControl({ mapRef }) {
/> />
</label> </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>
)} )}
</div> </div>

View 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>
)
}

View 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>
)
}

View 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

View 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

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View 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

View file

@ -4,6 +4,7 @@ import { Lock } from 'lucide-react'
/** /**
* RadialMenu - ATAK-style radial context menu * RadialMenu - ATAK-style radial context menu
* Themed to match Navi light/dark palette using CSS custom properties.
* *
* Props: * Props:
* - open: boolean * - open: boolean
@ -138,17 +139,11 @@ export default function RadialMenu({
const content = ( const content = (
<> <>
{/* Full-screen transparent backdrop for dismiss */} {/* Full-screen backdrop for dismiss — matches modal overlay opacity */}
<div <div
className="radial-backdrop"
onClick={handleBackdropClick} onClick={handleBackdropClick}
onContextMenu={handleBackdropClick} onContextMenu={handleBackdropClick}
style={{
position: 'fixed',
inset: 0,
zIndex: 9998,
background: 'transparent',
cursor: 'default',
}}
/> />
{/* Radial menu container */} {/* Radial menu container */}
@ -163,6 +158,7 @@ export default function RadialMenu({
zIndex: 9999, zIndex: 9999,
transform: 'translate(-50%, -50%)', transform: 'translate(-50%, -50%)',
animation: 'radialFadeIn 100ms ease-out', animation: 'radialFadeIn 100ms ease-out',
filter: 'drop-shadow(var(--shadow-lg))',
}} }}
onMouseMove={handlePointerMove} onMouseMove={handlePointerMove}
onMouseUp={handlePointerUp} onMouseUp={handlePointerUp}
@ -183,35 +179,43 @@ export default function RadialMenu({
<g key={wedge.id} className="radial-wedge" data-wedge-id={wedge.id}> <g key={wedge.id} className="radial-wedge" data-wedge-id={wedge.id}>
<path <path
d={generateWedgePath(i)} 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" className="wedge-path"
/> />
<g transform={`translate(${iconPos.x}, ${iconPos.y})`}> <g transform={`translate(${iconPos.x}, ${iconPos.y})`}>
{Icon && ( {Icon && (
<Icon <foreignObject
size={18} x={-9}
stroke="rgba(230, 220, 210, 0.9)" y={-12}
strokeWidth={1.5} width={18}
style={{ transform: 'translate(-9px, -12px)' }} height={18}
/> style={{ overflow: 'visible' }}
>
<Icon
size={18}
className="wedge-icon"
strokeWidth={1.5}
/>
</foreignObject>
)} )}
{wedge.requiresAuth && ( {wedge.requiresAuth && (
<Lock <foreignObject
size={10} x={4}
stroke="rgba(230, 220, 210, 0.6)" y={-14}
strokeWidth={1.5} width={10}
style={{ transform: 'translate(4px, -14px)' }} height={10}
/> style={{ overflow: 'visible' }}
>
<Lock
size={10}
className="wedge-lock"
strokeWidth={1.5}
/>
</foreignObject>
)} )}
<text <text
y={10} y={10}
textAnchor="middle" textAnchor="middle"
fontSize="9" className="wedge-label"
fill="rgba(230, 220, 210, 0.8)"
style={{ pointerEvents: 'none', userSelect: 'none' }}
> >
{wedge.label} {wedge.label}
</text> </text>
@ -225,25 +229,19 @@ export default function RadialMenu({
cx={0} cx={0}
cy={0} cy={0}
r={innerRadius - 2} r={innerRadius - 2}
fill="rgba(50, 45, 40, 0.95)" className="center-disc"
stroke="rgba(180, 160, 140, 0.4)"
strokeWidth="1"
/> />
<text <text
y={-4} y={-4}
textAnchor="middle" textAnchor="middle"
fontSize="10" className="center-coords"
fontFamily="monospace"
fill="rgba(230, 220, 210, 0.9)"
> >
{lat?.toFixed(4)} {lat?.toFixed(4)}
</text> </text>
<text <text
y={8} y={8}
textAnchor="middle" textAnchor="middle"
fontSize="10" className="center-coords"
fontFamily="monospace"
fill="rgba(230, 220, 210, 0.9)"
> >
{lon?.toFixed(4)} {lon?.toFixed(4)}
</text> </text>
@ -251,9 +249,7 @@ export default function RadialMenu({
<text <text
y={20} y={20}
textAnchor="middle" textAnchor="middle"
fontSize="9" className="center-label"
fill="rgba(200, 180, 160, 0.9)"
style={{ fontStyle: 'italic' }}
> >
{centerLabel.length > 15 ? centerLabel.slice(0, 15) + '…' : centerLabel} {centerLabel.length > 15 ? centerLabel.slice(0, 15) + '…' : centerLabel}
</text> </text>
@ -261,12 +257,87 @@ export default function RadialMenu({
</svg> </svg>
<style>{` <style>{`
.radial-wedge.active .wedge-path { /* Backdrop — matches modal overlay */
fill: rgba(180, 160, 140, 0.4) !important; .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 { .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 { @keyframes radialFadeIn {
from { opacity: 0; transform: translate(-50%, -50%) scale(0.9); } from { opacity: 0; transform: translate(-50%, -50%) scale(0.9); }
to { opacity: 1; transform: translate(-50%, -50%) scale(1); } to { opacity: 1; transform: translate(-50%, -50%) scale(1); }

View 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

View file

@ -30,6 +30,8 @@ const FALLBACK_CONFIG = {
has_landclass: false, has_landclass: false,
has_public_lands_layer: false, has_public_lands_layer: false,
has_contours: true, has_contours: true,
has_contours_test: true,
has_contours_test_10ft: true,
has_address_book_write: false, has_address_book_write: false,
has_contacts: false, has_contacts: false,
}, },

86
src/config.js.bak Normal file
View 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
View 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
View 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
}