Add hillshade and traffic overlay layers with layer control UI

- New LayerControl component with popover toggles for hillshade/traffic
- MapView: add/remove hillshade raster-dem and traffic raster layers
- Overlay layers persist in localStorage, survive theme swaps
- Hillshade defaults ON, traffic defaults OFF when available

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Matt 2026-04-21 00:52:20 +00:00
commit 4020d5ae0a
4 changed files with 362 additions and 2 deletions

View file

@ -6,6 +6,7 @@ import { decodePolyline } from './utils/decode'
import MapView from './components/MapView' import MapView from './components/MapView'
import Panel from './components/Panel' import Panel from './components/Panel'
import PlaceDetail from './components/PlaceDetail' import PlaceDetail from './components/PlaceDetail'
import LayerControl from './components/LayerControl'
export default function App() { export default function App() {
const mapViewRef = useRef(null) const mapViewRef = useRef(null)
@ -106,6 +107,7 @@ export default function App() {
<MapView ref={mapViewRef} /> <MapView ref={mapViewRef} />
<Panel onManeuverClick={handleManeuverClick} /> <Panel onManeuverClick={handleManeuverClick} />
<PlaceDetail /> <PlaceDetail />
<LayerControl mapRef={mapViewRef} />
</div> </div>
) )
} }

View file

@ -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 (
<div ref={panelRef} className="layer-control">
<button
className="layer-control-btn"
onClick={() => setOpen((v) => !v)}
title="Map layers"
aria-label="Toggle map layers"
>
<Layers size={18} />
</button>
{open && (
<div className="layer-control-popover">
<div className="layer-control-header">Layers</div>
{showHillshade && (
<label className="layer-control-item">
<span className="layer-control-label">Hillshade</span>
<input
type="checkbox"
className="layer-control-toggle"
checked={hillshade}
onChange={(e) => setHillshade(e.target.checked)}
/>
</label>
)}
{showTraffic && (
<label className="layer-control-item">
<span className="layer-control-label">Traffic</span>
<input
type="checkbox"
className="layer-control-toggle"
checked={traffic}
onChange={(e) => setTraffic(e.target.checked)}
/>
</label>
)}
</div>
)}
</div>
)
}

View file

@ -6,10 +6,14 @@ import { layers, namedTheme } from 'protomaps-themes-base'
import { useStore } from '../store' import { useStore } from '../store'
import { decodePolyline } from '../utils/decode' import { decodePolyline } from '../utils/decode'
import { fetchReverse } from '../api' import { fetchReverse } from '../api'
import { getConfig } from '../config' import { getConfig, hasFeature } from '../config'
const ROUTE_SOURCE = 'route-source' const ROUTE_SOURCE = 'route-source'
const ROUTE_LAYER_PREFIX = 'route-layer-' 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 */ /** Build a full MapLibre style object for the given theme */
function buildStyle(themeName) { function buildStyle(themeName) {
@ -37,6 +41,83 @@ const CHEVRON_SVG = `<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http
<path d="M8 1 L14 13 L8 10 L2 13 Z" fill="var(--accent)" stroke="var(--bg-raised)" stroke-width="1.5" stroke-linejoin="round"/> <path d="M8 1 L14 13 L8 10 L2 13 Z" fill="var(--accent)" stroke="var(--bg-raised)" stroke-width="1.5" stroke-linejoin="round"/>
</svg>` </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 MapView = forwardRef(function MapView(_, ref) {
const mapRef = useRef(null) const mapRef = useRef(null)
const mapInstance = useRef(null) const mapInstance = useRef(null)
@ -46,6 +127,8 @@ const MapView = forwardRef(function MapView(_, ref) {
const previewMarkerRef = useRef(null) const previewMarkerRef = useRef(null)
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)
const activeLayersRef = useRef({ hillshade: false, traffic: 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)
@ -65,6 +148,30 @@ const MapView = forwardRef(function MapView(_, ref) {
getMap() { getMap() {
return mapInstance.current 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 // Initialize map
@ -164,6 +271,26 @@ const MapView = forwardRef(function MapView(_, ref) {
type: 'geojson', type: 'geojson',
data: { type: 'FeatureCollection', features: [] }, 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 mapInstance.current = map
@ -221,12 +348,17 @@ const MapView = forwardRef(function MapView(_, ref) {
map.setStyle(buildStyle(theme), { diff: false }) 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.once('style.load', () => {
map.addSource(ROUTE_SOURCE, { map.addSource(ROUTE_SOURCE, {
type: 'geojson', type: 'geojson',
data: { type: 'FeatureCollection', features: [] }, data: { type: 'FeatureCollection', features: [] },
}) })
// Re-add active overlay layers
if (activeLayersRef.current.hillshade) addHillshade(map)
if (activeLayersRef.current.traffic) addTraffic(map)
// Restore view // Restore view
map.jumpTo({ center, zoom, bearing, pitch }) map.jumpTo({ center, zoom, bearing, pitch })
// Re-render route if exists // Re-render route if exists

View file

@ -282,3 +282,103 @@ body {
transform: translateX(0); transform: translateX(0);
opacity: 1; 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);
}