diff --git a/src/components/LayerControl.jsx b/src/components/LayerControl.jsx index 35b2313..b62484b 100644 --- a/src/components/LayerControl.jsx +++ b/src/components/LayerControl.jsx @@ -1,306 +1,343 @@ -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) - 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') - - 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)) - } else { - // Defaults: hillshade ON if available, others OFF - setHillshade(hsAvailable) - setTraffic(false) - setPublicLands(false) - setContours(false) - setContoursTest(false) - setContoursTest10ft(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 }) - 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 }) - 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 }) - 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 }) - 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 }) - 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]) - - // 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') - - // Don't render if no overlay features available - if (!showHillshade && !showTraffic && !showPublicLands && !showContours && !showContoursTest && !showContoursTest10ft) return null - - return ( -
- - - {open && ( -
-
Layers
- - {showHillshade && ( - - )} - - {showTraffic && ( - - )} - - {showPublicLands && ( - - )} - - {showContours && ( - - )} - - {showContoursTest && ( - - )} - - {showContoursTest10ft && ( - - )} -
- )} -
- ) -} +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) + const [usfsTrails, setUsfsTrails] = 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 usfsAvailable = hasFeature('has_usfs_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)) + } 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 }) + 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 }) + 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 }) + 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 }) + 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 }) + 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 }) + }, [usfsTrails, 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') + + // Don't render if no overlay features available + if (!showHillshade && !showTraffic && !showPublicLands && !showContours && !showContoursTest && !showContoursTest10ft && !showUsfsTrails) return null + + return ( +
+ + + {open && ( +
+
Layers
+ + {showHillshade && ( + + )} + + {showTraffic && ( + + )} + + {showPublicLands && ( + + )} + + {showContours && ( + + )} + + {showContoursTest && ( + + )} + + {showContoursTest10ft && ( + + )} + + {showUsfsTrails && ( + + )} +
+ )} +
+ ) +} diff --git a/src/components/MapView.jsx b/src/components/MapView.jsx index 367207f..477d8cc 100644 --- a/src/components/MapView.jsx +++ b/src/components/MapView.jsx @@ -42,6 +42,12 @@ const CONTOUR_TEST_10FT_LABEL = 'contour-test-10ft-label' const MEASURE_SOURCE = 'measure-source' const MEASURE_LINE_LAYER = 'measure-line-layer' const MEASURE_POINT_LAYER = 'measure-point-layer' +const USFS_SOURCE = 'usfs-trails-source' +const USFS_ROADS_LAYER = 'usfs-roads-layer' +const USFS_TRAILS_LAYER = 'usfs-trails-layer' +const USFS_ROADS_LABEL = 'usfs-roads-label' +const USFS_TRAILS_LABEL = 'usfs-trails-label' + // Highlight state - use data-driven expressions to target specific features const INTERACTIVE_LABEL_LAYERS = ['pois', 'places_subplace', 'places_locality', 'places_region', 'places_country'] @@ -813,6 +819,119 @@ function removeContoursTest10ft(map) { if (map.getLayer(CONTOUR_TEST_10FT_MINOR)) map.removeLayer(CONTOUR_TEST_10FT_MINOR) if (map.getSource(CONTOUR_TEST_10FT_SOURCE)) map.removeSource(CONTOUR_TEST_10FT_SOURCE) } +/** Add USFS trails and roads vector tile overlay */ +function addUsfsTrails(map) { + if (!map || map.getSource(USFS_SOURCE)) return + + map.addSource(USFS_SOURCE, { + type: "vector", + url: "pmtiles:///tiles/usfs-trails-roads.pmtiles", + }) + + // Insert below first symbol layer (above other overlays, below labels) + let beforeId = undefined + for (const layer of map.getStyle().layers) { + if (layer.type === "symbol") { + beforeId = layer.id + break + } + } + + const isDark = document.documentElement.getAttribute("data-theme") === "dark" + const opMod = isDark ? 0.8 : 1.0 + + // Roads layer - solid khaki/tan line + map.addLayer({ + id: USFS_ROADS_LAYER, + type: "line", + source: USFS_SOURCE, + "source-layer": "roads", + minzoom: 10, + paint: { + "line-color": isDark ? "#9a8b70" : "#b8a87a", + "line-opacity": 0.65 * opMod, + "line-width": ["interpolate", ["linear"], ["zoom"], 10, 1.0, 14, 2.0, 16, 3.0], + }, + }, beforeId) + + // Trails layer - dashed earth-tone brown + map.addLayer({ + id: USFS_TRAILS_LAYER, + type: "line", + source: USFS_SOURCE, + "source-layer": "trails", + minzoom: 10, + paint: { + "line-color": isDark ? "#a88960" : "#8b7355", + "line-opacity": 0.7 * opMod, + "line-width": ["interpolate", ["linear"], ["zoom"], 10, 1.5, 14, 2.5, 16, 3.5], + "line-dasharray": [2, 1.5], + }, + }, beforeId) + + // Road labels (zoom 12+) + map.addLayer({ + id: USFS_ROADS_LABEL, + type: "symbol", + source: USFS_SOURCE, + "source-layer": "roads", + minzoom: 12, + filter: ["has", "NAME"], + layout: { + "text-field": ["get", "NAME"], + "text-size": 10, + "text-font": ["Noto Sans Regular"], + "symbol-placement": "line", + "text-anchor": "center", + "symbol-spacing": 300, + "text-max-angle": 25, + "text-allow-overlap": false, + }, + paint: { + "text-color": isDark ? "#c0b090" : "#6a5a40", + "text-halo-color": isDark ? "#1a1a1a" : "#ffffff", + "text-halo-width": 1.5, + "text-opacity": 0.85, + }, + }) + + // Trail labels (zoom 12+) + map.addLayer({ + id: USFS_TRAILS_LABEL, + type: "symbol", + source: USFS_SOURCE, + "source-layer": "trails", + minzoom: 12, + filter: ["has", "TRAIL_NAME"], + layout: { + "text-field": ["get", "TRAIL_NAME"], + "text-size": 10, + "text-font": ["Noto Sans Regular"], + "symbol-placement": "line", + "text-anchor": "center", + "symbol-spacing": 300, + "text-max-angle": 25, + "text-allow-overlap": false, + }, + paint: { + "text-color": isDark ? "#c8a878" : "#5a4530", + "text-halo-color": isDark ? "#1a1a1a" : "#ffffff", + "text-halo-width": 1.5, + "text-opacity": 0.85, + }, + }) +} + +/** Remove USFS trails/roads layers and source */ +function removeUsfsTrails(map) { + if (!map) return + if (map.getLayer(USFS_TRAILS_LABEL)) map.removeLayer(USFS_TRAILS_LABEL) + if (map.getLayer(USFS_ROADS_LABEL)) map.removeLayer(USFS_ROADS_LABEL) + if (map.getLayer(USFS_TRAILS_LAYER)) map.removeLayer(USFS_TRAILS_LAYER) + if (map.getLayer(USFS_ROADS_LAYER)) map.removeLayer(USFS_ROADS_LAYER) + if (map.getSource(USFS_SOURCE)) map.removeSource(USFS_SOURCE) +} + /** Add boundary polygon layers with computed accent color (MapLibre rejects CSS vars in paint) */ const BOUNDARY_FILL_LAYER = 'boundary-fill-layer' @@ -871,7 +990,7 @@ const MapView = forwardRef(function MapView(_, ref) { 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, contours: false, contoursTest: false, contoursTest10ft: false }) + const activeLayersRef = useRef({ hillshade: false, traffic: false, contours: false, contoursTest: false, contoursTest10ft: false, usfsTrails: false }) // Flag to suppress map-click when a stop pin was clicked const pinClickedRef = useRef(false) const highlightedFeatureRef = useRef(null) // { source, sourceLayer, id } for setFeatureState @@ -1317,6 +1436,19 @@ const MapView = forwardRef(function MapView(_, ref) { removeContoursTest10ft(map) activeLayersRef.current.contoursTest10ft = false }, + addUsfsTrailsLayer() { + const map = mapInstance.current + if (!map) return + addUsfsTrails(map) + activeLayersRef.current.usfsTrails = true + }, + removeUsfsTrailsLayer() { + const map = mapInstance.current + if (!map) return + removeUsfsTrails(map) + activeLayersRef.current.usfsTrails = false + }, + })) // Initialize map @@ -1446,6 +1578,55 @@ const MapView = forwardRef(function MapView(_, ref) { const { lng, lat } = e.lngLat const MARKER_RADIUS_PX = 14 // half of 28px preview marker + // Check for USFS trails/roads click (show info popup) + const usfsLayers = [USFS_TRAILS_LAYER, USFS_ROADS_LAYER] + const usfsFeatures = map.queryRenderedFeatures(e.point, { layers: usfsLayers }) + const usfsFeature = usfsFeatures.find(f => f.properties) + if (usfsFeature && hasFeature('has_usfs_trails')) { + const props = usfsFeature.properties + const isTrail = usfsFeature.layer?.id === USFS_TRAILS_LAYER + const name = isTrail ? (props.TRAIL_NAME || 'Unnamed Trail') : (props.NAME || 'Unnamed Road') + const typeLabel = isTrail ? 'USFS Trail' : 'USFS Road' + + // Build popup content + let html = '
' + html += '' + name + '' + html += '
' + typeLabel + '
' + + if (isTrail) { + // Trail-specific info + if (props.TRAIL_TYPE) html += '
Type: ' + props.TRAIL_TYPE + '
' + if (props.TRAIL_SURF) html += '
Surface: ' + props.TRAIL_SURF + '
' + if (props.GIS_MILES) html += '
Length: ' + parseFloat(props.GIS_MILES).toFixed(1) + ' mi
' + // Allowed uses + const uses = [] + if (props.HIKER_PEDE === 'Y') uses.push('Hiking') + if (props.BICYCLE_MA === 'Y') uses.push('Biking') + if (props.MOTORCYCLE === 'Y') uses.push('Motorcycle') + if (props.ATV_MANAGE === 'Y') uses.push('ATV') + if (props.HORSE_MANA === 'Y') uses.push('Horse') + if (uses.length > 0) html += '
Allowed: ' + uses.join(', ') + '
' + } else { + // Road-specific info + if (props.OPER_MAINT) html += '
Maintenance: ' + props.OPER_MAINT + '
' + if (props.SURFACE_TY) html += '
Surface: ' + props.SURFACE_TY + '
' + if (props.ROUTE_STAT) html += '
Status: ' + props.ROUTE_STAT + '
' + } + html += '
' + + // Remove existing popup + if (popupRef.current) popupRef.current.remove() + + const popup = new maplibregl.Popup({ offset: 10, closeButton: true }) + .setLngLat([lng, lat]) + .setHTML(html) + .addTo(map) + popupRef.current = popup + return + } + + + // Query rendered features at click point (label/POI priority) const labelLayers = ['pois', 'places_subplace', 'places_locality', 'places_region', 'places_country'] const features = map.queryRenderedFeatures(e.point, { layers: labelLayers }) diff --git a/src/config.js b/src/config.js index 37ec84e..3bd129c 100644 --- a/src/config.js +++ b/src/config.js @@ -33,6 +33,7 @@ const FALLBACK_CONFIG = { has_contours_test: true, has_contours_test_10ft: false, has_address_book_write: false, + has_usfs_trails: false, has_contacts: false, }, defaults: {