From e786bb88707788999a17070a9c78414ff21f63cc Mon Sep 17 00:00:00 2001 From: Matt Date: Sat, 2 May 2026 02:01:56 +0000 Subject: [PATCH] feat: Add satellite imagery with Map/Satellite/Hybrid view modes - Add viewMode state to store with localStorage persistence - Add satellite layer functions to MapView (ESRI World Imagery via nginx proxy) - Add view mode segmented control in LayerControl popover - Add view-mode-control CSS styles - Hide/show vector fills and lines based on view mode Co-Authored-By: Claude Opus 4.5 --- src/components/LayerControl.jsx | 738 +++++++++++++++++--------------- src/components/MapView.jsx | 190 ++++++++ src/index.css | 33 ++ src/store.js | 5 + 4 files changed, 623 insertions(+), 343 deletions(-) diff --git a/src/components/LayerControl.jsx b/src/components/LayerControl.jsx index c67b5ae..ee41ccb 100644 --- a/src/components/LayerControl.jsx +++ b/src/components/LayerControl.jsx @@ -1,222 +1,227 @@ -import { useState, useEffect, useRef } from 'react' -import { Layers, Trees, Mountain } 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 [publicLands, setPublicLands] = useState(false) - const [contours, setContours] = useState(false) - const [contoursTest, setContoursTest] = useState(false) - const [contoursTest10ft, setContoursTest10ft] = useState(false) +import { useState, useEffect, useRef } from 'react' +import { Layers, Map, Satellite, Globe } from 'lucide-react' +import { hasFeature, getConfig } from '../config' +import { useStore } from '../store' + +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 [publicLands, setPublicLands] = useState(false) + const [contours, setContours] = useState(false) + const [contoursTest, setContoursTest] = useState(false) + const [contoursTest10ft, setContoursTest10ft] = useState(false) const [usfsTrails, setUsfsTrails] = useState(false) - const [blmTrails, setBlmTrails] = 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') - const plAvailable = hasFeature('has_public_lands_layer') - const ctAvailable = hasFeature('has_contours') - const ctTestAvailable = hasFeature('has_contours_test') - const ctTest10ftAvailable = hasFeature('has_contours_test_10ft') + const [blmTrails, setBlmTrails] = useState(false) + const panelRef = useRef(null) + + // View mode: map | satellite | hybrid + const viewMode = useStore((s) => s.viewMode) + const setViewMode = useStore((s) => s.setViewMode) + + // Initialize from localStorage or defaults on mount + useEffect(() => { + const saved = loadPrefs() + const hsAvailable = hasFeature('has_hillshade') + const trAvailable = hasFeature('has_traffic_overlay') + const plAvailable = hasFeature('has_public_lands_layer') + const ctAvailable = hasFeature('has_contours') + const ctTestAvailable = hasFeature('has_contours_test') + const ctTest10ftAvailable = hasFeature('has_contours_test_10ft') const usfsAvailable = hasFeature('has_usfs_trails') - const blmAvailable = hasFeature('has_blm_trails') - - if (saved) { - setHillshade(hsAvailable && (saved.hillshade ?? true)) - setTraffic(trAvailable && (saved.traffic ?? false)) - setPublicLands(plAvailable && (saved.publicLands ?? false)) - setContours(ctAvailable && (saved.contours ?? false)) - setContoursTest(ctTestAvailable && (saved.contoursTest ?? false)) - setContoursTest10ft(ctTest10ftAvailable && (saved.contoursTest10ft ?? false)) + const blmAvailable = hasFeature('has_blm_trails') + + if (saved) { + setHillshade(hsAvailable && (saved.hillshade ?? true)) + setTraffic(trAvailable && (saved.traffic ?? false)) + setPublicLands(plAvailable && (saved.publicLands ?? false)) + setContours(ctAvailable && (saved.contours ?? false)) + setContoursTest(ctTestAvailable && (saved.contoursTest ?? false)) + setContoursTest10ft(ctTest10ftAvailable && (saved.contoursTest10ft ?? false)) setUsfsTrails(usfsAvailable && (saved.usfsTrails ?? false)) - setBlmTrails(blmAvailable && (saved.blmTrails ?? false)) - } else { - // Defaults: hillshade ON if available, others OFF - setHillshade(hsAvailable) - setTraffic(false) - setPublicLands(false) - setContours(false) - setContoursTest(false) - setContoursTest10ft(false) - setUsfsTrails(false) - } - }, []) - - // Apply layers when prefs change - useEffect(() => { - const mapView = mapRef?.current - if (!mapView) return - const map = mapView.getMap?.() - if (!map) return - - const apply = () => { - if (hillshade && hasFeature('has_hillshade')) { - mapView.addHillshadeLayer?.() - } else { - mapView.removeHillshadeLayer?.() - } - } - - if (map.isStyleLoaded()) { - apply() - } else { - map.once('style.load', apply) - } - savePrefs({ hillshade, traffic, publicLands, contours, contoursTest, contoursTest10ft, usfsTrails, blmTrails }) - return () => map.off('style.load', apply) - }, [hillshade, mapRef]) - - useEffect(() => { - const mapView = mapRef?.current - if (!mapView) return - const map = mapView.getMap?.() - if (!map) return - - const apply = () => { - if (traffic && hasFeature('has_traffic_overlay')) { - mapView.addTrafficLayer?.() - } else { - mapView.removeTrafficLayer?.() - } - } - - if (map.isStyleLoaded()) { - apply() - } else { - map.once('style.load', apply) - } - savePrefs({ hillshade, traffic, publicLands, contours, contoursTest, contoursTest10ft, usfsTrails, blmTrails }) - return () => map.off('style.load', apply) - }, [traffic, mapRef]) - - useEffect(() => { - const mapView = mapRef?.current - if (!mapView) return - const map = mapView.getMap?.() - if (!map) return - - const apply = () => { - if (publicLands && hasFeature('has_public_lands_layer')) { - mapView.addPublicLandsLayer?.() - } else { - mapView.removePublicLandsLayer?.() - } - } - - if (map.isStyleLoaded()) { - apply() - } else { - map.once('style.load', apply) - } - savePrefs({ hillshade, traffic, publicLands, contours, contoursTest, contoursTest10ft, usfsTrails, blmTrails }) - 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, contoursTest, contoursTest10ft, usfsTrails, blmTrails }) - return () => map.off('style.load', apply) - }, [contours, mapRef]) - - useEffect(() => { - const mapView = mapRef?.current - if (!mapView) return - const map = mapView.getMap?.() - if (!map) return - - const apply = () => { - if (contoursTest && hasFeature('has_contours_test')) { - mapView.addContoursTestLayer?.() - } else { - mapView.removeContoursTestLayer?.() - } - } - - if (map.isStyleLoaded()) { - apply() - } else { - map.once('style.load', apply) - } - savePrefs({ hillshade, traffic, publicLands, contours, contoursTest, contoursTest10ft, usfsTrails, blmTrails }) - return () => map.off('style.load', apply) - }, [contoursTest, mapRef]) - - // Apply contoursTest10ft layer - useEffect(() => { - const map = mapRef.current?.getMap?.() - if (!map) return - - const apply = () => { - if (contoursTest10ft && hasFeature('has_contours_test_10ft')) { - mapRef.current?.addContoursTest10ftLayer?.() - } else { - mapRef.current?.removeContoursTest10ftLayer?.() - } - } - - if (map.isStyleLoaded()) { - apply() - } else { - map.once('style.load', apply) - } - }, [contoursTest10ft, mapRef]) - - // Apply usfsTrails layer - useEffect(() => { - const map = mapRef.current?.getMap?.() - if (!map) return - - const apply = () => { - if (usfsTrails && hasFeature('has_usfs_trails')) { - mapRef.current?.addUsfsTrailsLayer?.() - } else { - mapRef.current?.removeUsfsTrailsLayer?.() - } - } - - if (map.isStyleLoaded()) { - apply() - } else { - map.once('style.load', apply) - } - savePrefs({ hillshade, traffic, publicLands, contours, contoursTest, contoursTest10ft, usfsTrails, blmTrails }) - }, [usfsTrails, mapRef]) + setBlmTrails(blmAvailable && (saved.blmTrails ?? false)) + } else { + // Defaults: hillshade ON if available, others OFF + setHillshade(hsAvailable) + setTraffic(false) + setPublicLands(false) + setContours(false) + setContoursTest(false) + setContoursTest10ft(false) + setUsfsTrails(false) + } + }, []) + + // Apply layers when prefs change + useEffect(() => { + const mapView = mapRef?.current + if (!mapView) return + const map = mapView.getMap?.() + if (!map) return + + const apply = () => { + if (hillshade && hasFeature('has_hillshade')) { + mapView.addHillshadeLayer?.() + } else { + mapView.removeHillshadeLayer?.() + } + } + + if (map.isStyleLoaded()) { + apply() + } else { + map.once('style.load', apply) + } + savePrefs({ hillshade, traffic, publicLands, contours, contoursTest, contoursTest10ft, usfsTrails, blmTrails }) + return () => map.off('style.load', apply) + }, [hillshade, mapRef]) + + useEffect(() => { + const mapView = mapRef?.current + if (!mapView) return + const map = mapView.getMap?.() + if (!map) return + + const apply = () => { + if (traffic && hasFeature('has_traffic_overlay')) { + mapView.addTrafficLayer?.() + } else { + mapView.removeTrafficLayer?.() + } + } + + if (map.isStyleLoaded()) { + apply() + } else { + map.once('style.load', apply) + } + savePrefs({ hillshade, traffic, publicLands, contours, contoursTest, contoursTest10ft, usfsTrails, blmTrails }) + return () => map.off('style.load', apply) + }, [traffic, mapRef]) + + useEffect(() => { + const mapView = mapRef?.current + if (!mapView) return + const map = mapView.getMap?.() + if (!map) return + + const apply = () => { + if (publicLands && hasFeature('has_public_lands_layer')) { + mapView.addPublicLandsLayer?.() + } else { + mapView.removePublicLandsLayer?.() + } + } + + if (map.isStyleLoaded()) { + apply() + } else { + map.once('style.load', apply) + } + savePrefs({ hillshade, traffic, publicLands, contours, contoursTest, contoursTest10ft, usfsTrails, blmTrails }) + 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, contoursTest, contoursTest10ft, usfsTrails, blmTrails }) + return () => map.off('style.load', apply) + }, [contours, mapRef]) + + useEffect(() => { + const mapView = mapRef?.current + if (!mapView) return + const map = mapView.getMap?.() + if (!map) return + + const apply = () => { + if (contoursTest && hasFeature('has_contours_test')) { + mapView.addContoursTestLayer?.() + } else { + mapView.removeContoursTestLayer?.() + } + } + + if (map.isStyleLoaded()) { + apply() + } else { + map.once('style.load', apply) + } + savePrefs({ hillshade, traffic, publicLands, contours, contoursTest, contoursTest10ft, usfsTrails, blmTrails }) + return () => map.off('style.load', apply) + }, [contoursTest, mapRef]) + + // Apply contoursTest10ft layer + useEffect(() => { + const map = mapRef.current?.getMap?.() + if (!map) return + + const apply = () => { + if (contoursTest10ft && hasFeature('has_contours_test_10ft')) { + mapRef.current?.addContoursTest10ftLayer?.() + } else { + mapRef.current?.removeContoursTest10ftLayer?.() + } + } + + if (map.isStyleLoaded()) { + apply() + } else { + map.once('style.load', apply) + } + }, [contoursTest10ft, mapRef]) + + // Apply usfsTrails layer + useEffect(() => { + const map = mapRef.current?.getMap?.() + if (!map) return + + const apply = () => { + if (usfsTrails && hasFeature('has_usfs_trails')) { + mapRef.current?.addUsfsTrailsLayer?.() + } else { + mapRef.current?.removeUsfsTrailsLayer?.() + } + } + + if (map.isStyleLoaded()) { + apply() + } else { + map.once('style.load', apply) + } + savePrefs({ hillshade, traffic, publicLands, contours, contoursTest, contoursTest10ft, usfsTrails, blmTrails }) + }, [usfsTrails, mapRef]) // Apply blmTrails layer useEffect(() => { @@ -238,129 +243,176 @@ export default function LayerControl({ mapRef }) { } savePrefs({ hillshade, traffic, publicLands, contours, contoursTest, contoursTest10ft, usfsTrails, blmTrails }) }, [blmTrails, mapRef]) - - // Close on outside click - useEffect(() => { - if (!open) return - function handleClick(e) { - if (panelRef.current && !panelRef.current.contains(e.target)) { - setOpen(false) - } - } - document.addEventListener('pointerdown', handleClick) - return () => document.removeEventListener('pointerdown', handleClick) - }, [open]) - - const showHillshade = hasFeature('has_hillshade') - const showTraffic = hasFeature('has_traffic_overlay') - const showPublicLands = hasFeature('has_public_lands_layer') - const showContours = hasFeature('has_contours') - const showContoursTest = hasFeature('has_contours_test') - const showContoursTest10ft = hasFeature('has_contours_test_10ft') + + // Apply view mode changes + useEffect(() => { + const mapView = mapRef?.current + if (!mapView) return + const map = mapView.getMap?.() + if (!map) return + + const apply = () => { + mapView.setViewMode?.(viewMode) + } + + if (map.isStyleLoaded()) { + apply() + } else { + map.once('style.load', apply) + } + return () => map.off('style.load', apply) + }, [viewMode, mapRef]) + + // Close on outside click + useEffect(() => { + if (!open) return + function handleClick(e) { + if (panelRef.current && !panelRef.current.contains(e.target)) { + setOpen(false) + } + } + document.addEventListener('pointerdown', handleClick) + return () => document.removeEventListener('pointerdown', handleClick) + }, [open]) + + const showHillshade = hasFeature('has_hillshade') + const showTraffic = hasFeature('has_traffic_overlay') + const showPublicLands = hasFeature('has_public_lands_layer') + const showContours = hasFeature('has_contours') + const showContoursTest = hasFeature('has_contours_test') + const showContoursTest10ft = hasFeature('has_contours_test_10ft') const showUsfsTrails = hasFeature('has_usfs_trails') - const showBlmTrails = hasFeature('has_blm_trails') - - // Don't render if no overlay features available - if (!showHillshade && !showTraffic && !showPublicLands && !showContours && !showContoursTest && !showContoursTest10ft && !showUsfsTrails && !showBlmTrails) return null - - return ( -
- - - {open && ( -
-
Layers
- - {showHillshade && ( - - )} - - {showTraffic && ( - - )} - - {showPublicLands && ( - - )} - - {showContours && ( - - )} - - {showContoursTest && ( - - )} - - {showContoursTest10ft && ( - - )} - - {showUsfsTrails && ( - - )} + const showBlmTrails = hasFeature('has_blm_trails') + + // Don't render if no overlay features available + if (!showHillshade && !showTraffic && !showPublicLands && !showContours && !showContoursTest && !showContoursTest10ft && !showUsfsTrails && !showBlmTrails) return null + + return ( +
+ + + {open && ( +
+ {/* View mode segmented control */} +
+ + + +
+ +
Layers
+ + {showHillshade && ( + + )} + + {showTraffic && ( + + )} + + {showPublicLands && ( + + )} + + {showContours && ( + + )} + + {showContoursTest && ( + + )} + + {showContoursTest10ft && ( + + )} + + {showUsfsTrails && ( + + )} {showBlmTrails && ( )} -
- )} -
- ) -} +
+ )} +
+ ) +} diff --git a/src/components/MapView.jsx b/src/components/MapView.jsx index 37ec829..47454ee 100644 --- a/src/components/MapView.jsx +++ b/src/components/MapView.jsx @@ -65,6 +65,8 @@ const BLM_ROUTES_SNOW = 'blm-routes-snow' const BLM_ROUTES_OTHER = 'blm-routes-other' const BLM_ROUTES_LABEL = 'blm-routes-label' const BLM_ROUTES_HIT = 'blm-routes-hit' +const SATELLITE_SOURCE = 'satellite-source' +const SATELLITE_LAYER = 'satellite-layer' // Highlight state - use data-driven expressions to target specific features @@ -1251,6 +1253,154 @@ function removeBlmTrails(map) { } +// ═══════════════════════════════════════════════════════════════════════════ +// SATELLITE IMAGERY +// ═══════════════════════════════════════════════════════════════════════════ + +/** Add satellite raster source (called once on map load) */ +function addSatelliteSource(map) { + if (!map || map.getSource(SATELLITE_SOURCE)) return + map.addSource(SATELLITE_SOURCE, { + type: 'raster', + tiles: ['/tiles/satellite/{z}/{x}/{y}'], + tileSize: 256, + maxzoom: 18, + attribution: '© Esri', + }) +} + +/** Add satellite raster layer with theme-specific styling */ +function addSatelliteLayer(map, themeId) { + if (!map) return + if (map.getLayer(SATELLITE_LAYER)) return + if (!map.getSource(SATELLITE_SOURCE)) { + addSatelliteSource(map) + } + + const theme = getTheme(themeId) + const sat = theme.satellite || {} + + // Find the first layer to insert below (we want satellite at the bottom) + const layers = map.getStyle().layers + let firstLayerId = layers.length > 0 ? layers[0].id : undefined + + map.addLayer({ + id: SATELLITE_LAYER, + type: 'raster', + source: SATELLITE_SOURCE, + paint: { + 'raster-opacity': sat.opacity ?? 1.0, + 'raster-brightness-min': sat.brightnessMin ?? 0.0, + 'raster-brightness-max': sat.brightnessMax ?? 1.0, + 'raster-contrast': sat.contrast ?? 0.0, + 'raster-saturation': sat.saturation ?? 0.0, + 'raster-hue-rotate': sat.hueRotate ?? 0, + }, + }, firstLayerId) +} + +/** Remove satellite raster layer */ +function removeSatelliteLayer(map) { + if (!map) return + if (map.getLayer(SATELLITE_LAYER)) { + map.removeLayer(SATELLITE_LAYER) + } +} + +/** Update satellite layer paint properties for current theme */ +function updateSatellitePaint(map, themeId) { + if (!map || !map.getLayer(SATELLITE_LAYER)) return + + const theme = getTheme(themeId) + const sat = theme.satellite || {} + + map.setPaintProperty(SATELLITE_LAYER, 'raster-opacity', sat.opacity ?? 1.0) + map.setPaintProperty(SATELLITE_LAYER, 'raster-brightness-min', sat.brightnessMin ?? 0.0) + map.setPaintProperty(SATELLITE_LAYER, 'raster-brightness-max', sat.brightnessMax ?? 1.0) + map.setPaintProperty(SATELLITE_LAYER, 'raster-contrast', sat.contrast ?? 0.0) + map.setPaintProperty(SATELLITE_LAYER, 'raster-saturation', sat.saturation ?? 0.0) + map.setPaintProperty(SATELLITE_LAYER, 'raster-hue-rotate', sat.hueRotate ?? 0) +} + +// Track which vector layers are hidden in satellite/hybrid mode +let hiddenVectorLayers = [] + +/** Hide vector fill layers for satellite mode */ +function hideVectorFills(map) { + if (!map) return + hiddenVectorLayers = [] + + const style = map.getStyle() + if (!style || !style.layers) return + + for (const layer of style.layers) { + // Hide fill layers (land, water, parks, buildings, etc.) + // But keep line, symbol, and circle layers + if (layer.type === 'fill' || layer.type === 'fill-extrusion') { + // Don't hide our own overlay fills (public lands, etc) + if (layer.id.startsWith('public-lands') || + layer.id.startsWith('boundary') || + layer.id.startsWith('route')) continue + + const visibility = map.getLayoutProperty(layer.id, 'visibility') + if (visibility !== 'none') { + hiddenVectorLayers.push(layer.id) + map.setLayoutProperty(layer.id, 'visibility', 'none') + } + } + } +} + +/** Show all hidden vector layers */ +function showVectorFills(map) { + if (!map) return + + for (const layerId of hiddenVectorLayers) { + if (map.getLayer(layerId)) { + map.setLayoutProperty(layerId, 'visibility', 'visible') + } + } + hiddenVectorLayers = [] +} + +/** Set map to satellite-only mode */ +function setSatelliteMode(map, themeId) { + if (!map) return + addSatelliteLayer(map, themeId) + hideVectorFills(map) + // Also hide line layers in pure satellite mode (keep only labels for reference) + const style = map.getStyle() + if (style && style.layers) { + for (const layer of style.layers) { + if (layer.type === 'line' && !layer.id.startsWith('route') && + !layer.id.startsWith('boundary') && !layer.id.startsWith('measure')) { + const visibility = map.getLayoutProperty(layer.id, 'visibility') + if (visibility !== 'none') { + hiddenVectorLayers.push(layer.id) + map.setLayoutProperty(layer.id, 'visibility', 'none') + } + } + } + } +} + +/** Set map to hybrid mode (satellite + labels/roads) */ +function setHybridMode(map, themeId) { + if (!map) return + addSatelliteLayer(map, themeId) + hideVectorFills(map) + // In hybrid mode, keep road lines and labels visible + // They're already visible by default, just fills are hidden +} + +/** Set map back to normal map mode */ +function setMapMode(map) { + if (!map) return + removeSatelliteLayer(map) + showVectorFills(map) +} + + /** Add boundary polygon layers with computed accent color (MapLibre rejects CSS vars in paint) */ const BOUNDARY_FILL_LAYER = 'boundary-fill-layer' @@ -1780,6 +1930,26 @@ const MapView = forwardRef(function MapView(_, ref) { activeLayersRef.current.blmTrails = false }, + // View mode functions + setViewMode(mode) { + const map = mapInstance.current + if (!map) return + + if (mode === 'satellite') { + setSatelliteMode(map, currentThemeRef.current) + } else if (mode === 'hybrid') { + setHybridMode(map, currentThemeRef.current) + } else { + setMapMode(map) + } + }, + + updateSatelliteTheme() { + const map = mapInstance.current + if (!map) return + updateSatellitePaint(map, currentThemeRef.current) + }, + })) // Initialize map @@ -2122,6 +2292,17 @@ const MapView = forwardRef(function MapView(_, ref) { }) map.on('load', () => { + // Add satellite source (persists across view modes) + addSatelliteSource(map) + + // Restore view mode from localStorage + const savedViewMode = localStorage.getItem('navi-view-mode') || 'map' + if (savedViewMode === 'satellite') { + setSatelliteMode(map, currentThemeRef.current) + } else if (savedViewMode === 'hybrid') { + setHybridMode(map, currentThemeRef.current) + } + // Guard against double-mount in React strict mode if (!map.getSource(ROUTE_SOURCE)) { map.addSource(ROUTE_SOURCE, { @@ -2357,6 +2538,15 @@ const MapView = forwardRef(function MapView(_, ref) { if (activeLayersRef.current.usfsTrails) addUsfsTrails(map, currentThemeRef.current) if (activeLayersRef.current.blmTrails) addBlmTrails(map, currentThemeRef.current) + // Re-add satellite source and restore view mode + addSatelliteSource(map) + const savedViewMode = localStorage.getItem('navi-view-mode') || 'map' + if (savedViewMode === 'satellite') { + setSatelliteMode(map, currentThemeRef.current) + } else if (savedViewMode === 'hybrid') { + setHybridMode(map, currentThemeRef.current) + } + // Clear highlights on theme change (paint values will be re-stored on next interaction) clearAllHighlights(map) originalPaintValues = {} diff --git a/src/index.css b/src/index.css index 9e241c0..2673a26 100644 --- a/src/index.css +++ b/src/index.css @@ -314,6 +314,39 @@ body { box-shadow: var(--shadow-lg); } +.view-mode-control { + display: flex; + gap: 2px; + padding: 8px; + border-bottom: 1px solid var(--border-subtle); +} + +.view-mode-btn { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + gap: 2px; + padding: 6px 8px; + border: none; + border-radius: 6px; + background: transparent; + color: var(--text-secondary); + font-size: var(--text-xs); + cursor: pointer; + transition: all 0.15s; +} + +.view-mode-btn:hover { + background: var(--bg-overlay); + color: var(--text-primary); +} + +.view-mode-btn.active { + background: var(--accent); + color: var(--text-inverse); +} + .layer-control-header { padding: 4px 12px 6px; font-size: var(--text-xs); diff --git a/src/store.js b/src/store.js index 9b4aa30..bc36648 100644 --- a/src/store.js +++ b/src/store.js @@ -100,8 +100,13 @@ export const useStore = create((set, get) => ({ autocompleteOpen: false, theme: 'dark', // 'dark' | 'light' (resolved value — what's actually applied) themeOverride: null, // null | 'dark' | 'light' (manual override, persisted) + viewMode: 'map', // 'map' | 'satellite' | 'hybrid' setSheetState: (s) => set({ sheetState: s }), + setViewMode: (mode) => { + set({ viewMode: mode }) + localStorage.setItem('navi-view-mode', mode) + }, setPanelOpen: (open) => set({ panelOpen: open }), setAutocompleteOpen: (open) => set({ autocompleteOpen: open }), setTheme: (theme) => set({ theme }),