mirror of
https://github.com/zvx-echo6/navi.git
synced 2026-05-20 14:44:51 +02:00
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:
parent
5299376fec
commit
e786bb8870
4 changed files with 623 additions and 343 deletions
|
|
@ -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 (
|
||||
<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={20} />
|
||||
</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>
|
||||
)}
|
||||
|
||||
{showPublicLands && (
|
||||
<label className="layer-control-item">
|
||||
<span className="layer-control-label">Public Lands</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="layer-control-toggle"
|
||||
checked={publicLands}
|
||||
onChange={(e) => setPublicLands(e.target.checked)}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
|
||||
{showContours && (
|
||||
<label className="layer-control-item">
|
||||
<span className="layer-control-label">Contours</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="layer-control-toggle"
|
||||
checked={contours}
|
||||
onChange={(e) => setContours(e.target.checked)}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
|
||||
{showContoursTest && (
|
||||
<label className="layer-control-item">
|
||||
<span className="layer-control-label">Contours (Test)</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
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>
|
||||
)}
|
||||
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 (
|
||||
<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={20} />
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="layer-control-popover">
|
||||
{/* View mode segmented control */}
|
||||
<div className="view-mode-control">
|
||||
<button
|
||||
className={`view-mode-btn ${viewMode === 'map' ? 'active' : ''}`}
|
||||
onClick={() => setViewMode('map')}
|
||||
title="Map view"
|
||||
>
|
||||
<Map size={14} />
|
||||
<span>Map</span>
|
||||
</button>
|
||||
<button
|
||||
className={`view-mode-btn ${viewMode === 'satellite' ? 'active' : ''}`}
|
||||
onClick={() => setViewMode('satellite')}
|
||||
title="Satellite view"
|
||||
>
|
||||
<Satellite size={14} />
|
||||
<span>Satellite</span>
|
||||
</button>
|
||||
<button
|
||||
className={`view-mode-btn ${viewMode === 'hybrid' ? 'active' : ''}`}
|
||||
onClick={() => setViewMode('hybrid')}
|
||||
title="Hybrid view"
|
||||
>
|
||||
<Globe size={14} />
|
||||
<span>Hybrid</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
)}
|
||||
|
||||
{showPublicLands && (
|
||||
<label className="layer-control-item">
|
||||
<span className="layer-control-label">Public Lands</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="layer-control-toggle"
|
||||
checked={publicLands}
|
||||
onChange={(e) => setPublicLands(e.target.checked)}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
|
||||
{showContours && (
|
||||
<label className="layer-control-item">
|
||||
<span className="layer-control-label">Contours</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="layer-control-toggle"
|
||||
checked={contours}
|
||||
onChange={(e) => setContours(e.target.checked)}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
|
||||
{showContoursTest && (
|
||||
<label className="layer-control-item">
|
||||
<span className="layer-control-label">Contours (Test)</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
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 && (
|
||||
<label className="layer-control-item">
|
||||
|
|
@ -373,8 +425,8 @@ export default function LayerControl({ mapRef }) {
|
|||
/>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = {}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue