mirror of
https://github.com/zvx-echo6/navi.git
synced 2026-05-20 14:44:51 +02:00
feat(map): add USFS trails and roads layer
- Add USFS trails/roads as toggleable map layer via PMTiles - Trails: dashed brown lines, roads: solid khaki lines - Labels at zoom 12+ for trail and road names - Click handler shows popup with trail/road info - Feature-flag gated with has_usfs_trails (default false) - Add Trails toggle to Layer Control panel Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
e53ff561e8
commit
2b90f8b17a
3 changed files with 526 additions and 307 deletions
|
|
@ -1,306 +1,343 @@
|
||||||
import { useState, useEffect, useRef } from 'react'
|
import { useState, useEffect, useRef } from 'react'
|
||||||
import { Layers, Trees, Mountain } from 'lucide-react'
|
import { Layers, Trees, Mountain } from 'lucide-react'
|
||||||
import { hasFeature, getConfig } from '../config'
|
import { hasFeature, getConfig } from '../config'
|
||||||
|
|
||||||
const STORAGE_KEY = 'navi-layer-prefs'
|
const STORAGE_KEY = 'navi-layer-prefs'
|
||||||
|
|
||||||
function loadPrefs() {
|
function loadPrefs() {
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(STORAGE_KEY)
|
const raw = localStorage.getItem(STORAGE_KEY)
|
||||||
if (raw) return JSON.parse(raw)
|
if (raw) return JSON.parse(raw)
|
||||||
} catch {}
|
} catch {}
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
function savePrefs(prefs) {
|
function savePrefs(prefs) {
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(prefs))
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(prefs))
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function LayerControl({ mapRef }) {
|
export default function LayerControl({ mapRef }) {
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const [hillshade, setHillshade] = useState(false)
|
const [hillshade, setHillshade] = useState(false)
|
||||||
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 [contoursTest, setContoursTest] = useState(false)
|
||||||
const [contoursTest10ft, setContoursTest10ft] = useState(false)
|
const [contoursTest10ft, setContoursTest10ft] = useState(false)
|
||||||
const panelRef = useRef(null)
|
const [usfsTrails, setUsfsTrails] = useState(false)
|
||||||
|
const panelRef = useRef(null)
|
||||||
// Initialize from localStorage or defaults on mount
|
|
||||||
useEffect(() => {
|
// Initialize from localStorage or defaults on mount
|
||||||
const saved = loadPrefs()
|
useEffect(() => {
|
||||||
const hsAvailable = hasFeature('has_hillshade')
|
const saved = loadPrefs()
|
||||||
const trAvailable = hasFeature('has_traffic_overlay')
|
const hsAvailable = hasFeature('has_hillshade')
|
||||||
|
const trAvailable = hasFeature('has_traffic_overlay')
|
||||||
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 ctTestAvailable = hasFeature('has_contours_test')
|
||||||
const ctTest10ftAvailable = hasFeature('has_contours_test_10ft')
|
const ctTest10ftAvailable = hasFeature('has_contours_test_10ft')
|
||||||
|
const usfsAvailable = hasFeature('has_usfs_trails')
|
||||||
if (saved) {
|
|
||||||
setHillshade(hsAvailable && (saved.hillshade ?? true))
|
if (saved) {
|
||||||
setTraffic(trAvailable && (saved.traffic ?? false))
|
setHillshade(hsAvailable && (saved.hillshade ?? true))
|
||||||
setPublicLands(plAvailable && (saved.publicLands ?? false))
|
setTraffic(trAvailable && (saved.traffic ?? false))
|
||||||
setContours(ctAvailable && (saved.contours ?? false))
|
setPublicLands(plAvailable && (saved.publicLands ?? false))
|
||||||
setContoursTest(ctTestAvailable && (saved.contoursTest ?? false))
|
setContours(ctAvailable && (saved.contours ?? false))
|
||||||
setContoursTest10ft(ctTest10ftAvailable && (saved.contoursTest10ft ?? false))
|
setContoursTest(ctTestAvailable && (saved.contoursTest ?? false))
|
||||||
} else {
|
setContoursTest10ft(ctTest10ftAvailable && (saved.contoursTest10ft ?? false))
|
||||||
// Defaults: hillshade ON if available, others OFF
|
setUsfsTrails(usfsAvailable && (saved.usfsTrails ?? false))
|
||||||
setHillshade(hsAvailable)
|
} else {
|
||||||
setTraffic(false)
|
// Defaults: hillshade ON if available, others OFF
|
||||||
setPublicLands(false)
|
setHillshade(hsAvailable)
|
||||||
setContours(false)
|
setTraffic(false)
|
||||||
setContoursTest(false)
|
setPublicLands(false)
|
||||||
setContoursTest10ft(false)
|
setContours(false)
|
||||||
}
|
setContoursTest(false)
|
||||||
}, [])
|
setContoursTest10ft(false)
|
||||||
|
setUsfsTrails(false)
|
||||||
// Apply layers when prefs change
|
}
|
||||||
useEffect(() => {
|
}, [])
|
||||||
const mapView = mapRef?.current
|
|
||||||
if (!mapView) return
|
// Apply layers when prefs change
|
||||||
const map = mapView.getMap?.()
|
useEffect(() => {
|
||||||
if (!map) return
|
const mapView = mapRef?.current
|
||||||
|
if (!mapView) return
|
||||||
const apply = () => {
|
const map = mapView.getMap?.()
|
||||||
if (hillshade && hasFeature('has_hillshade')) {
|
if (!map) return
|
||||||
mapView.addHillshadeLayer?.()
|
|
||||||
} else {
|
const apply = () => {
|
||||||
mapView.removeHillshadeLayer?.()
|
if (hillshade && hasFeature('has_hillshade')) {
|
||||||
}
|
mapView.addHillshadeLayer?.()
|
||||||
}
|
} else {
|
||||||
|
mapView.removeHillshadeLayer?.()
|
||||||
if (map.isStyleLoaded()) {
|
}
|
||||||
apply()
|
}
|
||||||
} else {
|
|
||||||
map.once('style.load', apply)
|
if (map.isStyleLoaded()) {
|
||||||
}
|
apply()
|
||||||
savePrefs({ hillshade, traffic, publicLands, contours, contoursTest, contoursTest10ft })
|
} else {
|
||||||
return () => map.off('style.load', apply)
|
map.once('style.load', apply)
|
||||||
}, [hillshade, mapRef])
|
}
|
||||||
|
savePrefs({ hillshade, traffic, publicLands, contours, contoursTest, contoursTest10ft, usfsTrails })
|
||||||
useEffect(() => {
|
return () => map.off('style.load', apply)
|
||||||
const mapView = mapRef?.current
|
}, [hillshade, mapRef])
|
||||||
if (!mapView) return
|
|
||||||
const map = mapView.getMap?.()
|
useEffect(() => {
|
||||||
if (!map) return
|
const mapView = mapRef?.current
|
||||||
|
if (!mapView) return
|
||||||
const apply = () => {
|
const map = mapView.getMap?.()
|
||||||
if (traffic && hasFeature('has_traffic_overlay')) {
|
if (!map) return
|
||||||
mapView.addTrafficLayer?.()
|
|
||||||
} else {
|
const apply = () => {
|
||||||
mapView.removeTrafficLayer?.()
|
if (traffic && hasFeature('has_traffic_overlay')) {
|
||||||
}
|
mapView.addTrafficLayer?.()
|
||||||
}
|
} else {
|
||||||
|
mapView.removeTrafficLayer?.()
|
||||||
if (map.isStyleLoaded()) {
|
}
|
||||||
apply()
|
}
|
||||||
} else {
|
|
||||||
map.once('style.load', apply)
|
if (map.isStyleLoaded()) {
|
||||||
}
|
apply()
|
||||||
savePrefs({ hillshade, traffic, publicLands, contours, contoursTest, contoursTest10ft })
|
} else {
|
||||||
return () => map.off('style.load', apply)
|
map.once('style.load', apply)
|
||||||
}, [traffic, mapRef])
|
}
|
||||||
|
savePrefs({ hillshade, traffic, publicLands, contours, contoursTest, contoursTest10ft, usfsTrails })
|
||||||
useEffect(() => {
|
return () => map.off('style.load', apply)
|
||||||
const mapView = mapRef?.current
|
}, [traffic, mapRef])
|
||||||
if (!mapView) return
|
|
||||||
const map = mapView.getMap?.()
|
useEffect(() => {
|
||||||
if (!map) return
|
const mapView = mapRef?.current
|
||||||
|
if (!mapView) return
|
||||||
const apply = () => {
|
const map = mapView.getMap?.()
|
||||||
if (publicLands && hasFeature('has_public_lands_layer')) {
|
if (!map) return
|
||||||
mapView.addPublicLandsLayer?.()
|
|
||||||
} else {
|
const apply = () => {
|
||||||
mapView.removePublicLandsLayer?.()
|
if (publicLands && hasFeature('has_public_lands_layer')) {
|
||||||
}
|
mapView.addPublicLandsLayer?.()
|
||||||
}
|
} else {
|
||||||
|
mapView.removePublicLandsLayer?.()
|
||||||
if (map.isStyleLoaded()) {
|
}
|
||||||
apply()
|
}
|
||||||
} else {
|
|
||||||
map.once('style.load', apply)
|
if (map.isStyleLoaded()) {
|
||||||
}
|
apply()
|
||||||
savePrefs({ hillshade, traffic, publicLands, contours, contoursTest, contoursTest10ft })
|
} else {
|
||||||
return () => map.off('style.load', apply)
|
map.once('style.load', apply)
|
||||||
}, [publicLands, mapRef])
|
}
|
||||||
|
savePrefs({ hillshade, traffic, publicLands, contours, contoursTest, contoursTest10ft, usfsTrails })
|
||||||
useEffect(() => {
|
return () => map.off('style.load', apply)
|
||||||
const mapView = mapRef?.current
|
}, [publicLands, mapRef])
|
||||||
if (!mapView) return
|
|
||||||
const map = mapView.getMap?.()
|
useEffect(() => {
|
||||||
if (!map) return
|
const mapView = mapRef?.current
|
||||||
|
if (!mapView) return
|
||||||
const apply = () => {
|
const map = mapView.getMap?.()
|
||||||
if (contours && hasFeature('has_contours')) {
|
if (!map) return
|
||||||
mapView.addContoursLayer?.()
|
|
||||||
} else {
|
const apply = () => {
|
||||||
mapView.removeContoursLayer?.()
|
if (contours && hasFeature('has_contours')) {
|
||||||
}
|
mapView.addContoursLayer?.()
|
||||||
}
|
} else {
|
||||||
|
mapView.removeContoursLayer?.()
|
||||||
if (map.isStyleLoaded()) {
|
}
|
||||||
apply()
|
}
|
||||||
} else {
|
|
||||||
map.once('style.load', apply)
|
if (map.isStyleLoaded()) {
|
||||||
}
|
apply()
|
||||||
savePrefs({ hillshade, traffic, publicLands, contours, contoursTest, contoursTest10ft })
|
} else {
|
||||||
return () => map.off('style.load', apply)
|
map.once('style.load', apply)
|
||||||
}, [contours, mapRef])
|
}
|
||||||
|
savePrefs({ hillshade, traffic, publicLands, contours, contoursTest, contoursTest10ft, usfsTrails })
|
||||||
useEffect(() => {
|
return () => map.off('style.load', apply)
|
||||||
const mapView = mapRef?.current
|
}, [contours, mapRef])
|
||||||
if (!mapView) return
|
|
||||||
const map = mapView.getMap?.()
|
useEffect(() => {
|
||||||
if (!map) return
|
const mapView = mapRef?.current
|
||||||
|
if (!mapView) return
|
||||||
const apply = () => {
|
const map = mapView.getMap?.()
|
||||||
if (contoursTest && hasFeature('has_contours_test')) {
|
if (!map) return
|
||||||
mapView.addContoursTestLayer?.()
|
|
||||||
} else {
|
const apply = () => {
|
||||||
mapView.removeContoursTestLayer?.()
|
if (contoursTest && hasFeature('has_contours_test')) {
|
||||||
}
|
mapView.addContoursTestLayer?.()
|
||||||
}
|
} else {
|
||||||
|
mapView.removeContoursTestLayer?.()
|
||||||
if (map.isStyleLoaded()) {
|
}
|
||||||
apply()
|
}
|
||||||
} else {
|
|
||||||
map.once('style.load', apply)
|
if (map.isStyleLoaded()) {
|
||||||
}
|
apply()
|
||||||
savePrefs({ hillshade, traffic, publicLands, contours, contoursTest, contoursTest10ft })
|
} else {
|
||||||
return () => map.off('style.load', apply)
|
map.once('style.load', apply)
|
||||||
}, [contoursTest, mapRef])
|
}
|
||||||
|
savePrefs({ hillshade, traffic, publicLands, contours, contoursTest, contoursTest10ft, usfsTrails })
|
||||||
// Apply contoursTest10ft layer
|
return () => map.off('style.load', apply)
|
||||||
useEffect(() => {
|
}, [contoursTest, mapRef])
|
||||||
const map = mapRef.current?.getMap?.()
|
|
||||||
if (!map) return
|
// Apply contoursTest10ft layer
|
||||||
|
useEffect(() => {
|
||||||
const apply = () => {
|
const map = mapRef.current?.getMap?.()
|
||||||
if (contoursTest10ft && hasFeature('has_contours_test_10ft')) {
|
if (!map) return
|
||||||
mapRef.current?.addContoursTest10ftLayer?.()
|
|
||||||
} else {
|
const apply = () => {
|
||||||
mapRef.current?.removeContoursTest10ftLayer?.()
|
if (contoursTest10ft && hasFeature('has_contours_test_10ft')) {
|
||||||
}
|
mapRef.current?.addContoursTest10ftLayer?.()
|
||||||
}
|
} else {
|
||||||
|
mapRef.current?.removeContoursTest10ftLayer?.()
|
||||||
if (map.isStyleLoaded()) {
|
}
|
||||||
apply()
|
}
|
||||||
} else {
|
|
||||||
map.once('style.load', apply)
|
if (map.isStyleLoaded()) {
|
||||||
}
|
apply()
|
||||||
}, [contoursTest10ft, mapRef])
|
} else {
|
||||||
|
map.once('style.load', apply)
|
||||||
// Close on outside click
|
}
|
||||||
useEffect(() => {
|
}, [contoursTest10ft, mapRef])
|
||||||
if (!open) return
|
|
||||||
function handleClick(e) {
|
// Apply usfsTrails layer
|
||||||
if (panelRef.current && !panelRef.current.contains(e.target)) {
|
useEffect(() => {
|
||||||
setOpen(false)
|
const map = mapRef.current?.getMap?.()
|
||||||
}
|
if (!map) return
|
||||||
}
|
|
||||||
document.addEventListener('pointerdown', handleClick)
|
const apply = () => {
|
||||||
return () => document.removeEventListener('pointerdown', handleClick)
|
if (usfsTrails && hasFeature('has_usfs_trails')) {
|
||||||
}, [open])
|
mapRef.current?.addUsfsTrailsLayer?.()
|
||||||
|
} else {
|
||||||
const showHillshade = hasFeature('has_hillshade')
|
mapRef.current?.removeUsfsTrailsLayer?.()
|
||||||
const showTraffic = hasFeature('has_traffic_overlay')
|
}
|
||||||
const showPublicLands = hasFeature('has_public_lands_layer')
|
}
|
||||||
const showContours = hasFeature('has_contours')
|
|
||||||
const showContoursTest = hasFeature('has_contours_test')
|
if (map.isStyleLoaded()) {
|
||||||
const showContoursTest10ft = hasFeature('has_contours_test_10ft')
|
apply()
|
||||||
|
} else {
|
||||||
// Don't render if no overlay features available
|
map.once('style.load', apply)
|
||||||
if (!showHillshade && !showTraffic && !showPublicLands && !showContours && !showContoursTest && !showContoursTest10ft) return null
|
}
|
||||||
|
savePrefs({ hillshade, traffic, publicLands, contours, contoursTest, contoursTest10ft, usfsTrails })
|
||||||
return (
|
}, [usfsTrails, mapRef])
|
||||||
<div ref={panelRef} className="layer-control">
|
|
||||||
<button
|
// Close on outside click
|
||||||
className="layer-control-btn"
|
useEffect(() => {
|
||||||
onClick={() => setOpen((v) => !v)}
|
if (!open) return
|
||||||
title="Map layers"
|
function handleClick(e) {
|
||||||
aria-label="Toggle map layers"
|
if (panelRef.current && !panelRef.current.contains(e.target)) {
|
||||||
>
|
setOpen(false)
|
||||||
<Layers size={18} />
|
}
|
||||||
</button>
|
}
|
||||||
|
document.addEventListener('pointerdown', handleClick)
|
||||||
{open && (
|
return () => document.removeEventListener('pointerdown', handleClick)
|
||||||
<div className="layer-control-popover">
|
}, [open])
|
||||||
<div className="layer-control-header">Layers</div>
|
|
||||||
|
const showHillshade = hasFeature('has_hillshade')
|
||||||
{showHillshade && (
|
const showTraffic = hasFeature('has_traffic_overlay')
|
||||||
<label className="layer-control-item">
|
const showPublicLands = hasFeature('has_public_lands_layer')
|
||||||
<span className="layer-control-label">Hillshade</span>
|
const showContours = hasFeature('has_contours')
|
||||||
<input
|
const showContoursTest = hasFeature('has_contours_test')
|
||||||
type="checkbox"
|
const showContoursTest10ft = hasFeature('has_contours_test_10ft')
|
||||||
className="layer-control-toggle"
|
const showUsfsTrails = hasFeature('has_usfs_trails')
|
||||||
checked={hillshade}
|
|
||||||
onChange={(e) => setHillshade(e.target.checked)}
|
// Don't render if no overlay features available
|
||||||
/>
|
if (!showHillshade && !showTraffic && !showPublicLands && !showContours && !showContoursTest && !showContoursTest10ft && !showUsfsTrails) return null
|
||||||
</label>
|
|
||||||
)}
|
return (
|
||||||
|
<div ref={panelRef} className="layer-control">
|
||||||
{showTraffic && (
|
<button
|
||||||
<label className="layer-control-item">
|
className="layer-control-btn"
|
||||||
<span className="layer-control-label">Traffic</span>
|
onClick={() => setOpen((v) => !v)}
|
||||||
<input
|
title="Map layers"
|
||||||
type="checkbox"
|
aria-label="Toggle map layers"
|
||||||
className="layer-control-toggle"
|
>
|
||||||
checked={traffic}
|
<Layers size={18} />
|
||||||
onChange={(e) => setTraffic(e.target.checked)}
|
</button>
|
||||||
/>
|
|
||||||
</label>
|
{open && (
|
||||||
)}
|
<div className="layer-control-popover">
|
||||||
|
<div className="layer-control-header">Layers</div>
|
||||||
{showPublicLands && (
|
|
||||||
<label className="layer-control-item">
|
{showHillshade && (
|
||||||
<span className="layer-control-label">Public Lands</span>
|
<label className="layer-control-item">
|
||||||
<input
|
<span className="layer-control-label">Hillshade</span>
|
||||||
type="checkbox"
|
<input
|
||||||
className="layer-control-toggle"
|
type="checkbox"
|
||||||
checked={publicLands}
|
className="layer-control-toggle"
|
||||||
onChange={(e) => setPublicLands(e.target.checked)}
|
checked={hillshade}
|
||||||
/>
|
onChange={(e) => setHillshade(e.target.checked)}
|
||||||
</label>
|
/>
|
||||||
)}
|
</label>
|
||||||
|
)}
|
||||||
{showContours && (
|
|
||||||
<label className="layer-control-item">
|
{showTraffic && (
|
||||||
<span className="layer-control-label">Contours</span>
|
<label className="layer-control-item">
|
||||||
<input
|
<span className="layer-control-label">Traffic</span>
|
||||||
type="checkbox"
|
<input
|
||||||
className="layer-control-toggle"
|
type="checkbox"
|
||||||
checked={contours}
|
className="layer-control-toggle"
|
||||||
onChange={(e) => setContours(e.target.checked)}
|
checked={traffic}
|
||||||
/>
|
onChange={(e) => setTraffic(e.target.checked)}
|
||||||
</label>
|
/>
|
||||||
)}
|
</label>
|
||||||
|
)}
|
||||||
{showContoursTest && (
|
|
||||||
<label className="layer-control-item">
|
{showPublicLands && (
|
||||||
<span className="layer-control-label">Contours (Test)</span>
|
<label className="layer-control-item">
|
||||||
<input
|
<span className="layer-control-label">Public Lands</span>
|
||||||
type="checkbox"
|
<input
|
||||||
className="layer-control-toggle"
|
type="checkbox"
|
||||||
checked={contoursTest}
|
className="layer-control-toggle"
|
||||||
onChange={(e) => setContoursTest(e.target.checked)}
|
checked={publicLands}
|
||||||
/>
|
onChange={(e) => setPublicLands(e.target.checked)}
|
||||||
</label>
|
/>
|
||||||
)}
|
</label>
|
||||||
|
)}
|
||||||
{showContoursTest10ft && (
|
|
||||||
<label className="layer-control-item">
|
{showContours && (
|
||||||
<span className="layer-control-label">Contours (Test 10ft)</span>
|
<label className="layer-control-item">
|
||||||
<input
|
<span className="layer-control-label">Contours</span>
|
||||||
type="checkbox"
|
<input
|
||||||
className="layer-control-toggle"
|
type="checkbox"
|
||||||
checked={contoursTest10ft}
|
className="layer-control-toggle"
|
||||||
onChange={(e) => setContoursTest10ft(e.target.checked)}
|
checked={contours}
|
||||||
/>
|
onChange={(e) => setContours(e.target.checked)}
|
||||||
</label>
|
/>
|
||||||
)}
|
</label>
|
||||||
</div>
|
)}
|
||||||
)}
|
|
||||||
</div>
|
{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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showUsfsTrails && (
|
||||||
|
<label className="layer-control-item">
|
||||||
|
<span className="layer-control-label">USFS Trails</span>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="layer-control-toggle"
|
||||||
|
checked={usfsTrails}
|
||||||
|
onChange={(e) => setUsfsTrails(e.target.checked)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,12 @@ const CONTOUR_TEST_10FT_LABEL = 'contour-test-10ft-label'
|
||||||
const MEASURE_SOURCE = 'measure-source'
|
const MEASURE_SOURCE = 'measure-source'
|
||||||
const MEASURE_LINE_LAYER = 'measure-line-layer'
|
const MEASURE_LINE_LAYER = 'measure-line-layer'
|
||||||
const MEASURE_POINT_LAYER = 'measure-point-layer'
|
const MEASURE_POINT_LAYER = 'measure-point-layer'
|
||||||
|
const USFS_SOURCE = 'usfs-trails-source'
|
||||||
|
const USFS_ROADS_LAYER = 'usfs-roads-layer'
|
||||||
|
const USFS_TRAILS_LAYER = 'usfs-trails-layer'
|
||||||
|
const USFS_ROADS_LABEL = 'usfs-roads-label'
|
||||||
|
const USFS_TRAILS_LABEL = 'usfs-trails-label'
|
||||||
|
|
||||||
|
|
||||||
// Highlight state - use data-driven expressions to target specific features
|
// Highlight state - use data-driven expressions to target specific features
|
||||||
const INTERACTIVE_LABEL_LAYERS = ['pois', 'places_subplace', 'places_locality', 'places_region', 'places_country']
|
const INTERACTIVE_LABEL_LAYERS = ['pois', 'places_subplace', 'places_locality', 'places_region', 'places_country']
|
||||||
|
|
@ -813,6 +819,119 @@ function removeContoursTest10ft(map) {
|
||||||
if (map.getLayer(CONTOUR_TEST_10FT_MINOR)) map.removeLayer(CONTOUR_TEST_10FT_MINOR)
|
if (map.getLayer(CONTOUR_TEST_10FT_MINOR)) map.removeLayer(CONTOUR_TEST_10FT_MINOR)
|
||||||
if (map.getSource(CONTOUR_TEST_10FT_SOURCE)) map.removeSource(CONTOUR_TEST_10FT_SOURCE)
|
if (map.getSource(CONTOUR_TEST_10FT_SOURCE)) map.removeSource(CONTOUR_TEST_10FT_SOURCE)
|
||||||
}
|
}
|
||||||
|
/** Add USFS trails and roads vector tile overlay */
|
||||||
|
function addUsfsTrails(map) {
|
||||||
|
if (!map || map.getSource(USFS_SOURCE)) return
|
||||||
|
|
||||||
|
map.addSource(USFS_SOURCE, {
|
||||||
|
type: "vector",
|
||||||
|
url: "pmtiles:///tiles/usfs-trails-roads.pmtiles",
|
||||||
|
})
|
||||||
|
|
||||||
|
// Insert below first symbol layer (above other overlays, 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
|
||||||
|
|
||||||
|
// Roads layer - solid khaki/tan line
|
||||||
|
map.addLayer({
|
||||||
|
id: USFS_ROADS_LAYER,
|
||||||
|
type: "line",
|
||||||
|
source: USFS_SOURCE,
|
||||||
|
"source-layer": "roads",
|
||||||
|
minzoom: 10,
|
||||||
|
paint: {
|
||||||
|
"line-color": isDark ? "#9a8b70" : "#b8a87a",
|
||||||
|
"line-opacity": 0.65 * opMod,
|
||||||
|
"line-width": ["interpolate", ["linear"], ["zoom"], 10, 1.0, 14, 2.0, 16, 3.0],
|
||||||
|
},
|
||||||
|
}, beforeId)
|
||||||
|
|
||||||
|
// Trails layer - dashed earth-tone brown
|
||||||
|
map.addLayer({
|
||||||
|
id: USFS_TRAILS_LAYER,
|
||||||
|
type: "line",
|
||||||
|
source: USFS_SOURCE,
|
||||||
|
"source-layer": "trails",
|
||||||
|
minzoom: 10,
|
||||||
|
paint: {
|
||||||
|
"line-color": isDark ? "#a88960" : "#8b7355",
|
||||||
|
"line-opacity": 0.7 * opMod,
|
||||||
|
"line-width": ["interpolate", ["linear"], ["zoom"], 10, 1.5, 14, 2.5, 16, 3.5],
|
||||||
|
"line-dasharray": [2, 1.5],
|
||||||
|
},
|
||||||
|
}, beforeId)
|
||||||
|
|
||||||
|
// Road labels (zoom 12+)
|
||||||
|
map.addLayer({
|
||||||
|
id: USFS_ROADS_LABEL,
|
||||||
|
type: "symbol",
|
||||||
|
source: USFS_SOURCE,
|
||||||
|
"source-layer": "roads",
|
||||||
|
minzoom: 12,
|
||||||
|
filter: ["has", "NAME"],
|
||||||
|
layout: {
|
||||||
|
"text-field": ["get", "NAME"],
|
||||||
|
"text-size": 10,
|
||||||
|
"text-font": ["Noto Sans Regular"],
|
||||||
|
"symbol-placement": "line",
|
||||||
|
"text-anchor": "center",
|
||||||
|
"symbol-spacing": 300,
|
||||||
|
"text-max-angle": 25,
|
||||||
|
"text-allow-overlap": false,
|
||||||
|
},
|
||||||
|
paint: {
|
||||||
|
"text-color": isDark ? "#c0b090" : "#6a5a40",
|
||||||
|
"text-halo-color": isDark ? "#1a1a1a" : "#ffffff",
|
||||||
|
"text-halo-width": 1.5,
|
||||||
|
"text-opacity": 0.85,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Trail labels (zoom 12+)
|
||||||
|
map.addLayer({
|
||||||
|
id: USFS_TRAILS_LABEL,
|
||||||
|
type: "symbol",
|
||||||
|
source: USFS_SOURCE,
|
||||||
|
"source-layer": "trails",
|
||||||
|
minzoom: 12,
|
||||||
|
filter: ["has", "TRAIL_NAME"],
|
||||||
|
layout: {
|
||||||
|
"text-field": ["get", "TRAIL_NAME"],
|
||||||
|
"text-size": 10,
|
||||||
|
"text-font": ["Noto Sans Regular"],
|
||||||
|
"symbol-placement": "line",
|
||||||
|
"text-anchor": "center",
|
||||||
|
"symbol-spacing": 300,
|
||||||
|
"text-max-angle": 25,
|
||||||
|
"text-allow-overlap": false,
|
||||||
|
},
|
||||||
|
paint: {
|
||||||
|
"text-color": isDark ? "#c8a878" : "#5a4530",
|
||||||
|
"text-halo-color": isDark ? "#1a1a1a" : "#ffffff",
|
||||||
|
"text-halo-width": 1.5,
|
||||||
|
"text-opacity": 0.85,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remove USFS trails/roads layers and source */
|
||||||
|
function removeUsfsTrails(map) {
|
||||||
|
if (!map) return
|
||||||
|
if (map.getLayer(USFS_TRAILS_LABEL)) map.removeLayer(USFS_TRAILS_LABEL)
|
||||||
|
if (map.getLayer(USFS_ROADS_LABEL)) map.removeLayer(USFS_ROADS_LABEL)
|
||||||
|
if (map.getLayer(USFS_TRAILS_LAYER)) map.removeLayer(USFS_TRAILS_LAYER)
|
||||||
|
if (map.getLayer(USFS_ROADS_LAYER)) map.removeLayer(USFS_ROADS_LAYER)
|
||||||
|
if (map.getSource(USFS_SOURCE)) map.removeSource(USFS_SOURCE)
|
||||||
|
}
|
||||||
|
|
||||||
/** Add boundary polygon layers with computed accent color (MapLibre rejects CSS vars in paint) */
|
/** Add boundary polygon layers with computed accent color (MapLibre rejects CSS vars in paint) */
|
||||||
const BOUNDARY_FILL_LAYER = 'boundary-fill-layer'
|
const BOUNDARY_FILL_LAYER = 'boundary-fill-layer'
|
||||||
|
|
||||||
|
|
@ -871,7 +990,7 @@ const MapView = forwardRef(function MapView(_, ref) {
|
||||||
const watchIdRef = useRef(null)
|
const watchIdRef = useRef(null)
|
||||||
const currentThemeRef = useRef('dark')
|
const currentThemeRef = useRef('dark')
|
||||||
// Track which overlay layers are currently active (for theme swap re-add)
|
// Track which overlay layers are currently active (for theme swap re-add)
|
||||||
const activeLayersRef = useRef({ hillshade: false, traffic: false, contours: false, contoursTest: false, contoursTest10ft: false })
|
const activeLayersRef = useRef({ hillshade: false, traffic: false, contours: false, contoursTest: false, contoursTest10ft: false, usfsTrails: false })
|
||||||
// Flag to suppress map-click when a stop pin was clicked
|
// Flag to suppress map-click when a stop pin was clicked
|
||||||
const pinClickedRef = useRef(false)
|
const pinClickedRef = useRef(false)
|
||||||
const highlightedFeatureRef = useRef(null) // { source, sourceLayer, id } for setFeatureState
|
const highlightedFeatureRef = useRef(null) // { source, sourceLayer, id } for setFeatureState
|
||||||
|
|
@ -1317,6 +1436,19 @@ const MapView = forwardRef(function MapView(_, ref) {
|
||||||
removeContoursTest10ft(map)
|
removeContoursTest10ft(map)
|
||||||
activeLayersRef.current.contoursTest10ft = false
|
activeLayersRef.current.contoursTest10ft = false
|
||||||
},
|
},
|
||||||
|
addUsfsTrailsLayer() {
|
||||||
|
const map = mapInstance.current
|
||||||
|
if (!map) return
|
||||||
|
addUsfsTrails(map)
|
||||||
|
activeLayersRef.current.usfsTrails = true
|
||||||
|
},
|
||||||
|
removeUsfsTrailsLayer() {
|
||||||
|
const map = mapInstance.current
|
||||||
|
if (!map) return
|
||||||
|
removeUsfsTrails(map)
|
||||||
|
activeLayersRef.current.usfsTrails = false
|
||||||
|
},
|
||||||
|
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// Initialize map
|
// Initialize map
|
||||||
|
|
@ -1446,6 +1578,55 @@ const MapView = forwardRef(function MapView(_, ref) {
|
||||||
const { lng, lat } = e.lngLat
|
const { lng, lat } = e.lngLat
|
||||||
const MARKER_RADIUS_PX = 14 // half of 28px preview marker
|
const MARKER_RADIUS_PX = 14 // half of 28px preview marker
|
||||||
|
|
||||||
|
// Check for USFS trails/roads click (show info popup)
|
||||||
|
const usfsLayers = [USFS_TRAILS_LAYER, USFS_ROADS_LAYER]
|
||||||
|
const usfsFeatures = map.queryRenderedFeatures(e.point, { layers: usfsLayers })
|
||||||
|
const usfsFeature = usfsFeatures.find(f => f.properties)
|
||||||
|
if (usfsFeature && hasFeature('has_usfs_trails')) {
|
||||||
|
const props = usfsFeature.properties
|
||||||
|
const isTrail = usfsFeature.layer?.id === USFS_TRAILS_LAYER
|
||||||
|
const name = isTrail ? (props.TRAIL_NAME || 'Unnamed Trail') : (props.NAME || 'Unnamed Road')
|
||||||
|
const typeLabel = isTrail ? 'USFS Trail' : 'USFS Road'
|
||||||
|
|
||||||
|
// Build popup content
|
||||||
|
let html = '<div style="font-size:12px;max-width:240px;line-height:1.4">'
|
||||||
|
html += '<strong style="font-size:13px">' + name + '</strong>'
|
||||||
|
html += '<div style="color:var(--text-secondary);font-size:11px;margin-bottom:4px">' + typeLabel + '</div>'
|
||||||
|
|
||||||
|
if (isTrail) {
|
||||||
|
// Trail-specific info
|
||||||
|
if (props.TRAIL_TYPE) html += '<div><b>Type:</b> ' + props.TRAIL_TYPE + '</div>'
|
||||||
|
if (props.TRAIL_SURF) html += '<div><b>Surface:</b> ' + props.TRAIL_SURF + '</div>'
|
||||||
|
if (props.GIS_MILES) html += '<div><b>Length:</b> ' + parseFloat(props.GIS_MILES).toFixed(1) + ' mi</div>'
|
||||||
|
// Allowed uses
|
||||||
|
const uses = []
|
||||||
|
if (props.HIKER_PEDE === 'Y') uses.push('Hiking')
|
||||||
|
if (props.BICYCLE_MA === 'Y') uses.push('Biking')
|
||||||
|
if (props.MOTORCYCLE === 'Y') uses.push('Motorcycle')
|
||||||
|
if (props.ATV_MANAGE === 'Y') uses.push('ATV')
|
||||||
|
if (props.HORSE_MANA === 'Y') uses.push('Horse')
|
||||||
|
if (uses.length > 0) html += '<div><b>Allowed:</b> ' + uses.join(', ') + '</div>'
|
||||||
|
} else {
|
||||||
|
// Road-specific info
|
||||||
|
if (props.OPER_MAINT) html += '<div><b>Maintenance:</b> ' + props.OPER_MAINT + '</div>'
|
||||||
|
if (props.SURFACE_TY) html += '<div><b>Surface:</b> ' + props.SURFACE_TY + '</div>'
|
||||||
|
if (props.ROUTE_STAT) html += '<div><b>Status:</b> ' + props.ROUTE_STAT + '</div>'
|
||||||
|
}
|
||||||
|
html += '</div>'
|
||||||
|
|
||||||
|
// Remove existing popup
|
||||||
|
if (popupRef.current) popupRef.current.remove()
|
||||||
|
|
||||||
|
const popup = new maplibregl.Popup({ offset: 10, closeButton: true })
|
||||||
|
.setLngLat([lng, lat])
|
||||||
|
.setHTML(html)
|
||||||
|
.addTo(map)
|
||||||
|
popupRef.current = popup
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// Query rendered features at click point (label/POI priority)
|
// Query rendered features at click point (label/POI priority)
|
||||||
const labelLayers = ['pois', 'places_subplace', 'places_locality', 'places_region', 'places_country']
|
const labelLayers = ['pois', 'places_subplace', 'places_locality', 'places_region', 'places_country']
|
||||||
const features = map.queryRenderedFeatures(e.point, { layers: labelLayers })
|
const features = map.queryRenderedFeatures(e.point, { layers: labelLayers })
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@ const FALLBACK_CONFIG = {
|
||||||
has_contours_test: true,
|
has_contours_test: true,
|
||||||
has_contours_test_10ft: false,
|
has_contours_test_10ft: false,
|
||||||
has_address_book_write: false,
|
has_address_book_write: false,
|
||||||
|
has_usfs_trails: false,
|
||||||
has_contacts: false,
|
has_contacts: false,
|
||||||
},
|
},
|
||||||
defaults: {
|
defaults: {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue