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:
Matt 2026-04-24 00:44:20 +00:00
commit f6cbf5f2cc
3 changed files with 173 additions and 7 deletions

View file

@ -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>

View file

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

View file

@ -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,
}, },