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 { Layers, Trees } from 'lucide-react'
|
||||
import { Layers, Trees, Mountain } from 'lucide-react'
|
||||
import { hasFeature, getConfig } from '../config'
|
||||
|
||||
const STORAGE_KEY = 'navi-layer-prefs'
|
||||
|
|
@ -21,6 +21,7 @@ export default function LayerControl({ mapRef }) {
|
|||
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
|
||||
|
|
@ -30,16 +31,19 @@ export default function LayerControl({ mapRef }) {
|
|||
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, traffic + publicLands OFF
|
||||
// Defaults: hillshade ON if available, others OFF
|
||||
setHillshade(hsAvailable)
|
||||
setTraffic(false)
|
||||
setPublicLands(false)
|
||||
setContours(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
|
|
@ -63,7 +67,7 @@ export default function LayerControl({ mapRef }) {
|
|||
} else {
|
||||
map.once('style.load', apply)
|
||||
}
|
||||
savePrefs({ hillshade, traffic, publicLands })
|
||||
savePrefs({ hillshade, traffic, publicLands, contours })
|
||||
return () => map.off('style.load', apply)
|
||||
}, [hillshade, mapRef])
|
||||
|
||||
|
|
@ -86,7 +90,7 @@ export default function LayerControl({ mapRef }) {
|
|||
} else {
|
||||
map.once('style.load', apply)
|
||||
}
|
||||
savePrefs({ hillshade, traffic, publicLands })
|
||||
savePrefs({ hillshade, traffic, publicLands, contours })
|
||||
return () => map.off('style.load', apply)
|
||||
}, [traffic, mapRef])
|
||||
|
||||
|
|
@ -109,10 +113,33 @@ export default function LayerControl({ mapRef }) {
|
|||
} else {
|
||||
map.once('style.load', apply)
|
||||
}
|
||||
savePrefs({ hillshade, traffic, publicLands })
|
||||
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
|
||||
|
|
@ -128,9 +155,10 @@ export default function LayerControl({ mapRef }) {
|
|||
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) return null
|
||||
if (!showHillshade && !showTraffic && !showPublicLands && !showContours) return null
|
||||
|
||||
return (
|
||||
<div ref={panelRef} className="layer-control">
|
||||
|
|
@ -182,6 +210,18 @@ export default function LayerControl({ mapRef }) {
|
|||
/>
|
||||
</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>
|
||||
|
|
|
|||
|
|
@ -18,6 +18,11 @@ 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) {
|
||||
|
|
@ -266,6 +271,109 @@ function removePublicLands(map) {
|
|||
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)
|
||||
|
|
@ -276,7 +384,7 @@ const MapView = forwardRef(function MapView(_, ref) {
|
|||
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 })
|
||||
const activeLayersRef = useRef({ hillshade: false, traffic: false, contours: false })
|
||||
// Flag to suppress map-click when a stop pin was clicked
|
||||
const pinClickedRef = useRef(false)
|
||||
|
||||
|
|
@ -332,6 +440,18 @@ const MapView = forwardRef(function MapView(_, ref) {
|
|||
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
|
||||
|
|
@ -419,6 +539,10 @@ const MapView = forwardRef(function MapView(_, ref) {
|
|||
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)
|
||||
|
|
@ -520,6 +644,7 @@ const MapView = forwardRef(function MapView(_, ref) {
|
|||
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 })
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ const FALLBACK_CONFIG = {
|
|||
has_traffic_overlay: false,
|
||||
has_landclass: false,
|
||||
has_public_lands_layer: false,
|
||||
has_contours: true,
|
||||
has_address_book_write: false,
|
||||
has_contacts: false,
|
||||
},
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue