From f6cbf5f2cce3f56f7f93cdf14e42a02b14cffe93 Mon Sep 17 00:00:00 2001 From: Matt Date: Fri, 24 Apr 2026 00:44:20 +0000 Subject: [PATCH] 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 --- src/components/LayerControl.jsx | 52 +++++++++++-- src/components/MapView.jsx | 127 +++++++++++++++++++++++++++++++- src/config.js | 1 + 3 files changed, 173 insertions(+), 7 deletions(-) diff --git a/src/components/LayerControl.jsx b/src/components/LayerControl.jsx index 2e234cc..3030d0d 100644 --- a/src/components/LayerControl.jsx +++ b/src/components/LayerControl.jsx @@ -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 (
@@ -182,6 +210,18 @@ export default function LayerControl({ mapRef }) { /> )} + + {showContours && ( + + )}
)} diff --git a/src/components/MapView.jsx b/src/components/MapView.jsx index 7da3865..16694eb 100644 --- a/src/components/MapView.jsx +++ b/src/components/MapView.jsx @@ -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 }) diff --git a/src/config.js b/src/config.js index 0d84fe4..0bb7c74 100644 --- a/src/config.js +++ b/src/config.js @@ -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, },