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 <noreply@anthropic.com>
This commit is contained in:
Matt 2026-05-02 02:01:56 +00:00
commit e786bb8870
4 changed files with 623 additions and 343 deletions

View file

@ -1,222 +1,227 @@
import { useState, useEffect, useRef } from 'react' import { useState, useEffect, useRef } from 'react'
import { Layers, Trees, Mountain } from 'lucide-react' import { Layers, Map, Satellite, Globe } from 'lucide-react'
import { hasFeature, getConfig } from '../config' import { hasFeature, getConfig } from '../config'
import { useStore } from '../store'
const STORAGE_KEY = 'navi-layer-prefs'
const STORAGE_KEY = 'navi-layer-prefs'
function loadPrefs() {
try { function loadPrefs() {
const raw = localStorage.getItem(STORAGE_KEY) try {
if (raw) return JSON.parse(raw) const raw = localStorage.getItem(STORAGE_KEY)
} catch {} if (raw) return JSON.parse(raw)
return null } catch {}
} return null
}
function savePrefs(prefs) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(prefs)) function savePrefs(prefs) {
} localStorage.setItem(STORAGE_KEY, JSON.stringify(prefs))
}
export default function LayerControl({ mapRef }) {
const [open, setOpen] = useState(false) export default function LayerControl({ mapRef }) {
const [hillshade, setHillshade] = useState(false) const [open, setOpen] = useState(false)
const [traffic, setTraffic] = useState(false) const [hillshade, setHillshade] = useState(false)
const [publicLands, setPublicLands] = useState(false) const [traffic, setTraffic] = useState(false)
const [contours, setContours] = useState(false) const [publicLands, setPublicLands] = useState(false)
const [contoursTest, setContoursTest] = useState(false) const [contours, setContours] = useState(false)
const [contoursTest10ft, setContoursTest10ft] = useState(false) const [contoursTest, setContoursTest] = useState(false)
const [contoursTest10ft, setContoursTest10ft] = useState(false)
const [usfsTrails, setUsfsTrails] = useState(false) const [usfsTrails, setUsfsTrails] = useState(false)
const [blmTrails, setBlmTrails] = useState(false) const [blmTrails, setBlmTrails] = useState(false)
const panelRef = useRef(null) const panelRef = useRef(null)
// Initialize from localStorage or defaults on mount // View mode: map | satellite | hybrid
useEffect(() => { const viewMode = useStore((s) => s.viewMode)
const saved = loadPrefs() const setViewMode = useStore((s) => s.setViewMode)
const hsAvailable = hasFeature('has_hillshade')
const trAvailable = hasFeature('has_traffic_overlay') // Initialize from localStorage or defaults on mount
const plAvailable = hasFeature('has_public_lands_layer') useEffect(() => {
const ctAvailable = hasFeature('has_contours') const saved = loadPrefs()
const ctTestAvailable = hasFeature('has_contours_test') const hsAvailable = hasFeature('has_hillshade')
const ctTest10ftAvailable = hasFeature('has_contours_test_10ft') 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 usfsAvailable = hasFeature('has_usfs_trails')
const blmAvailable = hasFeature('has_blm_trails') const blmAvailable = hasFeature('has_blm_trails')
if (saved) { if (saved) {
setHillshade(hsAvailable && (saved.hillshade ?? true)) setHillshade(hsAvailable && (saved.hillshade ?? true))
setTraffic(trAvailable && (saved.traffic ?? false)) setTraffic(trAvailable && (saved.traffic ?? false))
setPublicLands(plAvailable && (saved.publicLands ?? false)) setPublicLands(plAvailable && (saved.publicLands ?? false))
setContours(ctAvailable && (saved.contours ?? false)) setContours(ctAvailable && (saved.contours ?? false))
setContoursTest(ctTestAvailable && (saved.contoursTest ?? false)) setContoursTest(ctTestAvailable && (saved.contoursTest ?? false))
setContoursTest10ft(ctTest10ftAvailable && (saved.contoursTest10ft ?? false)) setContoursTest10ft(ctTest10ftAvailable && (saved.contoursTest10ft ?? false))
setUsfsTrails(usfsAvailable && (saved.usfsTrails ?? false)) setUsfsTrails(usfsAvailable && (saved.usfsTrails ?? false))
setBlmTrails(blmAvailable && (saved.blmTrails ?? false)) setBlmTrails(blmAvailable && (saved.blmTrails ?? false))
} else { } else {
// Defaults: hillshade ON if available, others OFF // Defaults: hillshade ON if available, others OFF
setHillshade(hsAvailable) setHillshade(hsAvailable)
setTraffic(false) setTraffic(false)
setPublicLands(false) setPublicLands(false)
setContours(false) setContours(false)
setContoursTest(false) setContoursTest(false)
setContoursTest10ft(false) setContoursTest10ft(false)
setUsfsTrails(false) setUsfsTrails(false)
} }
}, []) }, [])
// Apply layers when prefs change // Apply layers when prefs change
useEffect(() => { useEffect(() => {
const mapView = mapRef?.current const mapView = mapRef?.current
if (!mapView) return if (!mapView) return
const map = mapView.getMap?.() const map = mapView.getMap?.()
if (!map) return if (!map) return
const apply = () => { const apply = () => {
if (hillshade && hasFeature('has_hillshade')) { if (hillshade && hasFeature('has_hillshade')) {
mapView.addHillshadeLayer?.() mapView.addHillshadeLayer?.()
} else { } else {
mapView.removeHillshadeLayer?.() mapView.removeHillshadeLayer?.()
} }
} }
if (map.isStyleLoaded()) { if (map.isStyleLoaded()) {
apply() apply()
} else { } else {
map.once('style.load', apply) map.once('style.load', apply)
} }
savePrefs({ hillshade, traffic, publicLands, contours, contoursTest, contoursTest10ft, usfsTrails, blmTrails }) savePrefs({ hillshade, traffic, publicLands, contours, contoursTest, contoursTest10ft, usfsTrails, blmTrails })
return () => map.off('style.load', apply) return () => map.off('style.load', apply)
}, [hillshade, mapRef]) }, [hillshade, mapRef])
useEffect(() => { useEffect(() => {
const mapView = mapRef?.current const mapView = mapRef?.current
if (!mapView) return if (!mapView) return
const map = mapView.getMap?.() const map = mapView.getMap?.()
if (!map) return if (!map) return
const apply = () => { const apply = () => {
if (traffic && hasFeature('has_traffic_overlay')) { if (traffic && hasFeature('has_traffic_overlay')) {
mapView.addTrafficLayer?.() mapView.addTrafficLayer?.()
} else { } else {
mapView.removeTrafficLayer?.() mapView.removeTrafficLayer?.()
} }
} }
if (map.isStyleLoaded()) { if (map.isStyleLoaded()) {
apply() apply()
} else { } else {
map.once('style.load', apply) map.once('style.load', apply)
} }
savePrefs({ hillshade, traffic, publicLands, contours, contoursTest, contoursTest10ft, usfsTrails, blmTrails }) savePrefs({ hillshade, traffic, publicLands, contours, contoursTest, contoursTest10ft, usfsTrails, blmTrails })
return () => map.off('style.load', apply) return () => map.off('style.load', apply)
}, [traffic, mapRef]) }, [traffic, mapRef])
useEffect(() => { useEffect(() => {
const mapView = mapRef?.current const mapView = mapRef?.current
if (!mapView) return if (!mapView) return
const map = mapView.getMap?.() const map = mapView.getMap?.()
if (!map) return if (!map) return
const apply = () => { const apply = () => {
if (publicLands && hasFeature('has_public_lands_layer')) { if (publicLands && hasFeature('has_public_lands_layer')) {
mapView.addPublicLandsLayer?.() mapView.addPublicLandsLayer?.()
} else { } else {
mapView.removePublicLandsLayer?.() mapView.removePublicLandsLayer?.()
} }
} }
if (map.isStyleLoaded()) { if (map.isStyleLoaded()) {
apply() apply()
} else { } else {
map.once('style.load', apply) map.once('style.load', apply)
} }
savePrefs({ hillshade, traffic, publicLands, contours, contoursTest, contoursTest10ft, usfsTrails, blmTrails }) savePrefs({ hillshade, traffic, publicLands, contours, contoursTest, contoursTest10ft, usfsTrails, blmTrails })
return () => map.off('style.load', apply) return () => map.off('style.load', apply)
}, [publicLands, mapRef]) }, [publicLands, mapRef])
useEffect(() => { useEffect(() => {
const mapView = mapRef?.current const mapView = mapRef?.current
if (!mapView) return if (!mapView) return
const map = mapView.getMap?.() const map = mapView.getMap?.()
if (!map) return if (!map) return
const apply = () => { const apply = () => {
if (contours && hasFeature('has_contours')) { if (contours && hasFeature('has_contours')) {
mapView.addContoursLayer?.() mapView.addContoursLayer?.()
} else { } else {
mapView.removeContoursLayer?.() mapView.removeContoursLayer?.()
} }
} }
if (map.isStyleLoaded()) { if (map.isStyleLoaded()) {
apply() apply()
} else { } else {
map.once('style.load', apply) map.once('style.load', apply)
} }
savePrefs({ hillshade, traffic, publicLands, contours, contoursTest, contoursTest10ft, usfsTrails, blmTrails }) savePrefs({ hillshade, traffic, publicLands, contours, contoursTest, contoursTest10ft, usfsTrails, blmTrails })
return () => map.off('style.load', apply) return () => map.off('style.load', apply)
}, [contours, mapRef]) }, [contours, mapRef])
useEffect(() => { useEffect(() => {
const mapView = mapRef?.current const mapView = mapRef?.current
if (!mapView) return if (!mapView) return
const map = mapView.getMap?.() const map = mapView.getMap?.()
if (!map) return if (!map) return
const apply = () => { const apply = () => {
if (contoursTest && hasFeature('has_contours_test')) { if (contoursTest && hasFeature('has_contours_test')) {
mapView.addContoursTestLayer?.() mapView.addContoursTestLayer?.()
} else { } else {
mapView.removeContoursTestLayer?.() mapView.removeContoursTestLayer?.()
} }
} }
if (map.isStyleLoaded()) { if (map.isStyleLoaded()) {
apply() apply()
} else { } else {
map.once('style.load', apply) map.once('style.load', apply)
} }
savePrefs({ hillshade, traffic, publicLands, contours, contoursTest, contoursTest10ft, usfsTrails, blmTrails }) savePrefs({ hillshade, traffic, publicLands, contours, contoursTest, contoursTest10ft, usfsTrails, blmTrails })
return () => map.off('style.load', apply) return () => map.off('style.load', apply)
}, [contoursTest, mapRef]) }, [contoursTest, mapRef])
// Apply contoursTest10ft layer // Apply contoursTest10ft layer
useEffect(() => { useEffect(() => {
const map = mapRef.current?.getMap?.() const map = mapRef.current?.getMap?.()
if (!map) return if (!map) return
const apply = () => { const apply = () => {
if (contoursTest10ft && hasFeature('has_contours_test_10ft')) { if (contoursTest10ft && hasFeature('has_contours_test_10ft')) {
mapRef.current?.addContoursTest10ftLayer?.() mapRef.current?.addContoursTest10ftLayer?.()
} else { } else {
mapRef.current?.removeContoursTest10ftLayer?.() mapRef.current?.removeContoursTest10ftLayer?.()
} }
} }
if (map.isStyleLoaded()) { if (map.isStyleLoaded()) {
apply() apply()
} else { } else {
map.once('style.load', apply) map.once('style.load', apply)
} }
}, [contoursTest10ft, mapRef]) }, [contoursTest10ft, mapRef])
// Apply usfsTrails layer // Apply usfsTrails layer
useEffect(() => { useEffect(() => {
const map = mapRef.current?.getMap?.() const map = mapRef.current?.getMap?.()
if (!map) return if (!map) return
const apply = () => { const apply = () => {
if (usfsTrails && hasFeature('has_usfs_trails')) { if (usfsTrails && hasFeature('has_usfs_trails')) {
mapRef.current?.addUsfsTrailsLayer?.() mapRef.current?.addUsfsTrailsLayer?.()
} else { } else {
mapRef.current?.removeUsfsTrailsLayer?.() mapRef.current?.removeUsfsTrailsLayer?.()
} }
} }
if (map.isStyleLoaded()) { if (map.isStyleLoaded()) {
apply() apply()
} else { } else {
map.once('style.load', apply) map.once('style.load', apply)
} }
savePrefs({ hillshade, traffic, publicLands, contours, contoursTest, contoursTest10ft, usfsTrails, blmTrails }) savePrefs({ hillshade, traffic, publicLands, contours, contoursTest, contoursTest10ft, usfsTrails, blmTrails })
}, [usfsTrails, mapRef]) }, [usfsTrails, mapRef])
// Apply blmTrails layer // Apply blmTrails layer
useEffect(() => { useEffect(() => {
@ -238,129 +243,176 @@ export default function LayerControl({ mapRef }) {
} }
savePrefs({ hillshade, traffic, publicLands, contours, contoursTest, contoursTest10ft, usfsTrails, blmTrails }) savePrefs({ hillshade, traffic, publicLands, contours, contoursTest, contoursTest10ft, usfsTrails, blmTrails })
}, [blmTrails, mapRef]) }, [blmTrails, mapRef])
// Close on outside click // Apply view mode changes
useEffect(() => { useEffect(() => {
if (!open) return const mapView = mapRef?.current
function handleClick(e) { if (!mapView) return
if (panelRef.current && !panelRef.current.contains(e.target)) { const map = mapView.getMap?.()
setOpen(false) if (!map) return
}
} const apply = () => {
document.addEventListener('pointerdown', handleClick) mapView.setViewMode?.(viewMode)
return () => document.removeEventListener('pointerdown', handleClick) }
}, [open])
if (map.isStyleLoaded()) {
const showHillshade = hasFeature('has_hillshade') apply()
const showTraffic = hasFeature('has_traffic_overlay') } else {
const showPublicLands = hasFeature('has_public_lands_layer') map.once('style.load', apply)
const showContours = hasFeature('has_contours') }
const showContoursTest = hasFeature('has_contours_test') return () => map.off('style.load', apply)
const showContoursTest10ft = hasFeature('has_contours_test_10ft') }, [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 showUsfsTrails = hasFeature('has_usfs_trails')
const showBlmTrails = hasFeature('has_blm_trails') const showBlmTrails = hasFeature('has_blm_trails')
// Don't render if no overlay features available // Don't render if no overlay features available
if (!showHillshade && !showTraffic && !showPublicLands && !showContours && !showContoursTest && !showContoursTest10ft && !showUsfsTrails && !showBlmTrails) return null if (!showHillshade && !showTraffic && !showPublicLands && !showContours && !showContoursTest && !showContoursTest10ft && !showUsfsTrails && !showBlmTrails) return null
return ( return (
<div ref={panelRef} className="layer-control"> <div ref={panelRef} className="layer-control">
<button <button
className="layer-control-btn" className="layer-control-btn"
onClick={() => setOpen((v) => !v)} onClick={() => setOpen((v) => !v)}
title="Map layers" title="Map layers"
aria-label="Toggle map layers" aria-label="Toggle map layers"
> >
<Layers size={20} /> <Layers size={20} />
</button> </button>
{open && ( {open && (
<div className="layer-control-popover"> <div className="layer-control-popover">
<div className="layer-control-header">Layers</div> {/* View mode segmented control */}
<div className="view-mode-control">
{showHillshade && ( <button
<label className="layer-control-item"> className={`view-mode-btn ${viewMode === 'map' ? 'active' : ''}`}
<span className="layer-control-label">Hillshade</span> onClick={() => setViewMode('map')}
<input title="Map view"
type="checkbox" >
className="layer-control-toggle" <Map size={14} />
checked={hillshade} <span>Map</span>
onChange={(e) => setHillshade(e.target.checked)} </button>
/> <button
</label> className={`view-mode-btn ${viewMode === 'satellite' ? 'active' : ''}`}
)} onClick={() => setViewMode('satellite')}
title="Satellite view"
{showTraffic && ( >
<label className="layer-control-item"> <Satellite size={14} />
<span className="layer-control-label">Traffic</span> <span>Satellite</span>
<input </button>
type="checkbox" <button
className="layer-control-toggle" className={`view-mode-btn ${viewMode === 'hybrid' ? 'active' : ''}`}
checked={traffic} onClick={() => setViewMode('hybrid')}
onChange={(e) => setTraffic(e.target.checked)} title="Hybrid view"
/> >
</label> <Globe size={14} />
)} <span>Hybrid</span>
</button>
{showPublicLands && ( </div>
<label className="layer-control-item">
<span className="layer-control-label">Public Lands</span> <div className="layer-control-header">Layers</div>
<input
type="checkbox" {showHillshade && (
className="layer-control-toggle" <label className="layer-control-item">
checked={publicLands} <span className="layer-control-label">Hillshade</span>
onChange={(e) => setPublicLands(e.target.checked)} <input
/> type="checkbox"
</label> className="layer-control-toggle"
)} checked={hillshade}
onChange={(e) => setHillshade(e.target.checked)}
{showContours && ( />
<label className="layer-control-item"> </label>
<span className="layer-control-label">Contours</span> )}
<input
type="checkbox" {showTraffic && (
className="layer-control-toggle" <label className="layer-control-item">
checked={contours} <span className="layer-control-label">Traffic</span>
onChange={(e) => setContours(e.target.checked)} <input
/> type="checkbox"
</label> className="layer-control-toggle"
)} checked={traffic}
onChange={(e) => setTraffic(e.target.checked)}
{showContoursTest && ( />
<label className="layer-control-item"> </label>
<span className="layer-control-label">Contours (Test)</span> )}
<input
type="checkbox" {showPublicLands && (
className="layer-control-toggle" <label className="layer-control-item">
checked={contoursTest} <span className="layer-control-label">Public Lands</span>
onChange={(e) => setContoursTest(e.target.checked)} <input
/> type="checkbox"
</label> className="layer-control-toggle"
)} checked={publicLands}
onChange={(e) => setPublicLands(e.target.checked)}
{showContoursTest10ft && ( />
<label className="layer-control-item"> </label>
<span className="layer-control-label">Contours (Test 10ft)</span> )}
<input
type="checkbox" {showContours && (
className="layer-control-toggle" <label className="layer-control-item">
checked={contoursTest10ft} <span className="layer-control-label">Contours</span>
onChange={(e) => setContoursTest10ft(e.target.checked)} <input
/> type="checkbox"
</label> className="layer-control-toggle"
)} checked={contours}
onChange={(e) => setContours(e.target.checked)}
{showUsfsTrails && ( />
<label className="layer-control-item"> </label>
<span className="layer-control-label">USFS Trails</span> )}
<input
type="checkbox" {showContoursTest && (
className="layer-control-toggle" <label className="layer-control-item">
checked={usfsTrails} <span className="layer-control-label">Contours (Test)</span>
onChange={(e) => setUsfsTrails(e.target.checked)} <input
/> type="checkbox"
</label> className="layer-control-toggle"
)} checked={contoursTest}
onChange={(e) => setContoursTest(e.target.checked)}
/>
</label>
)}
{showContoursTest10ft && (
<label className="layer-control-item">
<span className="layer-control-label">Contours (Test 10ft)</span>
<input
type="checkbox"
className="layer-control-toggle"
checked={contoursTest10ft}
onChange={(e) => setContoursTest10ft(e.target.checked)}
/>
</label>
)}
{showUsfsTrails && (
<label className="layer-control-item">
<span className="layer-control-label">USFS Trails</span>
<input
type="checkbox"
className="layer-control-toggle"
checked={usfsTrails}
onChange={(e) => setUsfsTrails(e.target.checked)}
/>
</label>
)}
{showBlmTrails && ( {showBlmTrails && (
<label className="layer-control-item"> <label className="layer-control-item">
@ -373,8 +425,8 @@ export default function LayerControl({ mapRef }) {
/> />
</label> </label>
)} )}
</div> </div>
)} )}
</div> </div>
) )
} }

View file

@ -65,6 +65,8 @@ const BLM_ROUTES_SNOW = 'blm-routes-snow'
const BLM_ROUTES_OTHER = 'blm-routes-other' const BLM_ROUTES_OTHER = 'blm-routes-other'
const BLM_ROUTES_LABEL = 'blm-routes-label' const BLM_ROUTES_LABEL = 'blm-routes-label'
const BLM_ROUTES_HIT = 'blm-routes-hit' 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 // 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) */ /** Add boundary polygon layers with computed accent color (MapLibre rejects CSS vars in paint) */
const BOUNDARY_FILL_LAYER = 'boundary-fill-layer' const BOUNDARY_FILL_LAYER = 'boundary-fill-layer'
@ -1780,6 +1930,26 @@ const MapView = forwardRef(function MapView(_, ref) {
activeLayersRef.current.blmTrails = false 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 // Initialize map
@ -2122,6 +2292,17 @@ const MapView = forwardRef(function MapView(_, ref) {
}) })
map.on('load', () => { 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 // Guard against double-mount in React strict mode
if (!map.getSource(ROUTE_SOURCE)) { if (!map.getSource(ROUTE_SOURCE)) {
map.addSource(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.usfsTrails) addUsfsTrails(map, currentThemeRef.current)
if (activeLayersRef.current.blmTrails) addBlmTrails(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) // Clear highlights on theme change (paint values will be re-stored on next interaction)
clearAllHighlights(map) clearAllHighlights(map)
originalPaintValues = {} originalPaintValues = {}

View file

@ -314,6 +314,39 @@ body {
box-shadow: var(--shadow-lg); 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 { .layer-control-header {
padding: 4px 12px 6px; padding: 4px 12px 6px;
font-size: var(--text-xs); font-size: var(--text-xs);

View file

@ -100,8 +100,13 @@ export const useStore = create((set, get) => ({
autocompleteOpen: false, autocompleteOpen: false,
theme: 'dark', // 'dark' | 'light' (resolved value — what's actually applied) theme: 'dark', // 'dark' | 'light' (resolved value — what's actually applied)
themeOverride: null, // null | 'dark' | 'light' (manual override, persisted) themeOverride: null, // null | 'dark' | 'light' (manual override, persisted)
viewMode: 'map', // 'map' | 'satellite' | 'hybrid'
setSheetState: (s) => set({ sheetState: s }), setSheetState: (s) => set({ sheetState: s }),
setViewMode: (mode) => {
set({ viewMode: mode })
localStorage.setItem('navi-view-mode', mode)
},
setPanelOpen: (open) => set({ panelOpen: open }), setPanelOpen: (open) => set({ panelOpen: open }),
setAutocompleteOpen: (open) => set({ autocompleteOpen: open }), setAutocompleteOpen: (open) => set({ autocompleteOpen: open }),
setTheme: (theme) => set({ theme }), setTheme: (theme) => set({ theme }),