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 && ( +
+
Layers
+ + {showHillshade && ( + + )} + + {showTraffic && ( + + )} +
+ )} +
+ ) +} 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); +}