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