From bad5aeb262e1082029d1218da87fa2628097146f Mon Sep 17 00:00:00 2001 From: ztocode Date: Mon, 9 Feb 2026 13:01:05 -0500 Subject: [PATCH 1/5] add major regional trails layer and update metrics/add open space vector tiles layer --- src/App.js | 20 +- src/components/BufferAnalysisWindow/index.js | 4 +- .../ControlPanel/ProjectTrailsProfile.js | 66 - .../ControlPanel/RegionalTrailsProfile.js | 134 ++ src/components/ControlPanel/index.js | 164 +- src/components/Map/CommunityIdentify.js | 2 +- src/components/Map/CommunityTrailsProfile.js | 384 ++-- src/components/Map/OriginalTrailsMap.js | 384 ++-- src/components/Map/ProjectMetricsPanel.js | 745 ++++++-- src/components/Map/ProjectMetricsPanel.scss | 35 +- src/components/Map/ProjectTrailsProfile.js | 766 -------- src/components/Map/RegionalTrailsProfile.js | 1668 +++++++++++++++++ src/components/Map/index.js | 20 +- .../Map/layers/EnvironmentalJusticeLayer.js | 8 +- src/components/Map/layers/MajorTrailsLayer.js | 281 +++ src/components/Map/layers/OpenSpaceLayer.js | 177 +- .../Map/layers/TrailsRegNameSyncLayer.js | 731 +++++--- src/styles/ControlPanel.scss | 18 +- src/styles/Header.scss | 6 +- src/styles/Map.scss | 10 +- src/styles/Popup.scss | 3 + webpack.config.js | 9 +- 22 files changed, 3930 insertions(+), 1705 deletions(-) delete mode 100644 src/components/ControlPanel/ProjectTrailsProfile.js create mode 100644 src/components/ControlPanel/RegionalTrailsProfile.js delete mode 100644 src/components/Map/ProjectTrailsProfile.js create mode 100644 src/components/Map/RegionalTrailsProfile.js create mode 100644 src/components/Map/layers/MajorTrailsLayer.js diff --git a/src/App.js b/src/App.js index b74f1bf..d496e8a 100644 --- a/src/App.js +++ b/src/App.js @@ -19,14 +19,14 @@ const App = () => { const proposedTrails = LayerData.proposed; const landlines = LayerData.landline; - // Don't show intro modal if user navigates directly to communityTrailsProfile + // Don't show intro modal if user navigates directly to communityTrailsProfile or regionalTrailsProfile const [showIntroModal, toggleIntroModal] = useState( - location.pathname !== '/communityTrailsProfile' + location.pathname !== '/communityTrailsProfile' && location.pathname !== '/regionalTrailsProfile' ); // Update intro modal visibility when path changes useEffect(() => { - if (location.pathname === '/communityTrailsProfile' && showIntroModal) { + if ((location.pathname === '/communityTrailsProfile' || location.pathname === '/regionalTrailsProfile') && showIntroModal) { toggleIntroModal(false); } }, [location.pathname]); @@ -52,8 +52,8 @@ const App = () => { const [municipalityTrails, setMunicipalityTrails] = useState([]); const [showMunicipalityView, setShowMunicipalityView] = useState(false); const [showMunicipalityProfileMap, setShowMunicipalityProfileMap] = useState(false); - const [showProjectTrailsView, setShowProjectTrailsView] = useState(false); - const [showProjectTrailsProfileMap, setShowProjectTrailsProfileMap] = useState(false); + const [showRegionalTrailsView, setShowRegionalTrailsView] = useState(false); + const [showRegionalTrailsProfileMap, setShowRegionalTrailsProfileMap] = useState(false); // Layer toggle states for municipality profile const [showCommuterRail, setShowCommuterRail] = useState(false); @@ -66,7 +66,7 @@ const App = () => { const [showTrailsRegNameSync, setShowTrailsRegNameSync] = useState(false); const [showTransitLandStops, setShowTransitLandStops] = useState(false); - // Project trails profile state + // Regional trails profile state const [projectRegNames, setProjectRegNames] = useState([]); const [selectedProjectRegName, setSelectedProjectRegName] = useState(null); const [projectColorPalette, setProjectColorPalette] = useState({}); @@ -123,10 +123,10 @@ const App = () => { setShowMunicipalityView, showMunicipalityProfileMap, setShowMunicipalityProfileMap, - showProjectTrailsView, - setShowProjectTrailsView, - showProjectTrailsProfileMap, - setShowProjectTrailsProfileMap, + showRegionalTrailsView, + setShowRegionalTrailsView, + showRegionalTrailsProfileMap, + setShowRegionalTrailsProfileMap, // Layer toggle states showCommuterRail, setShowCommuterRail, diff --git a/src/components/BufferAnalysisWindow/index.js b/src/components/BufferAnalysisWindow/index.js index 0ef863f..4479b29 100644 --- a/src/components/BufferAnalysisWindow/index.js +++ b/src/components/BufferAnalysisWindow/index.js @@ -303,7 +303,7 @@ const BufferAnalysisWindow = ({
-
Subway Stations
+
T-stops
{bufferResults.subwayStations ? bufferResults.subwayStations.length : 0}
@@ -522,7 +522,7 @@ const BufferAnalysisWindow = ({ className="me-2" style={{ transform: 'scale(0.8)' }} /> - MBTA Subway Stations ({bufferResults.subwayStations ? bufferResults.subwayStations.length : 0}) + T-stops ({bufferResults.subwayStations ? bufferResults.subwayStations.length : 0})
{!showSubwayStations ? ( diff --git a/src/components/ControlPanel/ProjectTrailsProfile.js b/src/components/ControlPanel/ProjectTrailsProfile.js deleted file mode 100644 index b456d4e..0000000 --- a/src/components/ControlPanel/ProjectTrailsProfile.js +++ /dev/null @@ -1,66 +0,0 @@ -import React, { useState, useEffect, useContext } from "react"; -import Form from "react-bootstrap/Form"; -import Button from "react-bootstrap/Button"; -import { LayerContext } from "../../App"; - -const ProjectTrailsProfile = ({ - regNames = [], - selectedRegNames = new Set(), - onToggleRegName -}) => { - const { basemaps } = useContext(LayerContext); - - const handleProjectToggle = (regName) => { - if (onToggleRegName) { - onToggleRegName(regName); - } - }; - - return ( -
- {/* Project List */} - {regNames.length === 0 ? ( -
-

Loading projects...

-
- ) : ( -
- {regNames.map((regName, index) => { - const isSelected = selectedRegNames.has(regName); - - return ( -
handleProjectToggle(regName)} - > - handleProjectToggle(regName)} - label={ - - {regName} - - } - style={{ cursor: 'pointer' }} - onClick={(e) => e.stopPropagation()} - /> -
- ); - })} -
- )} -
- ); -}; - -export default ProjectTrailsProfile; - diff --git a/src/components/ControlPanel/RegionalTrailsProfile.js b/src/components/ControlPanel/RegionalTrailsProfile.js new file mode 100644 index 0000000..07107f3 --- /dev/null +++ b/src/components/ControlPanel/RegionalTrailsProfile.js @@ -0,0 +1,134 @@ +import React, { useState, useEffect, useContext, useMemo, useRef } from "react"; +import Form from "react-bootstrap/Form"; +import Button from "react-bootstrap/Button"; +import { LayerContext } from "../../App"; + +// Major trails list - these names should match grouped_reg_name values in export_major_trails FeatureServer +const MAJOR_TRAILS = [ + "Bay Circuit", + "Northern Strand", + "Minuteman", + "Neponset River", + "Charles River Greenway", + "Bruce Freeman" +]; + +const RegionalTrailsProfile = ({ + regNames = [], + selectedRegNames = new Set(), + onToggleRegName, + selectedMajorTrails = [], + onToggleMajorTrail +}) => { + const { basemaps } = useContext(LayerContext); + + // Check if a major trail is selected (check selectedMajorTrails array) + const isMajorTrailSelected = (majorTrail) => { + return selectedMajorTrails.includes(majorTrail); + }; + + const handleProjectToggle = (regName) => { + if (onToggleRegName) { + onToggleRegName(regName); + } + }; + + const handleMajorTrailToggle = (majorTrail) => { + if (onToggleMajorTrail) { + // Use the major trail toggle handler - directly queries export_major_trails FeatureServer + onToggleMajorTrail(majorTrail); + } + }; + + return ( +
+ {regNames.length === 0 ? ( +
+

Loading projects...

+
+ ) : ( + <> + {/* Major Trails Projects List */} +
+ Major regional trails +
+ {MAJOR_TRAILS.map((majorTrail) => { + const isSelected = isMajorTrailSelected(majorTrail); + + return ( +
handleMajorTrailToggle(majorTrail)} + > + handleMajorTrailToggle(majorTrail)} + label={ + + {majorTrail} + + } + style={{ cursor: 'pointer' }} + onClick={(e) => e.stopPropagation()} + /> +
+ ); + })} +
+
+ + {/* Other Trails Project List */} + {regNames.length > 0 && ( +
+ Other regional trails +
+ {regNames.map((regName, index) => { + const isSelected = selectedRegNames.has(regName); + + return ( +
handleProjectToggle(regName)} + > + handleProjectToggle(regName)} + label={ + + {regName} + + } + style={{ cursor: 'pointer' }} + onClick={(e) => e.stopPropagation()} + /> +
+ ); + })} +
+
+ )} + + )} +
+ ); +}; + +export default RegionalTrailsProfile; diff --git a/src/components/ControlPanel/index.js b/src/components/ControlPanel/index.js index b6e7c3a..0cb309c 100644 --- a/src/components/ControlPanel/index.js +++ b/src/components/ControlPanel/index.js @@ -4,14 +4,16 @@ import Button from "react-bootstrap/Button"; import TypeButton from "./TypeButton"; import Legend from "./Legend"; import MunicipalityProfile from "./MunicipalityProfile"; -import ProjectTrailsProfile from "./ProjectTrailsProfile"; +import RegionalTrailsProfile from "./RegionalTrailsProfile"; import { ModalContext } from "../../App"; import { LayerContext } from "../../App"; import { useNavigate, useLocation } from "react-router-dom"; const ControlPanel = ({ selectedRegNames = null, - onToggleRegName = null + onToggleRegName = null, + selectedMajorTrails = [], + onToggleMajorTrail = null }) => { const navigate = useNavigate(); const location = useLocation(); @@ -38,10 +40,10 @@ const ControlPanel = ({ setShowMunicipalityView, showMunicipalityProfileMap, setShowMunicipalityProfileMap, - showProjectTrailsView, - setShowProjectTrailsView, - showProjectTrailsProfileMap, - setShowProjectTrailsProfileMap, + showRegionalTrailsView, + setShowRegionalTrailsView, + showRegionalTrailsProfileMap, + setShowRegionalTrailsProfileMap, projectRegNames, setProjectRegNames, selectedProjectRegName, @@ -95,37 +97,70 @@ const ControlPanel = ({ if ((sharedView === 'municipality' || currentPath === '/communityTrailsProfile') && !showMunicipalityView) { // Automatically switch to municipality view - setSavedTrailLayers([...trailLayers]); - setSavedProposedLayers([...proposedLayers]); - setTrailLayers([]); - setProposedLayers([]); + // Only save trail layers if we're coming from the original map view (not from another profile) + if (!showRegionalTrailsView) { + setSavedTrailLayers([...trailLayers]); + setSavedProposedLayers([...proposedLayers]); + setTrailLayers([]); + setProposedLayers([]); + } if (showMaHouseDistricts) toggleMaHouseDistricts(false); if (showMaSenateDistricts) toggleMaSenateDistricts(false); if (showMunicipalities) toggleMunicipalities(false); + // Hide all map layers by default when switching profiles + setShowCommuterRail(false); + setShowStationLabels(false); + setShowBlueBikeStations(false); + setShowSubwayStations(false); + setShowEnvironmentalJustice(false); + setShowOpenSpace(false); + setShowLandlinesFeatureService(false); + setShowTrailsRegNameSync(false); + setShowTransitLandStops(false); setShowMunicipalityProfileMap(true); setSelectedMunicipality(null); setShowMunicipalityView(true); - } else if (currentPath === '/projectTrailsProfile' && !showProjectTrailsView) { - // Automatically switch to project trails view - setSavedTrailLayers([...trailLayers]); - setSavedProposedLayers([...proposedLayers]); - setTrailLayers([]); - setProposedLayers([]); + // Disable regional trails profile when switching to community profile + setShowRegionalTrailsProfileMap(false); + setShowRegionalTrailsView(false); + } else if (currentPath === '/regionalTrailsProfile' && !showRegionalTrailsView) { + // Automatically switch to regional trails view + // Only save trail layers if we're coming from the original map view (not from another profile) + if (!showMunicipalityView) { + setSavedTrailLayers([...trailLayers]); + setSavedProposedLayers([...proposedLayers]); + setTrailLayers([]); + setProposedLayers([]); + } if (showMaHouseDistricts) toggleMaHouseDistricts(false); if (showMaSenateDistricts) toggleMaSenateDistricts(false); - // Always turn off municipalities in project trails profile + // Always turn off municipalities in regional trails profile toggleMunicipalities(false); - setShowProjectTrailsProfileMap(true); - setShowProjectTrailsView(true); - } else if (currentPath !== '/communityTrailsProfile' && currentPath !== '/projectTrailsProfile' && (showMunicipalityView || showProjectTrailsView)) { + // Hide all map layers by default when switching profiles + setShowCommuterRail(false); + setShowStationLabels(false); + setShowBlueBikeStations(false); + setShowSubwayStations(false); + setShowEnvironmentalJustice(false); + setShowOpenSpace(false); + setShowLandlinesFeatureService(false); + setShowTrailsRegNameSync(false); + setShowTransitLandStops(false); + setShowRegionalTrailsProfileMap(true); + setShowRegionalTrailsView(true); + // Disable community profile when switching to regional profile + setShowMunicipalityProfileMap(false); + setShowMunicipalityView(false); + setSelectedMunicipality(null); + } else if (currentPath !== '/communityTrailsProfile' && currentPath !== '/regionalTrailsProfile' && (showMunicipalityView || showRegionalTrailsView)) { // If we're not on a profile path but a view is active, switch back setTrailLayers(savedTrailLayers); setProposedLayers(savedProposedLayers); setShowMunicipalityProfileMap(false); - setShowProjectTrailsProfileMap(false); + setShowRegionalTrailsProfileMap(false); setSelectedMunicipality(null); setShowMunicipalityView(false); - setShowProjectTrailsView(false); + setShowRegionalTrailsView(false); setShowCommuterRail(false); setShowStationLabels(false); setShowBlueBikeStations(false); @@ -239,10 +274,10 @@ const ControlPanel = ({ } }, [selectedMunicipality, municipalityTrails, showMunicipalityView]); - // Handle project trails view toggle - const handleProjectTrailsToggle = () => { - if (!showProjectTrailsView) { - // Switching TO project trails view + // Handle regional trails view toggle + const handleRegionalTrailsToggle = () => { + if (!showRegionalTrailsView) { + // Switching TO regional trails view isNavigatingRef.current = true; // Save current trail layers @@ -256,15 +291,15 @@ const ControlPanel = ({ // Turn off other district layers if (showMaHouseDistricts) toggleMaHouseDistricts(false); if (showMaSenateDistricts) toggleMaSenateDistricts(false); - // Always turn off municipalities in project trails profile + // Always turn off municipalities in regional trails profile toggleMunicipalities(false); - // Enable the project trails profile map - setShowProjectTrailsProfileMap(true); - setShowProjectTrailsView(true); + // Enable the regional trails profile map + setShowRegionalTrailsProfileMap(true); + setShowRegionalTrailsView(true); - // Navigate to /projectTrailsProfile - navigate('/projectTrailsProfile'); + // Navigate to /regionalTrailsProfile + navigate('/regionalTrailsProfile'); } else { // Switching BACK to trail filters isNavigatingRef.current = true; @@ -273,9 +308,9 @@ const ControlPanel = ({ setTrailLayers(savedTrailLayers); setProposedLayers(savedProposedLayers); - // Disable the project trails profile map - setShowProjectTrailsProfileMap(false); - setShowProjectTrailsView(false); + // Disable the regional trails profile map + setShowRegionalTrailsProfileMap(false); + setShowRegionalTrailsView(false); // Navigate back to root path navigate('/'); @@ -283,17 +318,17 @@ const ControlPanel = ({ }; return ( -
+
- {showProjectTrailsView ? ( + {showRegionalTrailsView ? ( <> - Project Trails Profile + Regional Trails Profile @@ -352,7 +387,7 @@ const ControlPanel = ({ Find the trails that work for you! )} - {!showProjectTrailsView && !showMunicipalityView && ( + {!showRegionalTrailsView && !showMunicipalityView && (
)} - {showProjectTrailsView ? ( + {showRegionalTrailsView ? (
- {})} + selectedMajorTrails={selectedMajorTrails || []} + onToggleMajorTrail={onToggleMajorTrail || (() => {})} /> + + {/* Layer controls for Regional Trails Profile */} +
+ Additional Layers: + + + + +
) : !showMunicipalityView ? ( <> diff --git a/src/components/Map/CommunityIdentify.js b/src/components/Map/CommunityIdentify.js index bf2c540..bca1d67 100644 --- a/src/components/Map/CommunityIdentify.js +++ b/src/components/Map/CommunityIdentify.js @@ -57,7 +57,7 @@ const CommunityIdentify = ({ point, identifyResult, handleShowPopup, handleCarou if (element.layerId === 'transit-land-stop' || element.layerName === 'Transit Stop') { // For transit stops, use Stop Name name = normalizeCandidate(attrs["Stop Name"] || attrs["stop_name"] || attrs["name"]); - } else if (element.layerId === 'subway-station' || element.layerName === 'MBTA Subway Station') { + } else if (element.layerId === 'subway-station' || element.layerName === 'T-stop' || element.layerName === 'MBTA Subway Station') { // For subway stations, use name field name = normalizeCandidate(attrs["name"]); } else if (element.layerId === 'blue-bike-station' || element.layerName === 'Blue Bike Station') { diff --git a/src/components/Map/CommunityTrailsProfile.js b/src/components/Map/CommunityTrailsProfile.js index 6aa8fed..f5b4e2c 100644 --- a/src/components/Map/CommunityTrailsProfile.js +++ b/src/components/Map/CommunityTrailsProfile.js @@ -52,6 +52,7 @@ const CommunityTrailsProfile = ({ municipalityTrails, setMunicipalityTrails, showMunicipalityView, + showMunicipalityProfileMap, showCommuterRail, setShowCommuterRail, showStationLabels, @@ -62,8 +63,8 @@ const CommunityTrailsProfile = ({ setShowSubwayStations, showEnvironmentalJustice, setShowEnvironmentalJustice, - showOpenSpace, - setShowOpenSpace, + showOpenSpace: showOpenSpaceCommunity, + setShowOpenSpace: setShowOpenSpaceCommunity, showLandlinesFeatureService, setShowLandlinesFeatureService, showTrailsRegNameSync, @@ -88,20 +89,41 @@ const CommunityTrailsProfile = ({ const [hoveredCommuterRailStation, setHoveredCommuterRailStation] = useState(null); const [hoveredSubwayStation, setHoveredSubwayStation] = useState(null); const [hoveredTransitStop, setHoveredTransitStop] = useState(null); + const [transitStopClickInfo, setTransitStopClickInfo] = useState(null); // Store Transit Stop click info for popup const [ejHoverPoint, setEjHoverPoint] = useState(null); const [ejHoverInfo, setEjHoverInfo] = useState(null); const ejIdentifyTimeoutRef = useRef(null); + const [isHoveringGeometry, setIsHoveringGeometry] = useState(false); - // OpenSpace hover state - const [openSpaceHoverInfo, setOpenSpaceHoverInfo] = useState(null); + // Use global OpenSpace state instead of local state to persist across profile switches + const showOpenSpace = showOpenSpaceCommunity; // OpenSpace click state const [openSpaceClickInfo, setOpenSpaceClickInfo] = useState(null); - // Handle OpenSpace hover - const handleOpenSpaceHover = (hoverInfo) => { - setOpenSpaceHoverInfo(hoverInfo); - }; + // Listen for OpenSpace toggle events (only for Community Trails Profile) + useEffect(() => { + const handleToggleOpenSpace = (event) => { + if (showMunicipalityProfileMap) { + setShowOpenSpaceCommunity(event.detail.show); + // Zoom to level 11 when OpenSpace is opened + if (event.detail.show && mapRef?.current) { + const map = mapRef.current.getMap(); + if (map) { + map.easeTo({ + zoom: 11, + duration: 1000 + }); + } + } + } + }; + + window.addEventListener('toggleOpenSpace', handleToggleOpenSpace); + return () => { + window.removeEventListener('toggleOpenSpace', handleToggleOpenSpace); + }; + }, [showMunicipalityProfileMap, setShowOpenSpaceCommunity, mapRef]); // Trail type visibility state - default all visible const [visibleTrailTypes, setVisibleTrailTypes] = useState(() => { @@ -112,6 +134,16 @@ const CommunityTrailsProfile = ({ }); return initialVisibility; }); + + // Update cursor when EJ hover info changes (since EJ is a raster layer) + useEffect(() => { + if (showEnvironmentalJustice && ejHoverInfo) { + setIsHoveringGeometry(true); + } else if (showEnvironmentalJustice && !ejHoverInfo && !hoveredTrail && !hoveredBlueBikeStation && !hoveredSubwayStation && !hoveredTransitStop) { + // Only clear if no other features are being hovered + setIsHoveringGeometry(false); + } + }, [ejHoverInfo, showEnvironmentalJustice, hoveredTrail, hoveredBlueBikeStation, hoveredSubwayStation, hoveredTransitStop]); // Fetched data states const [commuterRailData, setCommuterRailData] = useState(null); @@ -201,7 +233,7 @@ const CommunityTrailsProfile = ({ const handleToggleBlueBikeStations = (event) => setShowBlueBikeStations(event.detail.show); const handleToggleSubwayStations = (event) => setShowSubwayStations(event.detail.show); const handleToggleEnvironmentalJustice = (event) => setShowEnvironmentalJustice(event.detail.show); - const handleToggleOpenSpace = (event) => setShowOpenSpace(event.detail.show); + const handleToggleOpenSpace = (event) => setShowOpenSpaceCommunity(event.detail.show); const handleToggleLandlinesFeatureService = (event) => setShowLandlinesFeatureService(event.detail.show); const handleToggleTrailsRegNameSync = (event) => setShowTrailsRegNameSync(event.detail.show); const handleToggleTransitLandStops = (event) => setShowTransitLandStops(event.detail.show); @@ -214,7 +246,7 @@ const CommunityTrailsProfile = ({ setShowBlueBikeStations(false); setShowSubwayStations(false); setShowEnvironmentalJustice(false); - setShowOpenSpace(false); + setShowOpenSpaceCommunity(false); setShowLandlinesFeatureService(false); setShowTrailsRegNameSync(false); setShowTransitLandStops(false); @@ -347,7 +379,7 @@ const CommunityTrailsProfile = ({ {...viewport} width="100%" height="100%" - cursor={isBufferActive ? "crosshair" : "default"} + cursor={isBufferActive ? "crosshair" : (isHoveringGeometry ? "pointer" : "default")} transformRequest={(url, resourceType) => { // Use transformRequest to add API key header for Transit.land tiles // This is recommended by Transit.land documentation @@ -364,7 +396,7 @@ const CommunityTrailsProfile = ({ return { url }; }} interactiveLayerIds={[ - ...(showOpenSpace ? ['openspace-layer', 'openspace-outline'] : []), + ...(showOpenSpace ? ['openspace-layer-community', 'openspace-outline-community'] : []), ...(showTransitLandStops ? ['transit-land-stops'] : []), "municipality-profile-base", ...geojsonTrailLayers.map(layer => `geojson-trail-${layer.id}`) @@ -385,6 +417,105 @@ const CommunityTrailsProfile = ({ } if (event.features) { + // Check for Transit.land stops clicks FIRST (before clearing other tooltips) + if (showTransitLandStops && event.lngLat) { + let transitStopFeature = event.features.find((f) => + f.layer && f.layer.id === "transit-land-stops" + ); + console.log('transitStopFeature from event.features:', transitStopFeature); + + // If not found in event.features, query the map directly with tolerance for point features + if (!transitStopFeature) { + const map = mapRef.current?.getMap(); + if (map) { + const point = [event.lngLat.lng, event.lngLat.lat]; + + // Try querying with a small box around the point for better point detection + const tolerance = 10; // pixels + const box = [ + [event.lngLat.lng - 0.001, event.lngLat.lat - 0.001], + [event.lngLat.lng + 0.001, event.lngLat.lat + 0.001] + ]; + + // First try point query + let queriedFeatures = map.queryRenderedFeatures(point, { + layers: ['transit-land-stops'] + }); + + // If no results, try box query + if (queriedFeatures.length === 0) { + queriedFeatures = map.queryRenderedFeatures(box, { + layers: ['transit-land-stops'] + }); + } + + // If still no results, try with a larger tolerance using multiple points + if (queriedFeatures.length === 0) { + const points = [ + point, + [event.lngLat.lng - 0.0005, event.lngLat.lat], + [event.lngLat.lng + 0.0005, event.lngLat.lat], + [event.lngLat.lng, event.lngLat.lat - 0.0005], + [event.lngLat.lng, event.lngLat.lat + 0.0005] + ]; + + for (const pt of points) { + queriedFeatures = map.queryRenderedFeatures(pt, { + layers: ['transit-land-stops'] + }); + if (queriedFeatures.length > 0) { + break; + } + } + } + + if (queriedFeatures.length > 0) { + transitStopFeature = queriedFeatures[0]; + console.log('transitStopFeature from query:', transitStopFeature); + } + } + } + + if (transitStopFeature && event.lngLat) { + console.log('Setting transit stop click info:', transitStopFeature); + + // Clear other tooltips first + toggleIdentifyPopup(false); + setOpenSpaceClickInfo(null); + + // Use onestop_id for comparison (more reliable than stop_id) + const stopId = transitStopFeature.properties?.onestop_id || + transitStopFeature.properties?.stop_id || + transitStopFeature.id; + + // If clicking on the same transit stop, close the popup + if (transitStopClickInfo) { + const currentStopId = transitStopClickInfo.feature.properties?.onestop_id || + transitStopClickInfo.feature.properties?.stop_id || + transitStopClickInfo.feature.id; + if (currentStopId === stopId) { + console.log('Closing transit stop popup (same stop)'); + setTransitStopClickInfo(null); + return; + } + } + + // Set transit stop click info for popup + const clickInfo = { + point: { lng: event.lngLat.lng, lat: event.lngLat.lat }, + feature: transitStopFeature + }; + console.log('Setting transitStopClickInfo:', clickInfo); + console.log('Feature properties:', transitStopFeature.properties); + console.log('showTransitLandStops:', showTransitLandStops); + setTransitStopClickInfo(clickInfo); + return; + } + } + + // Clear transit stop tooltip when clicking on other features (only if not a transit stop) + setTransitStopClickInfo(null); + // Check for GeoJSON trail clicks const trailFeatures = event.features.filter((f) => f.layer && f.layer.id.startsWith("geojson-trail-") @@ -495,7 +626,7 @@ const CommunityTrailsProfile = ({ const mockResult = { layerId: 'subway-station', - layerName: 'MBTA Subway Station', + layerName: 'T-stop', attributes: stationInfo }; @@ -511,57 +642,10 @@ const CommunityTrailsProfile = ({ return; } - // Check for Transit.land stops clicks - if (showTransitLandStops) { - let transitStopFeature = event.features?.find((f) => - f.layer && f.layer.id === "transit-land-stops" - ); - - // If not found in event.features, query the map directly - if (!transitStopFeature && event.lngLat) { - const map = mapRef.current?.getMap(); - if (map) { - const point = [event.lngLat.lng, event.lngLat.lat]; - const queriedFeatures = map.queryRenderedFeatures(point, { - layers: ['transit-land-stops'] - }); - if (queriedFeatures.length > 0) { - transitStopFeature = queriedFeatures[0]; - } - } - } - - if (transitStopFeature && event.lngLat) { - const stopProps = transitStopFeature.properties || {}; - const stopName = stopProps?.stop_name || stopProps?.name || 'Unknown Stop'; - // Only show stop name in tooltip - const stopInfo = { - 'Stop Name': stopName - }; - - const mockResult = { - layerId: 'transit-land-stop', - layerName: 'Transit Stop', - attributes: stopInfo - }; - - if (event.lngLat && !isNaN(event.lngLat.lng) && !isNaN(event.lngLat.lat)) { - toggleIdentifyPopup(false); - setTimeout(() => { - setIdentifyPoint({ lng: event.lngLat.lng, lat: event.lngLat.lat }); - setIdentifyInfo([mockResult]); - setPointIndex(0); - toggleIdentifyPopup(true); - }, 10); - } - return; - } - } - // Check for OpenSpace clicks if (showOpenSpace) { const openSpaceFeature = event.features.find((f) => - f.layer && (f.layer.id === 'openspace-layer' || f.layer.id === 'openspace-outline') + f.layer && (f.layer.id === 'openspace-layer-community' || f.layer.id === 'openspace-outline-community') ); if (openSpaceFeature && event.lngLat) { @@ -602,12 +686,26 @@ const CommunityTrailsProfile = ({ if (showOpenSpace && openSpaceClickInfo) { const map = mapRef.current?.getMap(); if (map && event.lngLat) { + const point = [event.lngLat.lng, event.lngLat.lat]; + const queriedFeatures = map.queryRenderedFeatures(point, { + layers: ['openspace-layer-community', 'openspace-outline-community'] + }); + if (queriedFeatures.length === 0) { + setOpenSpaceClickInfo(null); + } + } + } + + // Clear transit stop tooltip when clicking on empty space + if (showTransitLandStops && transitStopClickInfo && event.lngLat) { + const map = mapRef.current?.getMap(); + if (map) { const point = [event.lngLat.lng, event.lngLat.lat]; const queriedFeatures = map.queryRenderedFeatures(point, { - layers: ['openspace-layer', 'openspace-outline'] + layers: ['transit-land-stops'] }); if (queriedFeatures.length === 0) { - setOpenSpaceClickInfo(null); + setTransitStopClickInfo(null); } } } @@ -627,39 +725,32 @@ const CommunityTrailsProfile = ({ setBufferPreviewCenter(null); } - // Handle OpenSpace layer hover FIRST (before other features) - if (showOpenSpace && event.lngLat) { - const map = mapRef.current?.getMap(); - if (map) { - // First check event.features - const openSpaceFeature = features.find(f => - f.layer && (f.layer.id === 'openspace-layer' || f.layer.id === 'openspace-outline') - ); - - if (openSpaceFeature) { - setOpenSpaceHoverInfo({ - point: event.lngLat, - feature: openSpaceFeature - }); - } else { - // If not in event.features, query the map directly - const queriedFeatures = map.queryRenderedFeatures(event.point, { - layers: ['openspace-layer', 'openspace-outline'] - }); - if (queriedFeatures.length > 0) { - const feature = queriedFeatures.find(f => f.layer.id === 'openspace-layer') || queriedFeatures[0]; - setOpenSpaceHoverInfo({ - point: event.lngLat, - feature: feature - }); - } else { - setOpenSpaceHoverInfo(null); - } - } - } - } else { - setOpenSpaceHoverInfo(null); + // Check if hovering over any interactive geometry + let hasInteractiveFeature = false; + + if (features.length > 0) { + hasInteractiveFeature = features.some((f) => { + if (!f.layer) return false; + const layerId = f.layer.id; + // Check for trails + if (layerId.startsWith("geojson-trail-") && !layerId.includes("hover")) return true; + // Check for OpenSpace + if (showOpenSpace && (layerId === "openspace-layer-community" || layerId === "openspace-outline-community")) return true; + // Check for TransitLand stops + if (showTransitLandStops && layerId === "transit-land-stops") return true; + // Check for municipality + if (layerId === "municipality-profile-base") return true; + // Check for Blue Bike Stations + if (showBlueBikeStations && layerId === "blue-bike-stations") return true; + // Check for Subway Stations + if (showSubwayStations && layerId === "subway-stations") return true; + // Check for Commuter Rail Stations + if (showCommuterRail && (layerId === "commuter-rail-stations" || layerId === "commuter-rail-station-labels")) return true; + return false; + }); } + + setIsHoveringGeometry(hasInteractiveFeature); // Handle trail hover if (features.length > 0) { @@ -719,6 +810,7 @@ const CommunityTrailsProfile = ({ setHoveredBlueBikeStation(null); setHoveredSubwayStation(null); setHoveredTransitStop(null); + setIsHoveringGeometry(false); } // Handle Environmental Justice layer hover @@ -757,6 +849,7 @@ const CommunityTrailsProfile = ({ .then((res) => { if (res.data.results && res.data.results.length > 0) { setEjHoverInfo(res.data.results[0]); + setIsHoveringGeometry(true); } else { setEjHoverInfo(null); } @@ -782,9 +875,9 @@ const CommunityTrailsProfile = ({ setHoveredBlueBikeStation(null); setHoveredSubwayStation(null); setHoveredTransitStop(null); - setOpenSpaceHoverInfo(null); setEjHoverPoint(null); setEjHoverInfo(null); + setIsHoveringGeometry(false); if (ejIdentifyTimeoutRef.current) { clearTimeout(ejIdentifyTimeoutRef.current); } @@ -847,49 +940,6 @@ const CommunityTrailsProfile = ({ )} - {/* OpenSpace Hover Tooltip */} - {showOpenSpace && openSpaceHoverInfo && openSpaceHoverInfo.point && openSpaceHoverInfo.feature && !openSpaceClickInfo && ( - - {(() => { - const properties = openSpaceHoverInfo.feature.properties || {}; - - return ( -
-
OpenSpace
- {properties.SITE_NAME && ( -
{properties.SITE_NAME}
- )} - {properties.FEE_OWNER && ( -
Owner: {properties.FEE_OWNER}
- )} - {properties.OWNER_TYPE && ( -
Owner Type: {properties.OWNER_TYPE}
- )} - {properties.PRIM_PURP && ( -
Primary Purpose: {properties.PRIM_PURP}
- )} - {properties.PUB_ACCESS && ( -
Public Access: {properties.PUB_ACCESS}
- )} - {properties.GIS_ACRES !== null && properties.GIS_ACRES !== undefined && ( -
Acres: {parseFloat(properties.GIS_ACRES).toFixed(2)}
- )} - {!properties.SITE_NAME && !properties.FEE_OWNER && ( -
No data available
- )} -
- ); - })()} -
- )} - {/* OpenSpace Click Popup */} {showOpenSpace && openSpaceClickInfo && openSpaceClickInfo.point && openSpaceClickInfo.feature && ( )} + + {/* Transit Stop Click Popup */} + {(() => { + const shouldShowPopup = showTransitLandStops && transitStopClickInfo && transitStopClickInfo.point && transitStopClickInfo.feature; + console.log('Popup render check:', { + showTransitLandStops, + hasTransitStopClickInfo: !!transitStopClickInfo, + hasPoint: !!(transitStopClickInfo && transitStopClickInfo.point), + hasFeature: !!(transitStopClickInfo && transitStopClickInfo.feature), + shouldShowPopup + }); + + return shouldShowPopup ? ( + { + console.log('Closing transit stop popup'); + setTransitStopClickInfo(null); + }} + anchor="top" + offset={12} + > + {(() => { + const properties = transitStopClickInfo.feature.properties || {}; + console.log('Popup properties:', properties); + // Try multiple possible property names for stop name + // For vector tiles, properties might be in _vectorTileFeature + const vectorTileProps = transitStopClickInfo.feature._vectorTileFeature?.properties || {}; + const allProps = { ...properties, ...vectorTileProps }; + + const stopName = allProps.stop_name || + allProps.name || + allProps.name_en || + allProps.stop_name_en || + allProps.title || + allProps.stop_title || + allProps.onestop_id || + 'Unknown Stop'; + + console.log('Stop name:', stopName); + + return ( +
+
Transit Stop
+
{stopName}
+
+ ); + })()} +
+ ) : null; + })()} {showControlPanel && (
@@ -988,6 +1091,7 @@ const CommunityTrailsProfile = ({ )} @@ -1006,7 +1110,7 @@ const CommunityTrailsProfile = ({ )} diff --git a/src/components/Map/OriginalTrailsMap.js b/src/components/Map/OriginalTrailsMap.js index bc1a134..a0085a7 100644 --- a/src/components/Map/OriginalTrailsMap.js +++ b/src/components/Map/OriginalTrailsMap.js @@ -49,18 +49,18 @@ const OriginalTrailsMap = ({ const [identifyInfo, setIdentifyInfo] = useState(null); const [identifyPoint, setIdentifyPoint] = useState(null); const [pointIndex, setPointIndex] = useState(0); - const [hoverPoint, setHoverPoint] = useState(null); - const [hoverFeature, setHoverFeature] = useState(null); - const [hoverFilterKey, setHoverFilterKey] = useState(null); - const [hoverFilterValue, setHoverFilterValue] = useState(null); - const [senateHoverPoint, setSenateHoverPoint] = useState(null); - const [senateHoverFeature, setSenateHoverFeature] = useState(null); - const [senateHoverFilterKey, setSenateHoverFilterKey] = useState(null); - const [senateHoverFilterValue, setSenateHoverFilterValue] = useState(null); - const [muniHoverPoint, setMuniHoverPoint] = useState(null); - const [muniHoverFeature, setMuniHoverFeature] = useState(null); - const [muniHoverFilterKey, setMuniHoverFilterKey] = useState(null); - const [muniHoverFilterValue, setMuniHoverFilterValue] = useState(null); + const [clickHousePoint, setClickHousePoint] = useState(null); + const [clickHouseFeature, setClickHouseFeature] = useState(null); + const [clickHouseFilterKey, setClickHouseFilterKey] = useState(null); + const [clickHouseFilterValue, setClickHouseFilterValue] = useState(null); + const [clickSenatePoint, setClickSenatePoint] = useState(null); + const [clickSenateFeature, setClickSenateFeature] = useState(null); + const [clickSenateFilterKey, setClickSenateFilterKey] = useState(null); + const [clickSenateFilterValue, setClickSenateFilterValue] = useState(null); + const [clickMuniPoint, setClickMuniPoint] = useState(null); + const [clickMuniFeature, setClickMuniFeature] = useState(null); + const [clickMuniFilterKey, setClickMuniFilterKey] = useState(null); + const [clickMuniFilterValue, setClickMuniFilterValue] = useState(null); const [showOneLayerNotice, setShowOneLayerNotice] = useState(false); const [isZooming, setIsZooming] = useState(false); @@ -82,39 +82,39 @@ const OriginalTrailsMap = ({ React.useEffect(() => { if (isZooming) { const timer = setTimeout(() => { - setHoverFeature(null); - setHoverPoint(null); - setHoverFilterKey(null); - setHoverFilterValue(null); - setSenateHoverFeature(null); - setSenateHoverPoint(null); - setSenateHoverFilterKey(null); - setSenateHoverFilterValue(null); - setMuniHoverFeature(null); - setMuniHoverPoint(null); - setMuniHoverFilterKey(null); - setMuniHoverFilterValue(null); + setClickHouseFeature(null); + setClickHousePoint(null); + setClickHouseFilterKey(null); + setClickHouseFilterValue(null); + setClickSenateFeature(null); + setClickSenatePoint(null); + setClickSenateFilterKey(null); + setClickSenateFilterValue(null); + setClickMuniFeature(null); + setClickMuniPoint(null); + setClickMuniFilterKey(null); + setClickMuniFilterValue(null); setIsZooming(false); }, 1100); return () => clearTimeout(timer); } }, [isZooming]); - // Clear hover states when identify popup closes + // Clear click states when identify popup closes React.useEffect(() => { if (!showIdentifyPopup) { - setHoverFeature(null); - setHoverPoint(null); - setHoverFilterKey(null); - setHoverFilterValue(null); - setSenateHoverFeature(null); - setSenateHoverPoint(null); - setSenateHoverFilterKey(null); - setSenateHoverFilterValue(null); - setMuniHoverFeature(null); - setMuniHoverPoint(null); - setMuniHoverFilterKey(null); - setMuniHoverFilterValue(null); + setClickHouseFeature(null); + setClickHousePoint(null); + setClickHouseFilterKey(null); + setClickHouseFilterValue(null); + setClickSenateFeature(null); + setClickSenatePoint(null); + setClickSenateFilterKey(null); + setClickSenateFilterValue(null); + setClickMuniFeature(null); + setClickMuniPoint(null); + setClickMuniFilterKey(null); + setClickMuniFilterValue(null); } }, [showIdentifyPopup]); @@ -156,8 +156,8 @@ const OriginalTrailsMap = ({ ); visibleMaHouseDistrictsLayers.push( @@ -204,8 +204,8 @@ const OriginalTrailsMap = ({ ); visibleMaSenateDistrictsLayers.push( @@ -253,8 +253,8 @@ const OriginalTrailsMap = ({ visibleMunicipalitiesLayers.push( @@ -291,65 +291,12 @@ const OriginalTrailsMap = ({ setViewport(newViewport); }} onClick={(event) => { - // Check if clicking on a municipality when municipalities layer is visible - if (showMunicipalities && event.features) { - const muniFeature = event.features.find((f) => f.layer && f.layer.id === "municipalities-fill"); - if (muniFeature) { - const townName = muniFeature.properties.town || muniFeature.properties.NAME; - if (townName) { - const muniName = townName.toLowerCase(); - setSelectedMunicipality({ - name: muniName, - properties: muniFeature.properties, - geometry: muniFeature.geometry - }); - return; - } - } - } - - // Handle identify popup for trails - const allLayers = [ - ...existingTrails.filter((et) => trailLayers.includes(et.id)).map((et) => et["esri-id"]), - ...proposedTrails.filter((et) => proposedLayers.includes(et.id)).map((et) => et["esri-id"]), - ].join(","); - if (trailLayers.length > 0 || proposedLayers.length > 0) { - const currentMap = mapRef.current.getMap(); - const currentMapBounds = currentMap.getBounds(); - axios - .get(TRAILMAP_IDENTIFY_SOURCE, { - params: { - geometry: `${event.lngLat.lng},${event.lngLat.lat}`, - geometryType: "esriGeometryPoint", - sr: 4326, - layers: "visible:" + allLayers, - tolerance: 3, - mapExtent: `${currentMapBounds._sw.lng},${currentMapBounds._sw.lat},${currentMapBounds._ne.lng},${currentMapBounds._ne.lat}`, - imageDisplay: `600,550,96`, - returnGeometry: false, - f: "pjson", - }, - }) - .then((res) => { - if (res.data.results.length > 0) { - const identifyResult = []; - for (let i = 0; i < Math.min(5, res.data.results.length); i++) { - identifyResult.push(res.data.results[i]); - } - setIdentifyInfo(identifyResult); - toggleIdentifyPopup(true); - setIdentifyPoint(event.lngLat); - } - }); - } - }} - onMouseMove={(event) => { const map = mapRef.current && mapRef.current.getMap ? mapRef.current.getMap() : null; - const features = event.features || []; - - // Handle MA House Districts hover + let handled = false; + + // Handle MA House Districts click if (showMaHouseDistricts) { - let districtFeature = features.find((f) => f.layer && f.layer.id === "ma-house-districts-fill"); + let districtFeature = event.features?.find((f) => f.layer && f.layer.id === "ma-house-districts-fill"); if (!districtFeature && map) { const x = event.point.x; @@ -363,8 +310,18 @@ const OriginalTrailsMap = ({ } if (districtFeature) { - setHoverFeature(districtFeature); - setHoverPoint(event.lngLat); + // Clear other click states + setClickSenateFeature(null); + setClickSenatePoint(null); + setClickSenateFilterKey(null); + setClickSenateFilterValue(null); + setClickMuniFeature(null); + setClickMuniPoint(null); + setClickMuniFilterKey(null); + setClickMuniFilterValue(null); + + setClickHouseFeature(districtFeature); + setClickHousePoint(event.lngLat); const props = districtFeature.properties || {}; const key = (props.REPDISTNUM !== undefined && "REPDISTNUM") || @@ -372,19 +329,15 @@ const OriginalTrailsMap = ({ (props.OBJECTID !== undefined && "OBJECTID") || null; const value = key ? props[key] : null; - setHoverFilterKey(key); - setHoverFilterValue(value); - } else { - setHoverFeature(null); - setHoverPoint(null); - setHoverFilterKey(null); - setHoverFilterValue(null); + setClickHouseFilterKey(key); + setClickHouseFilterValue(value); + handled = true; } } - // Handle MA Senate Districts hover - if (showMaSenateDistricts) { - let senateFeature = features.find((f) => f.layer && f.layer.id === "ma-senate-districts-fill"); + // Handle MA Senate Districts click + if (!handled && showMaSenateDistricts) { + let senateFeature = event.features?.find((f) => f.layer && f.layer.id === "ma-senate-districts-fill"); if (!senateFeature && map) { const x = event.point.x; @@ -398,27 +351,33 @@ const OriginalTrailsMap = ({ } if (senateFeature) { - setSenateHoverFeature(senateFeature); - setSenateHoverPoint(event.lngLat); + // Clear other click states + setClickHouseFeature(null); + setClickHousePoint(null); + setClickHouseFilterKey(null); + setClickHouseFilterValue(null); + setClickMuniFeature(null); + setClickMuniPoint(null); + setClickMuniFilterKey(null); + setClickMuniFilterValue(null); + + setClickSenateFeature(senateFeature); + setClickSenatePoint(event.lngLat); const props = senateFeature.properties || {}; const key = (props.DIST_CODE !== undefined && "DIST_CODE") || (props.OBJECTID !== undefined && "OBJECTID") || null; const value = key ? props[key] : null; - setSenateHoverFilterKey(key); - setSenateHoverFilterValue(value); - } else { - setSenateHoverFeature(null); - setSenateHoverPoint(null); - setSenateHoverFilterKey(null); - setSenateHoverFilterValue(null); + setClickSenateFilterKey(key); + setClickSenateFilterValue(value); + handled = true; } } - // Handle Municipalities hover - if (showMunicipalities) { - let muniFeature = features.find((f) => f.layer && f.layer.id === "municipalities-fill"); + // Check if clicking on a municipality when municipalities layer is visible + if (!handled && showMunicipalities) { + let muniFeature = event.features?.find((f) => f.layer && f.layer.id === "municipalities-fill"); if (!muniFeature && map) { const x = event.point.x; @@ -432,38 +391,92 @@ const OriginalTrailsMap = ({ } if (muniFeature) { - setMuniHoverFeature(muniFeature); - setMuniHoverPoint(event.lngLat); - const props = muniFeature.properties || {}; - const key = - (props.town !== undefined && "town") || - (props.NAME !== undefined && "NAME") || - (props.OBJECTID !== undefined && "OBJECTID") || - null; - const value = key ? props[key] : null; - setMuniHoverFilterKey(key); - setMuniHoverFilterValue(value); - } else { - setMuniHoverFeature(null); - setMuniHoverPoint(null); - setMuniHoverFilterKey(null); - setMuniHoverFilterValue(null); + const townName = muniFeature.properties.town || muniFeature.properties.NAME; + if (townName) { + const muniName = townName.toLowerCase(); + setSelectedMunicipality({ + name: muniName, + properties: muniFeature.properties, + geometry: muniFeature.geometry + }); + + // Clear other click states + setClickHouseFeature(null); + setClickHousePoint(null); + setClickHouseFilterKey(null); + setClickHouseFilterValue(null); + setClickSenateFeature(null); + setClickSenatePoint(null); + setClickSenateFilterKey(null); + setClickSenateFilterValue(null); + + // Also show click tooltip + setClickMuniFeature(muniFeature); + setClickMuniPoint(event.lngLat); + const props = muniFeature.properties || {}; + const key = + (props.town !== undefined && "town") || + (props.NAME !== undefined && "NAME") || + (props.OBJECTID !== undefined && "OBJECTID") || + null; + const value = key ? props[key] : null; + setClickMuniFilterKey(key); + setClickMuniFilterValue(value); + handled = true; + } } } - }} - onMouseLeave={() => { - setHoverFeature(null); - setHoverPoint(null); - setHoverFilterKey(null); - setHoverFilterValue(null); - setSenateHoverFeature(null); - setSenateHoverPoint(null); - setSenateHoverFilterKey(null); - setSenateHoverFilterValue(null); - setMuniHoverFeature(null); - setMuniHoverPoint(null); - setMuniHoverFilterKey(null); - setMuniHoverFilterValue(null); + + // If none of the above layers were clicked, clear all click states + if (!handled) { + setClickHouseFeature(null); + setClickHousePoint(null); + setClickHouseFilterKey(null); + setClickHouseFilterValue(null); + setClickSenateFeature(null); + setClickSenatePoint(null); + setClickSenateFilterKey(null); + setClickSenateFilterValue(null); + setClickMuniFeature(null); + setClickMuniPoint(null); + setClickMuniFilterKey(null); + setClickMuniFilterValue(null); + } + + // Handle identify popup for trails + const allLayers = [ + ...existingTrails.filter((et) => trailLayers.includes(et.id)).map((et) => et["esri-id"]), + ...proposedTrails.filter((et) => proposedLayers.includes(et.id)).map((et) => et["esri-id"]), + ].join(","); + if (trailLayers.length > 0 || proposedLayers.length > 0) { + const currentMap = mapRef.current.getMap(); + const currentMapBounds = currentMap.getBounds(); + axios + .get(TRAILMAP_IDENTIFY_SOURCE, { + params: { + geometry: `${event.lngLat.lng},${event.lngLat.lat}`, + geometryType: "esriGeometryPoint", + sr: 4326, + layers: "visible:" + allLayers, + tolerance: 3, + mapExtent: `${currentMapBounds._sw.lng},${currentMapBounds._sw.lat},${currentMapBounds._ne.lng},${currentMapBounds._ne.lat}`, + imageDisplay: `600,550,96`, + returnGeometry: false, + f: "pjson", + }, + }) + .then((res) => { + if (res.data.results.length > 0) { + const identifyResult = []; + for (let i = 0; i < Math.min(5, res.data.results.length); i++) { + identifyResult.push(res.data.results[i]); + } + setIdentifyInfo(identifyResult); + toggleIdentifyPopup(true); + setIdentifyPoint(event.lngLat); + } + }); + } }} mapboxAccessToken={MAPBOX_TOKEN} mapStyle={baseLayer.url} @@ -476,18 +489,18 @@ const OriginalTrailsMap = ({ identifyResult={identifyInfo} handleShowPopup={() => { toggleIdentifyPopup(false); - setHoverFeature(null); - setHoverPoint(null); - setHoverFilterKey(null); - setHoverFilterValue(null); - setSenateHoverFeature(null); - setSenateHoverPoint(null); - setSenateHoverFilterKey(null); - setSenateHoverFilterValue(null); - setMuniHoverFeature(null); - setMuniHoverPoint(null); - setMuniHoverFilterKey(null); - setMuniHoverFilterValue(null); + setClickHouseFeature(null); + setClickHousePoint(null); + setClickHouseFilterKey(null); + setClickHouseFilterValue(null); + setClickSenateFeature(null); + setClickSenatePoint(null); + setClickSenateFilterKey(null); + setClickSenateFilterValue(null); + setClickMuniFeature(null); + setClickMuniPoint(null); + setClickMuniFilterKey(null); + setClickMuniFilterValue(null); }} handleCarousel={setPointIndex} /> @@ -571,17 +584,16 @@ const OriginalTrailsMap = ({ clickHandler={() => toggleBasemapPanel(!showBasemapPanel)} /> - {showMaHouseDistricts && hoverFeature && hoverPoint && ( + {showMaHouseDistricts && clickHouseFeature && clickHousePoint && ( {(() => { - const p = hoverFeature.properties || {}; + const p = clickHouseFeature.properties || {}; const repName = p.REP || ""; const distName = p.REP_DIST || ""; const distNum = p.DIST_CODE || ""; @@ -596,17 +608,16 @@ const OriginalTrailsMap = ({ )} - {showMaSenateDistricts && senateHoverFeature && senateHoverPoint && ( + {showMaSenateDistricts && clickSenateFeature && clickSenatePoint && ( {(() => { - const p = senateHoverFeature.properties || {}; + const p = clickSenateFeature.properties || {}; const repName = p.SENATOR || ""; const distName = p.SEN_DIST || ""; const distNum = p.SENDISTNUM || ""; @@ -621,17 +632,16 @@ const OriginalTrailsMap = ({ )} - {showMunicipalities && muniHoverFeature && muniHoverPoint && ( + {showMunicipalities && clickMuniFeature && clickMuniPoint && ( {(() => { - const p = muniHoverFeature.properties || {}; + const p = clickMuniFeature.properties || {}; const townName = p.town || "N/A"; const capitalizedTownName = townName && townName !== "N/A" ? townName.charAt(0).toUpperCase() + townName.slice(1).toLowerCase() : townName; return ( diff --git a/src/components/Map/ProjectMetricsPanel.js b/src/components/Map/ProjectMetricsPanel.js index 3327151..3260374 100644 --- a/src/components/Map/ProjectMetricsPanel.js +++ b/src/components/Map/ProjectMetricsPanel.js @@ -1,129 +1,582 @@ -import React from "react"; +import React, { useState } from "react"; const ProjectMetricsPanel = ({ selectedRegNames = new Set(), - projectMetrics = {} + selectedMajorTrails = [], + projectMetrics = {}, + onZoomToProject = null }) => { - // Reverse order so newest selected projects appear at the top - const selectedProjectsMetrics = Array.from(selectedRegNames) - .map(regName => ({ - regName, - metrics: projectMetrics[regName] - })) + const [expandedProjects, setExpandedProjects] = useState(new Set()); + const [isPanelVisible, setIsPanelVisible] = useState(true); + const [expandedLengthByType, setExpandedLengthByType] = useState(new Map()); // Map of projectName -> { existing: true/false, planned: true/false, gap: true/false } + + // Combine regular projects and major trails, reverse order so newest selected projects appear at the top + const regularProjects = Array.from(selectedRegNames).map(regName => ({ + regName, + metrics: projectMetrics[regName] + })); + + const majorTrails = selectedMajorTrails.map(majorTrailName => ({ + regName: majorTrailName, + metrics: projectMetrics[majorTrailName] + })); + + const selectedProjectsMetrics = [...regularProjects, ...majorTrails] .filter(item => item.metrics) .reverse(); // Reverse to show newest at top + const toggleProject = (regName) => { + const newExpanded = new Set(expandedProjects); + if (newExpanded.has(regName)) { + newExpanded.delete(regName); + } else { + newExpanded.add(regName); + } + setExpandedProjects(newExpanded); + }; + + const toggleLengthByTypeSection = (regName, section) => { + const newExpanded = new Map(expandedLengthByType); + const projectState = newExpanded.get(regName) || { existing: true, planned: true, gap: true }; + projectState[section] = !projectState[section]; + newExpanded.set(regName, projectState); + setExpandedLengthByType(newExpanded); + }; + // Render metrics for a single project const renderProjectMetrics = (projectItem, index) => { const { regName, metrics } = projectItem; + const isExpanded = expandedProjects.has(regName); return (
-
-
- {regName} -
-
- -
- {/* Municipalities */} - {metrics.municipalities && metrics.municipalities.length > 0 && ( -
- Municipalities ({metrics.municipalities.length}): -
- {metrics.municipalities.map((muni, muniIndex) => ( -
- • {muni} -
- ))} -
+ {/* Project Header - Always Visible */} +
toggleProject(regName)} + onMouseEnter={(e) => { + e.currentTarget.style.backgroundColor = '#e9ecef'; + }} + onMouseLeave={(e) => { + e.currentTarget.style.backgroundColor = '#f8f9fa'; + }} + > +
+
+ {regName} +
+ {/* Quick Stats */} +
+ {metrics && metrics.totalLengthMiles ? ( + <> + + {metrics.totalLengthMiles} miles total + + {metrics.percentageComplete && ( + = 50 ? '#28a745' : '#ffc107' }}> + {metrics.percentageComplete}% complete + + )} + + ) : ( + + + Loading metrics... + + )}
- )} - - {/* Total Length */} -
- Total Length: {metrics.totalLengthMiles} miles
+
+ {onZoomToProject && ( + + )} + +
+
- {/* Completed Length */} - {metrics.completedLengthMiles && ( -
- Completed: {metrics.completedLengthMiles} miles -
- )} + {/* Expanded Content */} + {isExpanded && ( +
+ {/* Loading State */} + {!metrics || !metrics.totalLengthMiles ? ( +
+ + Loading metrics... +
+ ) : ( + <> + {/* Progress Bar */} + {metrics.percentageComplete && ( +
+
+ + + Completion Progress + + {metrics.percentageComplete}% +
+
+
= 50 ? '#28a745' : '#ffc107', + transition: 'width 0.3s ease' + }}>
+
+
+ )} - {/* Percentage Complete */} - {metrics.percentageComplete && ( -
- Percentage Complete: {metrics.percentageComplete}% -
- )} - - {/* Length by Type */} - {metrics.lengthByType && metrics.lengthByType.length > 0 && ( -
- Length by Type: -
- {metrics.lengthByType.map((item, idx) => ( -
- • {item.type}: {item.miles} miles -
- ))} + {/* Key Metrics Grid */} +
+
+
+ {metrics.totalLengthMiles} +
+
+ + Total Miles +
-
- )} - - {/* Parks */} - {metrics.parks && metrics.parks.length > 0 && ( -
- Parks ({metrics.parks.length}): -
- {metrics.parks.map((park, parkIndex) => ( -
- • {park} + {metrics.completedLengthMiles && ( +
+
+ {metrics.completedLengthMiles}
- ))} -
+
+ + Completed Miles +
+
+ )}
- )} - {/* Trail Steward */} - {metrics.steward && ( -
- Trail Steward: {metrics.steward} -
- )} - - {/* Trail Website */} - {metrics.website && ( -
- Website:{' '} - - {metrics.website} - -
- )} - - {/* Key Gaps */} - {metrics.gaps && metrics.gaps.length > 0 && ( -
- Key Gaps ({metrics.gaps.length}): -
- {metrics.gaps.map((gap, gapIndex) => ( -
- • {gap.type}: {gap.lengthMiles} miles + {/* Municipalities */} + {metrics.municipalities && metrics.municipalities.length > 0 && ( +
+
+ + Municipalities ({metrics.municipalities.length}) +
+
15 ? '120px' : 'none', + overflowY: metrics.municipalities.length > 15 ? 'auto' : 'visible', + padding: metrics.municipalities.length > 15 ? '8px' : '0', + backgroundColor: metrics.municipalities.length > 15 ? '#f8f9fa' : 'transparent', + borderRadius: '4px' + }} + > + {metrics.municipalities.map((muni, muniIndex) => ( + + {muni} + + ))} +
+
+ )} + + {/* Length by Type - Separated into Existing, Planned, and Gap */} + {metrics.lengthByType && metrics.lengthByType.length > 0 && ( +
+
+ + Length by Type +
+ + {/* Existing Trails */} + {metrics.lengthByType.filter(item => item.category === 'existing').length > 0 && (() => { + const existingItems = metrics.lengthByType.filter(item => item.category === 'existing'); + const isExpanded = expandedLengthByType.get(regName)?.existing !== false; + return ( +
+
toggleLengthByTypeSection(regName, 'existing')} + onMouseEnter={(e) => { + e.currentTarget.style.color = '#1e5a8a'; + }} + onMouseLeave={(e) => { + e.currentTarget.style.color = '#2774bd'; + }} + > + Existing + +
+ {isExpanded && ( +
+ {existingItems.map((item, idx, filtered) => ( +
+ {item.type} + {item.miles} mi +
+ ))} +
+ )} +
+ ); + })()} + + {/* Planned Trails */} + {metrics.lengthByType.filter(item => item.category === 'planned').length > 0 && (() => { + const plannedItems = metrics.lengthByType.filter(item => item.category === 'planned'); + const isExpanded = expandedLengthByType.get(regName)?.planned !== false; + return ( +
+
toggleLengthByTypeSection(regName, 'planned')} + onMouseEnter={(e) => { + e.currentTarget.style.color = '#4a148c'; + }} + onMouseLeave={(e) => { + e.currentTarget.style.color = '#6a1b9a'; + }} + > + Planned/Envisioned/Design + +
+ {isExpanded && ( +
+ {plannedItems.map((item, idx, filtered) => ( +
+ {item.type} + {item.miles} mi +
+ ))} +
+ )} +
+ ); + })()} + + {/* Gap Trails */} + {metrics.lengthByType.filter(item => item.category === 'gap').length > 0 && (() => { + const gapItems = metrics.lengthByType.filter(item => item.category === 'gap'); + const isExpanded = expandedLengthByType.get(regName)?.gap !== false; + return ( +
+
toggleLengthByTypeSection(regName, 'gap')} + onMouseEnter={(e) => { + e.currentTarget.style.color = '#cc0000'; + }} + onMouseLeave={(e) => { + e.currentTarget.style.color = '#FF0000'; + }} + > + Gap + +
+ {isExpanded && ( +
+ {gapItems.map((item, idx, filtered) => ( +
+ {item.type} + {item.miles} mi +
+ ))} +
+ )} +
+ ); + })()} +
+ )} + + {/* Parks */} + {metrics.parks && metrics.parks.length > 0 && ( +
+
+ + Parks ({metrics.parks.length}) +
+
+ {metrics.parks.slice(0, 5).map((park, parkIndex) => ( +
+ • {park} +
+ ))} + {metrics.parks.length > 5 && ( +
+ + {metrics.parks.length - 5} more +
+ )} +
+
+ )} + + {/* Trail Steward & Website */} + {(metrics.steward || metrics.website) && ( + -
- )} -
+ )} + + + )} + +
+ )}
); }; @@ -134,18 +587,92 @@ const ProjectMetricsPanel = ({ } return ( -
-
-
Project Metrics
-
-
- {selectedProjectsMetrics.map((projectItem, index) => - renderProjectMetrics(projectItem, index) - )} -
+
+ {isPanelVisible ? ( + <> +
setIsPanelVisible(false)} + onMouseEnter={(e) => { + e.currentTarget.style.backgroundColor = 'rgba(39, 116, 189, 0.1)'; + }} + onMouseLeave={(e) => { + e.currentTarget.style.backgroundColor = ''; + }} + > +
+ + Regional Trails Metrics + + ({selectedProjectsMetrics.length}) + + +
+
+
+ {selectedProjectsMetrics.map((projectItem, index) => + renderProjectMetrics(projectItem, index) + )} +
+ + ) : ( +
setIsPanelVisible(true)} + onMouseEnter={(e) => { + e.currentTarget.style.backgroundColor = 'rgba(39, 116, 189, 0.1)'; + }} + onMouseLeave={(e) => { + e.currentTarget.style.backgroundColor = ''; + }} + > + +
+ )}
); }; export default ProjectMetricsPanel; - diff --git a/src/components/Map/ProjectMetricsPanel.scss b/src/components/Map/ProjectMetricsPanel.scss index 34f1789..d788212 100644 --- a/src/components/Map/ProjectMetricsPanel.scss +++ b/src/components/Map/ProjectMetricsPanel.scss @@ -2,24 +2,27 @@ position: absolute; left: 280px; // Position after the control panel top: 60px; // Below header - width: 350px; + width: 380px; height: calc(100vh - 60px); background-color: $lighter-blue; - box-shadow: rgba(0, 0, 0, 0.1) 0px 0px 10px 2px; + box-shadow: rgba(0, 0, 0, 0.15) 0px 4px 12px 2px; z-index: 5; // Below control panel display: flex; flex-direction: column; + border-radius: 0 8px 8px 0; &__header { - padding: 20px; + padding: 18px 20px; border-bottom: 2px solid #dee2e6; - background-color: rgba(255, 255, 255, 0.5); + background-color: #ffffff; + flex-shrink: 0; h5 { font-family: $title-font; - font-size: 16px; + font-size: 17px; font-weight: 600; color: #2774bd; + margin: 0; } } @@ -27,20 +30,22 @@ flex: 1; overflow-y: auto; overflow-x: hidden; - padding: 20px; + padding: 16px; + background-color: #f5f7fa; // Custom scrollbar styling &::-webkit-scrollbar { - width: 8px; + width: 10px; } &::-webkit-scrollbar-track { background: rgba(0, 0, 0, 0.05); + border-radius: 5px; } &::-webkit-scrollbar-thumb { background: rgba(39, 116, 189, 0.3); - border-radius: 4px; + border-radius: 5px; &:hover { background: rgba(39, 116, 189, 0.5); @@ -48,9 +53,21 @@ } } + // Regional trails metrics card styles + .project-metrics-card { + &:hover { + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + } + } + // Responsive width for larger screens @media only screen and (min-width: 1200px) { - width: 400px; + width: 420px; + } + + @media only screen and (max-width: 1024px) { + width: 320px; + left: 260px; } } diff --git a/src/components/Map/ProjectTrailsProfile.js b/src/components/Map/ProjectTrailsProfile.js deleted file mode 100644 index 1f3f560..0000000 --- a/src/components/Map/ProjectTrailsProfile.js +++ /dev/null @@ -1,766 +0,0 @@ -import React, { useState, useRef, useEffect, useContext, useMemo } from "react"; -import { useNavigate, useLocation } from "react-router-dom"; -import ReactMapGL, { NavigationControl, GeolocateControl, ScaleControl, Popup, Source, Layer } from "react-map-gl"; -import BasemapPanel from "../BasemapPanel"; -import ControlPanel from "../ControlPanel"; -import Control from "./Control"; -import FilterIcon from "../../assets/icons/filter-icon.svg"; -import CommunityIdentify from "./CommunityIdentify"; -import ProjectMetricsPanel from "./ProjectMetricsPanel"; -import GeocoderPanel from "../Geocoder/GeocoderPanel"; -import { LayerContext } from "../../App"; -import TrailsRegNameSyncLayer from "./layers/TrailsRegNameSyncLayer"; -import OpenSpaceLayer from "./layers/OpenSpaceLayer"; -import EnvironmentalJusticeLayer from "./layers/EnvironmentalJusticeLayer"; -import massachusettsData from "../../data/massachusetts.json"; -import * as turf from "@turf/turf"; -import bbox from "@turf/bbox"; - -const MAPBOX_TOKEN = process.env.REACT_APP_MAPBOX_API_TOKEN; - -const ProjectTrailsProfile = ({ - viewport, - setViewport, - baseLayer, - showBasemapPanel, - toggleBasemapPanel, - showControlPanel, - toggleControlPanel, - mapRef -}) => { - const navigate = useNavigate(); - const location = useLocation(); - const { - showTrailsRegNameSync, - setShowTrailsRegNameSync, - basemaps, - setProjectRegNames, - setSelectedProjectRegName, - setProjectColorPalette, - showMunicipalities, - toggleMunicipalities, - showOpenSpace, - setShowOpenSpace, - showEnvironmentalJustice, - setShowEnvironmentalJustice, - } = useContext(LayerContext); - - const [showIdentifyPopup, toggleIdentifyPopup] = useState(false); - const [identifyInfo, setIdentifyInfo] = useState(null); - const [identifyPoint, setIdentifyPoint] = useState(null); - const [pointIndex, setPointIndex] = useState(0); - const [regNames, setRegNames] = useState([]); - const [selectedRegNames, setSelectedRegNames] = useState(new Set()); // Track selected projects (Set for easy toggle) - - // Reset selected projects when entering Project Trails Profile and show municipalities by default - useEffect(() => { - if (location.pathname === '/projectTrailsProfile') { - setSelectedRegNames(new Set()); - // Show municipalities by default - toggleMunicipalities(true); - } - }, [location.pathname, toggleMunicipalities]); - const [hoveredTrail, setHoveredTrail] = useState(null); - const [colorPalette, setColorPalette] = useState({}); - const allRegNamesRef = useRef(new Set()); // Track all unique reg_names seen using ref - const [allTrailsData, setAllTrailsData] = useState(null); // Store all trail data from TrailsRegNameSyncLayer - const [openSpaceData, setOpenSpaceData] = useState(null); // Store OpenSpace data for park intersection calculations - const [openSpaceClickInfo, setOpenSpaceClickInfo] = useState(null); // Store OpenSpace click info for popup - - // Get trail type label based on seg_type and fac_stat - const getTrailTypeLabel = (segType, facStat) => { - const key = `${segType},${facStat}`; - const typeMap = { - "1,1": "Shared Use Path - Existing", - "1,2": "Shared Use Path - Design", - "1,3": "Shared Use Path - Envisioned", - "6,3": "Shared Use Path - Unimproved Surface", - "6,1": "Shared Use Path - Unimproved Surface", - "6,2": "Shared Use Path - Unimproved Surface", - "2,1": "Protected Bike Lane and Sidewalk", - "2,2": "Protected Bike Lane - Design or Construction", - "2,3": "Protected Bike Lane - Design or Construction", - "3,1": "Bike Lane and Sidewalk", - "3,2": "Bike Lane - Design or Construction", - "3,3": "Bike Lane - Design or Construction", - "4,3": "Shared Street - Urban", - "4,1": "Shared Street - Urban", - "5,1": "Shared Street - Suburban", - "5,3": "Shared Street - Envisioned", - "9,1": "Gap - Facility Type TBD", - "9,2": "Gap - Facility Type TBD", - "9,3": "Gap - Facility Type TBD", - "11,1": "Foot Trail - Natural Surface", - "11,3": "Foot Trail - Envisioned Natural Surface", - "11,2": "Foot Trail - Envisioned Natural Surface", - "12,1": "Foot Trail - Roadway Section", - "12,2": "Foot Trail - Envisioned Roadway Section", - "12,3": "Foot Trail - Envisioned Roadway Section" - }; - - return typeMap[key] || "Unknown Trail Type"; - }; - - // Color palette generation function - const generateColorPalette = (regNamesArray) => { - const colors = [ - "#FF6B35", "#4ECDC4", "#45B7D1", "#FFA07A", "#98D8C8", - "#F7DC6F", "#BB8FCE", "#85C1E2", "#F8B739", "#52BE80", - "#EC7063", "#5DADE2", "#F1948A", "#58D68D", "#F4D03F", - "#AF7AC5", "#7FB3D3", "#F5B041", "#82E0AA", "#F39C12", - "#E74C3C", "#3498DB", "#E67E22", "#1ABC9C", "#9B59B6", - "#34495E", "#16A085", "#27AE60", "#2980B9", "#8E44AD" - ]; - - const palette = {}; - regNamesArray.forEach((name, index) => { - if (name && name.trim() !== "") { - palette[name] = colors[index % colors.length]; - } - }); - - return palette; - }; - - // Update color palette when new reg_names are discovered - useEffect(() => { - if (regNames.length > 0) { - // Add new reg_names to the ref set - const previousSize = allRegNamesRef.current.size; - regNames.forEach(name => { - if (name && name.trim() !== "") { - allRegNamesRef.current.add(name); - } - }); - - // Only update if we have new reg_names - if (allRegNamesRef.current.size !== previousSize) { - // Generate stable color palette based on sorted reg_names - const sortedRegNames = Array.from(allRegNamesRef.current).sort(); - const palette = generateColorPalette(sortedRegNames); - - setColorPalette(palette); - if (setProjectRegNames) setProjectRegNames(sortedRegNames); - if (setProjectColorPalette) setProjectColorPalette(palette); - } else { - // Even if no new reg_names, update context with current regNames - if (setProjectRegNames) setProjectRegNames(regNames); - } - } - }, [regNames, setProjectRegNames, setProjectColorPalette]); - - // Update selected reg names in context - useEffect(() => { - if (setSelectedProjectRegName) { - // Convert Set to array for context (or pass first selected if single selection expected) - const selectedArray = Array.from(selectedRegNames); - setSelectedProjectRegName(selectedArray.length > 0 ? selectedArray[0] : null); - } - }, [selectedRegNames, setSelectedProjectRegName]); - - // Zoom to selected project trails extent - const previousSelectedRef = useRef(new Set()); - useEffect(() => { - if (!allTrailsData || !allTrailsData.features || selectedRegNames.size === 0) { - previousSelectedRef.current = new Set(selectedRegNames); - return; - } - - const map = mapRef.current?.getMap(); - if (!map) { - previousSelectedRef.current = new Set(selectedRegNames); - return; - } - - // Find newly selected projects (projects that were just added) - const newlySelected = Array.from(selectedRegNames).filter( - regName => !previousSelectedRef.current.has(regName) - ); - - // Only zoom if a new project was just selected - if (newlySelected.length > 0) { - // Get trails for the newly selected project(s) - const trailsToZoom = allTrailsData.features.filter(feature => { - const regName = (feature.properties?.reg_name || "").trim(); - return newlySelected.some(selected => selected.trim() === regName); - }); - - if (trailsToZoom.length > 0) { - try { - // Create a FeatureCollection with the trails - const featureCollection = { - type: "FeatureCollection", - features: trailsToZoom - }; - - // Calculate bounding box - const bounds = bbox(featureCollection); - - // Fit map to bounds with padding - map.fitBounds( - [ - [bounds[0], bounds[1]], // Southwest corner - [bounds[2], bounds[3]] // Northeast corner - ], - { - padding: { top: 100, bottom: 100, left: 100, right: 100 }, - duration: 1000, - maxZoom: 15 - } - ); - } catch (e) { - console.warn("Error fitting bounds to project trails:", e); - } - } - } - - // Update previous selected ref - previousSelectedRef.current = new Set(selectedRegNames); - }, [selectedRegNames, allTrailsData]); - - // Handle trail click - const handleTrailClick = async (event) => { - const map = mapRef.current?.getMap(); - if (!map || !event.lngLat) { - toggleIdentifyPopup(false); - setOpenSpaceClickInfo(null); - return; - } - - // Check for OpenSpace clicks first - if (showOpenSpace && event.features) { - const openSpaceFeature = event.features.find((f) => - f.layer && (f.layer.id === 'openspace-layer' || f.layer.id === 'openspace-outline') - ); - - if (openSpaceFeature) { - // If clicking on the same OpenSpace feature, close the popup - if (openSpaceClickInfo && - openSpaceClickInfo.feature.properties?.OBJECTID === openSpaceFeature.properties?.OBJECTID) { - setOpenSpaceClickInfo(null); - } else { - setOpenSpaceClickInfo({ - point: { lng: event.lngLat.lng, lat: event.lngLat.lat }, - feature: openSpaceFeature - }); - } - toggleIdentifyPopup(false); - return; - } - } - - let trailFeatures = []; - - // First, try to get features from event.features - if (event.features && event.features.length > 0) { - trailFeatures = event.features.filter((f) => - f.layer && (f.layer.id === "trails-reg-name-sync-layer" || f.layer.id === "gaps-reg-name-sync-layer") - ); - } - - // If no features found, query the map directly - if (trailFeatures.length === 0) { - const point = [event.lngLat.lng, event.lngLat.lat]; - // Query all rendered features at the click point - const allFeatures = map.queryRenderedFeatures(point); - - // Filter for trail layers (including gaps) - trailFeatures = allFeatures.filter((f) => - f.layer && (f.layer.id === "trails-reg-name-sync-layer" || f.layer.id === "gaps-reg-name-sync-layer") - ); - } - - if (trailFeatures.length > 0) { - const trailResults = trailFeatures.map(feature => { - const props = feature.properties || {}; - const segType = props.seg_type; - const facStat = props.fac_stat; - const trailTypeLabel = getTrailTypeLabel(segType, facStat); - - return { - layerName: trailTypeLabel, - attributes: props - }; - }); - - if (trailResults.length > 0) { - const popupCoords = { lng: event.lngLat.lng, lat: event.lngLat.lat }; - - toggleIdentifyPopup(false); - setOpenSpaceClickInfo(null); - setTimeout(() => { - setIdentifyPoint(popupCoords); - setIdentifyInfo(trailResults); - setPointIndex(0); - toggleIdentifyPopup(true); - }, 10); - } - } else { - // If clicking on empty space, close popups - toggleIdentifyPopup(false); - if (showOpenSpace) { - const point = [event.lngLat.lng, event.lngLat.lat]; - const queriedFeatures = map.queryRenderedFeatures(point, { - layers: ['openspace-layer', 'openspace-outline'] - }); - if (queriedFeatures.length === 0) { - setOpenSpaceClickInfo(null); - } - } - } - }; - - // Handle trail hover - const handleTrailHover = (event) => { - const map = mapRef.current?.getMap(); - if (!map || !event.lngLat) { - setHoveredTrail(null); - return; - } - - const point = [event.lngLat.lng, event.lngLat.lat]; - const features = event.features || map.queryRenderedFeatures(point); - - // Handle trail hover (including gaps) - const trailFeature = features.find((f) => - f.layer && (f.layer.id === "trails-reg-name-sync-layer" || f.layer.id === "gaps-reg-name-sync-layer") - ); - - if (trailFeature) { - setHoveredTrail({ - properties: trailFeature.properties, - lngLat: event.lngLat, - featureId: trailFeature.properties?.OBJECTID || - trailFeature.properties?.objectid || - trailFeature.id || - null - }); - return; - } - - // No municipality hover handling - municipalities are always visible but not interactive - setHoveredTrail(null); - }; - - // Calculate metrics for selected projects - const projectMetrics = useMemo(() => { - if (!allTrailsData || !allTrailsData.features || selectedRegNames.size === 0) { - return {}; - } - - const metrics = {}; - - // Process each selected project - Array.from(selectedRegNames).forEach(regName => { - // Filter trails for this project - const projectTrails = allTrailsData.features.filter( - feature => (feature.properties?.reg_name || "").trim() === regName.trim() - ); - - if (projectTrails.length === 0) { - metrics[regName] = { - totalLength: 0, - totalLengthMiles: 0, - municipalities: [] - }; - return; - } - - // Calculate total length and categorize by status and type - let totalLengthFeet = 0; - let completedLengthFeet = 0; // fac_stat = 1 means existing/completed - const lengthByType = {}; // Track length by trail type - const gaps = []; // Track gaps (seg_type = 9) - - projectTrails.forEach(trail => { - const props = trail.properties || {}; - const segType = props.seg_type; - const facStat = props.fac_stat; - const trailTypeLabel = getTrailTypeLabel(segType, facStat); - - // Try to get length from attributes first - const lengthAttr = props.length_ft || - props['Facility Length in Feet'] || - props.Shape_Length || - 0; - - let lengthFeet = Number(lengthAttr) || 0; - - // If no length attribute, calculate from geometry using turf - if (lengthFeet === 0 && trail.geometry) { - try { - const lengthMeters = turf.length(trail, { units: 'meters' }); - lengthFeet = lengthMeters * 3.28084; // Convert meters to feet - } catch (e) { - console.warn("Error calculating trail length:", e); - } - } - - totalLengthFeet += lengthFeet; - - // Track completed length (fac_stat = 1) - if (facStat === 1 || facStat === "1") { - completedLengthFeet += lengthFeet; - } - - // Track length by type - if (!lengthByType[trailTypeLabel]) { - lengthByType[trailTypeLabel] = 0; - } - lengthByType[trailTypeLabel] += lengthFeet; - - // Track gaps (seg_type = 9) - if (segType === 9 || segType === "9") { - gaps.push({ - type: trailTypeLabel, - length: lengthFeet, - geometry: trail.geometry - }); - } - }); - - const totalLengthMiles = totalLengthFeet / 5280; - const completedLengthMiles = completedLengthFeet / 5280; - const percentageComplete = totalLengthFeet > 0 - ? ((completedLengthFeet / totalLengthFeet) * 100).toFixed(1) - : 0; - - // Determine which municipalities the trails are in - const municipalitySet = new Set(); - - if (massachusettsData && massachusettsData.features) { - projectTrails.forEach(trail => { - if (trail.geometry) { - try { - const trailFeature = turf.feature(trail.geometry); - - massachusettsData.features.forEach(muni => { - if (muni.geometry) { - try { - const muniPolygon = turf.feature(muni.geometry); - const intersects = turf.booleanIntersects(trailFeature, muniPolygon); - - if (intersects) { - const muniName = muni.properties?.town || muni.properties?.NAME || null; - if (muniName) { - municipalitySet.add(muniName); - } - } - } catch (e) { - // Skip if geometry is invalid - } - } - }); - } catch (e) { - // Skip if trail geometry is invalid - } - } - }); - } - - // Find parks (OpenSpace) that trails pass through - const parksSet = new Set(); - if (openSpaceData && openSpaceData.features) { - projectTrails.forEach(trail => { - if (trail.geometry) { - try { - const trailFeature = turf.feature(trail.geometry); - - openSpaceData.features.forEach(park => { - if (park.geometry) { - try { - const parkPolygon = turf.feature(park.geometry); - const intersects = turf.booleanIntersects(trailFeature, parkPolygon); - - if (intersects) { - const parkName = park.properties?.SITE_NAME || - park.properties?.NAME || - park.properties?.name || - "Unnamed Park"; - parksSet.add(parkName); - } - } catch (e) { - // Skip if geometry is invalid - } - } - }); - } catch (e) { - // Skip if trail geometry is invalid - } - } - }); - } - - // Get trail steward and website from first trail (assuming they're consistent for a project) - const firstTrail = projectTrails[0]; - const steward = firstTrail?.properties?.steward || - firstTrail?.properties?.Steward || - firstTrail?.properties?.STEWARD || - null; - const website = firstTrail?.properties?.website || - firstTrail?.properties?.Website || - firstTrail?.properties?.WEBSITE || - firstTrail?.properties?.url || - firstTrail?.properties?.URL || - null; - - // Convert lengthByType to array with miles - const lengthByTypeArray = Object.entries(lengthByType).map(([type, feet]) => ({ - type, - miles: (feet / 5280).toFixed(2) - })); - - metrics[regName] = { - totalLength: totalLengthFeet, - totalLengthMiles: totalLengthMiles.toFixed(2), - completedLengthMiles: completedLengthMiles.toFixed(2), - percentageComplete: percentageComplete, - municipalities: Array.from(municipalitySet).sort(), - parks: Array.from(parksSet).sort(), - steward: steward, - website: website, - lengthByType: lengthByTypeArray, - gaps: gaps.map(gap => ({ - type: gap.type, - lengthMiles: (gap.length / 5280).toFixed(2) - })) - }; - }); - - return metrics; - }, [allTrailsData, selectedRegNames, openSpaceData]); - - - // Get all layer IDs for trails reg name sync (only selected projects) - const getTrailLayerIds = () => { - const layerIds = []; - if (selectedRegNames.size > 0) { - // Add regular trail layer - layerIds.push("trails-reg-name-sync-layer"); - // Add gap layer - layerIds.push("gaps-reg-name-sync-layer"); - } - // Add OpenSpace layers if OpenSpace is shown - if (showOpenSpace) { - layerIds.push("openspace-layer"); - layerIds.push("openspace-outline"); - } - // Don't add municipalities-fill to interactive layers since we don't want hover - return layerIds; - }; - - // Municipality layers function - always show, no hover - const municipalitiesLayers = () => { - const visibleMunicipalitiesLayers = []; - // Always show municipalities in project trails profile - visibleMunicipalitiesLayers.push( - - ); - return visibleMunicipalitiesLayers; - }; - - // Ensure baseLayer and MAPBOX_TOKEN exist before rendering - if (!baseLayer || !baseLayer.url || !MAPBOX_TOKEN) { - return null; - } - - return ( -
- { - setViewport(event.viewState); - }} - onClick={handleTrailClick} - onMouseMove={handleTrailHover} - onMouseLeave={() => { - setHoveredTrail(null); - }} - mapboxAccessToken={MAPBOX_TOKEN} - mapStyle={baseLayer.url} - scrollZoom={true} - transitionDuration="1000" - > - {/* Trails Reg Name Sync Layer */} - - - {/* Hover popup */} - {hoveredTrail && hoveredTrail.lngLat && ( - -
- {hoveredTrail.properties?.reg_name || 'Unknown Project'} -
-
- )} - - {/* Identify popup */} - {showIdentifyPopup && identifyPoint && identifyInfo && identifyInfo.length > 0 && ( - { - toggleIdentifyPopup(false); - }} - handleCarousel={setPointIndex} - /> - )} - - {/* OpenSpace Click Popup */} - {showOpenSpace && openSpaceClickInfo && openSpaceClickInfo.point && openSpaceClickInfo.feature && ( - setOpenSpaceClickInfo(null)} - anchor="top" - offset={12} - > - {(() => { - const properties = openSpaceClickInfo.feature.properties || {}; - - return ( -
-
OpenSpace
- {properties.SITE_NAME && ( -
{properties.SITE_NAME}
- )} - {properties.FEE_OWNER && ( -
Owner: {properties.FEE_OWNER}
- )} - {properties.OWNER_TYPE && ( -
Owner Type: {properties.OWNER_TYPE}
- )} - {properties.PRIM_PURP && ( -
Primary Purpose: {properties.PRIM_PURP}
- )} - {properties.PUB_ACCESS && ( -
Public Access: {properties.PUB_ACCESS}
- )} - {properties.GIS_ACRES !== null && properties.GIS_ACRES !== undefined && ( -
Acres: {parseFloat(properties.GIS_ACRES).toFixed(2)}
- )} - {!properties.SITE_NAME && !properties.FEE_OWNER && ( -
No data available
- )} -
- ); - })()} -
- )} - - {/* Municipality Map Layer - always visible */} - - {municipalitiesLayers()} - - - {/* OpenSpace Layer */} - {showOpenSpace && ( - - )} - - {/* Environmental Justice Layer */} - {showEnvironmentalJustice && ( - - )} - - {/* Geocoder - styled to appear inside control panel */} - - - {/* Map controls */} - - - - - {/* Control Panel Toggle Button */} - toggleControlPanel(!showControlPanel)} - /> -
- - - {/* Basemap Panel */} - {showBasemapPanel && ( - - )} - - {/* Control Panel */} - {showControlPanel && ( -
- { - const newSelected = new Set(selectedRegNames); - if (newSelected.has(regName)) { - newSelected.delete(regName); - } else { - newSelected.add(regName); - } - setSelectedRegNames(newSelected); - }} - /> -
- )} - - {/* Project Metrics Panel - separate window on the left */} - -
- ); -}; - -export default ProjectTrailsProfile; - diff --git a/src/components/Map/RegionalTrailsProfile.js b/src/components/Map/RegionalTrailsProfile.js new file mode 100644 index 0000000..0ecedab --- /dev/null +++ b/src/components/Map/RegionalTrailsProfile.js @@ -0,0 +1,1668 @@ +import React, { useState, useRef, useEffect, useContext, useMemo } from "react"; +import { useNavigate, useLocation } from "react-router-dom"; +import ReactMapGL, { NavigationControl, GeolocateControl, ScaleControl, Popup, Source, Layer } from "react-map-gl"; +import BasemapPanel from "../BasemapPanel"; +import ControlPanel from "../ControlPanel"; +import Control from "./Control"; +import FilterIcon from "../../assets/icons/filter-icon.svg"; +import CommunityIdentify from "./CommunityIdentify"; +import ProjectMetricsPanel from "./ProjectMetricsPanel"; +import GeocoderPanel from "../Geocoder/GeocoderPanel"; +import { LayerContext } from "../../App"; +import TrailsRegNameSyncLayer from "./layers/TrailsRegNameSyncLayer"; +import MajorTrailsLayer from "./layers/MajorTrailsLayer"; +import OpenSpaceLayer from "./layers/OpenSpaceLayer"; +import EnvironmentalJusticeLayer from "./layers/EnvironmentalJusticeLayer"; +import massachusettsData from "../../data/massachusetts.json"; +import muniKeys from "../../data/ma_muni_keys.json"; +import * as turf from "@turf/turf"; +import bbox from "@turf/bbox"; + +const MAPBOX_TOKEN = process.env.REACT_APP_MAPBOX_API_TOKEN; + +const RegionalTrailsProfile = ({ + viewport, + setViewport, + baseLayer, + showBasemapPanel, + toggleBasemapPanel, + showControlPanel, + toggleControlPanel, + mapRef +}) => { + const navigate = useNavigate(); + const location = useLocation(); + const { + showTrailsRegNameSync, + setShowTrailsRegNameSync, + basemaps, + setProjectRegNames, + setSelectedProjectRegName, + setProjectColorPalette, + showMunicipalities, + toggleMunicipalities, + showOpenSpace: showOpenSpaceFromContext, + setShowOpenSpace: setShowOpenSpaceFromContext, + showEnvironmentalJustice, + setShowEnvironmentalJustice, + } = useContext(LayerContext); + + const [showIdentifyPopup, toggleIdentifyPopup] = useState(false); + const [identifyInfo, setIdentifyInfo] = useState(null); + const [identifyPoint, setIdentifyPoint] = useState(null); + const [pointIndex, setPointIndex] = useState(0); + const [regNames, setRegNames] = useState([]); + const [selectedRegNames, setSelectedRegNames] = useState(new Set()); // Track selected projects (Set for easy toggle) + const [selectedMajorTrails, setSelectedMajorTrails] = useState([]); // Track selected major trails (array of grouped_reg_name values) + + // Reset selected projects when entering Regional Trails Profile and show municipalities by default + useEffect(() => { + if (location.pathname === '/regionalTrailsProfile') { + setSelectedRegNames(new Set()); + setSelectedMajorTrails([]); + // Show municipalities by default + toggleMunicipalities(true); + } + }, [location.pathname, toggleMunicipalities]); + const [hoveredTrail, setHoveredTrail] = useState(null); + const [colorPalette, setColorPalette] = useState({}); + const allRegNamesRef = useRef(new Set()); // Track all unique reg_names seen using ref + const [allTrailsData, setAllTrailsData] = useState(null); // Store all trail data from TrailsRegNameSyncLayer + const [majorTrailsData, setMajorTrailsData] = useState(null); // Store all major trail data from MajorTrailsLayer + + // Use global OpenSpace state instead of local state to persist across profile switches + const showOpenSpace = showOpenSpaceFromContext; + + const [openSpaceClickInfo, setOpenSpaceClickInfo] = useState(null); // Store OpenSpace click info for popup + + // Listen for OpenSpace toggle events (only for Regional Trails Profile) + useEffect(() => { + const handleToggleOpenSpace = (event) => { + if (location.pathname === '/regionalTrailsProfile') { + setShowOpenSpaceFromContext(event.detail.show); + // Zoom to level 11 when OpenSpace is opened + if (event.detail.show && mapRef?.current) { + const map = mapRef.current.getMap(); + if (map) { + map.easeTo({ + zoom: 11, + duration: 1000 + }); + } + } + } + }; + + window.addEventListener('toggleOpenSpace', handleToggleOpenSpace); + return () => { + window.removeEventListener('toggleOpenSpace', handleToggleOpenSpace); + }; + }, [location.pathname, setShowOpenSpaceFromContext, mapRef]); + const [environmentalJusticeClickInfo, setEnvironmentalJusticeClickInfo] = useState(null); // Store Environmental Justice click info for popup + const [majorTrailClickInfo, setMajorTrailClickInfo] = useState(null); // Store Major Trail click info for popup + const [regularTrailClickInfo, setRegularTrailClickInfo] = useState(null); // Store Regular Trail click info for popup + + // Helper function to get municipality name from muni_id + const getMunicipalityName = (muniId) => { + if (!muniId || muniId === "Null" || muniId === "" || muniId === 0) return null; + const municipality = muniKeys.find( + (muni) => + muni.muni_id === parseInt(muniId) || + muni.muni_id === muniId || + muni.muni_id.toString() === muniId.toString() + ); + return municipality ? municipality.muni_name : null; + }; + + // Get trail type label based on seg_type and fac_stat + const getTrailTypeLabel = (segType, facStat) => { + const key = `${segType},${facStat}`; + const typeMap = { + "1,1": "Shared Use Path - Existing", + "1,2": "Shared Use Path - Design", + "1,3": "Shared Use Path - Envisioned", + "6,3": "Shared Use Path - Unimproved Surface", + "6,1": "Shared Use Path - Unimproved Surface", + "6,2": "Shared Use Path - Unimproved Surface", + "2,1": "Protected Bike Lane and Sidewalk", + "2,2": "Protected Bike Lane - Design or Construction", + "2,3": "Protected Bike Lane - Design or Construction", + "3,1": "Bike Lane and Sidewalk", + "3,2": "Bike Lane - Design or Construction", + "3,3": "Bike Lane - Design or Construction", + "4,3": "Shared Street - Urban", + "4,1": "Shared Street - Urban", + "5,1": "Shared Street - Suburban", + "5,3": "Shared Street - Envisioned", + "9,1": "Gap - Facility Type TBD", + "9,2": "Gap - Facility Type TBD", + "9,3": "Gap - Facility Type TBD", + "11,1": "Foot Trail - Natural Surface", + "11,3": "Foot Trail - Envisioned Natural Surface", + "11,2": "Foot Trail - Envisioned Natural Surface", + "12,1": "Foot Trail - Roadway Section", + "12,2": "Foot Trail - Envisioned Roadway Section", + "12,3": "Foot Trail - Envisioned Roadway Section" + }; + + return typeMap[key] || "Unknown Trail Type"; + }; + + // Color palette generation function + const generateColorPalette = (regNamesArray) => { + const colors = [ + "#FF6B35", "#4ECDC4", "#45B7D1", "#FFA07A", "#98D8C8", + "#F7DC6F", "#BB8FCE", "#85C1E2", "#F8B739", "#52BE80", + "#EC7063", "#5DADE2", "#F1948A", "#58D68D", "#F4D03F", + "#AF7AC5", "#7FB3D3", "#F5B041", "#82E0AA", "#F39C12", + "#E74C3C", "#3498DB", "#E67E22", "#1ABC9C", "#9B59B6", + "#34495E", "#16A085", "#27AE60", "#2980B9", "#8E44AD" + ]; + + const palette = {}; + regNamesArray.forEach((name, index) => { + if (name && name.trim() !== "") { + palette[name] = colors[index % colors.length]; + } + }); + + return palette; + }; + + // Update color palette when new reg_names are discovered + useEffect(() => { + if (regNames.length > 0) { + // Add new reg_names to the ref set + const previousSize = allRegNamesRef.current.size; + regNames.forEach(name => { + if (name && name.trim() !== "") { + allRegNamesRef.current.add(name); + } + }); + + // Only update if we have new reg_names + if (allRegNamesRef.current.size !== previousSize) { + // Generate stable color palette based on sorted reg_names + const sortedRegNames = Array.from(allRegNamesRef.current).sort(); + const palette = generateColorPalette(sortedRegNames); + + setColorPalette(palette); + if (setProjectRegNames) setProjectRegNames(sortedRegNames); + if (setProjectColorPalette) setProjectColorPalette(palette); + } else { + // Even if no new reg_names, update context with current regNames + if (setProjectRegNames) setProjectRegNames(regNames); + } + } + }, [regNames, setProjectRegNames, setProjectColorPalette]); + + // Update selected reg names in context + useEffect(() => { + if (setSelectedProjectRegName) { + // Convert Set to array for context (or pass first selected if single selection expected) + const selectedArray = Array.from(selectedRegNames); + setSelectedProjectRegName(selectedArray.length > 0 ? selectedArray[0] : null); + } + }, [selectedRegNames, setSelectedProjectRegName]); + + // Function to zoom to a specific project by regName (works for both regular projects and major trails) + const handleZoomToProject = (regName) => { + const map = mapRef.current?.getMap(); + if (!map || !regName) { + return; + } + + let trailsToZoom = []; + + // Check if it's a major trail (check selectedMajorTrails) + if (selectedMajorTrails.includes(regName) && majorTrailsData && majorTrailsData.features) { + trailsToZoom = majorTrailsData.features.filter(feature => { + const groupedRegName = (feature.properties?.grouped_reg_name || "").trim(); + return groupedRegName === regName.trim(); + }); + } + // Otherwise, check regular projects + else if (allTrailsData && allTrailsData.features) { + trailsToZoom = allTrailsData.features.filter(feature => { + const featureRegName = (feature.properties?.reg_name || "").trim(); + return featureRegName === regName.trim(); + }); + } + + if (trailsToZoom.length === 0) { + return; + } + + if (trailsToZoom.length > 0) { + try { + // Create a FeatureCollection with the trails + const featureCollection = { + type: "FeatureCollection", + features: trailsToZoom + }; + + // Calculate bounding box + const bounds = bbox(featureCollection); + + // Fit map to bounds with padding + map.fitBounds( + [ + [bounds[0], bounds[1]], // Southwest corner + [bounds[2], bounds[3]] // Northeast corner + ], + { + padding: { top: 100, bottom: 100, left: 100, right: 100 }, + duration: 1000, + maxZoom: 15 + } + ); + } catch (e) { + console.warn("Error fitting bounds to project trails:", e); + } + } + }; + + // Zoom to selected project trails extent + const previousSelectedRef = useRef(new Set()); + useEffect(() => { + if (!allTrailsData || !allTrailsData.features || selectedRegNames.size === 0) { + previousSelectedRef.current = new Set(selectedRegNames); + return; + } + + const map = mapRef.current?.getMap(); + if (!map) { + previousSelectedRef.current = new Set(selectedRegNames); + return; + } + + // Find newly selected projects (projects that were just added) + const newlySelected = Array.from(selectedRegNames).filter( + regName => !previousSelectedRef.current.has(regName) + ); + + // Only zoom if a new project was just selected + if (newlySelected.length > 0) { + // Get trails for the newly selected project(s) + const trailsToZoom = allTrailsData.features.filter(feature => { + const regName = (feature.properties?.reg_name || "").trim(); + return newlySelected.some(selected => selected.trim() === regName); + }); + + if (trailsToZoom.length > 0) { + try { + // Create a FeatureCollection with the trails + const featureCollection = { + type: "FeatureCollection", + features: trailsToZoom + }; + + // Calculate bounding box + const bounds = bbox(featureCollection); + + // Fit map to bounds with padding + map.fitBounds( + [ + [bounds[0], bounds[1]], // Southwest corner + [bounds[2], bounds[3]] // Northeast corner + ], + { + padding: { top: 100, bottom: 100, left: 100, right: 100 }, + duration: 1000, + maxZoom: 15 + } + ); + } catch (e) { + console.warn("Error fitting bounds to project trails:", e); + } + } + } + + // Update previous selected ref + previousSelectedRef.current = new Set(selectedRegNames); + }, [selectedRegNames, allTrailsData]); + + // Query Environmental Justice feature at a point + const queryEnvironmentalJusticeAtPoint = async (lng, lat) => { + try { + // Convert lat/lon to Web Mercator (EPSG:3857) + const toWebMercator = (lon, lat) => { + const x = lon * 20037508.34 / 180; + let y = Math.log(Math.tan((90 + lat) * Math.PI / 360)) / (Math.PI / 180); + y = y * 20037508.34 / 180; + return { x, y }; + }; + + const pointMerc = toWebMercator(lng, lat); + + // Create a small buffer around the point for querying + const bufferRadius = 100; // meters in Web Mercator + const pointGeometry = { + x: pointMerc.x, + y: pointMerc.y, + spatialReference: { wkid: 3857 } + }; + + const EJ2020_SERVICE_URL = "https://arcgisserver.digital.mass.gov/arcgisserver/rest/services/AGOL/EJ2020/MapServer/0"; + + // Query using point geometry + const params = new URLSearchParams(); + params.set("where", "1=1"); + params.set("geometry", JSON.stringify(pointGeometry)); + params.set("geometryType", "esriGeometryPoint"); + params.set("inSR", "3857"); + params.set("spatialRel", "esriSpatialRelIntersects"); + params.set("outFields", "*"); + params.set("outSR", "4326"); + params.set("f", "geojson"); + params.set("returnGeometry", "true"); + + const url = `${EJ2020_SERVICE_URL}/query?${params.toString()}`; + const response = await fetch(url); + const data = await response.json(); + + if (data.features && data.features.length > 0) { + // Return the first feature (closest match) + return data.features[0]; + } + return null; + } catch (error) { + console.error("Error querying Environmental Justice feature:", error); + return null; + } + }; + + // Handle trail click + const handleTrailClick = async (event) => { + const map = mapRef.current?.getMap(); + if (!map || !event.lngLat) { + toggleIdentifyPopup(false); + setOpenSpaceClickInfo(null); + setEnvironmentalJusticeClickInfo(null); + setMajorTrailClickInfo(null); + return; + } + + // Check for OpenSpace clicks first (before other layers, like Community Trails Profile) + if (showOpenSpace) { + let openSpaceFeature = null; + + // First try to get from event.features + if (event.features) { + openSpaceFeature = event.features.find((f) => + f.layer && (f.layer.id === 'openspace-layer-regional' || f.layer.id === 'openspace-outline-regional') + ); + } + + + // If not found in event.features, query the map directly + if (!openSpaceFeature) { + const centerPoint = [event.lngLat.lng, event.lngLat.lat]; + + // Check if layers exist before querying + const style = map.getStyle(); + const layersExist = style && style.layers && ( + style.layers.some(layer => layer.id === 'openspace-layer-regional') || + style.layers.some(layer => layer.id === 'openspace-outline-regional') + ); + + if (layersExist) { + const layerFilter = { + layers: ['openspace-layer-regional', 'openspace-outline-regional'] + }; + + try { + // First try exact point query + let queriedFeatures = map.queryRenderedFeatures(centerPoint, layerFilter); + if (queriedFeatures.length > 0) { + openSpaceFeature = queriedFeatures.find(f => f.layer.id === 'openspace-layer-regional') || queriedFeatures[0]; + } else { + // Try querying with a small tolerance for better detection + const zoom = map.getZoom(); + const tolerance = Math.max(0.0001, 0.0005 / Math.pow(2, zoom - 10)); + + const queryPoints = [ + centerPoint, + [event.lngLat.lng + tolerance, event.lngLat.lat], + [event.lngLat.lng - tolerance, event.lngLat.lat], + [event.lngLat.lng, event.lngLat.lat + tolerance], + [event.lngLat.lng, event.lngLat.lat - tolerance] + ]; + + for (const queryPoint of queryPoints) { + try { + queriedFeatures = map.queryRenderedFeatures(queryPoint, layerFilter); + if (queriedFeatures.length > 0) { + openSpaceFeature = queriedFeatures.find(f => f.layer.id === 'openspace-layer-regional') || queriedFeatures[0]; + break; + } + } catch (err) { + // Layer might not exist, continue to next point + continue; + } + } + } + } catch (err) { + // Layer doesn't exist or query failed + console.warn('Error querying OpenSpace layers:', err.message); + } + } + } + + if (openSpaceFeature && event.lngLat) { + // If clicking on the same OpenSpace feature, close the popup + if (openSpaceClickInfo && + openSpaceClickInfo.feature.properties?.OBJECTID === openSpaceFeature.properties?.OBJECTID) { + setOpenSpaceClickInfo(null); + } else { + // Always clear existing tooltips first, then reopen + setOpenSpaceClickInfo(null); + toggleIdentifyPopup(false); + setEnvironmentalJusticeClickInfo(null); + setMajorTrailClickInfo(null); + setRegularTrailClickInfo(null); + + // Use setTimeout to ensure the tooltip reopens after clearing + setTimeout(() => { + setOpenSpaceClickInfo({ + point: { lng: event.lngLat.lng, lat: event.lngLat.lat }, + feature: openSpaceFeature + }); + }, 10); + } + return; + } + } + + // Check for Major Trail clicks first (before other trail layers) + if (selectedMajorTrails && selectedMajorTrails.length > 0) { + let majorTrailFeature = null; + + // First try to get from event.features + if (event.features) { + majorTrailFeature = event.features.find((f) => + f.layer && f.layer.id === "major-trails-layer" + ); + } + + // If not found in event.features, query the map directly with multiple points for better line detection + if (!majorTrailFeature) { + const centerPoint = [event.lngLat.lng, event.lngLat.lat]; + + // Check if layers exist before querying + const style = map.getStyle(); + const layersExist = style && style.layers && ( + style.layers.some(layer => layer.id === 'major-trails-layer') + ); + + if (layersExist) { + const layerFilter = { + layers: ['major-trails-layer'] + }; + + try { + // First try exact point query + let queriedFeatures = map.queryRenderedFeatures(centerPoint, layerFilter); + if (queriedFeatures.length > 0) { + majorTrailFeature = queriedFeatures[0]; + } else { + // Try querying multiple points in a small radius around the click + const zoom = map.getZoom(); + const tolerance = Math.max(0.0001, 0.0005 / Math.pow(2, zoom - 10)); + + const queryPoints = [ + centerPoint, + [event.lngLat.lng + tolerance, event.lngLat.lat], + [event.lngLat.lng - tolerance, event.lngLat.lat], + [event.lngLat.lng, event.lngLat.lat + tolerance], + [event.lngLat.lng, event.lngLat.lat - tolerance] + ]; + + for (const queryPoint of queryPoints) { + try { + queriedFeatures = map.queryRenderedFeatures(queryPoint, layerFilter); + if (queriedFeatures.length > 0) { + majorTrailFeature = queriedFeatures[0]; + break; + } + } catch (err) { + // Layer might not exist, continue to next point + continue; + } + } + } + } catch (err) { + // Layer doesn't exist or query failed + console.warn('Error querying major trail layers:', err.message); + } + } + } + + if (majorTrailFeature) { + // Always clear existing tooltips first, then reopen + setMajorTrailClickInfo(null); + toggleIdentifyPopup(false); + setOpenSpaceClickInfo(null); + setEnvironmentalJusticeClickInfo(null); + setRegularTrailClickInfo(null); + + // Use setTimeout to ensure the tooltip reopens after clearing + setTimeout(() => { + setMajorTrailClickInfo({ + point: { lng: event.lngLat.lng, lat: event.lngLat.lat }, + feature: majorTrailFeature + }); + }, 10); + return; + } + } + + // Check for Environmental Justice clicks (since it's a raster layer, we need to query) + if (showEnvironmentalJustice) { + const ejFeature = await queryEnvironmentalJusticeAtPoint(event.lngLat.lng, event.lngLat.lat); + if (ejFeature) { + // If clicking on the same EJ feature, close the popup + if (environmentalJusticeClickInfo && + environmentalJusticeClickInfo.feature.properties?.OBJECTID === ejFeature.properties?.OBJECTID) { + setEnvironmentalJusticeClickInfo(null); + } else { + setEnvironmentalJusticeClickInfo({ + point: { lng: event.lngLat.lng, lat: event.lngLat.lat }, + feature: ejFeature + }); + } + toggleIdentifyPopup(false); + setOpenSpaceClickInfo(null); + return; + } + } + + let trailFeatures = []; + + // First, try to get features from event.features + if (event.features && event.features.length > 0) { + trailFeatures = event.features.filter((f) => + f.layer && (f.layer.id === "trails-reg-name-sync-layer" || f.layer.id === "gaps-reg-name-sync-layer") + ); + } + + // If no features found, query the map directly with multiple points for better line detection + if (trailFeatures.length === 0) { + const centerPoint = [event.lngLat.lng, event.lngLat.lat]; + + // Check if layers exist before querying + const style = map.getStyle(); + const layersExist = style && style.layers && ( + style.layers.some(layer => layer.id === 'trails-reg-name-sync-layer') || + style.layers.some(layer => layer.id === 'gaps-reg-name-sync-layer') + ); + + if (layersExist) { + const layerFilter = { + layers: ['trails-reg-name-sync-layer', 'gaps-reg-name-sync-layer'] + }; + + try { + // First try exact point query + let allFeatures = map.queryRenderedFeatures(centerPoint, layerFilter); + trailFeatures = allFeatures.filter((f) => + f.layer && (f.layer.id === "trails-reg-name-sync-layer" || f.layer.id === "gaps-reg-name-sync-layer") + ); + + // If still no features, try querying multiple points in a small radius around the click + // This helps catch thin lines that might not be exactly at the click point + if (trailFeatures.length === 0) { + // Query points in a small cross pattern around the click point + // Calculate tolerance based on current zoom level for better accuracy + const zoom = map.getZoom(); + const tolerance = Math.max(0.0001, 0.0005 / Math.pow(2, zoom - 10)); // Adaptive tolerance based on zoom + + const queryPoints = [ + centerPoint, + [event.lngLat.lng + tolerance, event.lngLat.lat], + [event.lngLat.lng - tolerance, event.lngLat.lat], + [event.lngLat.lng, event.lngLat.lat + tolerance], + [event.lngLat.lng, event.lngLat.lat - tolerance], + [event.lngLat.lng + tolerance, event.lngLat.lat + tolerance], + [event.lngLat.lng - tolerance, event.lngLat.lat - tolerance], + [event.lngLat.lng + tolerance, event.lngLat.lat - tolerance], + [event.lngLat.lng - tolerance, event.lngLat.lat + tolerance] + ]; + + for (const queryPoint of queryPoints) { + try { + allFeatures = map.queryRenderedFeatures(queryPoint, layerFilter); + const foundFeatures = allFeatures.filter((f) => + f.layer && (f.layer.id === "trails-reg-name-sync-layer" || f.layer.id === "gaps-reg-name-sync-layer") + ); + if (foundFeatures.length > 0) { + trailFeatures = foundFeatures; + break; + } + } catch (err) { + // Layer might not exist, continue to next point + continue; + } + } + } + } catch (err) { + // Layer doesn't exist or query failed, try fallback + console.warn('Error querying trail layers:', err.message); + } + } + } + + // Final fallback: query all layers at the point and filter (only if layers don't exist or query failed) + if (trailFeatures.length === 0) { + try { + const centerPoint = [event.lngLat.lng, event.lngLat.lat]; + const allFeatures = map.queryRenderedFeatures(centerPoint); + trailFeatures = allFeatures.filter((f) => + f.layer && (f.layer.id === "trails-reg-name-sync-layer" || f.layer.id === "gaps-reg-name-sync-layer") + ); + } catch (err) { + // Silently fail if query doesn't work + console.warn('Error querying all features:', err.message); + } + } + + if (trailFeatures.length > 0) { + // Get the first trail feature for the tooltip + const trailFeature = trailFeatures[0]; + + // Always clear existing tooltips first, then reopen + setRegularTrailClickInfo(null); + toggleIdentifyPopup(false); + setOpenSpaceClickInfo(null); + setEnvironmentalJusticeClickInfo(null); + setMajorTrailClickInfo(null); + + // Use setTimeout to ensure the tooltip reopens after clearing + setTimeout(() => { + setRegularTrailClickInfo({ + point: { lng: event.lngLat.lng, lat: event.lngLat.lat }, + feature: trailFeature + }); + }, 10); + return; // Exit early after setting trail click info + } + + // If clicking on empty space, check if we should close OpenSpace popup + if (showOpenSpace && openSpaceClickInfo) { + const map = mapRef.current?.getMap(); + if (map && event.lngLat) { + const point = [event.lngLat.lng, event.lngLat.lat]; + try { + const queriedFeatures = map.queryRenderedFeatures(point, { + layers: ['openspace-layer-regional', 'openspace-outline-regional'] + }); + if (queriedFeatures.length === 0) { + setOpenSpaceClickInfo(null); + } + } catch (err) { + // If query fails, close the popup + setOpenSpaceClickInfo(null); + } + } + } + + // If clicking on empty space, close popups + toggleIdentifyPopup(false); + setEnvironmentalJusticeClickInfo(null); + setMajorTrailClickInfo(null); + setRegularTrailClickInfo(null); + }; + + // Handle trail hover + const handleTrailHover = (event) => { + const map = mapRef.current?.getMap(); + if (!map || !event.lngLat) { + setHoveredTrail(null); + return; + } + + const point = [event.lngLat.lng, event.lngLat.lat]; + let features = event.features; + + // If event.features is not available, query the map with error handling + if (!features) { + try { + features = map.queryRenderedFeatures(point); + } catch (err) { + // Layer might not exist yet, set hoveredTrail to null + console.warn('Error querying features for hover:', err.message); + setHoveredTrail(null); + return; + } + } + + // Check for major trail hover first (for cursor, but no popup) + if (selectedMajorTrails && selectedMajorTrails.length > 0) { + const majorTrailFeature = features.find((f) => + f.layer && f.layer.id === "major-trails-layer" + ); + if (majorTrailFeature) { + // Set hover for cursor purposes, but mark it as major trail so popup won't show + setHoveredTrail({ + properties: majorTrailFeature.properties, + lngLat: event.lngLat, + featureId: majorTrailFeature.properties?.OBJECTID || + majorTrailFeature.properties?.objectid || + majorTrailFeature.id || + null, + isMajorTrail: true // Flag to prevent popup display + }); + return; + } + } + + // Handle regular trail hover (for cursor only, no popup) + const trailFeature = features.find((f) => + f.layer && (f.layer.id === "trails-reg-name-sync-layer" || f.layer.id === "gaps-reg-name-sync-layer") + ); + + if (trailFeature) { + // Set hover for cursor purposes only, mark as regular trail so popup won't show + setHoveredTrail({ + properties: trailFeature.properties, + lngLat: event.lngLat, + featureId: trailFeature.properties?.OBJECTID || + trailFeature.properties?.objectid || + trailFeature.id || + null, + isRegularTrail: true // Flag to prevent hover popup display + }); + return; + } + + + // Environmental Justice is a raster layer, so hover detection is not possible + // Cursor will be set to pointer when clicking on it + + // No municipality hover handling - municipalities are always visible but not interactive + setHoveredTrail(null); + }; + + // Helper function to calculate metrics for a set of trails + const calculateTrailMetrics = (trails, name) => { + if (!trails || trails.length === 0) { + return { + totalLength: 0, + totalLengthMiles: 0, + municipalities: [] + }; + } + + // Calculate total length and categorize by status and type + let totalLengthFeet = 0; + let completedLengthFeet = 0; // fac_stat = 1 means existing/completed + const lengthByTypeExisting = {}; // Track length by trail type for existing trails + const lengthByTypePlanned = {}; // Track length by trail type for planned trails + const gaps = []; // Track gaps (seg_type = 9) + + trails.forEach(trail => { + const props = trail.properties || {}; + const segType = props.seg_type; + const facStat = props.fac_stat; + const trailTypeLabel = getTrailTypeLabel(segType, facStat); + + // Try to get length from attributes first + const lengthAttr = props.length_ft || + props['Facility Length in Feet'] || + props.Shape_Length || + 0; + + let lengthFeet = Number(lengthAttr) || 0; + + // If no length attribute, calculate from geometry using turf + if (lengthFeet === 0 && trail.geometry) { + try { + const lengthMeters = turf.length(trail, { units: 'meters' }); + lengthFeet = lengthMeters * 3.28084; // Convert meters to feet + } catch (e) { + console.warn("Error calculating trail length:", e); + } + } + + totalLengthFeet += lengthFeet; + + // Track completed length (fac_stat = 1) + if (facStat === 1 || facStat === "1") { + completedLengthFeet += lengthFeet; + } + + // Track gaps (seg_type = 9) separately + if (segType === 9 || segType === "9") { + gaps.push({ + type: trailTypeLabel, + length: lengthFeet, + geometry: trail.geometry + }); + } else { + // Track length by type, separated into existing and planned + if (facStat === 1 || facStat === "1") { + // Existing trails + if (!lengthByTypeExisting[trailTypeLabel]) { + lengthByTypeExisting[trailTypeLabel] = 0; + } + lengthByTypeExisting[trailTypeLabel] += lengthFeet; + } else { + // Planned trails (fac_stat = 2 or other values) + if (!lengthByTypePlanned[trailTypeLabel]) { + lengthByTypePlanned[trailTypeLabel] = 0; + } + lengthByTypePlanned[trailTypeLabel] += lengthFeet; + } + } + }); + + const totalLengthMiles = totalLengthFeet / 5280; + const completedLengthMiles = completedLengthFeet / 5280; + const percentageComplete = totalLengthFeet > 0 + ? ((completedLengthFeet / totalLengthFeet) * 100).toFixed(1) + : 0; + + // Determine which municipalities the trails are in using muni_id from feature properties + const municipalitySet = new Set(); + + // Helper function to get municipality name from muni_id + const getMunicipalityName = (muniId) => { + if (!muniId || muniId === "Null" || muniId === "" || muniId === 0) return null; + const municipality = muniKeys.find( + (muni) => + muni.muni_id === parseInt(muniId) || + muni.muni_id === muniId || + muni.muni_id.toString() === muniId.toString() + ); + return municipality ? municipality.muni_name : null; + }; + + // Extract muni_id from trail properties and look up municipality name + trails.forEach(trail => { + const props = trail.properties || {}; + // Try different possible field names for muni_id + const muniId = props.muni_id || + props.MUNI_ID || + props.muniId || + props.MuniId || + props.municipality_id || + props.MUNICIPALITY_ID || + null; + + if (muniId) { + const muniName = getMunicipalityName(muniId); + if (muniName) { + municipalitySet.add(muniName); + } + } + }); + + // Parks intersection calculation removed - using VectorTileServer now + const parksSet = new Set(); + + // Get trail steward and website from first trail (assuming they're consistent for a project) + const firstTrail = trails[0]; + const steward = firstTrail?.properties?.steward || + firstTrail?.properties?.Steward || + firstTrail?.properties?.STEWARD || + null; + const website = firstTrail?.properties?.website || + firstTrail?.properties?.Website || + firstTrail?.properties?.WEBSITE || + firstTrail?.properties?.url || + firstTrail?.properties?.URL || + null; + + // Convert lengthByType to arrays with miles, separated by existing, planned, and gap + const lengthByTypeExistingArray = Object.entries(lengthByTypeExisting).map(([type, feet]) => ({ + type, + miles: (feet / 5280).toFixed(2), + category: 'existing' + })); + + const lengthByTypePlannedArray = Object.entries(lengthByTypePlanned).map(([type, feet]) => ({ + type, + miles: (feet / 5280).toFixed(2), + category: 'planned' + })); + + const lengthByTypeGapArray = gaps.map(gap => ({ + type: gap.type, + miles: (gap.length / 5280).toFixed(2), + category: 'gap' + })); + + // Combine all length by type into a single array with categories + const lengthByTypeArray = [ + ...lengthByTypeExistingArray.map(item => ({ ...item, category: 'existing' })), + ...lengthByTypePlannedArray.map(item => ({ ...item, category: 'planned' })), + ...lengthByTypeGapArray.map(item => ({ ...item, category: 'gap' })) + ]; + + return { + totalLength: totalLengthFeet, + totalLengthMiles: totalLengthMiles.toFixed(2), + completedLengthMiles: completedLengthMiles.toFixed(2), + percentageComplete: percentageComplete, + municipalities: Array.from(municipalitySet).sort(), + parks: Array.from(parksSet).sort(), + steward: steward, + website: website, + lengthByType: lengthByTypeArray, + gaps: gaps.map(gap => ({ + type: gap.type, + lengthMiles: (gap.length / 5280).toFixed(2) + })) + }; + }; + + // Calculate metrics for selected projects and major trails (including hidden ones for metrics display) + const projectMetrics = useMemo(() => { + const metrics = {}; + + // Process each selected regular project (include hidden ones for metrics) + if (allTrailsData && allTrailsData.features && selectedRegNames.size > 0) { + Array.from(selectedRegNames).forEach(regName => { + // Filter trails for this project + const projectTrails = allTrailsData.features.filter( + feature => (feature.properties?.reg_name || "").trim() === regName.trim() + ); + + metrics[regName] = calculateTrailMetrics(projectTrails, regName); + }); + } + + // Process each selected major trail + if (majorTrailsData && majorTrailsData.features && selectedMajorTrails.length > 0) { + selectedMajorTrails.forEach(majorTrailName => { + // Filter trails for this major trail by grouped_reg_name + const majorTrailTrails = majorTrailsData.features.filter( + feature => { + const groupedRegName = (feature.properties?.grouped_reg_name || "").trim(); + return groupedRegName === majorTrailName.trim(); + } + ); + + metrics[majorTrailName] = calculateTrailMetrics(majorTrailTrails, majorTrailName); + }); + } + + return metrics; + }, [allTrailsData, selectedRegNames, majorTrailsData, selectedMajorTrails]); + + + // Get all layer IDs for trails reg name sync (always include trail layers for click detection) + const getTrailLayerIds = () => { + const layerIds = []; + // Always add regular trail layers for click detection (even if no projects selected) + layerIds.push("trails-reg-name-sync-layer"); + layerIds.push("gaps-reg-name-sync-layer"); + // Add Major Trail layer if major trails are selected (now includes gaps) + if (selectedMajorTrails && selectedMajorTrails.length > 0) { + layerIds.push("major-trails-layer"); + } + // Add OpenSpace layers if OpenSpace is shown + if (showOpenSpace) { + layerIds.push("openspace-layer-regional"); + layerIds.push("openspace-outline-regional"); + } + // Add Environmental Justice layer if shown + if (showEnvironmentalJustice) { + layerIds.push("environmental-justice-layer-regional"); + } + // Don't add municipalities-fill to interactive layers since we don't want hover + return layerIds; + }; + + // Municipality layers function - always show, no hover + const municipalitiesLayers = () => { + const visibleMunicipalitiesLayers = []; + // Always show municipalities in regional trails profile + visibleMunicipalitiesLayers.push( + + ); + return visibleMunicipalitiesLayers; + }; + + // Ensure trails layers are always on top + useEffect(() => { + if (!mapRef?.current) return; + + const map = mapRef.current.getMap(); + if (!map) return; + + const ensureTrailsOnTop = () => { + if (!map.isStyleLoaded()) { + map.once('styledata', ensureTrailsOnTop); + return; + } + + try { + // Complete list of all trail layer IDs that should be on top + const trailLayerIds = [ + 'trails-reg-name-sync-layer', + 'trails-reg-name-sync-layer-hover', + 'trails-reg-name-sync-layer-click', + 'gaps-reg-name-sync-layer', + 'major-trails-layer', + 'major-trails-layer-hover', + 'major-trails-layer-click' + ]; + + // Get all layer IDs in the current style + const style = map.getStyle(); + if (!style || !style.layers) return; + + const allLayerIds = style.layers.map(layer => layer.id); + + // Filter to get only existing trail layers + const existingTrailLayers = trailLayerIds.filter(id => map.getLayer(id)); + + if (existingTrailLayers.length === 0) return; + + // Find all non-trail layer IDs + const nonTrailLayerIds = allLayerIds.filter(id => !trailLayerIds.includes(id)); + + if (nonTrailLayerIds.length === 0) return; + + // Find the last non-trail layer ID - we'll move all trail layers after this + const lastNonTrailLayerId = nonTrailLayerIds[nonTrailLayerIds.length - 1]; + + // Move each trail layer to be after the last non-trail layer + // Process in order to maintain relative order among trail layers + existingTrailLayers.forEach(trailLayerId => { + try { + // Check current position + const currentBeforeId = map.getLayer(trailLayerId)?.metadata?.beforeId; + + // Only move if not already in correct position + // Move to be after the last non-trail layer + map.moveLayer(trailLayerId, lastNonTrailLayerId); + } catch (err) { + // Layer might not exist or already in correct position - ignore + } + }); + } catch (err) { + // Silently fail if there's an error + console.warn('Error ensuring trails on top:', err); + } + }; + + // Wait a bit for layers to be added + const timeoutId = setTimeout(ensureTrailsOnTop, 500); + + // Also listen for style changes and map movements + map.on('styledata', ensureTrailsOnTop); + map.on('moveend', ensureTrailsOnTop); + map.on('zoomend', ensureTrailsOnTop); + + return () => { + clearTimeout(timeoutId); + if (map && map.off) { + map.off('styledata', ensureTrailsOnTop); + map.off('moveend', ensureTrailsOnTop); + map.off('zoomend', ensureTrailsOnTop); + } + }; + }, [mapRef, selectedRegNames, selectedMajorTrails, showOpenSpace, showEnvironmentalJustice]); + + // Ensure baseLayer and MAPBOX_TOKEN exist before rendering + if (!baseLayer || !baseLayer.url || !MAPBOX_TOKEN) { + return null; + } + + return ( +
+ { + setViewport(event.viewState); + }} + onClick={handleTrailClick} + onMouseMove={handleTrailHover} + onMouseLeave={() => { + setHoveredTrail(null); + }} + mapboxAccessToken={MAPBOX_TOKEN} + mapStyle={baseLayer.url} + scrollZoom={true} + transitionDuration="1000" + transformRequest={(url, resourceType) => { + // Convert ArcGIS VectorTileServer URL format from {z}/{y}/{x}.pbf to {z}/{x}/{y}.pbf for Mapbox compatibility + if (resourceType === 'Tile' && url.includes('VectorTileServer/tile/')) { + // ArcGIS format: /tile/{z}/{y}/{x}.pbf + // Mapbox format: /tile/{z}/{x}/{y}.pbf + const convertedUrl = url.replace(/\/tile\/(\d+)\/(\d+)\/(\d+)\.pbf/, (match, z, y, x) => { + return `/tile/${z}/${x}/${y}.pbf`; + }); + return { url: convertedUrl }; + } + // For other requests, use default behavior + return { url }; + }} + > + {/* Municipality Map Layer - always visible */} + + {municipalitiesLayers()} + + + {/* OpenSpace Layer - rendered before trails to ensure trails appear on top */} + {showOpenSpace && ( + + )} + + {/* Environmental Justice Layer - rendered before trails to ensure trails appear on top */} + {showEnvironmentalJustice && ( + + )} + + {/* Major Trails Layer - rendered after other layers to appear on top */} + 0} + showRegionalTrailsProfile={true} + mapRef={mapRef} + selectedMajorTrails={selectedMajorTrails} + onTrailsDataChange={setMajorTrailsData} + hoveredTrail={ + hoveredTrail && (hoveredTrail.properties?.grouped_reg_name || hoveredTrail.isMajorTrail) + ? hoveredTrail + : null + } + clickedTrail={majorTrailClickInfo ? { + featureId: majorTrailClickInfo.feature.properties?.OBJECTID || + majorTrailClickInfo.feature.properties?.objectid || + majorTrailClickInfo.feature.id || + null + } : null} + /> + + {/* Trails Reg Name Sync Layer - rendered last to appear on top of all other layers */} + + + {/* No hover popup for regular trails - only show on click */} + + {/* Identify popup */} + {showIdentifyPopup && identifyPoint && identifyInfo && identifyInfo.length > 0 && ( + { + toggleIdentifyPopup(false); + }} + handleCarousel={setPointIndex} + /> + )} + + {/* Environmental Justice Click Popup */} + {showEnvironmentalJustice && environmentalJusticeClickInfo && environmentalJusticeClickInfo.point && environmentalJusticeClickInfo.feature && ( + setEnvironmentalJusticeClickInfo(null)} + anchor="top" + offset={12} + > + {(() => { + const properties = environmentalJusticeClickInfo.feature.properties || {}; + + return ( +
+
Environmental Justice
+ {properties.GEOGRAPHICAREANAME && ( +
Area: {properties.GEOGRAPHICAREANAME}
+ )} + {properties.MUNICIPALITY && ( +
Municipality: {properties.MUNICIPALITY}
+ )} + {properties.EJ_CRIT_DESC && ( +
EJ Criteria: {properties.EJ_CRIT_DESC}
+ )} + {properties.EJ && ( +
EJ Designated: {properties.EJ}
+ )} + {properties.TOTAL_POP !== undefined && properties.TOTAL_POP !== null && ( +
Total Population: {parseFloat(properties.TOTAL_POP).toLocaleString()}
+ )} + {properties.PCT_MINORITY !== undefined && properties.PCT_MINORITY !== null && ( +
Percent Minority: {parseFloat(properties.PCT_MINORITY).toFixed(1)}%
+ )} + {properties.LIMENGHHPCT !== undefined && properties.LIMENGHHPCT !== null && ( +
Limited English Households: {parseFloat(properties.LIMENGHHPCT).toFixed(1)}%
+ )} + {properties.BG_MHHI !== undefined && properties.BG_MHHI !== null && ( +
Median Household Income: ${parseFloat(properties.BG_MHHI).toLocaleString()}
+ )} + {properties.GEOID && ( +
GEOID: {properties.GEOID}
+ )} + {!properties.GEOGRAPHICAREANAME && !properties.MUNICIPALITY && !properties.EJ_CRIT_DESC && ( +
No data available
+ )} +
+ ); + })()} +
+ )} + + {/* Major Trail Click Popup */} + {selectedMajorTrails && selectedMajorTrails.length > 0 && majorTrailClickInfo && majorTrailClickInfo.point && majorTrailClickInfo.feature && ( + setMajorTrailClickInfo(null)} + anchor="top" + offset={12} + > + {(() => { + const properties = majorTrailClickInfo.feature.properties || {}; + const segType = properties.seg_type; + const facStat = properties.fac_stat; + const trailTypeLabel = getTrailTypeLabel(segType, facStat); + + // Get municipality name + const muniId = properties.muni_id || + properties.MUNI_ID || + properties.muniId || + properties.MuniId || + properties.municipality_id || + properties.MUNICIPALITY_ID || + null; + const municipalityName = muniId ? getMunicipalityName(muniId) : null; + + return ( +
+ {/* Regional Trail Name */} + {properties.grouped_reg_name && ( +
+ {properties.grouped_reg_name} +
+ )} + {trailTypeLabel && ( +
+ Type: {trailTypeLabel} +
+ )} + {municipalityName && ( +
+ Municipality: {municipalityName} +
+ )} + {properties.steward && ( +
+ Steward: {properties.steward} +
+ )} + {properties.website && ( + + )} + {properties.length_ft && ( +
+ Length: {(parseFloat(properties.length_ft) / 5280).toFixed(2)} miles +
+ )} + {properties.fac_stat && ( +
+ Status: {properties.fac_stat === 1 || properties.fac_stat === "1" ? "Existing" : properties.fac_stat === 2 || properties.fac_stat === "2" ? "Design/Construction" : "Envisioned"} +
+ )} +
+ ); + })()} +
+ )} + + {/* Regular Trail Click Popup */} + {regularTrailClickInfo && regularTrailClickInfo.point && regularTrailClickInfo.feature && ( + setRegularTrailClickInfo(null)} + anchor="top" + offset={12} + > + {(() => { + const properties = regularTrailClickInfo.feature.properties || {}; + const segType = properties.seg_type; + const facStat = properties.fac_stat; + const trailTypeLabel = getTrailTypeLabel(segType, facStat); + + // Get municipality name + const muniId = properties.muni_id || + properties.MUNI_ID || + properties.muniId || + properties.MuniId || + properties.municipality_id || + properties.MUNICIPALITY_ID || + null; + const municipalityName = muniId ? getMunicipalityName(muniId) : null; + + return ( +
+ {/* Regional Trail Name */} + {properties.reg_name && ( +
+ {properties.reg_name} +
+ )} + {trailTypeLabel && ( +
Type: {trailTypeLabel}
+ )} + {municipalityName && ( +
Municipality: {municipalityName}
+ )} + {properties.steward && ( +
Steward: {properties.steward}
+ )} + {properties.website && ( + + )} + {properties.length_ft && ( +
+ Length: {(parseFloat(properties.length_ft) / 5280).toFixed(2)} miles +
+ )} + {properties.fac_stat && ( +
+ Status: {properties.fac_stat === 1 || properties.fac_stat === "1" ? "Existing" : properties.fac_stat === 2 || properties.fac_stat === "2" ? "Design/Construction" : "Envisioned"} +
+ )} +
+ ); + })()} +
+ )} + + {/* OpenSpace Click Popup */} + {showOpenSpace && openSpaceClickInfo && openSpaceClickInfo.point && openSpaceClickInfo.feature && ( + { + setOpenSpaceClickInfo(null); + }} + anchor="top" + offset={12} + > + {(() => { + const properties = openSpaceClickInfo.feature.properties || {}; + + return ( +
+
OpenSpace
+ {properties.SITE_NAME && ( +
{properties.SITE_NAME}
+ )} + {properties.FEE_OWNER && ( +
Owner: {properties.FEE_OWNER}
+ )} + {properties.OWNER_TYPE && ( +
Owner Type: {properties.OWNER_TYPE}
+ )} + {properties.PRIM_PURP && ( +
Primary Purpose: {properties.PRIM_PURP}
+ )} + {properties.PUB_ACCESS && ( +
Public Access: {properties.PUB_ACCESS}
+ )} + {properties.GIS_ACRES !== null && properties.GIS_ACRES !== undefined && ( +
Acres: {parseFloat(properties.GIS_ACRES).toFixed(2)}
+ )} + {!properties.SITE_NAME && !properties.FEE_OWNER && ( +
No data available
+ )} +
+ ); + })()} +
+ )} + + {/* Geocoder - styled to appear inside control panel */} + + + {/* Map controls */} + + + + + {/* Trail Status Legend - only show when trails are selected */} + {(selectedRegNames.size > 0 || selectedMajorTrails.length > 0) && ( +
+
+ Trail Status +
+
+ {/* Existing */} +
+
+ Existing +
+ {/* Planned/Envisioned */} +
+
+ Planned/Envisioned/Design +
+ {/* Gap */} +
+
+ Gap +
+
+
+ )} + + {/* Control Panel Toggle Button */} + toggleControlPanel(!showControlPanel)} + /> +
+ + + {/* Basemap Panel */} + {showBasemapPanel && ( + + )} + + {/* Control Panel */} + {showControlPanel && ( +
+ { + const newSelected = new Set(selectedRegNames); + if (newSelected.has(regName)) { + newSelected.delete(regName); + } else { + newSelected.add(regName); + } + setSelectedRegNames(newSelected); + }} + onToggleMajorTrail={(majorTrailName) => { + const newSelected = [...selectedMajorTrails]; + const index = newSelected.indexOf(majorTrailName); + if (index > -1) { + newSelected.splice(index, 1); + } else { + newSelected.push(majorTrailName); + } + setSelectedMajorTrails(newSelected); + }} + /> +
+ )} + + {/* Regional Trails Metrics Panel - separate window on the left */} + +
+ ); +}; + +export default RegionalTrailsProfile; + diff --git a/src/components/Map/index.js b/src/components/Map/index.js index 458eb9f..7cc1da1 100644 --- a/src/components/Map/index.js +++ b/src/components/Map/index.js @@ -34,7 +34,7 @@ import * as turf from "@turf/turf"; // Extracted components import CommunityTrailsProfile from "./CommunityTrailsProfile"; import OriginalTrailsMap from "./OriginalTrailsMap"; -import ProjectTrailsProfile from "./ProjectTrailsProfile"; +import RegionalTrailsProfile from "./RegionalTrailsProfile"; // Extracted constants import { geojsonTrailLayers } from "./constants/geojsonTrailLayers"; @@ -67,8 +67,8 @@ const Map = () => { setMunicipalityTrails, showMunicipalityProfileMap, showMunicipalityView, - showProjectTrailsProfileMap, - showProjectTrailsView, + showRegionalTrailsProfileMap, + showRegionalTrailsView, // Layer toggle states from context showCommuterRail, setShowCommuterRail, @@ -127,15 +127,15 @@ const Map = () => { setMapParam(); }, []); - // Auto-switch to light basemap when entering municipality or project trails profile + // Auto-switch to light basemap when entering municipality or regional trails profile useEffect(() => { - if (showMunicipalityProfileMap || showProjectTrailsProfileMap) { + if (showMunicipalityProfileMap || showRegionalTrailsProfileMap) { const lightBasemap = basemaps.find((bm) => bm.id === 'mapboxLight'); if (lightBasemap && baseLayer.id !== 'mapboxLight') { setBaseLayer(lightBasemap); } } - }, [showMunicipalityProfileMap, showProjectTrailsProfileMap, basemaps, baseLayer, setBaseLayer]); + }, [showMunicipalityProfileMap, showRegionalTrailsProfileMap, basemaps, baseLayer, setBaseLayer]); const generateShareUrl = () => { return `${window.location.href.split("?")[0]}?baseLayer=${baseLayer.id}&trailLayers=${trailLayers.join( @@ -151,8 +151,8 @@ const Map = () => {
- {showProjectTrailsProfileMap ? ( - { /> )} - {/* Share Control Button - hidden in community trails profile */} - {!showMunicipalityProfileMap && ( + {/* Share Control Button - hidden in community trails profile and regional trails profile */} + {!showMunicipalityProfileMap && !showRegionalTrailsProfileMap && ( { +const EnvironmentalJusticeLayer = ({ showEnvironmentalJustice, showMunicipalityProfileMap, showRegionalTrailsProfile, mapRef }) => { const [imageUrl, setImageUrl] = useState(null); const [bounds, setBounds] = useState(null); const updateTimeoutRef = useRef(null); useEffect(() => { - if (!showEnvironmentalJustice || (!showMunicipalityProfileMap && !showProjectTrailsProfile) || !mapRef?.current) { + if (!showEnvironmentalJustice || (!showMunicipalityProfileMap && !showRegionalTrailsProfile) || !mapRef?.current) { setImageUrl(null); setBounds(null); return; @@ -81,9 +81,9 @@ const EnvironmentalJusticeLayer = ({ showEnvironmentalJustice, showMunicipalityP } }; } - }, [showEnvironmentalJustice, showMunicipalityProfileMap, showProjectTrailsProfile, mapRef]); + }, [showEnvironmentalJustice, showMunicipalityProfileMap, showRegionalTrailsProfile, mapRef]); - if (!showEnvironmentalJustice || !showMunicipalityProfileMap || !imageUrl || !bounds) { + if (!showEnvironmentalJustice || (!showMunicipalityProfileMap && !showRegionalTrailsProfile) || !imageUrl || !bounds) { return null; } diff --git a/src/components/Map/layers/MajorTrailsLayer.js b/src/components/Map/layers/MajorTrailsLayer.js new file mode 100644 index 0000000..17ffe0c --- /dev/null +++ b/src/components/Map/layers/MajorTrailsLayer.js @@ -0,0 +1,281 @@ +import React, { useEffect, useState, useRef } from "react"; +import { Source, Layer } from "react-map-gl"; + +/** + * Renders Major Trails layer from ArcGIS FeatureServer + * Data source: https://services.arcgis.com/c5WwApDsDjRhIVkH/arcgis/rest/services/export_major_trails/FeatureServer + * + * Uses FeatureServer query endpoint to fetch GeoJSON features filtered by grouped_reg_name. + * When selectedMajorTrails is provided, only shows trails matching those grouped_reg_name values. + */ +const MajorTrailsLayer = ({ + showMajorTrails, + showRegionalTrailsProfile, + mapRef, + selectedMajorTrails = [], // Array of selected major trail names (grouped_reg_name values) + onTrailsDataChange = null, // Callback to pass trail data to parent (for metrics) + hoveredTrail = null, // Hovered trail object with featureId + clickedTrail = null // Clicked trail object with featureId +}) => { + const [trailsData, setTrailsData] = useState(null); + const updateTimeoutRef = useRef(null); + const queryTimeoutRef = useRef(null); + const accumulatedTrailsRef = useRef(new Map()); // Store trails by feature ID to avoid duplicates + + // Determine if layer should be shown + const shouldShow = showMajorTrails && showRegionalTrailsProfile; + + // ArcGIS token for authentication + const ARCGIS_TOKEN = "AAPTxy8BH1VEsoebNVZXo8HurFEryhzMUuo6HFsZYNxtvAILm5qQYklTujgW8rejiSVEA_kTru4Y7QuNe5-QWMtEpK-_L9TLSHlHV4h_oeYUONaR40fn8mVNBCPvWBSuheHtx9FPMu5xWNxz4gqnZ-TPnErmJVpoN7thS4Zj2QiLg12SqmtHyaMnnYJH5AwdRA1VAFZLZrfwWTLw4zLogHqqonCw58CKKRJS4rqd-UgsAO8.AT1_U0702ST1"; + + // FeatureServer URL + const FEATURE_SERVER_URL = "https://services.arcgis.com/c5WwApDsDjRhIVkH/arcgis/rest/services/export_major_trails/FeatureServer/0"; + + useEffect(() => { + if (!shouldShow || !mapRef?.current || !selectedMajorTrails || selectedMajorTrails.length === 0) { + setTrailsData(null); + accumulatedTrailsRef.current.clear(); + if (onTrailsDataChange) { + onTrailsDataChange(null); + } + return; + } + + const updateLayer = () => { + const map = mapRef.current?.getMap(); + if (!map) return; + + // Build where clause based on selected major trails + const escapedTrails = selectedMajorTrails.map(trail => + `'${trail.replace(/'/g, "''")}'` + ); + const whereClause = `grouped_reg_name IN (${escapedTrails.join(',')})`; + + // Query all trails for selected major trails regardless of map bounds/zoom level + const url = `${FEATURE_SERVER_URL}/query?where=${encodeURIComponent(whereClause)}&outFields=*&outSR=4326&f=geojson&returnGeometry=true&maxRecordCount=10000&token=${ARCGIS_TOKEN}`; + + // Debounce queries (reduced delay for better UX) + if (queryTimeoutRef.current) { + clearTimeout(queryTimeoutRef.current); + } + + queryTimeoutRef.current = setTimeout(async () => { + try { + const response = await fetch(url); + const data = await response.json(); + + if (data.error) { + console.error("FeatureServer error:", data.error); + return; + } + + if (data.features && data.features.length > 0) { + // Replace data entirely (don't accumulate) since we're fetching all trails for selected projects + accumulatedTrailsRef.current.clear(); + data.features.forEach(feature => { + const featureId = feature.properties?.OBJECTID || + feature.properties?.objectid || + feature.id || + JSON.stringify(feature.geometry); + accumulatedTrailsRef.current.set(featureId, feature); + }); + + const featureCollection = { + type: "FeatureCollection", + features: data.features + }; + setTrailsData(featureCollection); + + // Notify parent component of trail data changes for metrics + if (onTrailsDataChange) { + onTrailsDataChange(featureCollection); + } + } else { + // No features returned - clear data + accumulatedTrailsRef.current.clear(); + setTrailsData(null); + if (onTrailsDataChange) { + onTrailsDataChange(null); + } + } + } catch (error) { + console.error("Error fetching Major Trails data:", error); + if (accumulatedTrailsRef.current.size === 0) { + setTrailsData(null); + } + } + }, 100); + }; + + // Clear accumulated data when selectedMajorTrails changes + accumulatedTrailsRef.current.clear(); + + // Initial update + updateLayer(); + + // No need to update on map move since we're fetching all trails regardless of bounds + return () => { + if (updateTimeoutRef.current) { + clearTimeout(updateTimeoutRef.current); + } + if (queryTimeoutRef.current) { + clearTimeout(queryTimeoutRef.current); + } + }; + }, [shouldShow, mapRef, selectedMajorTrails, onTrailsDataChange]); + + if (!shouldShow || !trailsData || !trailsData.features || trailsData.features.length === 0) { + return null; + } + + // Filter trails based on selected major trails + let filteredTrailsData = trailsData; + if (selectedMajorTrails && selectedMajorTrails.length > 0) { + filteredTrailsData = { + type: "FeatureCollection", + features: trailsData.features.filter(feature => { + const groupedRegName = (feature.properties?.grouped_reg_name || "").trim(); + return selectedMajorTrails.some(selected => { + const normalizedSelected = (selected || "").trim(); + return normalizedSelected === groupedRegName; + }); + }) + }; + } else { + return null; // Don't show anything if no major trails selected + } + + if (!filteredTrailsData.features || filteredTrailsData.features.length === 0) { + return null; + } + + // Get hovered and clicked feature IDs for highlights + const hoveredFeatureId = hoveredTrail?.featureId; + const clickedFeatureId = clickedTrail?.featureId; + + return ( + + {/* Combined trails layer - gaps in red, existing in blue, planned/envisioned/design in green */} + + {/* Hover layer - thicker line when hovering */} + + {/* Click highlight layer - thicker line when clicked, persists until tooltip closes */} + + + ); +}; + +export default MajorTrailsLayer; diff --git a/src/components/Map/layers/OpenSpaceLayer.js b/src/components/Map/layers/OpenSpaceLayer.js index fcd8108..7025601 100644 --- a/src/components/Map/layers/OpenSpaceLayer.js +++ b/src/components/Map/layers/OpenSpaceLayer.js @@ -1,140 +1,102 @@ -import React, { useEffect, useState, useRef } from "react"; +import React, { useEffect, useRef } from "react"; import { Source, Layer } from "react-map-gl"; /** - * Renders OpenSpace (Protected and Recreational OpenSpace) layer - * Data source: https://arcgisserver.digital.mass.gov/arcgisserver/rest/services/AGOL/openspace/FeatureServer/0 - * - * Uses ArcGIS FeatureServer query endpoint to fetch GeoJSON features within current map bounds. + * Renders OpenSpace (Protected and Recreational OpenSpace) layer using Mapbox Vector Tileset + * Tileset ID: ihill.7fjeze3n */ -const OpenSpaceLayer = ({ showOpenSpace, showMunicipalityProfileMap, showProjectTrailsProfile, mapRef, onDataChange }) => { - const [openSpaceData, setOpenSpaceData] = useState(null); - const [bounds, setBounds] = useState(null); - const updateTimeoutRef = useRef(null); - const queryTimeoutRef = useRef(null); - +const OPENSPACE_TILESET_ID = "ihill.7fjeze3n"; +const OPENSPACE_SOURCE_LAYER = "Protected_and_Recreational_Op-98eeg1"; + +const OpenSpaceLayer = ({ showOpenSpace, showMunicipalityProfileMap, showRegionalTrailsProfile, mapRef }) => { + // Use different source IDs for different pages to avoid conflicts + const sourceId = showMunicipalityProfileMap ? "openspace-source-community" : "openspace-source-regional"; + const fillLayerId = showMunicipalityProfileMap ? "openspace-layer-community" : "openspace-layer-regional"; + const outlineLayerId = showMunicipalityProfileMap ? "openspace-outline-community" : "openspace-outline-regional"; + const otherSourceId = showMunicipalityProfileMap ? "openspace-source-regional" : "openspace-source-community"; + const otherFillLayerId = showMunicipalityProfileMap ? "openspace-layer-regional" : "openspace-layer-community"; + const otherOutlineLayerId = showMunicipalityProfileMap ? "openspace-outline-regional" : "openspace-outline-community"; + + // Clean up the other page's source and layers when this component mounts useEffect(() => { - if (!showOpenSpace || (!showMunicipalityProfileMap && !showProjectTrailsProfile) || !mapRef?.current) { - setOpenSpaceData(null); - setBounds(null); - if (onDataChange) onDataChange(null); - return; - } - - const updateLayer = () => { - const map = mapRef.current?.getMap(); - if (!map) return; - - const mapBounds = map.getBounds(); - const sw = mapBounds.getSouthWest(); - const ne = mapBounds.getNorthEast(); - - // Convert lat/lon to Web Mercator (EPSG:3857) for ArcGIS query - const toWebMercator = (lon, lat) => { - const x = lon * 20037508.34 / 180; - let y = Math.log(Math.tan((90 + lat) * Math.PI / 360)) / (Math.PI / 180); - y = y * 20037508.34 / 180; - return { x, y }; - }; - - const swMerc = toWebMercator(sw.lng, sw.lat); - const neMerc = toWebMercator(ne.lng, ne.lat); - - const OPENSPACE_SERVICE_URL = "https://arcgisserver.digital.mass.gov/arcgisserver/rest/services/AGOL/openspace/FeatureServer/0"; - const bbox = `${swMerc.x},${swMerc.y},${neMerc.x},${neMerc.y}`; - - // Query GeoJSON from FeatureServer - const url = `${OPENSPACE_SERVICE_URL}/query?where=1=1&geometry=${bbox}&geometryType=esriGeometryEnvelope&inSR=3857&spatialRel=esriSpatialRelIntersects&outFields=*&outSR=4326&f=geojson&returnGeometry=true&maxRecordCount=1000`; - - // Debounce queries - if (queryTimeoutRef.current) { - clearTimeout(queryTimeoutRef.current); + if (!mapRef?.current) return; + + const map = mapRef.current.getMap(); + if (!map) return; + + const cleanupOtherPage = () => { + // Wait for style to load before cleaning up + if (!map.isStyleLoaded()) { + map.once('styledata', cleanupOtherPage); + return; } - queryTimeoutRef.current = setTimeout(async () => { - try { - const response = await fetch(url); - const data = await response.json(); - - if (data.features && data.features.length > 0) { - const featureCollection = { - type: "FeatureCollection", - features: data.features - }; - setOpenSpaceData(featureCollection); - setBounds([ - [sw.lng, sw.lat], - [ne.lng, ne.lat] - ]); - if (onDataChange) onDataChange(featureCollection); - } else { - setOpenSpaceData(null); - if (onDataChange) onDataChange(null); - } - } catch (error) { - console.error("Error fetching OpenSpace data:", error); - setOpenSpaceData(null); + // Remove the other page's layers and source if they exist + try { + // Remove other page's layers first + if (map.getLayer(otherFillLayerId)) { + map.removeLayer(otherFillLayerId); } - }, 300); - }; - - // Initial update - updateLayer(); - - // Update on map move (with debounce) - const map = mapRef.current?.getMap(); - if (map) { - const handleMoveEnd = () => { - if (updateTimeoutRef.current) { - clearTimeout(updateTimeoutRef.current); + if (map.getLayer(otherOutlineLayerId)) { + map.removeLayer(otherOutlineLayerId); } - updateTimeoutRef.current = setTimeout(updateLayer, 300); - }; - - map.on('moveend', handleMoveEnd); - map.on('zoomend', handleMoveEnd); - - return () => { - map.off('moveend', handleMoveEnd); - map.off('zoomend', handleMoveEnd); - if (updateTimeoutRef.current) { - clearTimeout(updateTimeoutRef.current); - } - if (queryTimeoutRef.current) { - clearTimeout(queryTimeoutRef.current); + + // Remove other page's source + if (map.getSource(otherSourceId)) { + map.removeSource(otherSourceId); } - }; - } - }, [showOpenSpace, showMunicipalityProfileMap, showProjectTrailsProfile, mapRef, onDataChange]); + } catch (err) { + // Source or layer might not exist, which is fine + } + }; - // Handle hover events - removed, will be handled in parent component's onMouseMove + // Small delay to ensure current page's layers are added first + const timeoutId = setTimeout(cleanupOtherPage, 100); + + return () => { + clearTimeout(timeoutId); + if (map && map.off) { + map.off('styledata', cleanupOtherPage); + } + }; + }, [mapRef, otherSourceId, otherFillLayerId, otherOutlineLayerId]); - if (!showOpenSpace || (!showMunicipalityProfileMap && !showProjectTrailsProfile) || !openSpaceData) { + if (!showOpenSpace || (!showMunicipalityProfileMap && !showRegionalTrailsProfile)) { return null; } + // Mapbox tileset URL format: mapbox://tileset-id + const tilesetUrl = `mapbox://${OPENSPACE_TILESET_ID}`; + return ( + {/* OpenSpace polygon fill layer */} + {/* OpenSpace outline line layer */} @@ -143,4 +105,3 @@ const OpenSpaceLayer = ({ showOpenSpace, showMunicipalityProfileMap, showProject }; export default OpenSpaceLayer; - diff --git a/src/components/Map/layers/TrailsRegNameSyncLayer.js b/src/components/Map/layers/TrailsRegNameSyncLayer.js index b07fb5a..39b1f79 100644 --- a/src/components/Map/layers/TrailsRegNameSyncLayer.js +++ b/src/components/Map/layers/TrailsRegNameSyncLayer.js @@ -26,23 +26,25 @@ const generateColorPalette = (regNames) => { }; /** - * Renders Trails Reg Name Sync layer from ArcGIS FeatureServer - * Data source: https://services.arcgis.com/c5WwApDsDjRhIVkH/arcgis/rest/services/Trails_Reg_Name_Sync/FeatureServer + * Renders Trails Reg Name Sync layer using ArcGIS FeatureServer for data + * Data source: https://services.arcgis.com/c5WwApDsDjRhIVkH/arcgis/rest/services/export_other_trails/FeatureServer * - * Uses FeatureServer query endpoint to fetch GeoJSON features within current map bounds. + * Uses FeatureServer queries to fetch GeoJSON data for rendering and data extraction (reg_names, metrics). + * Renders trails as GeoJSON to support filtering by selectedRegNames and color coding. * Supports color coding by reg_name attribute when useColorCoding is true. */ const TrailsRegNameSyncLayer = ({ showTrailsRegNameSync, showMunicipalityProfileMap, - showProjectTrailsProfile, + showRegionalTrailsProfile, mapRef, useColorCoding = false, onRegNamesChange = null, colorPalette = null, // External color palette for stable colors selectedRegNames = [], // Array of selected reg_names to display onTrailsDataChange = null, // Callback to pass trail data to parent - hoveredTrail = null // Hovered trail object with featureId + hoveredTrail = null, // Hovered trail object with featureId + clickedTrail = null // Clicked trail object with featureId }) => { const [trailsData, setTrailsData] = useState(null); const updateTimeoutRef = useRef(null); @@ -50,23 +52,68 @@ const TrailsRegNameSyncLayer = ({ const accumulatedTrailsRef = useRef(new Map()); // Store trails by feature ID to avoid duplicates // Determine if layer should be shown - const shouldShow = showTrailsRegNameSync && (showMunicipalityProfileMap || showProjectTrailsProfile); + const shouldShow = showTrailsRegNameSync && (showMunicipalityProfileMap || showRegionalTrailsProfile); - // Extract unique reg_name values and notify parent + // ArcGIS token for authentication + const ARCGIS_TOKEN = "AAPTxy8BH1VEsoebNVZXo8HurFEryhzMUuo6HFsZYNxtvAILm5qQYklTujgW8rejiSVEA_kTru4Y7QuNe5-QWMtEpK-_L9TLSHlHV4h_oeYUONaR40fn8mVNBCPvWBSuheHtx9FPMu5xWNxz4gqnZ-TPnErmJVpoN7thS4Zj2QiLg12SqmtHyaMnnYJH5AwdRA1VAFZLZrfwWTLw4zLogHqqonCw58CKKRJS4rqd-UgsAO8.AT1_U0702ST1"; + + // MapServer URL for tile display (tiles only, doesn't support queries) + const MAP_SERVER_URL = "https://services.arcgis.com/c5WwApDsDjRhIVkH/arcgis/rest/services/export_other_trails_tiles/MapServer"; + + // FeatureServer URL for data extraction (supports queries) + const FEATURE_SERVER_URL = "https://services.arcgis.com/c5WwApDsDjRhIVkH/arcgis/rest/services/export_other_trails/FeatureServer/0"; + + // Initial query to get all reg_names for the project list (runs once) useEffect(() => { - if (trailsData && trailsData.features && onRegNamesChange) { - const regNames = new Set(); - trailsData.features.forEach(feature => { - const regName = feature.properties?.reg_name; - if (regName && regName.trim() !== "") { - regNames.add(regName); - } - }); - - const uniqueRegNames = Array.from(regNames).sort(); - onRegNamesChange(uniqueRegNames); + if (!shouldShow || !onRegNamesChange) { + return; } - }, [trailsData, onRegNamesChange]); + + // Query for all reg_names - fetch all unique reg_names without geometry filter + const fetchAllRegNames = async () => { + try { + // Try querying without geometry first (faster and gets all reg_names) + // If that fails, fall back to geometry-based query with large bounds + let url = `${FEATURE_SERVER_URL}/query?where=reg_name IS NOT NULL&outFields=reg_name&returnGeometry=false&returnDistinctValues=true&f=geojson&maxRecordCount=10000&token=${ARCGIS_TOKEN}`; + + let response = await fetch(url); + let data = await response.json(); + + // If returnDistinctValues doesn't work, try without it + if (data.error || !data.features || data.features.length === 0) { + // Fallback: query with large bounds + const bbox = "-180,-90,180,90"; + url = `${FEATURE_SERVER_URL}/query?where=reg_name IS NOT NULL&geometry=${bbox}&geometryType=esriGeometryEnvelope&inSR=4326&spatialRel=esriSpatialRelIntersects&outFields=reg_name&returnGeometry=false&f=geojson&maxRecordCount=10000&token=${ARCGIS_TOKEN}`; + response = await fetch(url); + data = await response.json(); + } + + if (data.error) { + console.error("Error fetching reg_names from FeatureServer:", data.error); + return; + } + + if (data.features && data.features.length > 0) { + const regNames = new Set(); + data.features.forEach(feature => { + const regName = feature.properties?.reg_name; + if (regName && regName.trim() !== "") { + regNames.add(regName); + } + }); + const uniqueRegNames = Array.from(regNames).sort(); + onRegNamesChange(uniqueRegNames); + } + } catch (error) { + console.error("Error fetching all reg_names from FeatureServer:", error); + } + }; + + fetchAllRegNames(); + }, [shouldShow, onRegNamesChange]); + + // Note: reg_names list is populated by the initial query above + // trailsData is only used for rendering and metrics, not for populating the project list // Use external color palette if provided, otherwise generate one const effectiveColorPalette = useMemo(() => { @@ -107,45 +154,54 @@ const TrailsRegNameSyncLayer = ({ const map = mapRef.current?.getMap(); if (!map) return; - const zoom = map.getZoom(); - const mapBounds = map.getBounds(); - const sw = mapBounds.getSouthWest(); - const ne = mapBounds.getNorthEast(); - - // Expand bounds at lower zoom levels to ensure we capture more trails - // At zoom < 10, expand by 50%, at zoom < 8, expand by 100% - const expansionFactor = zoom < 8 ? 1.0 : zoom < 10 ? 0.5 : 0; - const latRange = ne.lat - sw.lat; - const lngRange = ne.lng - sw.lng; + let url; - const expandedSw = { - lat: sw.lat - (latRange * expansionFactor), - lng: sw.lng - (lngRange * expansionFactor) - }; - const expandedNe = { - lat: ne.lat + (latRange * expansionFactor), - lng: ne.lng + (lngRange * expansionFactor) - }; - - // Convert lat/lon to Web Mercator (EPSG:3857) for ArcGIS query - const toWebMercator = (lon, lat) => { - const x = lon * 20037508.34 / 180; - let y = Math.log(Math.tan((90 + lat) * Math.PI / 360)) / (Math.PI / 180); - y = y * 20037508.34 / 180; - return { x, y }; - }; + // If projects are selected, query all trails for those projects regardless of map bounds + if (selectedRegNames && selectedRegNames.length > 0) { + // Build WHERE clause to filter by selected reg_names + // Escape single quotes in reg_names and build OR conditions + const whereConditions = selectedRegNames.map(regName => { + const escapedName = (regName || "").replace(/'/g, "''"); // Escape single quotes for SQL + return `reg_name = '${escapedName}'`; + }).join(' OR '); + + const whereClause = `(${whereConditions})`; + + // Query all trails for selected projects (no geometry filter) + url = `${FEATURE_SERVER_URL}/query?where=${encodeURIComponent(whereClause)}&outFields=*&outSR=4326&f=geojson&returnGeometry=true&maxRecordCount=10000&token=${ARCGIS_TOKEN}`; + } else { + // No projects selected - use map bounds query for initial data loading + const zoom = map.getZoom(); + const mapBounds = map.getBounds(); + const sw = mapBounds.getSouthWest(); + const ne = mapBounds.getNorthEast(); - const swMerc = toWebMercator(expandedSw.lng, expandedSw.lat); - const neMerc = toWebMercator(expandedNe.lng, expandedNe.lat); + // Expand bounds at lower zoom levels to ensure we capture more trails + // At zoom < 10, expand by 50%, at zoom < 8, expand by 100% + const expansionFactor = zoom < 8 ? 1.0 : zoom < 10 ? 0.5 : 0; + const latRange = ne.lat - sw.lat; + const lngRange = ne.lng - sw.lng; + + const expandedSw = { + lat: sw.lat - (latRange * expansionFactor), + lng: sw.lng - (lngRange * expansionFactor) + }; + const expandedNe = { + lat: ne.lat + (latRange * expansionFactor), + lng: ne.lng + (lngRange * expansionFactor) + }; - const TRAILS_SERVICE_URL = "https://services.arcgis.com/c5WwApDsDjRhIVkH/arcgis/rest/services/Trails_Reg_Name_Sync/FeatureServer/0"; - const bbox = `${swMerc.x},${swMerc.y},${neMerc.x},${neMerc.y}`; - - // ArcGIS token for authentication - const ARCGIS_TOKEN = "AAPTxy8BH1VEsoebNVZXo8HurFEryhzMUuo6HFsZYNxtvAJokki38mbvq7ULTwGi9aIiHg4Ov6mdqMd5YhY2eBLginTWOW2s0AQjeI7Ykqag6M-aN9ebZDjDH92AC2SZZXZMAva-9dhU3So7ctlz8NNnI5atBAQ3zzUBhRN7PBfx6N9e-Dr6ye-m_8BAjmSw2KJDlOpeyXL4Zzk31HB6h95KMEUz0KeUmSeCEw6OsOp3Zeg.AT1_U0702ST1"; + // Use lat/lng directly (WGS84) for FeatureServer query + // Format: xmin,ymin,xmax,ymax (lng,lat,lng,lat) - ensure proper order + const xmin = Math.min(expandedSw.lng, expandedNe.lng); + const ymin = Math.min(expandedSw.lat, expandedNe.lat); + const xmax = Math.max(expandedSw.lng, expandedNe.lng); + const ymax = Math.max(expandedSw.lat, expandedNe.lat); + const bbox = `${xmin},${ymin},${xmax},${ymax}`; - // Query GeoJSON from FeatureServer with token authentication - const url = `${TRAILS_SERVICE_URL}/query?where=1=1&geometry=${bbox}&geometryType=esriGeometryEnvelope&inSR=3857&spatialRel=esriSpatialRelIntersects&outFields=*&outSR=4326&f=geojson&returnGeometry=true&maxRecordCount=2000&token=${ARCGIS_TOKEN}`; + // Query GeoJSON from FeatureServer with token authentication (for data extraction) + url = `${FEATURE_SERVER_URL}/query?where=1=1&geometry=${bbox}&geometryType=esriGeometryEnvelope&inSR=4326&spatialRel=esriSpatialRelIntersects&outFields=*&outSR=4326&f=geojson&returnGeometry=true&maxRecordCount=2000&token=${ARCGIS_TOKEN}`; + } // Debounce queries if (queryTimeoutRef.current) { @@ -155,36 +211,90 @@ const TrailsRegNameSyncLayer = ({ queryTimeoutRef.current = setTimeout(async () => { try { const response = await fetch(url); + + if (!response.ok) { + const errorText = await response.text(); + console.error(`FeatureServer query failed (${response.status}):`, errorText); + // Don't clear accumulated data on error + if (accumulatedTrailsRef.current.size === 0) { + setTrailsData(null); + } + return; + } + const data = await response.json(); + if (data.error) { + console.error("FeatureServer error:", data.error); + // Don't clear accumulated data on error + if (accumulatedTrailsRef.current.size === 0) { + setTrailsData(null); + } + return; + } + if (data.features && data.features.length > 0) { - // Add new features to accumulated data, using OBJECTID or a unique identifier - data.features.forEach(feature => { - const featureId = feature.properties?.OBJECTID || - feature.properties?.objectid || - feature.id || - JSON.stringify(feature.geometry); - accumulatedTrailsRef.current.set(featureId, feature); - }); - - // Convert accumulated features back to FeatureCollection - const allFeatures = Array.from(accumulatedTrailsRef.current.values()); - const featureCollection = { - type: "FeatureCollection", - features: allFeatures - }; - setTrailsData(featureCollection); - - // Notify parent component of trail data changes - if (onTrailsDataChange) { - onTrailsDataChange(featureCollection); + // If querying by selectedRegNames, replace data entirely (don't accumulate) + // If querying by bounds, accumulate data for panning + if (selectedRegNames && selectedRegNames.length > 0) { + // Replace data for selected projects + const featureCollection = { + type: "FeatureCollection", + features: data.features + }; + accumulatedTrailsRef.current.clear(); + data.features.forEach(feature => { + const featureId = feature.properties?.OBJECTID || + feature.properties?.objectid || + feature.id || + JSON.stringify(feature.geometry); + accumulatedTrailsRef.current.set(featureId, feature); + }); + setTrailsData(featureCollection); + + // Notify parent component of trail data changes + if (onTrailsDataChange) { + onTrailsDataChange(featureCollection); + } + } else { + // Accumulate data for bounds-based queries (when panning) + data.features.forEach(feature => { + const featureId = feature.properties?.OBJECTID || + feature.properties?.objectid || + feature.id || + JSON.stringify(feature.geometry); + accumulatedTrailsRef.current.set(featureId, feature); + }); + + // Convert accumulated features back to FeatureCollection + const allFeatures = Array.from(accumulatedTrailsRef.current.values()); + const featureCollection = { + type: "FeatureCollection", + features: allFeatures + }; + setTrailsData(featureCollection); + + // Notify parent component of trail data changes + if (onTrailsDataChange) { + onTrailsDataChange(featureCollection); + } + } + } else { + // No features returned + if (selectedRegNames && selectedRegNames.length > 0) { + // For selected projects, clear data if no results + accumulatedTrailsRef.current.clear(); + setTrailsData(null); + if (onTrailsDataChange) { + onTrailsDataChange(null); + } + } else if (accumulatedTrailsRef.current.size === 0) { + // Only set to null if we have no accumulated data + setTrailsData(null); } - } else if (accumulatedTrailsRef.current.size === 0) { - // Only set to null if we have no accumulated data - setTrailsData(null); } } catch (error) { - console.error("Error fetching Trails Reg Name Sync data:", error); + console.error("Error fetching export_other_trails FeatureServer data:", error); // Don't clear accumulated data on error if (accumulatedTrailsRef.current.size === 0) { setTrailsData(null); @@ -193,12 +303,16 @@ const TrailsRegNameSyncLayer = ({ }, 300); }; + // Clear accumulated data when selectedRegNames changes + accumulatedTrailsRef.current.clear(); + // Initial update updateLayer(); - // Update on map move (with debounce) + // Only update on map move if no projects are selected (when using bounds-based query) + // If projects are selected, we've already fetched all trails, so no need to update on move const map = mapRef.current?.getMap(); - if (map) { + if (map && (!selectedRegNames || selectedRegNames.length === 0)) { const handleMoveEnd = () => { if (updateTimeoutRef.current) { clearTimeout(updateTimeoutRef.current); @@ -219,10 +333,32 @@ const TrailsRegNameSyncLayer = ({ clearTimeout(queryTimeoutRef.current); } }; + } else { + // Cleanup timeouts if no map move listeners + return () => { + if (updateTimeoutRef.current) { + clearTimeout(updateTimeoutRef.current); + } + if (queryTimeoutRef.current) { + clearTimeout(queryTimeoutRef.current); + } + }; } - }, [shouldShow, mapRef]); + }, [shouldShow, mapRef, selectedRegNames]); - if (!shouldShow || !trailsData) { + // Render GeoJSON from MapServer queries (data extraction happens separately) + // trailsData is needed for reg_names extraction, metrics, and rendering + if (!shouldShow) { + return null; + } + + // If selectedRegNames is empty array, don't show any trails + if (selectedRegNames && selectedRegNames.length === 0) { + return null; + } + + // Need trailsData for rendering + if (!trailsData || !trailsData.features) { return null; } @@ -231,30 +367,65 @@ const TrailsRegNameSyncLayer = ({ // Filter to only show selected reg_names (if provided) // If selectedRegNames is empty, don't show any trails const regNamesToShow = selectedRegNames.length > 0 - ? Object.keys(effectiveColorPalette).filter(regName => selectedRegNames.includes(regName)) + ? Object.keys(effectiveColorPalette).filter(regName => { + // Normalize for comparison (trim and lowercase) + const normalizedRegName = (regName || "").trim().toLowerCase(); + return selectedRegNames.some(selected => { + const normalizedSelected = (selected || "").trim().toLowerCase(); + return normalizedRegName === normalizedSelected; + }); + }) : []; if (regNamesToShow.length === 0) { return null; // No selected projects, don't show any trails } + // For color coding, use GeoJSON from MapServer queries since raster tiles don't support filtering + // Filter trailsData to only show selected reg_names + if (!trailsData || !trailsData.features) { + return null; + } + + const filteredTrailsData = { + type: "FeatureCollection", + features: trailsData.features.filter(feature => { + const featureRegName = (feature.properties?.reg_name || "").trim(); + const segType = feature.properties?.seg_type; + return regNamesToShow.some(regName => { + const normalizedRegName = (regName || "").trim().toLowerCase(); + const normalizedFeatureRegName = featureRegName.toLowerCase(); + return normalizedFeatureRegName === normalizedRegName && segType !== 9 && segType !== "9"; + }); + }) + }; + + const filteredGapsData = { + type: "FeatureCollection", + features: trailsData.features.filter(feature => { + const featureRegName = (feature.properties?.reg_name || "").trim(); + const segType = feature.properties?.seg_type; + return regNamesToShow.some(regName => { + const normalizedRegName = (regName || "").trim().toLowerCase(); + const normalizedFeatureRegName = featureRegName.toLowerCase(); + return normalizedFeatureRegName === normalizedRegName && (segType === 9 || segType === "9"); + }); + }) + }; + return ( <> - {/* Render regular trails first */} + {/* Render regular trails with color coding */} {regNamesToShow.map((regName) => { const color = effectiveColorPalette[regName]; if (!color) return null; - const filteredFeatures = trailsData.features.filter( - feature => { - const featureRegName = (feature.properties?.reg_name || "").trim(); - const segType = feature.properties?.seg_type; - // Exclude gaps (seg_type === 9) from regular trail layer - return featureRegName === regName.trim() && segType !== 9 && segType !== "9"; - } - ); + const regNameTrails = filteredTrailsData.features.filter(f => { + const fRegName = (f.properties?.reg_name || "").trim().toLowerCase(); + return fRegName === regName.trim().toLowerCase(); + }); - if (filteredFeatures.length === 0) return null; + if (regNameTrails.length === 0) return null; return ( { - const filteredGapFeatures = trailsData.features.filter( - feature => { - const featureRegName = (feature.properties?.reg_name || "").trim(); - const segType = feature.properties?.seg_type; - // Only include gaps (seg_type === 9) for this reg_name - return featureRegName === regName.trim() && (segType === 9 || segType === "9"); - } - ); - - if (filteredGapFeatures.length === 0) return null; - - return ( - 0 && ( + + - - - ); - })} + layout={{ + "line-cap": "round", + "line-join": "round" + }} + /> + + )} ); } - // Default: single layer with single color - // Filter trails based on selectedRegNames if provided + // Default: Use GeoJSON from MapServer queries for filtering support + // Get hovered and clicked feature IDs + const hoveredFeatureId = hoveredTrail?.featureId; + const clickedFeatureId = clickedTrail?.featureId; + + // Filter trailsData based on selectedRegNames if provided let filteredTrailsData = trailsData; - if (selectedRegNames && selectedRegNames.length > 0) { + let filteredGapsData = null; + + if (selectedRegNames && selectedRegNames.length > 0 && trailsData && trailsData.features) { + const regularTrails = trailsData.features.filter(feature => { + const regName = (feature.properties?.reg_name || "").trim().toLowerCase(); + const segType = feature.properties?.seg_type; + return selectedRegNames.some(selected => { + const normalizedSelected = (selected || "").trim().toLowerCase(); + return normalizedSelected === regName && segType !== 9 && segType !== "9"; + }); + }); + + const gaps = trailsData.features.filter(feature => { + const regName = (feature.properties?.reg_name || "").trim().toLowerCase(); + const segType = feature.properties?.seg_type; + return selectedRegNames.some(selected => { + const normalizedSelected = (selected || "").trim().toLowerCase(); + return normalizedSelected === regName && (segType === 9 || segType === "9"); + }); + }); + filteredTrailsData = { type: "FeatureCollection", - features: trailsData.features.filter(feature => { - const regName = (feature.properties?.reg_name || "").trim(); - return selectedRegNames.some(selected => selected.trim() === regName); + features: regularTrails + }; + + if (gaps.length > 0) { + filteredGapsData = { + type: "FeatureCollection", + features: gaps + }; + } + } else if (trailsData && trailsData.features) { + // No filtering - show all trails + filteredTrailsData = { + type: "FeatureCollection", + features: trailsData.features.filter(f => { + const segType = f.properties?.seg_type; + return segType !== 9 && segType !== "9"; }) }; - } else if (selectedRegNames && selectedRegNames.length === 0) { - // If selectedRegNames is empty array, don't show any trails + + const gaps = trailsData.features.filter(f => { + const segType = f.properties?.seg_type; + return segType === 9 || segType === "9"; + }); + + if (gaps.length > 0) { + filteredGapsData = { + type: "FeatureCollection", + features: gaps + }; + } + } + + if (!filteredTrailsData || !filteredTrailsData.features || filteredTrailsData.features.length === 0) { return null; } - // Get hovered feature ID - const hoveredFeatureId = hoveredTrail?.featureId; + // Build hover filters + const regularTrailsHoverFilter = hoveredFeatureId !== null && hoveredFeatureId !== undefined + ? [ + "==", + ["coalesce", ["get", "OBJECTID"], ["get", "objectid"], ["get", "id"], -1], + hoveredFeatureId + ] + : ["==", ["get", "OBJECTID"], -1]; + + const gapsHoverFilter = hoveredFeatureId !== null && hoveredFeatureId !== undefined + ? [ + "==", + ["coalesce", ["get", "OBJECTID"], ["get", "objectid"], ["get", "id"], -1], + hoveredFeatureId + ] + : ["==", ["get", "OBJECTID"], -1]; + + const regularTrailsClickFilter = clickedFeatureId !== null && clickedFeatureId !== undefined + ? [ + "==", + ["coalesce", ["get", "OBJECTID"], ["get", "objectid"], ["get", "id"], -1], + clickedFeatureId + ] + : ["==", ["get", "OBJECTID"], -1]; + + const gapsClickFilter = clickedFeatureId !== null && clickedFeatureId !== undefined + ? [ + "==", + ["coalesce", ["get", "OBJECTID"], ["get", "objectid"], ["get", "id"], -1], + clickedFeatureId + ] + : ["==", ["get", "OBJECTID"], -1]; return ( - - {/* Regular trails (excluding gaps) */} - - {/* Hover layer for regular trails - wider */} - + <> + {/* Regular trails */} + + + {/* Hover layer for regular trails - wider */} + + {/* Click highlight layer for regular trails - thicker when clicked */} + + + {/* Gaps in red */} - - {/* Hover layer for gaps - wider */} - - + {filteredGapsData && filteredGapsData.features.length > 0 && ( + + + {/* Hover layer for gaps - wider */} + + {/* Click highlight layer for gaps - thicker when clicked */} + + + )} + ); }; diff --git a/src/styles/ControlPanel.scss b/src/styles/ControlPanel.scss index 863eddd..5739322 100644 --- a/src/styles/ControlPanel.scss +++ b/src/styles/ControlPanel.scss @@ -1,4 +1,13 @@ .ControlPanel { + background-color: $lighter-blue; + box-shadow: rgba(0, 0, 0, 0.1) 0px 0px 10px 2px; + font-size: $body-font-size; + height: 100vh; + left: 0; + top: 60px; + width: 260px; + z-index: 6; + &__toggle-btn { transition: all 0.3s ease; font-weight: 500; @@ -8,20 +17,11 @@ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); } } - background-color: $lighter-blue; - box-shadow: rgba(0, 0, 0, 0.1) 0px 0px 10px 2px; - font-size: $body-font-size; - height: 100vh; - left: 0; - top: 60px; - z-index: 6; @media only screen and (max-height: 920px) and (min-width: 1000px) { width: 275px; } - width: 260px; - &_opacity { background-color: $lighter-blue; height: 3rem; diff --git a/src/styles/Header.scss b/src/styles/Header.scss index 291f67f..3b0e67c 100644 --- a/src/styles/Header.scss +++ b/src/styles/Header.scss @@ -10,6 +10,9 @@ } &__title { + color: $white; + margin: unset; + @media only screen and (max-width: 790px) { font-size: 1.25rem; } @@ -41,9 +44,6 @@ padding: 0 20px 0 0; } } - - color: $white; - margin: unset; } &__about { diff --git a/src/styles/Map.scss b/src/styles/Map.scss index f402d6a..2133d75 100644 --- a/src/styles/Map.scss +++ b/src/styles/Map.scss @@ -67,8 +67,8 @@ } } -// Project Trails Profile Map - prevent scrolling -.project-trails-profile-map { +// Regional Trails Profile Map - prevent scrolling +.regional-trails-profile-map { height: 100%; width: 100%; overflow: hidden; @@ -84,7 +84,7 @@ width: 240px; } -// Geocoder styling to fit control panel width for project trails profile +// Geocoder styling to fit control panel width for regional trails profile .mapboxgl-ctrl-geocoder { width: 100% !important; max-width: 520px; @@ -106,8 +106,8 @@ } } -// Geocoder for project trails profile - same positioning as community profile -.project-trails-profile-map .mapboxgl-ctrl-top-left .mapboxgl-ctrl.mapboxgl-ctrl-geocoder { +// Geocoder for regional trails profile - same positioning as community profile +.regional-trails-profile-map .mapboxgl-ctrl-top-left .mapboxgl-ctrl.mapboxgl-ctrl-geocoder { width: 240px !important; @media only screen and (max-height: 920px) and (min-width: 1000px) { diff --git a/src/styles/Popup.scss b/src/styles/Popup.scss index 63b5d71..0e35e4c 100644 --- a/src/styles/Popup.scss +++ b/src/styles/Popup.scss @@ -59,3 +59,6 @@ background-size: 60% 60%; } } + + + diff --git a/webpack.config.js b/webpack.config.js index 5b472a0..6c9c108 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -42,7 +42,14 @@ module.exports = { use: [ "style-loader", "css-loader", - "sass-loader", + { + loader: "sass-loader", + options: { + sassOptions: { + quietDeps: true, + }, + }, + }, ], }, { From 41e1f882754c025c947e328e393b5c32f9898029 Mon Sep 17 00:00:00 2001 From: ztocode Date: Mon, 9 Feb 2026 14:01:52 -0500 Subject: [PATCH 2/5] add download to regional trails profile/update UI/fix tooltip by ordering map layers --- .../ControlPanel/RegionalTrailsProfile.js | 11 +- src/components/ControlPanel/index.js | 39 ------ src/components/Map/CommunityTrailsProfile.js | 4 +- src/components/Map/ProjectMetricsPanel.js | 122 ++++++++++++++++-- src/components/Map/RegionalTrailsProfile.js | 85 ++++++------ ...ncLayer.js => OtherRegionalTrailsLayer.js} | 26 ++-- 6 files changed, 177 insertions(+), 110 deletions(-) rename src/components/Map/layers/{TrailsRegNameSyncLayer.js => OtherRegionalTrailsLayer.js} (97%) diff --git a/src/components/ControlPanel/RegionalTrailsProfile.js b/src/components/ControlPanel/RegionalTrailsProfile.js index 07107f3..10d5e00 100644 --- a/src/components/ControlPanel/RegionalTrailsProfile.js +++ b/src/components/ControlPanel/RegionalTrailsProfile.js @@ -4,14 +4,15 @@ import Button from "react-bootstrap/Button"; import { LayerContext } from "../../App"; // Major trails list - these names should match grouped_reg_name values in export_major_trails FeatureServer +// Sorted alphabetically A to Z const MAJOR_TRAILS = [ "Bay Circuit", - "Northern Strand", + "Bruce Freeman", + "Charles River Greenway", "Minuteman", "Neponset River", - "Charles River Greenway", - "Bruce Freeman" -]; + "Northern Strand" +].sort(); const RegionalTrailsProfile = ({ regNames = [], @@ -90,7 +91,7 @@ const RegionalTrailsProfile = ({ {regNames.length > 0 && (
Other regional trails -
+
{regNames.map((regName, index) => { const isSelected = selectedRegNames.has(regName); diff --git a/src/components/ControlPanel/index.js b/src/components/ControlPanel/index.js index 0cb309c..6923761 100644 --- a/src/components/ControlPanel/index.js +++ b/src/components/ControlPanel/index.js @@ -427,45 +427,6 @@ const ControlPanel = ({ selectedMajorTrails={selectedMajorTrails || []} onToggleMajorTrail={onToggleMajorTrail || (() => {})} /> - - {/* Layer controls for Regional Trails Profile */} -
- Additional Layers: - - - - -
) : !showMunicipalityView ? ( <> diff --git a/src/components/Map/CommunityTrailsProfile.js b/src/components/Map/CommunityTrailsProfile.js index f5b4e2c..2b1f535 100644 --- a/src/components/Map/CommunityTrailsProfile.js +++ b/src/components/Map/CommunityTrailsProfile.js @@ -27,7 +27,7 @@ import BlueBikeStationsLayers from "./layers/BlueBikeStationsLayers"; import EnvironmentalJusticeLayer from "./layers/EnvironmentalJusticeLayer"; import OpenSpaceLayer from "./layers/OpenSpaceLayer"; import LandlinesLayer from "./layers/LandlinesLayer"; -import TrailsRegNameSyncLayer from "./layers/TrailsRegNameSyncLayer"; +import OtherRegionalTrailsLayer from "./layers/OtherRegionalTrailsLayer"; import TransitLandStopsLayer from "./layers/TransitLandStopsLayer"; import TransitLandRoutesLayer from "./layers/TransitLandRoutesLayer"; import { renderBufferCircle, renderBufferPreview, renderBufferCenter } from "./layers/BufferLayers"; @@ -1107,7 +1107,7 @@ const CommunityTrailsProfile = ({ {/* Trails Reg Name Sync Layer */} {showTrailsRegNameSync && ( - { const [expandedProjects, setExpandedProjects] = useState(new Set()); const [isPanelVisible, setIsPanelVisible] = useState(true); @@ -43,6 +45,57 @@ const ProjectMetricsPanel = ({ setExpandedLengthByType(newExpanded); }; + // Download GeoJSON for a specific trail + const downloadTrailGeoJSON = (regName, e) => { + e.stopPropagation(); + + let trailFeatures = []; + + // Check if it's a major trail + const isMajorTrail = selectedMajorTrails.includes(regName); + + if (isMajorTrail && majorTrailsData && majorTrailsData.features) { + // Filter major trails by grouped_reg_name + trailFeatures = majorTrailsData.features.filter( + feature => { + const groupedRegName = (feature.properties?.grouped_reg_name || "").trim(); + return groupedRegName === regName.trim(); + } + ); + } else if (allTrailsData && allTrailsData.features) { + // Filter regular trails by reg_name + trailFeatures = allTrailsData.features.filter( + feature => (feature.properties?.reg_name || "").trim() === regName.trim() + ); + } + + if (trailFeatures.length === 0) { + alert(`No trail data available for ${regName}`); + return; + } + + // Create GeoJSON FeatureCollection + const geoJSON = { + type: "FeatureCollection", + features: trailFeatures + }; + + // Create filename from regName (sanitize for filesystem) + const sanitizedName = regName.replace(/[^a-z0-9]/gi, '_').toLowerCase(); + const filename = `${sanitizedName}_trails.geojson`; + + // Create blob and download + const blob = new Blob([JSON.stringify(geoJSON, null, 2)], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = filename; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); + }; + // Render metrics for a single project const renderProjectMetrics = (projectItem, index) => { const { regName, metrics } = projectItem; @@ -155,6 +208,33 @@ const ProjectMetricsPanel = ({ Zoom )} + {isPanelVisible ? ( @@ -623,7 +703,7 @@ const ProjectMetricsPanel = ({ ({selectedProjectsMetrics.length}) setIsPanelVisible(true)} onMouseEnter={(e) => { @@ -661,14 +741,28 @@ const ProjectMetricsPanel = ({ e.currentTarget.style.backgroundColor = ''; }} > - +
+ + Regional Trails Metrics + + ({selectedProjectsMetrics.length}) + + +
)}
diff --git a/src/components/Map/RegionalTrailsProfile.js b/src/components/Map/RegionalTrailsProfile.js index 0ecedab..24f83af 100644 --- a/src/components/Map/RegionalTrailsProfile.js +++ b/src/components/Map/RegionalTrailsProfile.js @@ -9,7 +9,7 @@ import CommunityIdentify from "./CommunityIdentify"; import ProjectMetricsPanel from "./ProjectMetricsPanel"; import GeocoderPanel from "../Geocoder/GeocoderPanel"; import { LayerContext } from "../../App"; -import TrailsRegNameSyncLayer from "./layers/TrailsRegNameSyncLayer"; +import OtherRegionalTrailsLayer from "./layers/OtherRegionalTrailsLayer"; import MajorTrailsLayer from "./layers/MajorTrailsLayer"; import OpenSpaceLayer from "./layers/OpenSpaceLayer"; import EnvironmentalJusticeLayer from "./layers/EnvironmentalJusticeLayer"; @@ -67,7 +67,7 @@ const RegionalTrailsProfile = ({ const [hoveredTrail, setHoveredTrail] = useState(null); const [colorPalette, setColorPalette] = useState({}); const allRegNamesRef = useRef(new Set()); // Track all unique reg_names seen using ref - const [allTrailsData, setAllTrailsData] = useState(null); // Store all trail data from TrailsRegNameSyncLayer + const [allTrailsData, setAllTrailsData] = useState(null); // Store all trail data from OtherRegionalTrailsLayer const [majorTrailsData, setMajorTrailsData] = useState(null); // Store all major trail data from MajorTrailsLayer // Use global OpenSpace state instead of local state to persist across profile switches @@ -557,32 +557,12 @@ const RegionalTrailsProfile = ({ } } - // Check for Environmental Justice clicks (since it's a raster layer, we need to query) - if (showEnvironmentalJustice) { - const ejFeature = await queryEnvironmentalJusticeAtPoint(event.lngLat.lng, event.lngLat.lat); - if (ejFeature) { - // If clicking on the same EJ feature, close the popup - if (environmentalJusticeClickInfo && - environmentalJusticeClickInfo.feature.properties?.OBJECTID === ejFeature.properties?.OBJECTID) { - setEnvironmentalJusticeClickInfo(null); - } else { - setEnvironmentalJusticeClickInfo({ - point: { lng: event.lngLat.lng, lat: event.lngLat.lat }, - feature: ejFeature - }); - } - toggleIdentifyPopup(false); - setOpenSpaceClickInfo(null); - return; - } - } - let trailFeatures = []; // First, try to get features from event.features if (event.features && event.features.length > 0) { trailFeatures = event.features.filter((f) => - f.layer && (f.layer.id === "trails-reg-name-sync-layer" || f.layer.id === "gaps-reg-name-sync-layer") + f.layer && (f.layer.id === "other-regional-trails-layer" || f.layer.id === "gaps-other-regional-trails-layer") ); } @@ -593,20 +573,20 @@ const RegionalTrailsProfile = ({ // Check if layers exist before querying const style = map.getStyle(); const layersExist = style && style.layers && ( - style.layers.some(layer => layer.id === 'trails-reg-name-sync-layer') || - style.layers.some(layer => layer.id === 'gaps-reg-name-sync-layer') + style.layers.some(layer => layer.id === 'other-regional-trails-layer') || + style.layers.some(layer => layer.id === 'gaps-other-regional-trails-layer') ); if (layersExist) { const layerFilter = { - layers: ['trails-reg-name-sync-layer', 'gaps-reg-name-sync-layer'] + layers: ['other-regional-trails-layer', 'gaps-other-regional-trails-layer'] }; try { // First try exact point query let allFeatures = map.queryRenderedFeatures(centerPoint, layerFilter); trailFeatures = allFeatures.filter((f) => - f.layer && (f.layer.id === "trails-reg-name-sync-layer" || f.layer.id === "gaps-reg-name-sync-layer") + f.layer && (f.layer.id === "other-regional-trails-layer" || f.layer.id === "gaps-other-regional-trails-layer") ); // If still no features, try querying multiple points in a small radius around the click @@ -633,7 +613,7 @@ const RegionalTrailsProfile = ({ try { allFeatures = map.queryRenderedFeatures(queryPoint, layerFilter); const foundFeatures = allFeatures.filter((f) => - f.layer && (f.layer.id === "trails-reg-name-sync-layer" || f.layer.id === "gaps-reg-name-sync-layer") + f.layer && (f.layer.id === "other-regional-trails-layer" || f.layer.id === "gaps-other-regional-trails-layer") ); if (foundFeatures.length > 0) { trailFeatures = foundFeatures; @@ -658,7 +638,7 @@ const RegionalTrailsProfile = ({ const centerPoint = [event.lngLat.lng, event.lngLat.lat]; const allFeatures = map.queryRenderedFeatures(centerPoint); trailFeatures = allFeatures.filter((f) => - f.layer && (f.layer.id === "trails-reg-name-sync-layer" || f.layer.id === "gaps-reg-name-sync-layer") + f.layer && (f.layer.id === "other-regional-trails-layer" || f.layer.id === "gaps-other-regional-trails-layer") ); } catch (err) { // Silently fail if query doesn't work @@ -687,6 +667,35 @@ const RegionalTrailsProfile = ({ return; // Exit early after setting trail click info } + // Check for Environmental Justice clicks (only if no trail was clicked) + // Since EJ is a raster layer, we need to query it, but only if no trail features were found + if (showEnvironmentalJustice && trailFeatures.length === 0) { + const ejFeature = await queryEnvironmentalJusticeAtPoint(event.lngLat.lng, event.lngLat.lat); + if (ejFeature) { + // If clicking on the same EJ feature, close the popup + if (environmentalJusticeClickInfo && + environmentalJusticeClickInfo.feature.properties?.OBJECTID === ejFeature.properties?.OBJECTID) { + setEnvironmentalJusticeClickInfo(null); + } else { + // Always clear existing tooltips first, then reopen + setEnvironmentalJusticeClickInfo(null); + toggleIdentifyPopup(false); + setOpenSpaceClickInfo(null); + setMajorTrailClickInfo(null); + setRegularTrailClickInfo(null); + + // Use setTimeout to ensure the tooltip reopens after clearing + setTimeout(() => { + setEnvironmentalJusticeClickInfo({ + point: { lng: event.lngLat.lng, lat: event.lngLat.lat }, + feature: ejFeature + }); + }, 10); + } + return; + } + } + // If clicking on empty space, check if we should close OpenSpace popup if (showOpenSpace && openSpaceClickInfo) { const map = mapRef.current?.getMap(); @@ -758,7 +767,7 @@ const RegionalTrailsProfile = ({ // Handle regular trail hover (for cursor only, no popup) const trailFeature = features.find((f) => - f.layer && (f.layer.id === "trails-reg-name-sync-layer" || f.layer.id === "gaps-reg-name-sync-layer") + f.layer && (f.layer.id === "other-regional-trails-layer" || f.layer.id === "gaps-other-regional-trails-layer") ); if (trailFeature) { @@ -995,8 +1004,8 @@ const RegionalTrailsProfile = ({ const getTrailLayerIds = () => { const layerIds = []; // Always add regular trail layers for click detection (even if no projects selected) - layerIds.push("trails-reg-name-sync-layer"); - layerIds.push("gaps-reg-name-sync-layer"); + layerIds.push("other-regional-trails-layer"); + layerIds.push("gaps-other-regional-trails-layer"); // Add Major Trail layer if major trails are selected (now includes gaps) if (selectedMajorTrails && selectedMajorTrails.length > 0) { layerIds.push("major-trails-layer"); @@ -1049,10 +1058,10 @@ const RegionalTrailsProfile = ({ try { // Complete list of all trail layer IDs that should be on top const trailLayerIds = [ - 'trails-reg-name-sync-layer', - 'trails-reg-name-sync-layer-hover', - 'trails-reg-name-sync-layer-click', - 'gaps-reg-name-sync-layer', + 'other-regional-trails-layer', + 'other-regional-trails-layer-hover', + 'other-regional-trails-layer-click', + 'gaps-other-regional-trails-layer', 'major-trails-layer', 'major-trails-layer-hover', 'major-trails-layer-click' @@ -1213,7 +1222,7 @@ const RegionalTrailsProfile = ({ /> {/* Trails Reg Name Sync Layer - rendered last to appear on top of all other layers */} -
); diff --git a/src/components/Map/layers/TrailsRegNameSyncLayer.js b/src/components/Map/layers/OtherRegionalTrailsLayer.js similarity index 97% rename from src/components/Map/layers/TrailsRegNameSyncLayer.js rename to src/components/Map/layers/OtherRegionalTrailsLayer.js index 39b1f79..97e79b2 100644 --- a/src/components/Map/layers/TrailsRegNameSyncLayer.js +++ b/src/components/Map/layers/OtherRegionalTrailsLayer.js @@ -26,14 +26,14 @@ const generateColorPalette = (regNames) => { }; /** - * Renders Trails Reg Name Sync layer using ArcGIS FeatureServer for data + * Renders Other Regional Trails layer using ArcGIS FeatureServer for data * Data source: https://services.arcgis.com/c5WwApDsDjRhIVkH/arcgis/rest/services/export_other_trails/FeatureServer * * Uses FeatureServer queries to fetch GeoJSON data for rendering and data extraction (reg_names, metrics). * Renders trails as GeoJSON to support filtering by selectedRegNames and color coding. * Supports color coding by reg_name attribute when useColorCoding is true. */ -const TrailsRegNameSyncLayer = ({ +const OtherRegionalTrailsLayer = ({ showTrailsRegNameSync, showMunicipalityProfileMap, showRegionalTrailsProfile, @@ -457,12 +457,12 @@ const TrailsRegNameSyncLayer = ({ {/* Render gaps in red */} {filteredGapsData.features.length > 0 && ( {/* Regular trails */} {/* Hover layer for regular trails - wider */} {/* Click highlight layer for regular trails - thicker when clicked */} 0 && ( {/* Hover layer for gaps - wider */} {/* Click highlight layer for gaps - thicker when clicked */} Date: Mon, 9 Feb 2026 16:23:42 -0500 Subject: [PATCH 3/5] fix on map layer tooltip --- src/components/ControlPanel/index.js | 71 +- src/components/Map/CommunityIdentify.js | 157 +++- src/components/Map/CommunityTrailsProfile.js | 700 +++++++++--------- src/components/Map/RegionalTrailsProfile.js | 45 +- .../Map/layers/TransitLandRoutesLayer.js | 2 +- 5 files changed, 577 insertions(+), 398 deletions(-) diff --git a/src/components/ControlPanel/index.js b/src/components/ControlPanel/index.js index 6923761..df0f586 100644 --- a/src/components/ControlPanel/index.js +++ b/src/components/ControlPanel/index.js @@ -274,6 +274,47 @@ const ControlPanel = ({ } }, [selectedMunicipality, municipalityTrails, showMunicipalityView]); + // Handle switch from Regional to Community Trails Profile (close EJ and OpenSpace by default) + const handleSwitchToCommunityProfile = () => { + isNavigatingRef.current = true; + setShowEnvironmentalJustice(false); + setShowOpenSpace(false); + setShowCommuterRail(false); + setShowStationLabels(false); + setShowBlueBikeStations(false); + setShowSubwayStations(false); + setShowLandlinesFeatureService(false); + setShowTrailsRegNameSync(false); + setShowTransitLandStops(false); + setShowRegionalTrailsProfileMap(false); + setShowRegionalTrailsView(false); + setShowMunicipalityProfileMap(true); + setShowMunicipalityView(true); + setSelectedMunicipality(null); + navigate('/communityTrailsProfile'); + }; + + // Handle switch from Community to Regional Trails Profile (close EJ and OpenSpace by default) + const handleSwitchToRegionalProfile = () => { + isNavigatingRef.current = true; + setShowEnvironmentalJustice(false); + setShowOpenSpace(false); + setShowCommuterRail(false); + setShowStationLabels(false); + setShowBlueBikeStations(false); + setShowSubwayStations(false); + setShowLandlinesFeatureService(false); + setShowTrailsRegNameSync(false); + setShowTransitLandStops(false); + toggleMunicipalities(false); + setShowMunicipalityProfileMap(false); + setShowMunicipalityView(false); + setSelectedMunicipality(null); + setShowRegionalTrailsProfileMap(true); + setShowRegionalTrailsView(true); + navigate('/regionalTrailsProfile'); + }; + // Handle regional trails view toggle const handleRegionalTrailsToggle = () => { if (!showRegionalTrailsView) { @@ -327,11 +368,24 @@ const ControlPanel = ({ + {/* Map Layers Section */}
@@ -377,11 +431,24 @@ const ControlPanel = ({ + ) : ( Find the trails that work for you! diff --git a/src/components/Map/CommunityIdentify.js b/src/components/Map/CommunityIdentify.js index bca1d67..b34ace1 100644 --- a/src/components/Map/CommunityIdentify.js +++ b/src/components/Map/CommunityIdentify.js @@ -41,6 +41,9 @@ const CommunityIdentify = ({ point, identifyResult, handleShowPopup, handleCarou return v; }; + // Check if result is a trail (same as original trails filter) + const isTrailResult = (element) => typeof element.layerId === 'number'; + const identifyLayer = []; const identifyTrailName = []; const identifyMunicipality = []; @@ -63,20 +66,33 @@ const CommunityIdentify = ({ point, identifyResult, handleShowPopup, handleCarou } else if (element.layerId === 'blue-bike-station' || element.layerName === 'Blue Bike Station') { // For blue bike stations, use name field name = normalizeCandidate(attrs["name"]); + } else if (element.layerId === 'municipality' || element.layerName === 'Municipality') { + // For municipalities, use name field + name = normalizeCandidate(attrs["name"] || attrs["town"] || attrs["NAME"]); + } else if (isTrailResult(element)) { + // For trails: same as Identify.js - Local Name -> Regional Name -> Property Name (or snake_case equivalents) + const localName = normalizeCandidate(attrs["Local Name"] || attrs["local_name"]); + const regionalName = normalizeCandidate(attrs["Regional Name"] || attrs["reg_name"]); + const propertyName = normalizeCandidate(attrs["Property Name"] || attrs["prop_name"]); + name = localName || regionalName || propertyName || ""; } else { - // For trails, use the standard trail name logic + // For other layers (OpenSpace etc), use standard trail name logic or name field const localName = normalizeCandidate(attrs["local_name"]); const regionalName = normalizeCandidate(attrs["reg_name"]); const propertyName = normalizeCandidate(attrs["prop_name"]); - name = localName || regionalName || propertyName || ""; + name = localName || regionalName || propertyName || normalizeCandidate(attrs["name"] || attrs["SITE_NAME"]) || ""; } identifyTrailName.push(name); - identifyMunicipality.push(getMunicipalityName(attrs["muni_id"])); - identifyDate.push(attrs["open_date"] !== "Null" ? attrs["open_date"] : ""); + identifyMunicipality.push(getMunicipalityName(attrs["muni_id"] || attrs["Municipal ID"])); + identifyDate.push( + (attrs["Facility Opening Date"] !== undefined && attrs["Facility Opening Date"] !== "Null") + ? attrs["Facility Opening Date"] + : (attrs["open_date"] !== undefined && attrs["open_date"] !== "Null") ? attrs["open_date"] : "" + ); - const rawLengthFeet = attrs["length_ft"]; + const rawLengthFeet = attrs["Facility Length in Feet"] ?? attrs["length_ft"]; const normalizedLengthFeet = rawLengthFeet !== undefined && rawLengthFeet !== null && rawLengthFeet !== "Null" && rawLengthFeet !== " " ? rawLengthFeet @@ -84,43 +100,110 @@ const CommunityIdentify = ({ point, identifyResult, handleShowPopup, handleCarou identifyLength.push(normalizedLengthFeet); }); - // Check if any result is a transit stop - const isTransitStop = identifyResult.some(result => - result.layerId === 'transit-land-stop' || result.layerName === 'Transit Stop' - ); - const carouselItems = []; for (let i = 0; i < identifyResult.length; i++) { - const itemIsTransitStop = identifyResult[i].layerId === 'transit-land-stop' || identifyResult[i].layerName === 'Transit Stop'; + const element = identifyResult[i]; + const itemIsTrail = isTrailResult(element); + const attrs = element.attributes || {}; - carouselItems.push( - - {(identifyTrailName[i] && Name: {identifyTrailName[i]}) || - (!identifyTrailName[i] && Name: N/A)} - {!itemIsTransitStop && ( - <> + if (itemIsTrail) { + // For trails: use same format as original trails filter (Identify.js) + carouselItems.push( + + {(identifyTrailName[i] && Name: {identifyTrailName[i]}) || + (!identifyTrailName[i] && Name: N/A)} + {(identifyLayer[i] && ( + + Type:{" "} + {identifyLayer[i].split(" ")[0] !== "Existing" + ? identifyLayer[i] + : identifyLayer[i].split(" ").slice(1, identifyLayer[i].split(" ").length).join(" ")} + + )) || + (!identifyLayer[i] && Type: N/A)} + {(identifyMunicipality[i] && Municipality: {identifyMunicipality[i]}) || + (!identifyMunicipality[i] && Municipality: N/A)} + {(identifyDate[i] && Opening Date: {identifyDate[i]}) || + (!identifyDate[i] && Opening Date: N/A)} + {(identifyLength[i] && Length: {parseFloat(identifyLength[i]).toFixed(2)} ft) || + (!identifyLength[i] && Length: N/A)} + + ); + } else { + // For non-trails (transit stops, blue bike, T-stops, OpenSpace, etc): show all properties + const allProperties = Object.entries(attrs) + .filter(([key, value]) => { + if (value === null || value === undefined) return false; + const strValue = String(value).trim(); + const lowerValue = strValue.toLowerCase(); + return strValue !== '' && + strValue !== '0' && + lowerValue !== 'null' && + lowerValue !== '' && + lowerValue !== '(null)' && + lowerValue !== 'n/a' && + lowerValue !== 'na'; + }) + .sort(([keyA], [keyB]) => { + const importantFields = ['name', 'Name', 'STATION', 'station', 'Stop Name', 'stop_name', 'SITE_NAME', 'site_name', 'local_name', 'reg_name', 'prop_name', 'town', 'NAME']; + const aImportant = importantFields.indexOf(keyA); + const bImportant = importantFields.indexOf(keyB); + if (aImportant !== -1 && bImportant !== -1) return aImportant - bImportant; + if (aImportant !== -1) return -1; + if (bImportant !== -1) return 1; + return keyA.localeCompare(keyB); + }); + + carouselItems.push( + +
{(identifyLayer[i] && ( - - Type:{" "} +
{identifyLayer[i].split(" ")[0] !== "Existing" ? identifyLayer[i] : identifyLayer[i].split(" ").slice(1, identifyLayer[i].split(" ").length).join(" ")} - - )) || - (!identifyLayer[i] && Type: N/A)} - {(identifyMunicipality[i] && ( - Municipality: {identifyMunicipality[i]} - )) || - (!identifyMunicipality[i] && Municipality: N/A)} - {(identifyDate[i] && Opening Date: {identifyDate[i]}) || - (!identifyDate[i] && Opening Date: N/A)} - {(identifyLength[i] && ( - Length: {parseFloat(identifyLength[i]).toFixed(2)} ft - )) || (!identifyLength[i] && Length: N/A)} - - )} - - ); +
+ ))} + + {allProperties.length > 0 ? ( +
+ {allProperties.map(([key, value], idx) => { + let displayValue = value; + if (typeof value === 'number') { + if (key.toLowerCase().includes('length') || key.toLowerCase().includes('feet') || key.toLowerCase().includes('ft')) { + displayValue = parseFloat(value).toFixed(2) + ' ft'; + } else if (key.toLowerCase().includes('acre')) { + displayValue = parseFloat(value).toFixed(2) + ' acres'; + } else { + displayValue = value.toString(); + } + } else if (typeof value === 'boolean') { + displayValue = value ? 'Yes' : 'No'; + } else { + displayValue = String(value); + } + + const formattedKey = key + .replace(/_/g, ' ') + .replace(/([A-Z])/g, ' $1') + .replace(/^./, str => str.toUpperCase()) + .trim(); + + return ( +
+ {formattedKey}:{' '} + {displayValue} +
+ ); + })} +
+ ) : ( +
No properties available
+ )} +
+
+ ); + } } function handleSelect(event) { @@ -140,8 +223,8 @@ const CommunityIdentify = ({ point, identifyResult, handleShowPopup, handleCarou > {carouselItems} - {/* Hide edit button for transit stops */} - {!isTransitStop && ( + {/* Edit button only for trail tooltips */} + {identifyResult.length > 0 && isTrailResult(identifyResult[carouselIndex]) && (