mirror of
https://github.com/zvx-echo6/navi.git
synced 2026-05-20 22:54:42 +02:00
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:
parent
edc5a9788d
commit
4020d5ae0a
4 changed files with 362 additions and 2 deletions
|
|
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
126
src/components/LayerControl.jsx
Normal file
126
src/components/LayerControl.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
100
src/index.css
100
src/index.css
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue