mirror of
https://github.com/zvx-echo6/navi.git
synced 2026-05-20 14:44:51 +02:00
feat: add contours layer with tier-aware zoom visibility
- Adds contour PMTiles vector source (contours-na.pmtiles) - Minor/intermediate/index tier rendering at z11+/z8+/z4+ - Elevation labels on index contours at z12+ - Dark theme opacity adjustment - has_contours feature flag gated Completes T pipeline integration (Phase 1). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
cdd11dc043
commit
f6cbf5f2cc
3 changed files with 173 additions and 7 deletions
|
|
@ -1,5 +1,5 @@
|
||||||
import { useState, useEffect, useRef } from 'react'
|
import { useState, useEffect, useRef } from 'react'
|
||||||
import { Layers, Trees } 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'
|
||||||
|
|
@ -21,6 +21,7 @@ export default function LayerControl({ mapRef }) {
|
||||||
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 panelRef = useRef(null)
|
const panelRef = useRef(null)
|
||||||
|
|
||||||
// Initialize from localStorage or defaults on mount
|
// Initialize from localStorage or defaults on mount
|
||||||
|
|
@ -30,16 +31,19 @@ export default function LayerControl({ mapRef }) {
|
||||||
const trAvailable = hasFeature('has_traffic_overlay')
|
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')
|
||||||
|
|
||||||
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))
|
||||||
} else {
|
} else {
|
||||||
// Defaults: hillshade ON if available, traffic + publicLands OFF
|
// Defaults: hillshade ON if available, others OFF
|
||||||
setHillshade(hsAvailable)
|
setHillshade(hsAvailable)
|
||||||
setTraffic(false)
|
setTraffic(false)
|
||||||
setPublicLands(false)
|
setPublicLands(false)
|
||||||
|
setContours(false)
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
|
@ -63,7 +67,7 @@ export default function LayerControl({ mapRef }) {
|
||||||
} else {
|
} else {
|
||||||
map.once('style.load', apply)
|
map.once('style.load', apply)
|
||||||
}
|
}
|
||||||
savePrefs({ hillshade, traffic, publicLands })
|
savePrefs({ hillshade, traffic, publicLands, contours })
|
||||||
return () => map.off('style.load', apply)
|
return () => map.off('style.load', apply)
|
||||||
}, [hillshade, mapRef])
|
}, [hillshade, mapRef])
|
||||||
|
|
||||||
|
|
@ -86,7 +90,7 @@ export default function LayerControl({ mapRef }) {
|
||||||
} else {
|
} else {
|
||||||
map.once('style.load', apply)
|
map.once('style.load', apply)
|
||||||
}
|
}
|
||||||
savePrefs({ hillshade, traffic, publicLands })
|
savePrefs({ hillshade, traffic, publicLands, contours })
|
||||||
return () => map.off('style.load', apply)
|
return () => map.off('style.load', apply)
|
||||||
}, [traffic, mapRef])
|
}, [traffic, mapRef])
|
||||||
|
|
||||||
|
|
@ -109,10 +113,33 @@ export default function LayerControl({ mapRef }) {
|
||||||
} else {
|
} else {
|
||||||
map.once('style.load', apply)
|
map.once('style.load', apply)
|
||||||
}
|
}
|
||||||
savePrefs({ hillshade, traffic, publicLands })
|
savePrefs({ hillshade, traffic, publicLands, contours })
|
||||||
return () => map.off('style.load', apply)
|
return () => map.off('style.load', apply)
|
||||||
}, [publicLands, mapRef])
|
}, [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
|
// Close on outside click
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return
|
if (!open) return
|
||||||
|
|
@ -128,9 +155,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')
|
const showPublicLands = hasFeature('has_public_lands_layer')
|
||||||
|
const showContours = hasFeature('has_contours')
|
||||||
|
|
||||||
// Don't render if no overlay features available
|
// Don't render if no overlay features available
|
||||||
if (!showHillshade && !showTraffic && !showPublicLands) return null
|
if (!showHillshade && !showTraffic && !showPublicLands && !showContours) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={panelRef} className="layer-control">
|
<div ref={panelRef} className="layer-control">
|
||||||
|
|
@ -182,6 +210,18 @@ export default function LayerControl({ mapRef }) {
|
||||||
/>
|
/>
|
||||||
</label>
|
</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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,11 @@ const PUBLIC_LANDS_SOURCE = 'public-lands-tiles'
|
||||||
const PUBLIC_LANDS_FILL = 'public-lands-fill'
|
const PUBLIC_LANDS_FILL = 'public-lands-fill'
|
||||||
const PUBLIC_LANDS_LINE = 'public-lands-line'
|
const PUBLIC_LANDS_LINE = 'public-lands-line'
|
||||||
const PUBLIC_LANDS_LABEL = 'public-lands-label'
|
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 */
|
/** Build a full MapLibre style object for the given theme */
|
||||||
function buildStyle(themeName) {
|
function buildStyle(themeName) {
|
||||||
|
|
@ -266,6 +271,109 @@ function removePublicLands(map) {
|
||||||
if (map.getSource(PUBLIC_LANDS_SOURCE)) map.removeSource(PUBLIC_LANDS_SOURCE)
|
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 MapView = forwardRef(function MapView(_, ref) {
|
||||||
const mapRef = useRef(null)
|
const mapRef = useRef(null)
|
||||||
const mapInstance = useRef(null)
|
const mapInstance = useRef(null)
|
||||||
|
|
@ -276,7 +384,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 })
|
const activeLayersRef = useRef({ hillshade: false, traffic: false, contours: 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)
|
||||||
|
|
||||||
|
|
@ -332,6 +440,18 @@ const MapView = forwardRef(function MapView(_, ref) {
|
||||||
removePublicLands(map)
|
removePublicLands(map)
|
||||||
activeLayersRef.current.publicLands = false
|
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
|
// Initialize map
|
||||||
|
|
@ -419,6 +539,10 @@ const MapView = forwardRef(function MapView(_, ref) {
|
||||||
addPublicLands(map)
|
addPublicLands(map)
|
||||||
activeLayersRef.current.publicLands = true
|
activeLayersRef.current.publicLands = true
|
||||||
}
|
}
|
||||||
|
if (prefs.contours && hasFeature('has_contours')) {
|
||||||
|
addContours(map)
|
||||||
|
activeLayersRef.current.contours = 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)
|
||||||
|
|
@ -520,6 +644,7 @@ const MapView = forwardRef(function MapView(_, ref) {
|
||||||
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)
|
if (activeLayersRef.current.publicLands) addPublicLands(map)
|
||||||
|
if (activeLayersRef.current.contours) addContours(map)
|
||||||
|
|
||||||
// Restore view
|
// Restore view
|
||||||
map.jumpTo({ center, zoom, bearing, pitch })
|
map.jumpTo({ center, zoom, bearing, pitch })
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ const FALLBACK_CONFIG = {
|
||||||
has_traffic_overlay: false,
|
has_traffic_overlay: false,
|
||||||
has_landclass: false,
|
has_landclass: false,
|
||||||
has_public_lands_layer: false,
|
has_public_lands_layer: false,
|
||||||
|
has_contours: true,
|
||||||
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