diff --git a/src/components/LayerControl.jsx b/src/components/LayerControl.jsx index b62484b..cf2e34e 100644 --- a/src/components/LayerControl.jsx +++ b/src/components/LayerControl.jsx @@ -24,7 +24,8 @@ export default function LayerControl({ mapRef }) { const [contours, setContours] = 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 panelRef = useRef(null) // Initialize from localStorage or defaults on mount @@ -36,7 +37,8 @@ export default function LayerControl({ mapRef }) { 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') if (saved) { setHillshade(hsAvailable && (saved.hillshade ?? true)) @@ -45,7 +47,8 @@ export default function LayerControl({ mapRef }) { setContours(ctAvailable && (saved.contours ?? false)) setContoursTest(ctTestAvailable && (saved.contoursTest ?? false)) setContoursTest10ft(ctTest10ftAvailable && (saved.contoursTest10ft ?? false)) - setUsfsTrails(usfsAvailable && (saved.usfsTrails ?? false)) + setUsfsTrails(usfsAvailable && (saved.usfsTrails ?? false)) + setBlmTrails(blmAvailable && (saved.blmTrails ?? false)) } else { // Defaults: hillshade ON if available, others OFF setHillshade(hsAvailable) @@ -78,7 +81,7 @@ export default function LayerControl({ mapRef }) { } else { map.once('style.load', apply) } - savePrefs({ hillshade, traffic, publicLands, contours, contoursTest, contoursTest10ft, usfsTrails }) + savePrefs({ hillshade, traffic, publicLands, contours, contoursTest, contoursTest10ft, usfsTrails, blmTrails }) return () => map.off('style.load', apply) }, [hillshade, mapRef]) @@ -101,7 +104,7 @@ export default function LayerControl({ mapRef }) { } else { map.once('style.load', apply) } - savePrefs({ hillshade, traffic, publicLands, contours, contoursTest, contoursTest10ft, usfsTrails }) + savePrefs({ hillshade, traffic, publicLands, contours, contoursTest, contoursTest10ft, usfsTrails, blmTrails }) return () => map.off('style.load', apply) }, [traffic, mapRef]) @@ -124,7 +127,7 @@ export default function LayerControl({ mapRef }) { } else { map.once('style.load', apply) } - savePrefs({ hillshade, traffic, publicLands, contours, contoursTest, contoursTest10ft, usfsTrails }) + savePrefs({ hillshade, traffic, publicLands, contours, contoursTest, contoursTest10ft, usfsTrails, blmTrails }) return () => map.off('style.load', apply) }, [publicLands, mapRef]) @@ -147,7 +150,7 @@ export default function LayerControl({ mapRef }) { } else { map.once('style.load', apply) } - savePrefs({ hillshade, traffic, publicLands, contours, contoursTest, contoursTest10ft, usfsTrails }) + savePrefs({ hillshade, traffic, publicLands, contours, contoursTest, contoursTest10ft, usfsTrails, blmTrails }) return () => map.off('style.load', apply) }, [contours, mapRef]) @@ -170,7 +173,7 @@ export default function LayerControl({ mapRef }) { } else { map.once('style.load', apply) } - savePrefs({ hillshade, traffic, publicLands, contours, contoursTest, contoursTest10ft, usfsTrails }) + savePrefs({ hillshade, traffic, publicLands, contours, contoursTest, contoursTest10ft, usfsTrails, blmTrails }) return () => map.off('style.load', apply) }, [contoursTest, mapRef]) @@ -212,8 +215,29 @@ export default function LayerControl({ mapRef }) { } else { map.once('style.load', apply) } - savePrefs({ hillshade, traffic, publicLands, contours, contoursTest, contoursTest10ft, usfsTrails }) + savePrefs({ hillshade, traffic, publicLands, contours, contoursTest, contoursTest10ft, usfsTrails, blmTrails }) }, [usfsTrails, mapRef]) + + // Apply blmTrails layer + useEffect(() => { + const map = mapRef.current?.getMap?.() + if (!map) return + + const apply = () => { + if (blmTrails && hasFeature("has_blm_trails")) { + mapRef.current?.addBlmTrailsLayer?.() + } else { + mapRef.current?.removeBlmTrailsLayer?.() + } + } + + if (map.isStyleLoaded()) { + apply() + } else { + map.once("style.load", apply) + } + savePrefs({ hillshade, traffic, publicLands, contours, contoursTest, contoursTest10ft, usfsTrails, blmTrails }) + }, [blmTrails, mapRef]) // Close on outside click useEffect(() => { @@ -233,10 +257,11 @@ export default function LayerControl({ mapRef }) { 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') // Don't render if no overlay features available - if (!showHillshade && !showTraffic && !showPublicLands && !showContours && !showContoursTest && !showContoursTest10ft && !showUsfsTrails) return null + if (!showHillshade && !showTraffic && !showPublicLands && !showContours && !showContoursTest && !showContoursTest10ft && !showUsfsTrails && !showBlmTrails) return null return (
@@ -336,6 +361,18 @@ export default function LayerControl({ mapRef }) { /> )} + + {showBlmTrails && ( + + )}
)} diff --git a/src/components/MapView.jsx b/src/components/MapView.jsx index 4f2e9bc..42b51b9 100644 --- a/src/components/MapView.jsx +++ b/src/components/MapView.jsx @@ -49,6 +49,10 @@ const USFS_ROADS_LABEL = 'usfs-roads-label' const USFS_TRAILS_LABEL = 'usfs-trails-label' const USFS_ROADS_HIT = 'usfs-roads-hit' const USFS_TRAILS_HIT = 'usfs-trails-hit' +const BLM_SOURCE = 'blm-trails-source' +const BLM_ROUTES_LAYER = 'blm-routes-layer' +const BLM_ROUTES_LABEL = 'blm-routes-label' +const BLM_ROUTES_HIT = 'blm-routes-hit' // Highlight state - use data-driven expressions to target specific features @@ -974,6 +978,100 @@ function removeUsfsTrails(map) { if (map.getLayer(USFS_ROADS_HIT)) map.removeLayer(USFS_ROADS_HIT) if (map.getSource(USFS_SOURCE)) map.removeSource(USFS_SOURCE) } +/** Add BLM trails/roads vector tile overlay */ +function addBlmTrails(map) { + if (!map || map.getSource(BLM_SOURCE)) return + + map.addSource(BLM_SOURCE, { + type: "vector", + url: "pmtiles:///tiles/blm-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 + + // Invisible hit-area layer for easier clicking (wide transparent line) + map.addLayer({ + id: BLM_ROUTES_HIT, + type: "line", + source: BLM_SOURCE, + "source-layer": "blm_routes", + minzoom: 10, + paint: { + "line-color": "#000000", + "line-opacity": 0, + "line-width": 14, + }, + }, beforeId) + + // Routes layer - dashed line, muted olive/sage color + map.addLayer({ + id: BLM_ROUTES_LAYER, + type: "line", + source: BLM_SOURCE, + "source-layer": "blm_routes", + minzoom: 10, + paint: { + "line-color": isDark ? "#8a9a70" : "#6b7a55", + "line-opacity": 0.7 * opMod, + "line-width": ["interpolate", ["linear"], ["zoom"], 10, 1.5, 14, 2.5, 16, 3.5], + "line-dasharray": [3, 2], + }, + }, beforeId) + + // Route labels (zoom 12+) + map.addLayer({ + id: BLM_ROUTES_LABEL, + type: "symbol", + source: BLM_SOURCE, + "source-layer": "blm_routes", + minzoom: 12, + filter: ["has", "ROUTE_PRMRY_NM"], + layout: { + "text-field": ["get", "ROUTE_PRMRY_NM"], + "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 ? "#a0b090" : "#4a5a40", + "text-halo-color": isDark ? "#1a1a1a" : "#ffffff", + "text-halo-width": 1.5, + "text-opacity": 0.85, + }, + }) + + // Cursor pointer on hover for hit layer + map.on("mouseenter", BLM_ROUTES_HIT, () => { + map.getCanvas().style.cursor = "pointer" + }) + map.on("mouseleave", BLM_ROUTES_HIT, () => { + map.getCanvas().style.cursor = "" + }) +} + +/** Remove BLM trails/roads layers and source */ +function removeBlmTrails(map) { + if (!map) return + if (map.getLayer(BLM_ROUTES_LABEL)) map.removeLayer(BLM_ROUTES_LABEL) + if (map.getLayer(BLM_ROUTES_LAYER)) map.removeLayer(BLM_ROUTES_LAYER) + if (map.getLayer(BLM_ROUTES_HIT)) map.removeLayer(BLM_ROUTES_HIT) + if (map.getSource(BLM_SOURCE)) map.removeSource(BLM_SOURCE) +} + /** Add boundary polygon layers with computed accent color (MapLibre rejects CSS vars in paint) */ const BOUNDARY_FILL_LAYER = 'boundary-fill-layer' @@ -1033,7 +1131,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, usfsTrails: false }) + const activeLayersRef = useRef({ hillshade: false, traffic: false, contours: false, contoursTest: false, contoursTest10ft: false, usfsTrails: false, blmTrails: 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 @@ -1491,6 +1589,18 @@ const MapView = forwardRef(function MapView(_, ref) { removeUsfsTrails(map) activeLayersRef.current.usfsTrails = false }, + addBlmTrailsLayer() { + const map = mapInstance.current + if (!map) return + addBlmTrails(map) + activeLayersRef.current.blmTrails = true + }, + removeBlmTrailsLayer() { + const map = mapInstance.current + if (!map) return + removeBlmTrails(map) + activeLayersRef.current.blmTrails = false + }, })) @@ -1668,6 +1778,37 @@ const MapView = forwardRef(function MapView(_, ref) { return } + // Check for BLM routes click (show info popup) + const blmLayers = [BLM_ROUTES_HIT] + const blmFeatures = map.queryRenderedFeatures(e.point, { layers: blmLayers }) + const blmFeature = blmFeatures.find(f => f.properties) + if (blmFeature && hasFeature("has_blm_trails")) { + const props = blmFeature.properties + const name = props.ROUTE_PRMRY_NM || "Unnamed Route" + + // Build popup content + let html = "
" + html += "" + name + "" + html += "
BLM Route
" + + // Route info - handle potential field name variations across states + if (props.PLAN_ASSET_CLASS) html += "
Asset Class: " + props.PLAN_ASSET_CLASS + "
" + if (props.PLAN_MODE_TRNSPRT) html += "
Transport: " + props.PLAN_MODE_TRNSPRT + "
" + if (props.OBSRVE_SRFCE_TYPE) html += "
Surface: " + props.OBSRVE_SRFCE_TYPE + "
" + if (props.GIS_MILES) html += "
Length: " + parseFloat(props.GIS_MILES).toFixed(1) + " mi
" + 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) diff --git a/src/config.js b/src/config.js index 3bd129c..274edba 100644 --- a/src/config.js +++ b/src/config.js @@ -34,6 +34,7 @@ const FALLBACK_CONFIG = { has_contours_test_10ft: false, has_address_book_write: false, has_usfs_trails: false, + has_blm_trails: false, has_contacts: false, }, defaults: {