mirror of
https://github.com/zvx-echo6/navi.git
synced 2026-05-20 14:44:51 +02:00
Add Public Lands vector tile overlay layer (PAD-US)
Toggleable vector tile overlay rendering 651k PAD-US protected areas as colored polygons on the map. Data-driven styling by agency/designation (USFS green, NPS darker green, BLM tan, wilderness amber, state teal). Unit name labels at z10+. Feature-flagged via has_public_lands_layer. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
771d7eb3b3
commit
cdd11dc043
3 changed files with 212 additions and 5 deletions
|
|
@ -1,5 +1,5 @@
|
||||||
import { useState, useEffect, useRef } from 'react'
|
import { useState, useEffect, useRef } from 'react'
|
||||||
import { Layers } from 'lucide-react'
|
import { Layers, Trees } 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'
|
||||||
|
|
@ -20,6 +20,7 @@ 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 panelRef = useRef(null)
|
const panelRef = useRef(null)
|
||||||
|
|
||||||
// Initialize from localStorage or defaults on mount
|
// Initialize from localStorage or defaults on mount
|
||||||
|
|
@ -28,13 +29,17 @@ export default function LayerControl({ mapRef }) {
|
||||||
const hsAvailable = hasFeature('has_hillshade')
|
const hsAvailable = hasFeature('has_hillshade')
|
||||||
const trAvailable = hasFeature('has_traffic_overlay')
|
const trAvailable = hasFeature('has_traffic_overlay')
|
||||||
|
|
||||||
|
const plAvailable = hasFeature('has_public_lands_layer')
|
||||||
|
|
||||||
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))
|
||||||
} else {
|
} else {
|
||||||
// Defaults: hillshade ON if available, traffic OFF
|
// Defaults: hillshade ON if available, traffic + publicLands OFF
|
||||||
setHillshade(hsAvailable)
|
setHillshade(hsAvailable)
|
||||||
setTraffic(false)
|
setTraffic(false)
|
||||||
|
setPublicLands(false)
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
|
@ -58,7 +63,7 @@ export default function LayerControl({ mapRef }) {
|
||||||
} else {
|
} else {
|
||||||
map.once('style.load', apply)
|
map.once('style.load', apply)
|
||||||
}
|
}
|
||||||
savePrefs({ hillshade, traffic })
|
savePrefs({ hillshade, traffic, publicLands })
|
||||||
return () => map.off('style.load', apply)
|
return () => map.off('style.load', apply)
|
||||||
}, [hillshade, mapRef])
|
}, [hillshade, mapRef])
|
||||||
|
|
||||||
|
|
@ -81,10 +86,33 @@ export default function LayerControl({ mapRef }) {
|
||||||
} else {
|
} else {
|
||||||
map.once('style.load', apply)
|
map.once('style.load', apply)
|
||||||
}
|
}
|
||||||
savePrefs({ hillshade, traffic })
|
savePrefs({ hillshade, traffic, publicLands })
|
||||||
return () => map.off('style.load', apply)
|
return () => map.off('style.load', apply)
|
||||||
}, [traffic, mapRef])
|
}, [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
|
// Close on outside click
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return
|
if (!open) return
|
||||||
|
|
@ -99,9 +127,10 @@ export default function LayerControl({ mapRef }) {
|
||||||
|
|
||||||
const showHillshade = hasFeature('has_hillshade')
|
const showHillshade = hasFeature('has_hillshade')
|
||||||
const showTraffic = hasFeature('has_traffic_overlay')
|
const showTraffic = hasFeature('has_traffic_overlay')
|
||||||
|
const showPublicLands = hasFeature('has_public_lands_layer')
|
||||||
|
|
||||||
// Don't render if no overlay features available
|
// Don't render if no overlay features available
|
||||||
if (!showHillshade && !showTraffic) return null
|
if (!showHillshade && !showTraffic && !showPublicLands) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={panelRef} className="layer-control">
|
<div ref={panelRef} className="layer-control">
|
||||||
|
|
@ -141,6 +170,18 @@ export default function LayerControl({ mapRef }) {
|
||||||
/>
|
/>
|
||||||
</label>
|
</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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,10 @@ const HILLSHADE_SOURCE = 'hillshade-dem'
|
||||||
const HILLSHADE_LAYER = 'hillshade-layer'
|
const HILLSHADE_LAYER = 'hillshade-layer'
|
||||||
const TRAFFIC_SOURCE = 'traffic-tiles'
|
const TRAFFIC_SOURCE = 'traffic-tiles'
|
||||||
const TRAFFIC_LAYER = 'traffic-layer'
|
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 */
|
/** Build a full MapLibre style object for the given theme */
|
||||||
function buildStyle(themeName) {
|
function buildStyle(themeName) {
|
||||||
|
|
@ -118,6 +122,150 @@ function removeTraffic(map) {
|
||||||
if (map.getSource(TRAFFIC_SOURCE)) map.removeSource(TRAFFIC_SOURCE)
|
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 MapView = forwardRef(function MapView(_, ref) {
|
||||||
const mapRef = useRef(null)
|
const mapRef = useRef(null)
|
||||||
const mapInstance = useRef(null)
|
const mapInstance = useRef(null)
|
||||||
|
|
@ -172,6 +320,18 @@ const MapView = forwardRef(function MapView(_, ref) {
|
||||||
removeTraffic(map)
|
removeTraffic(map)
|
||||||
activeLayersRef.current.traffic = false
|
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
|
// Initialize map
|
||||||
|
|
@ -255,6 +415,10 @@ const MapView = forwardRef(function MapView(_, ref) {
|
||||||
addTraffic(map)
|
addTraffic(map)
|
||||||
activeLayersRef.current.traffic = true
|
activeLayersRef.current.traffic = true
|
||||||
}
|
}
|
||||||
|
if (prefs.publicLands && hasFeature('has_public_lands_layer')) {
|
||||||
|
addPublicLands(map)
|
||||||
|
activeLayersRef.current.publicLands = true
|
||||||
|
}
|
||||||
} else if (hasFeature('has_hillshade')) {
|
} else if (hasFeature('has_hillshade')) {
|
||||||
// Default: hillshade ON if available
|
// Default: hillshade ON if available
|
||||||
addHillshade(map)
|
addHillshade(map)
|
||||||
|
|
@ -355,6 +519,7 @@ const MapView = forwardRef(function MapView(_, ref) {
|
||||||
// Re-add active overlay layers
|
// Re-add active overlay layers
|
||||||
if (activeLayersRef.current.hillshade) addHillshade(map)
|
if (activeLayersRef.current.hillshade) addHillshade(map)
|
||||||
if (activeLayersRef.current.traffic) addTraffic(map)
|
if (activeLayersRef.current.traffic) addTraffic(map)
|
||||||
|
if (activeLayersRef.current.publicLands) addPublicLands(map)
|
||||||
|
|
||||||
// Restore view
|
// Restore view
|
||||||
map.jumpTo({ center, zoom, bearing, pitch })
|
map.jumpTo({ center, zoom, bearing, pitch })
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ const FALLBACK_CONFIG = {
|
||||||
has_3d_terrain: false,
|
has_3d_terrain: false,
|
||||||
has_traffic_overlay: false,
|
has_traffic_overlay: false,
|
||||||
has_landclass: false,
|
has_landclass: false,
|
||||||
|
has_public_lands_layer: false,
|
||||||
has_address_book_write: false,
|
has_address_book_write: false,
|
||||||
has_contacts: false,
|
has_contacts: false,
|
||||||
},
|
},
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue