diff --git a/src/App.jsx b/src/App.jsx
index 8bfad1a..b1ea55d 100644
--- a/src/App.jsx
+++ b/src/App.jsx
@@ -6,6 +6,7 @@ import { decodePolyline } from './utils/decode'
import MapView from './components/MapView'
import Panel from './components/Panel'
import PlaceDetail from './components/PlaceDetail'
+import LayerControl from './components/LayerControl'
export default function App() {
const mapViewRef = useRef(null)
@@ -106,6 +107,7 @@ export default function App() {
+
)
}
diff --git a/src/components/LayerControl.jsx b/src/components/LayerControl.jsx
new file mode 100644
index 0000000..52f8dec
--- /dev/null
+++ b/src/components/LayerControl.jsx
@@ -0,0 +1,126 @@
+import { useState, useEffect, useRef } from 'react'
+import { Layers } from 'lucide-react'
+import { hasFeature, getConfig } from '../config'
+
+const STORAGE_KEY = 'navi-layer-prefs'
+
+function loadPrefs() {
+ try {
+ const raw = localStorage.getItem(STORAGE_KEY)
+ if (raw) return JSON.parse(raw)
+ } catch {}
+ return null
+}
+
+function savePrefs(prefs) {
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(prefs))
+}
+
+export default function LayerControl({ mapRef }) {
+ const [open, setOpen] = useState(false)
+ const [hillshade, setHillshade] = useState(false)
+ const [traffic, setTraffic] = useState(false)
+ const panelRef = useRef(null)
+
+ // Initialize from localStorage or defaults on mount
+ useEffect(() => {
+ const saved = loadPrefs()
+ const hsAvailable = hasFeature('has_hillshade')
+ const trAvailable = hasFeature('has_traffic_overlay')
+
+ if (saved) {
+ setHillshade(hsAvailable && (saved.hillshade ?? true))
+ setTraffic(trAvailable && (saved.traffic ?? false))
+ } else {
+ // Defaults: hillshade ON if available, traffic OFF
+ setHillshade(hsAvailable)
+ setTraffic(false)
+ }
+ }, [])
+
+ // Apply layers when prefs change
+ useEffect(() => {
+ const map = mapRef?.current?.getMap?.()
+ if (!map || !map.isStyleLoaded()) return
+
+ if (hillshade && hasFeature('has_hillshade')) {
+ mapRef.current.addHillshadeLayer?.()
+ } else {
+ mapRef.current.removeHillshadeLayer?.()
+ }
+ savePrefs({ hillshade, traffic })
+ }, [hillshade, mapRef])
+
+ useEffect(() => {
+ const map = mapRef?.current?.getMap?.()
+ if (!map || !map.isStyleLoaded()) return
+
+ if (traffic && hasFeature('has_traffic_overlay')) {
+ mapRef.current.addTrafficLayer?.()
+ } else {
+ mapRef.current.removeTrafficLayer?.()
+ }
+ savePrefs({ hillshade, traffic })
+ }, [traffic, mapRef])
+
+ // Close on outside click
+ useEffect(() => {
+ if (!open) return
+ function handleClick(e) {
+ if (panelRef.current && !panelRef.current.contains(e.target)) {
+ setOpen(false)
+ }
+ }
+ document.addEventListener('mousedown', handleClick)
+ return () => document.removeEventListener('mousedown', handleClick)
+ }, [open])
+
+ const showHillshade = hasFeature('has_hillshade')
+ const showTraffic = hasFeature('has_traffic_overlay')
+
+ // Don't render if no overlay features available
+ if (!showHillshade && !showTraffic) return null
+
+ return (
+
+
+
+ {open && (
+
+ )}
+
+ )
+}
diff --git a/src/components/MapView.jsx b/src/components/MapView.jsx
index c5154b9..3c086b2 100644
--- a/src/components/MapView.jsx
+++ b/src/components/MapView.jsx
@@ -6,10 +6,14 @@ import { layers, namedTheme } from 'protomaps-themes-base'
import { useStore } from '../store'
import { decodePolyline } from '../utils/decode'
import { fetchReverse } from '../api'
-import { getConfig } from '../config'
+import { getConfig, hasFeature } from '../config'
const ROUTE_SOURCE = 'route-source'
const ROUTE_LAYER_PREFIX = 'route-layer-'
+const HILLSHADE_SOURCE = 'hillshade-dem'
+const HILLSHADE_LAYER = 'hillshade-layer'
+const TRAFFIC_SOURCE = 'traffic-tiles'
+const TRAFFIC_LAYER = 'traffic-layer'
/** Build a full MapLibre style object for the given theme */
function buildStyle(themeName) {
@@ -37,6 +41,83 @@ const CHEVRON_SVG = `
`
+/** Add hillshade raster-dem source + layer to the map */
+function addHillshade(map) {
+ if (!map || map.getSource(HILLSHADE_SOURCE)) return
+ const config = getConfig()
+ const hs = config?.tileset_hillshade
+ if (!hs?.url) return
+
+ map.addSource(HILLSHADE_SOURCE, {
+ type: 'raster-dem',
+ url: `pmtiles://${hs.url}`,
+ encoding: hs.encoding || 'terrarium',
+ tileSize: 256,
+ maxzoom: hs.max_zoom || 12,
+ })
+
+ // Insert below the first symbol/label layer for proper z-ordering
+ let beforeId = undefined
+ for (const layer of map.getStyle().layers) {
+ if (layer.type === 'symbol') {
+ beforeId = layer.id
+ break
+ }
+ }
+
+ map.addLayer({
+ id: HILLSHADE_LAYER,
+ type: 'hillshade',
+ source: HILLSHADE_SOURCE,
+ paint: {
+ 'hillshade-exaggeration': 0.5,
+ 'hillshade-illumination-direction': 315,
+ 'hillshade-shadow-color': '#000000',
+ 'hillshade-highlight-color': '#ffffff',
+ },
+ }, beforeId)
+}
+
+/** Remove hillshade layer + source */
+function removeHillshade(map) {
+ if (!map) return
+ if (map.getLayer(HILLSHADE_LAYER)) map.removeLayer(HILLSHADE_LAYER)
+ if (map.getSource(HILLSHADE_SOURCE)) map.removeSource(HILLSHADE_SOURCE)
+}
+
+/** Add traffic raster tile source + layer */
+function addTraffic(map) {
+ if (!map || map.getSource(TRAFFIC_SOURCE)) return
+ const config = getConfig()
+ const tr = config?.traffic
+ if (!tr?.proxy_url) return
+
+ const tileUrl = tr.proxy_url.replace('{z}', '{z}').replace('{x}', '{x}').replace('{y}', '{y}')
+
+ map.addSource(TRAFFIC_SOURCE, {
+ type: 'raster',
+ tiles: [tileUrl],
+ tileSize: 256,
+ maxzoom: 18,
+ })
+
+ map.addLayer({
+ id: TRAFFIC_LAYER,
+ type: 'raster',
+ source: TRAFFIC_SOURCE,
+ paint: {
+ 'raster-opacity': 0.6,
+ },
+ })
+}
+
+/** Remove traffic layer + source */
+function removeTraffic(map) {
+ if (!map) return
+ if (map.getLayer(TRAFFIC_LAYER)) map.removeLayer(TRAFFIC_LAYER)
+ if (map.getSource(TRAFFIC_SOURCE)) map.removeSource(TRAFFIC_SOURCE)
+}
+
const MapView = forwardRef(function MapView(_, ref) {
const mapRef = useRef(null)
const mapInstance = useRef(null)
@@ -46,6 +127,8 @@ const MapView = forwardRef(function MapView(_, ref) {
const previewMarkerRef = useRef(null)
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 })
// Flag to suppress map-click when a stop pin was clicked
const pinClickedRef = useRef(false)
@@ -65,6 +148,30 @@ const MapView = forwardRef(function MapView(_, ref) {
getMap() {
return mapInstance.current
},
+ addHillshadeLayer() {
+ const map = mapInstance.current
+ if (!map) return
+ addHillshade(map)
+ activeLayersRef.current.hillshade = true
+ },
+ removeHillshadeLayer() {
+ const map = mapInstance.current
+ if (!map) return
+ removeHillshade(map)
+ activeLayersRef.current.hillshade = false
+ },
+ addTrafficLayer() {
+ const map = mapInstance.current
+ if (!map) return
+ addTraffic(map)
+ activeLayersRef.current.traffic = true
+ },
+ removeTrafficLayer() {
+ const map = mapInstance.current
+ if (!map) return
+ removeTraffic(map)
+ activeLayersRef.current.traffic = false
+ },
}))
// Initialize map
@@ -164,6 +271,26 @@ const MapView = forwardRef(function MapView(_, ref) {
type: 'geojson',
data: { type: 'FeatureCollection', features: [] },
})
+
+ // Restore overlay layers from localStorage prefs
+ try {
+ const raw = localStorage.getItem('navi-layer-prefs')
+ if (raw) {
+ const prefs = JSON.parse(raw)
+ if (prefs.hillshade && hasFeature('has_hillshade')) {
+ addHillshade(map)
+ activeLayersRef.current.hillshade = true
+ }
+ if (prefs.traffic && hasFeature('has_traffic_overlay')) {
+ addTraffic(map)
+ activeLayersRef.current.traffic = true
+ }
+ } else if (hasFeature('has_hillshade')) {
+ // Default: hillshade ON if available
+ addHillshade(map)
+ activeLayersRef.current.hillshade = true
+ }
+ } catch {}
})
mapInstance.current = map
@@ -221,12 +348,17 @@ const MapView = forwardRef(function MapView(_, ref) {
map.setStyle(buildStyle(theme), { diff: false })
- // Re-add route source after style swap
+ // Re-add sources/layers after style swap
map.once('style.load', () => {
map.addSource(ROUTE_SOURCE, {
type: 'geojson',
data: { type: 'FeatureCollection', features: [] },
})
+
+ // Re-add active overlay layers
+ if (activeLayersRef.current.hillshade) addHillshade(map)
+ if (activeLayersRef.current.traffic) addTraffic(map)
+
// Restore view
map.jumpTo({ center, zoom, bearing, pitch })
// Re-render route if exists
diff --git a/src/index.css b/src/index.css
index 671ac29..ee35cf0 100644
--- a/src/index.css
+++ b/src/index.css
@@ -282,3 +282,103 @@ body {
transform: translateX(0);
opacity: 1;
}
+
+/* ═══ LAYER CONTROL ═══ */
+.layer-control {
+ position: absolute;
+ bottom: 32px;
+ right: 10px;
+ z-index: 10;
+}
+
+.layer-control-btn {
+ width: 36px;
+ height: 36px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: var(--bg-raised);
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ color: var(--text-secondary);
+ cursor: pointer;
+ box-shadow: var(--shadow);
+ transition: color 0.1s, border-color 0.1s;
+}
+
+.layer-control-btn:hover {
+ color: var(--text-primary);
+ border-color: var(--accent);
+}
+
+.layer-control-popover {
+ position: absolute;
+ bottom: 44px;
+ right: 0;
+ min-width: 160px;
+ background: var(--bg-raised);
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ padding: 8px 0;
+ box-shadow: var(--shadow-lg);
+}
+
+.layer-control-header {
+ padding: 4px 12px 6px;
+ font-size: var(--text-xs);
+ font-weight: 600;
+ color: var(--text-tertiary);
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+}
+
+.layer-control-item {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 6px 12px;
+ cursor: pointer;
+ transition: background 0.1s;
+}
+
+.layer-control-item:hover {
+ background: var(--bg-overlay);
+}
+
+.layer-control-label {
+ font-size: var(--text-sm);
+ color: var(--text-primary);
+}
+
+.layer-control-toggle {
+ appearance: none;
+ width: 32px;
+ height: 18px;
+ background: var(--border);
+ border-radius: 9px;
+ position: relative;
+ cursor: pointer;
+ transition: background 0.15s;
+ flex-shrink: 0;
+ margin-left: 12px;
+}
+
+.layer-control-toggle::after {
+ content: '';
+ position: absolute;
+ top: 2px;
+ left: 2px;
+ width: 14px;
+ height: 14px;
+ background: var(--text-primary);
+ border-radius: 50%;
+ transition: transform 0.15s;
+}
+
+.layer-control-toggle:checked {
+ background: var(--accent);
+}
+
+.layer-control-toggle:checked::after {
+ transform: translateX(14px);
+}