- )}
-
- {/* Parks */}
- {metrics.parks && metrics.parks.length > 0 && (
-
);
};
@@ -134,18 +676,106 @@ 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 = '';
+ }}
+ >
+
+
+ Regional Trails Metrics
+
+ ({selectedProjectsMetrics.length})
+
+
+
+
+ )}
);
};
export default ProjectMetricsPanel;
-
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..c861824
--- /dev/null
+++ b/src/components/Map/RegionalTrailsProfile.js
@@ -0,0 +1,998 @@
+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 "./tooltip/CommunityIdentify";
+import ProjectMetricsPanel from "./ProjectMetricsPanel";
+import GeocoderPanel from "../Geocoder/GeocoderPanel";
+import { LayerContext } from "../../App";
+import OtherRegionalTrailsLayer from "./layers/OtherRegionalTrailsLayer";
+import MajorTrailsLayer from "./layers/MajorTrailsLayer";
+import OpenSpaceLayer from "./layers/OpenSpaceLayer";
+import EnvironmentalJusticeLayer from "./layers/EnvironmentalJusticeLayer";
+import EnvironmentalJusticePopupContent from "./tooltip/EnvironmentalJusticePopupContent";
+import OpenSpacePopupContent from "./tooltip/OpenSpacePopupContent";
+import TrailPopupContent from "./tooltip/TrailPopupContent";
+import massachusettsData from "../../data/massachusetts.json";
+import { getMunicipalityName } from "./utils/municipalityUtils";
+import { queryFeatureAtPoint } from "./utils/arcgisPointQuery";
+import { getFeaturesAtPoint } from "./utils/mapQueryUtils";
+import { TRAIL_FACILITY_TYPE_LABELS, EJ2020_MAP_SERVER_URL } from "./constants/mapConstants";
+import bbox from "@turf/bbox";
+import styled from "styled-components";
+
+// Trail Status Legend
+const TrailStatusLegend = styled.div`
+ position: absolute;
+ bottom: 40px;
+ right: 10px;
+ background: rgba(255, 255, 255, 0.95);
+ border: 1px solid rgba(0, 0, 0, 0.1);
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
+ border-radius: 6px;
+ padding: 12px;
+ z-index: 1000;
+ font-size: 12px;
+ min-width: 180px;
+`;
+
+const TrailStatusLegendHeader = styled.div`
+ margin-bottom: 8px;
+ padding-bottom: 6px;
+ border-bottom: 1px solid rgba(0, 0, 0, 0.1);
+ font-weight: 600;
+ font-size: 13px;
+ color: #333;
+`;
+
+const TrailStatusLegendList = styled.div`
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+`;
+
+const TrailStatusLegendItem = styled.div`
+ display: flex;
+ align-items: center;
+ gap: 8px;
+`;
+
+const TrailStatusLegendSwatch = styled.div`
+ width: 30px;
+ height: 3px;
+ background-color: ${(props) => props.$color};
+ border-radius: 2px;
+ flex-shrink: 0;
+`;
+
+const TrailStatusLegendLabel = styled.span`
+ color: #333;
+`;
+
+const MAPBOX_TOKEN = process.env.REACT_APP_MAPBOX_API_TOKEN;
+
+const INTERACTIVE_LAYER_IDS = [
+ "major-trails-layer",
+ "other-regional-trails-layer",
+ "gaps-other-regional-trails-layer",
+ "openspace-layer-regional",
+ "openspace-outline-regional"
+];
+
+const RegionalTrailsProfile = ({
+ viewport,
+ setViewport,
+ baseLayer,
+ showBasemapPanel,
+ toggleBasemapPanel,
+ showControlPanel,
+ toggleControlPanel,
+ mapRef
+}) => {
+ const navigate = useNavigate();
+ const location = useLocation();
+ const {
+ showTrailsRegNameSync,
+ setShowTrailsRegNameSync,
+ basemaps,
+ setProjectRegNames,
+ setSelectedProjectRegName,
+ 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 allRegNamesRef = useRef(new Set()); // Track all unique reg_names seen using ref
+ 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
+ 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, only if current zoom is smaller than 11
+ if (event.detail.show && mapRef?.current) {
+ const map = mapRef.current.getMap();
+ if (map && map.getZoom() < 11) {
+ 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
+ const ejHoverTimeoutRef = useRef(null);
+ const ejHoverQueryIdRef = useRef(0);
+
+ const getTrailTypeLabel = (segType, facStat) =>
+ TRAIL_FACILITY_TYPE_LABELS[`${segType},${facStat}`]
+
+ // Update reg_names in context when discovered
+ useEffect(() => {
+ if (regNames.length > 0) {
+ const previousSize = allRegNamesRef.current.size;
+ regNames.forEach(name => {
+ if (name && name.trim() !== "") {
+ allRegNamesRef.current.add(name);
+ }
+ });
+
+ if (allRegNamesRef.current.size !== previousSize) {
+ const sortedRegNames = Array.from(allRegNamesRef.current).sort();
+ if (setProjectRegNames) setProjectRegNames(sortedRegNames);
+ } else if (setProjectRegNames) {
+ setProjectRegNames(regNames);
+ }
+ }
+ }, [regNames, setProjectRegNames]);
+
+ // 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 false;
+ }
+
+ 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 false;
+ }
+
+ 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: 500, right: 80 },
+ duration: 1000,
+ maxZoom: 12
+ }
+ );
+ return true;
+ } catch (e) {
+ console.warn("Error fitting bounds to project trails:", e);
+ return false;
+ }
+ };
+
+ // Zoom to trail when user checks a regional trail (other projects or major trails)
+ const previousSelectedRef = useRef(new Set());
+ const previousMajorTrailsRef = useRef([]);
+
+ useEffect(() => {
+ // Zoom when a new "other regional trail" project is selected
+ const newlySelected = Array.from(selectedRegNames).filter(
+ regName => !previousSelectedRef.current.has(regName)
+ );
+ if (newlySelected.length > 0) {
+ const zoomed = handleZoomToProject(newlySelected[0]);
+ if (zoomed) {
+ previousSelectedRef.current = new Set(selectedRegNames);
+ }
+ } else {
+ previousSelectedRef.current = new Set(selectedRegNames);
+ }
+ }, [selectedRegNames, allTrailsData]);
+
+ useEffect(() => {
+ // Zoom when a new major trail is selected
+ const newlySelected = selectedMajorTrails.filter(
+ name => !previousMajorTrailsRef.current.includes(name)
+ );
+ if (newlySelected.length > 0) {
+ const zoomed = handleZoomToProject(newlySelected[0]);
+ if (zoomed) {
+ previousMajorTrailsRef.current = [...selectedMajorTrails];
+ }
+ } else {
+ previousMajorTrailsRef.current = [...selectedMajorTrails];
+ }
+ }, [selectedMajorTrails, majorTrailsData]);
+
+ // Query Environmental Justice feature at a point
+ const queryEnvironmentalJusticeAtPoint = (lng, lat) =>
+ queryFeatureAtPoint(`${EJ2020_MAP_SERVER_URL}/0`, lng, lat);
+
+ const clearAllPopups = () => {
+ toggleIdentifyPopup(false);
+ setOpenSpaceClickInfo(null);
+ setEnvironmentalJusticeClickInfo(null);
+ setMajorTrailClickInfo(null);
+ setRegularTrailClickInfo(null);
+ };
+
+ const showPopup = (setter, data) => {
+ clearAllPopups();
+ setTimeout(() => setter(data), 10);
+ };
+
+ const pt = (e) => ({ lng: e.lngLat.lng, lat: e.lngLat.lat });
+
+ /**
+ * Handle map click: show popup for clicked feature (OpenSpace, trails, EJ) or clear all.
+ * Order: OpenSpace > major trails > regular trails > EJ (raster, needs server query).
+ */
+ const handleTrailClick = async (event) => {
+ const map = mapRef.current?.getMap();
+ if (!map || !event.lngLat) {
+ clearAllPopups();
+ return;
+ }
+
+ // 1. OpenSpace (vector)
+ if (showOpenSpace) {
+ const feature = getFeaturesAtPoint(map, event, ["openspace-layer-regional", "openspace-outline-regional"]);
+ if (feature) {
+ const isSame = openSpaceClickInfo?.feature?.properties?.OBJECTID === feature.properties?.OBJECTID;
+ if (isSame) setOpenSpaceClickInfo(null);
+ else showPopup(setOpenSpaceClickInfo, { point: pt(event), feature });
+ return;
+ }
+ }
+
+ // 2. Major trails (vector)
+ if (selectedMajorTrails?.length) {
+ const feature = getFeaturesAtPoint(map, event, ["major-trails-layer"]);
+ if (feature) {
+ showPopup(setMajorTrailClickInfo, { point: pt(event), feature });
+ return;
+ }
+ }
+
+ // 3. Regular trails (vector)
+ const trailFeatures = getFeaturesAtPoint(map, event, ["other-regional-trails-layer", "gaps-other-regional-trails-layer"], { returnAll: true });
+ if (trailFeatures.length) {
+ showPopup(setRegularTrailClickInfo, { point: pt(event), feature: trailFeatures[0] });
+ return;
+ }
+
+ // 4. EJ (raster - query server)
+ if (showEnvironmentalJustice) {
+ const ejFeature = await queryEnvironmentalJusticeAtPoint(event.lngLat.lng, event.lngLat.lat);
+ if (ejFeature) {
+ const isSame = environmentalJusticeClickInfo?.feature?.properties?.OBJECTID === ejFeature.properties?.OBJECTID;
+ if (isSame) setEnvironmentalJusticeClickInfo(null);
+ else showPopup(setEnvironmentalJusticeClickInfo, { point: pt(event), feature: ejFeature });
+ return;
+ }
+ }
+
+ clearAllPopups();
+ };
+
+ /**
+ * Handle map hover: set hoveredTrail to control cursor (pointer when over clickable features)
+ * and layer hover highlights (thicker line on trails).
+ *
+ * Flow:
+ * - Vector layers (trails, OpenSpace): use queryRenderedFeatures - any feature from
+ * INTERACTIVE_LAYER_IDS = pointer. MajorTrailsLayer/OtherRegionalTrailsLayer need
+ * featureId in hoveredTrail for their hover highlight.
+ * - EJ layer (raster): no vector data on client - debounced point query to server.
+ */
+ 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 (!features) {
+ try {
+ features = map.queryRenderedFeatures(point);
+ } catch (err) {
+ setHoveredTrail(null);
+ return;
+ }
+ }
+
+ // Vector layers: any feature from our layers = pointer (MajorTrailsLayer/OtherRegionalTrailsLayer need featureId for hover highlight)
+ const feature = features.find((f) => f.layer?.id && INTERACTIVE_LAYER_IDS.includes(f.layer.id));
+ if (feature) {
+ const layerId = feature.layer.id;
+ if (layerId === "major-trails-layer") {
+ setHoveredTrail({
+ properties: feature.properties,
+ lngLat: event.lngLat,
+ featureId: feature.properties?.OBJECTID ?? null,
+ isMajorTrail: true
+ });
+ } else if (layerId === "other-regional-trails-layer" || layerId === "gaps-other-regional-trails-layer") {
+ setHoveredTrail({
+ properties: feature.properties,
+ lngLat: event.lngLat,
+ featureId: feature.properties?.OBJECTID ?? null,
+ isRegularTrail: true
+ });
+ } else {
+ setHoveredTrail({ isOpenSpace: true });
+ }
+ return;
+ }
+
+ // EJ: raster layer - must query server (no vector data on client)
+ if (showEnvironmentalJustice) {
+ if (ejHoverTimeoutRef.current) clearTimeout(ejHoverTimeoutRef.current);
+ const queryId = ++ejHoverQueryIdRef.current;
+ ejHoverTimeoutRef.current = setTimeout(() => {
+ queryEnvironmentalJusticeAtPoint(event.lngLat.lng, event.lngLat.lat).then((ejFeature) => {
+ if (queryId !== ejHoverQueryIdRef.current) return;
+ setHoveredTrail(ejFeature ? { isEnvironmentalJustice: true } : null);
+ });
+ }, 150);
+ return;
+ }
+
+ if (ejHoverTimeoutRef.current) {
+ clearTimeout(ejHoverTimeoutRef.current);
+ ejHoverTimeoutRef.current = null;
+ }
+ 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/envisioned/design 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);
+
+ const lengthFeet = Number(props.length_ft) || 0;
+
+ 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();
+
+ // 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 || 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("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");
+ }
+ // 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 = [
+ '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'
+ ];
+
+ // 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={() => {
+ if (ejHoverTimeoutRef.current) {
+ clearTimeout(ejHoverTimeoutRef.current);
+ ejHoverTimeoutRef.current = null;
+ }
+ setHoveredTrail(null);
+ }}
+ mapboxAccessToken={MAPBOX_TOKEN}
+ mapStyle={baseLayer.url}
+ scrollZoom={true}
+ transitionDuration="1000"
+ transformRequest={(url, resourceType) => {
+ // Only transform non-Mapbox tile requests (e.g. ArcGIS VectorTileServer)
+ if (resourceType === 'Tile' && !url.includes('mapbox.com') && url.includes('VectorTileServer/tile/')) {
+ const convertedUrl = url.replace(/\/tile\/(\d+)\/(\d+)\/(\d+)\.pbf/, (match, z, y, x) => {
+ return `/tile/${z}/${x}/${y}.pbf`;
+ });
+ return { url: convertedUrl };
+ }
+ 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
+ } : null}
+ />
+
+ {/* other regional trails 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?.point && environmentalJusticeClickInfo?.feature && (
+ setEnvironmentalJusticeClickInfo(null)}
+ anchor="top"
+ offset={12}
+ >
+
+
+ )}
+
+ {/* Major Trail Click Popup */}
+ {selectedMajorTrails && selectedMajorTrails.length > 0 && majorTrailClickInfo && majorTrailClickInfo.point && majorTrailClickInfo.feature && (
+ setMajorTrailClickInfo(null)}
+ anchor="top"
+ offset={12}
+ >
+
+
+ )}
+
+ {/* Regular Trail Click Popup */}
+ {regularTrailClickInfo && regularTrailClickInfo.point && regularTrailClickInfo.feature && (
+ setRegularTrailClickInfo(null)}
+ anchor="top"
+ offset={12}
+ >
+
+
+ )}
+
+ {/* OpenSpace Click Popup */}
+ {showOpenSpace && openSpaceClickInfo?.point && openSpaceClickInfo?.feature && (
+ setOpenSpaceClickInfo(null)}
+ anchor="top"
+ offset={12}
+ >
+
+
+ )}
+
+ {/* 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
+
+
+
+ Planned/Envisioned/Design
+
+
+
+ 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/TrailLegend.js b/src/components/Map/TrailLegend.js
index 8113e66..b4edfd0 100644
--- a/src/components/Map/TrailLegend.js
+++ b/src/components/Map/TrailLegend.js
@@ -1,6 +1,6 @@
import React from 'react';
import '../../styles/TrailLegend.scss';
-import { geojsonTrailLayers } from './constants/geojsonTrailLayers';
+import { trailsProfileLayers } from './constants/mapConstants';
const TrailLegend = ({ visibleTrailTypes, onToggleTrailType }) => {
// If no visibility state provided, show all by default
@@ -16,8 +16,8 @@ const TrailLegend = ({ visibleTrailTypes, onToggleTrailType }) => {
};
// Separate trails into existing and planned
- const existingTrails = geojsonTrailLayers.filter(layer => !layer.name.includes('Planned'));
- const plannedTrails = geojsonTrailLayers.filter(layer => layer.name.includes('Planned'));
+ const existingTrails = trailsProfileLayers.filter(layer => !layer.name.includes('Planned'));
+ const plannedTrails = trailsProfileLayers.filter(layer => layer.name.includes('Planned'));
const renderTrailItem = (layer) => {
const visible = isVisible(layer.id);
diff --git a/src/components/Map/TrailLegend.scss b/src/components/Map/TrailLegend.scss
deleted file mode 100644
index c54b2f3..0000000
--- a/src/components/Map/TrailLegend.scss
+++ /dev/null
@@ -1,74 +0,0 @@
-.TrailLegend {
- position: absolute;
- bottom: 120px; // Position above the map controls
- right: 10px;
- background: rgba(255, 255, 255, 0.95);
- border: 1px solid rgba(0, 0, 0, 0.1);
- box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
- border-radius: 6px;
- padding: 12px;
- z-index: 1000;
- max-width: 280px;
- font-size: 12px;
- max-height: 60vh;
- overflow-y: auto;
-
- &__header {
- margin-bottom: 8px;
- padding-bottom: 6px;
- border-bottom: 1px solid rgba(0, 0, 0, 0.1);
- font-size: 13px;
- }
-
- &__section {
- margin-bottom: 12px;
-
- &:last-child {
- margin-bottom: 0;
- }
- }
-
- &__section-header {
- margin-bottom: 6px;
- padding-bottom: 4px;
- border-bottom: 1px solid rgba(0, 0, 0, 0.08);
- }
-
- &__items {
- display: flex;
- flex-direction: column;
- gap: 6px;
- }
-
- &__item {
- display: flex;
- align-items: center;
- gap: 8px;
- }
-
- &__icon {
- color: #666;
- transition: color 0.2s ease;
-
- &:hover {
- color: #333;
- }
- }
-
- &__line-container {
- flex-shrink: 0;
- display: flex;
- align-items: center;
- }
-
- &__line {
- display: block;
- }
-
- &__label {
- flex: 1;
- line-height: 1.3;
- color: #333;
- }
-}
-
diff --git a/src/components/Map/constants/geojsonTrailLayers.js b/src/components/Map/constants/geojsonTrailLayers.js
deleted file mode 100644
index 139eb90..0000000
--- a/src/components/Map/constants/geojsonTrailLayers.js
+++ /dev/null
@@ -1,17 +0,0 @@
-// GeoJSON trail layer definitions - using local files
-// Colors match LayerData.js for consistency
-export const geojsonTrailLayers = [
- { id: 0, name: "Protected Bike Lanes", filename: "existing_protected_bike_lanes.json", color: "#2166AC" },
- { id: 1, name: "Planned Protected Bike Lanes", filename: "planned_protected_bike_lanes.json", color: "#2166AC", dashArray: [2, 2] },
- { id: 2, name: "Bike Lanes", filename: "existing_bike_lanes.json", color: "#92C5DE" },
- { id: 3, name: "Planned Bike Lanes", filename: "proposed_bike_lanes.json", color: "#92C5DE", dashArray: [2, 2] },
- { id: 4, name: "Paved Foot Path", filename: "paved_footway.json", color: "#903366" },
- { id: 5, name: "Planned Paved Foot Path", filename: "proposed_paved_footway.json", color: "#903366", dashArray: [2, 2] },
- { id: 6, name: "Natural Surface Path", filename: "natural_surface_footway.json", color: "#A87196" },
- { id: 7, name: "Planned Natural Surface Path", filename: "proposed_natural_surface_footway.json", color: "#A87196", dashArray: [2, 2] },
- { id: 8, name: "Paved Shared Use", filename: "existing_paved_shared_use_paths.json", color: "#214A2D" },
- { id: 9, name: "Planned Paved Shared Use", filename: "proposed_paved_shared_use_paths.json", color: "#214A2D", dashArray: [2, 2] },
- { id: 10, name: "Planned Unimproved Shared Use", filename: "proposed_unimproved_shared_use_paths.json", color: "#4BAA40", dashArray: [2, 2] },
- { id: 11, name: "Unimproved Shared Use", filename: "existing_unimproved_shared_use_paths.json", color: "#4BAA40" }
-];
-
diff --git a/src/components/Map/constants/mapConstants.js b/src/components/Map/constants/mapConstants.js
index b33744d..7c98524 100644
--- a/src/components/Map/constants/mapConstants.js
+++ b/src/components/Map/constants/mapConstants.js
@@ -1,5 +1,65 @@
-export const FEATURE_SERVER_BASE =
- "https://geo.mapc.org/server/rest/services/transportation/AllTrails/FeatureServer";
+/**
+ * Map-related constants for trails, layers, and facility types.
+ */
-export const DEFAULT_BUFFER_RADIUS = 1609; // Default: 1 mile in meters
+// -----------------------------------------------------------------------------
+// Trails Profile Layers (Community Trails Profile)
+// Layer definitions using local GeoJSON files.
+// Colors match LayerData.js for consistency.
+// -----------------------------------------------------------------------------
+export const trailsProfileLayers = [
+ { id: 0, name: "Protected Bike Lanes", filename: "existing_protected_bike_lanes.json", color: "#2166AC" },
+ { id: 1, name: "Planned Protected Bike Lanes", filename: "planned_protected_bike_lanes.json", color: "#2166AC", dashArray: [2, 2] },
+ { id: 2, name: "Bike Lanes", filename: "existing_bike_lanes.json", color: "#92C5DE" },
+ { id: 3, name: "Planned Bike Lanes", filename: "proposed_bike_lanes.json", color: "#92C5DE", dashArray: [2, 2] },
+ { id: 4, name: "Paved Foot Path", filename: "paved_footway.json", color: "#903366" },
+ { id: 5, name: "Planned Paved Foot Path", filename: "proposed_paved_footway.json", color: "#903366", dashArray: [2, 2] },
+ { id: 6, name: "Natural Surface Path", filename: "natural_surface_footway.json", color: "#A87196" },
+ { id: 7, name: "Planned Natural Surface Path", filename: "proposed_natural_surface_footway.json", color: "#A87196", dashArray: [2, 2] },
+ { id: 8, name: "Paved Shared Use", filename: "existing_paved_shared_use_paths.json", color: "#214A2D" },
+ { id: 9, name: "Planned Paved Shared Use", filename: "proposed_paved_shared_use_paths.json", color: "#214A2D", dashArray: [2, 2] },
+ { id: 10, name: "Planned Unimproved Shared Use", filename: "proposed_unimproved_shared_use_paths.json", color: "#4BAA40", dashArray: [2, 2] },
+ { id: 11, name: "Unimproved Shared Use", filename: "existing_unimproved_shared_use_paths.json", color: "#4BAA40" }
+];
+// -----------------------------------------------------------------------------
+// Environmental Justice 2020 MapServer (for query and export)
+// Fallback for when REACT_APP_EJ2020_MAP_SERVER_URL is not set (e.g. dev server not restarted)
+// -----------------------------------------------------------------------------
+export const EJ2020_MAP_SERVER_URL =
+ process.env.REACT_APP_EJ2020_MAP_SERVER_URL ||
+ "https://arcgisserver.digital.mass.gov/arcgisserver/rest/services/AGOL/EJ2020/MapServer";
+
+// -----------------------------------------------------------------------------
+// Trail Facility Type Labels (Regional Trails / FeatureServer)
+// Maps seg_type,fac_stat to human-readable labels for display in popups and UI.
+// Key format: "seg_type,fac_stat" (e.g. "1,1" = Shared Use Path, Existing)
+// fac_stat: 1 = Existing, 2 = Design/Construction, 3 = Envisioned
+// -----------------------------------------------------------------------------
+export const TRAIL_FACILITY_TYPE_LABELS = {
+ "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"
+};
diff --git a/src/components/Map/index.js b/src/components/Map/index.js
index 458eb9f..9daa58e 100644
--- a/src/components/Map/index.js
+++ b/src/components/Map/index.js
@@ -18,8 +18,8 @@ import MASenateDistrictsButton from '../MASenateDistrictsButton';
import MunicipalitiesButton from '../MunicipalitiesButton';
import GeocoderPanel from "../Geocoder/GeocoderPanel";
import GlossaryModal from "../Modals/GlossaryModal";
-import Identify from "./Identify";
-import CommunityIdentify from "./CommunityIdentify";
+import Identify from "./tooltip/Identify";
+import CommunityIdentify from "./tooltip/CommunityIdentify";
import ShareModal from "../Modals/ShareModal";
import { ModalContext } from "../../App";
import { LayerContext } from "../../App";
@@ -34,9 +34,9 @@ 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";
+import { trailsProfileLayers } from "./constants/mapConstants";
const MAPBOX_TOKEN = process.env.REACT_APP_MAPBOX_API_TOKEN;
const TRAILMAP_SOURCE = process.env.REACT_APP_TRAIL_MAP_TILE_URL;
@@ -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 layerTrails = trailsByLayer[layerId];
- const layerInfo = geojsonTrailLayers.find(l => l.id === parseInt(layerId));
+ const layerInfo = trailsProfileLayers.find(l => l.id === parseInt(layerId));
// Check if this trail type is visible (default to true if not specified)
const isVisible = visibleTrailTypes[layerId] !== false;
@@ -104,7 +105,7 @@ const CommunityTrailsProfileLayers = ({
};
// Add dash array if the trail has one
- const layerInfo = geojsonTrailLayers.find(l => l.id === highlightedTrail.layerId);
+ const layerInfo = trailsProfileLayers.find(l => l.id === highlightedTrail.layerId);
if (layerInfo && layerInfo.dashArray) {
highlightPaint["line-dasharray"] = layerInfo.dashArray;
}
diff --git a/src/components/Map/layers/EnvironmentalJusticeLayer.js b/src/components/Map/layers/EnvironmentalJusticeLayer.js
index 18a4f34..6813d86 100644
--- a/src/components/Map/layers/EnvironmentalJusticeLayer.js
+++ b/src/components/Map/layers/EnvironmentalJusticeLayer.js
@@ -1,5 +1,6 @@
import React, { useEffect, useState, useRef } from "react";
import { Source, Layer } from "react-map-gl";
+import { EJ2020_MAP_SERVER_URL } from "../constants/mapConstants";
/**
* Renders Environmental Justice 2020 layer
@@ -9,13 +10,13 @@ import { Source, Layer } from "react-map-gl";
* Note: This creates a single image overlay that updates with map movement.
* For better performance with large datasets, consider using a tile proxy service.
*/
-const EnvironmentalJusticeLayer = ({ showEnvironmentalJustice, showMunicipalityProfileMap, showProjectTrailsProfile, mapRef }) => {
+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;
@@ -40,7 +41,6 @@ const EnvironmentalJusticeLayer = ({ showEnvironmentalJustice, showMunicipalityP
const swMerc = toWebMercator(sw.lng, sw.lat);
const neMerc = toWebMercator(ne.lng, ne.lat);
- const EJ2020_SERVICE_URL = "https://arcgisserver.digital.mass.gov/arcgisserver/rest/services/AGOL/EJ2020/MapServer";
const bbox = `${swMerc.x},${swMerc.y},${neMerc.x},${neMerc.y}`;
// Get map size for export
@@ -48,7 +48,7 @@ const EnvironmentalJusticeLayer = ({ showEnvironmentalJustice, showMunicipalityP
const width = mapSize.clientWidth || 1024;
const height = mapSize.clientHeight || 768;
- const url = `${EJ2020_SERVICE_URL}/export?bbox=${bbox}&bboxSR=3857&imageSR=3857&size=${width},${height}&f=image&format=png&transparent=true&layers=show:0`;
+ const url = `${EJ2020_MAP_SERVER_URL}/export?bbox=${bbox}&bboxSR=3857&imageSR=3857&size=${width},${height}&f=image&format=png&transparent=true&layers=show:0`;
setImageUrl(url);
setBounds([
@@ -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..2a86d60
--- /dev/null
+++ b/src/components/Map/layers/MajorTrailsLayer.js
@@ -0,0 +1,284 @@
+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;
+
+ // fac_stat can be number 1 or string "1" from ArcGIS - check both for existing (blue)
+ const isExistingTrail = ["any", ["==", ["get", "fac_stat"], 1], ["==", ["get", "fac_stat"], "1"]];
+
+ 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/OriginalTrailsFilterLayers.js b/src/components/Map/layers/OriginalTrailsFilterLayers.js
index 34f542a..e588ba3 100644
--- a/src/components/Map/layers/OriginalTrailsFilterLayers.js
+++ b/src/components/Map/layers/OriginalTrailsFilterLayers.js
@@ -4,7 +4,7 @@ import { Layer } from "react-map-gl";
/**
* Renders vector tile layers for original trails filters
*/
-const OriginalTrailsFilterLayers = ({ trailLayers, proposedLayers, existingTrails, proposedTrails }) => {
+const OriginalTrailsFilterLayers = ({ trailLayers, proposedLayers, existingTrails, proposedTrails, hoveredTrailId }) => {
const filterLayers = [];
const allLayers = [...trailLayers, ...proposedLayers];
@@ -13,16 +13,38 @@ const OriginalTrailsFilterLayers = ({ trailLayers, proposedLayers, existingTrail
const addLayer = layerSet.find((l) => l.id === layer);
if (addLayer) {
filterLayers.push(
-
+
+
+ {/* Hover layer: thicker line when hovering */}
+
+
);
}
});
diff --git a/src/components/Map/layers/OtherRegionalTrailsLayer.js b/src/components/Map/layers/OtherRegionalTrailsLayer.js
new file mode 100644
index 0000000..acbfaa4
--- /dev/null
+++ b/src/components/Map/layers/OtherRegionalTrailsLayer.js
@@ -0,0 +1,708 @@
+import React, { useEffect, useState, useRef, useMemo } from "react";
+import { Source, Layer } from "react-map-gl";
+
+/**
+ * Generates a color palette for different reg_name values
+ * Returns a consistent color for each unique reg_name
+ */
+const generateColorPalette = (regNames) => {
+ 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 = {};
+ regNames.forEach((name, index) => {
+ if (name && name.trim() !== "") {
+ palette[name] = colors[index % colors.length];
+ }
+ });
+
+ return palette;
+};
+
+/**
+ * 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 OtherRegionalTrailsLayer = ({
+ showTrailsRegNameSync,
+ showMunicipalityProfileMap,
+ showRegionalTrailsProfile,
+ mapRef,
+ useColorCoding = false,
+ onRegNamesChange = null,
+ selectedRegNames = [], // Array of selected reg_names to display
+ onTrailsDataChange = null, // Callback to pass trail data to parent
+ 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 = showTrailsRegNameSync && (showMunicipalityProfileMap || showRegionalTrailsProfile);
+
+ // 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 (!shouldShow || !onRegNamesChange) {
+ return;
+ }
+
+ // 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
+
+ // Generate color palette from current data when useColorCoding is enabled
+ const effectiveColorPalette = useMemo(() => {
+ if (!useColorCoding) {
+ return {};
+ }
+
+ // Generate from current data
+ if (!trailsData || !trailsData.features) {
+ return {};
+ }
+
+ 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();
+ return generateColorPalette(uniqueRegNames);
+ }, [useColorCoding, trailsData]);
+
+ useEffect(() => {
+ if (!shouldShow || !mapRef?.current) {
+ setTrailsData(null);
+ accumulatedTrailsRef.current.clear(); // Clear accumulated data when layer is hidden
+ return;
+ }
+
+ const updateLayer = () => {
+ const map = mapRef.current?.getMap();
+ if (!map) return;
+
+ let url;
+
+ // 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();
+
+ // 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)
+ };
+
+ // 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 (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) {
+ clearTimeout(queryTimeoutRef.current);
+ }
+
+ 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) {
+ // 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);
+ }
+ }
+ } catch (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);
+ }
+ }
+ }, 300);
+ };
+
+ // Clear accumulated data when selectedRegNames changes
+ accumulatedTrailsRef.current.clear();
+
+ // Initial update
+ updateLayer();
+
+ // 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 && (!selectedRegNames || selectedRegNames.length === 0)) {
+ const handleMoveEnd = () => {
+ if (updateTimeoutRef.current) {
+ clearTimeout(updateTimeoutRef.current);
+ }
+ 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);
+ }
+ };
+ } else {
+ // Cleanup timeouts if no map move listeners
+ return () => {
+ if (updateTimeoutRef.current) {
+ clearTimeout(updateTimeoutRef.current);
+ }
+ if (queryTimeoutRef.current) {
+ clearTimeout(queryTimeoutRef.current);
+ }
+ };
+ }
+ }, [shouldShow, mapRef, selectedRegNames]);
+
+ // 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;
+ }
+
+ // If using color coding, create separate layers for each reg_name
+ if (useColorCoding && Object.keys(effectiveColorPalette).length > 0) {
+ // 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 => {
+ // 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 with color coding */}
+ {regNamesToShow.map((regName) => {
+ const color = effectiveColorPalette[regName];
+ if (!color) return null;
+
+ const regNameTrails = filteredTrailsData.features.filter(f => {
+ const fRegName = (f.properties?.reg_name || "").trim().toLowerCase();
+ return fRegName === regName.trim().toLowerCase();
+ });
+
+ if (regNameTrails.length === 0) return null;
+
+ return (
+
+
+
+ );
+ })}
+
+ {/* Render gaps in red */}
+ {filteredGapsData.features.length > 0 && (
+
+
+
+ )}
+ >
+ );
+ }
+
+ // 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;
+ 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: 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";
+ })
+ };
+
+ 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;
+ }
+
+ // 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];
+
+ // fac_stat can be number 1 or string "1" from ArcGIS - check both for existing (blue)
+ const isExistingTrail = ["any", ["==", ["get", "fac_stat"], 1], ["==", ["get", "fac_stat"], "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 */}
+
+
+ {/* Hover layer for regular trails - wider */}
+
+ {/* Click highlight layer for regular trails - thicker when clicked */}
+
+
+
+ {/* Gaps in red */}
+ {filteredGapsData && filteredGapsData.features.length > 0 && (
+
+
+ {/* Hover layer for gaps - wider */}
+
+ {/* Click highlight layer for gaps - thicker when clicked */}
+
+
+ )}
+ >
+ );
+};
+
+export default OtherRegionalTrailsLayer;
diff --git a/src/components/Map/layers/TrailsRegNameSyncLayer.js b/src/components/Map/layers/TrailsRegNameSyncLayer.js
deleted file mode 100644
index b07fb5a..0000000
--- a/src/components/Map/layers/TrailsRegNameSyncLayer.js
+++ /dev/null
@@ -1,442 +0,0 @@
-import React, { useEffect, useState, useRef, useMemo } from "react";
-import { Source, Layer } from "react-map-gl";
-
-/**
- * Generates a color palette for different reg_name values
- * Returns a consistent color for each unique reg_name
- */
-const generateColorPalette = (regNames) => {
- 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 = {};
- regNames.forEach((name, index) => {
- if (name && name.trim() !== "") {
- palette[name] = colors[index % colors.length];
- }
- });
-
- return palette;
-};
-
-/**
- * Renders Trails Reg Name Sync layer from ArcGIS FeatureServer
- * Data source: https://services.arcgis.com/c5WwApDsDjRhIVkH/arcgis/rest/services/Trails_Reg_Name_Sync/FeatureServer
- *
- * Uses FeatureServer query endpoint to fetch GeoJSON features within current map bounds.
- * Supports color coding by reg_name attribute when useColorCoding is true.
- */
-const TrailsRegNameSyncLayer = ({
- showTrailsRegNameSync,
- showMunicipalityProfileMap,
- showProjectTrailsProfile,
- 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
-}) => {
- 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 = showTrailsRegNameSync && (showMunicipalityProfileMap || showProjectTrailsProfile);
-
- // Extract unique reg_name values and notify parent
- 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);
- }
- }, [trailsData, onRegNamesChange]);
-
- // Use external color palette if provided, otherwise generate one
- const effectiveColorPalette = useMemo(() => {
- if (!useColorCoding) {
- return {};
- }
-
- // If external palette is provided, use it
- if (colorPalette && Object.keys(colorPalette).length > 0) {
- return colorPalette;
- }
-
- // Otherwise, generate from current data (fallback)
- if (!trailsData || !trailsData.features) {
- return {};
- }
-
- 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();
- return generateColorPalette(uniqueRegNames);
- }, [useColorCoding, colorPalette, trailsData]);
-
- useEffect(() => {
- if (!shouldShow || !mapRef?.current) {
- setTrailsData(null);
- accumulatedTrailsRef.current.clear(); // Clear accumulated data when layer is hidden
- return;
- }
-
- const updateLayer = () => {
- 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;
-
- 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 };
- };
-
- const swMerc = toWebMercator(expandedSw.lng, expandedSw.lat);
- const neMerc = toWebMercator(expandedNe.lng, expandedNe.lat);
-
- 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";
-
- // 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}`;
-
- // Debounce queries
- if (queryTimeoutRef.current) {
- clearTimeout(queryTimeoutRef.current);
- }
-
- queryTimeoutRef.current = setTimeout(async () => {
- try {
- const response = await fetch(url);
- const data = await response.json();
-
- 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);
- }
- } 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);
- // Don't clear accumulated data on error
- if (accumulatedTrailsRef.current.size === 0) {
- setTrailsData(null);
- }
- }
- }, 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);
- }
- 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);
- }
- };
- }
- }, [shouldShow, mapRef]);
-
- if (!shouldShow || !trailsData) {
- return null;
- }
-
- // If using color coding, create separate layers for each reg_name
- if (useColorCoding && Object.keys(effectiveColorPalette).length > 0) {
- // 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))
- : [];
-
- if (regNamesToShow.length === 0) {
- return null; // No selected projects, don't show any trails
- }
-
- return (
- <>
- {/* Render regular trails first */}
- {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";
- }
- );
-
- if (filteredFeatures.length === 0) return null;
-
- return (
-
-
-
- );
- })}
-
- {/* Render gaps in red on top of regular trails */}
- {regNamesToShow.map((regName) => {
- 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 (
-
-
-
- );
- })}
- >
- );
- }
-
- // Default: single layer with single color
- // Filter trails based on selectedRegNames if provided
- let filteredTrailsData = trailsData;
- if (selectedRegNames && selectedRegNames.length > 0) {
- filteredTrailsData = {
- type: "FeatureCollection",
- features: trailsData.features.filter(feature => {
- const regName = (feature.properties?.reg_name || "").trim();
- return selectedRegNames.some(selected => selected.trim() === regName);
- })
- };
- } else if (selectedRegNames && selectedRegNames.length === 0) {
- // If selectedRegNames is empty array, don't show any trails
- return null;
- }
-
- // Get hovered feature ID
- const hoveredFeatureId = hoveredTrail?.featureId;
-
- return (
-
- {/* Regular trails (excluding gaps) */}
-
- {/* Hover layer for regular trails - wider */}
-
- {/* Gaps in red */}
-
- {/* Hover layer for gaps - wider */}
-
-
- );
-};
-
-export default TrailsRegNameSyncLayer;
diff --git a/src/components/Map/layers/TransitLandRoutesLayer.js b/src/components/Map/layers/TransitLandRoutesLayer.js
index ba83e7f..094fe5a 100644
--- a/src/components/Map/layers/TransitLandRoutesLayer.js
+++ b/src/components/Map/layers/TransitLandRoutesLayer.js
@@ -29,7 +29,7 @@ const TransitLandRoutesLayer = ({
id="transit-land-routes"
type="line"
source-layer="routes"
- interactive={false}
+ interactive={true}
paint={{
"line-color": [
"case",
diff --git a/src/components/Map/tooltip/BlueBikeStationPopupContent.js b/src/components/Map/tooltip/BlueBikeStationPopupContent.js
new file mode 100644
index 0000000..de8f188
--- /dev/null
+++ b/src/components/Map/tooltip/BlueBikeStationPopupContent.js
@@ -0,0 +1,61 @@
+import React from "react";
+import styled from "styled-components";
+
+const BlueBikePopupContainer = styled.div`
+ min-width: 200px;
+ color: #2774bd;
+ font-size: 12px;
+ word-wrap: break-word;
+ overflow-wrap: break-word;
+`;
+
+const BlueBikePopupTitle = styled.div`
+ font-weight: 600;
+ margin-bottom: 6px;
+ word-wrap: break-word;
+ overflow-wrap: break-word;
+`;
+
+const BlueBikePopupName = styled.div`
+ margin-bottom: 4px;
+ font-weight: 500;
+ word-wrap: break-word;
+ overflow-wrap: break-word;
+`;
+
+const BlueBikePopupRow = styled.div`
+ margin-bottom: 2px;
+ font-size: 11px;
+ color: #666;
+ word-wrap: break-word;
+ overflow-wrap: break-word;
+`;
+
+const BlueBikeStationPopupContent = ({ properties }) => {
+ const p = properties || {};
+ const stationName = p.Name || p.name || "Unknown Station";
+ const district = p.District || p.district || null;
+ const totalDocks = p.Total_docks ?? p.total_docks ?? null;
+ const number = p.Number || p.number || null;
+ const isPublic =
+ p.Public_ === "Yes" || p.public === "Yes"
+ ? "Yes"
+ : p.Public_ === "No" || p.public === "No"
+ ? "No"
+ : null;
+
+ return (
+
+ Blue Bike Station
+ {stationName}
+ {district && District: {district}}
+ {totalDocks != null && (
+ Total Docks: {totalDocks}
+ )}
+ {number && Station #: {number}}
+ {isPublic != null && Public: {isPublic}}
+
+ );
+};
+
+export default BlueBikeStationPopupContent;
diff --git a/src/components/Map/tooltip/CommunityIdentify.js b/src/components/Map/tooltip/CommunityIdentify.js
new file mode 100644
index 0000000..6e6c323
--- /dev/null
+++ b/src/components/Map/tooltip/CommunityIdentify.js
@@ -0,0 +1,230 @@
+import React, { useContext, useState } from "react";
+import { Popup } from "react-map-gl";
+import Button from "react-bootstrap/Button";
+import Carousel from "react-bootstrap/Carousel";
+import editIcon from "../../../assets/icons/edit-icon.svg";
+import { ModalContext } from "../../../App";
+import { getMunicipalityName } from "../utils/municipalityUtils";
+
+// Identify popup variant for Community Trails Profile:
+// trail name logic matches TrailListWindow: local_name -> reg_name -> prop_name,
+// treating "", "Null", " ", and "0" as missing.
+const CommunityIdentify = ({ point, identifyResult, handleShowPopup, handleCarousel }) => {
+ const { toggleEditModal } = useContext(ModalContext);
+ const [carouselIndex, setCarouselIndex] = useState(0);
+
+ const normalizeCandidate = (value) => {
+ const v = (value ?? "").toString().trim();
+ const lowered = v.toLowerCase();
+ // Treat common null-ish values as missing
+ if (
+ v === "" ||
+ v === "0" ||
+ lowered === "null" ||
+ lowered === "" ||
+ lowered === "(null)" ||
+ lowered === "n/a"
+ ) {
+ return "";
+ }
+ 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 = [];
+ const identifyDate = [];
+ const identifyLength = [];
+
+ identifyResult.forEach((element) => {
+ identifyLayer.push(element.layerName);
+
+ const attrs = element.attributes || {};
+
+ // Handle different layer types
+ let name = "";
+ 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 === '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') {
+ // 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 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 || normalizeCandidate(attrs["name"] || attrs["SITE_NAME"]) || "";
+ }
+
+ identifyTrailName.push(name);
+
+ 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["Facility Length in Feet"] ?? attrs["length_ft"];
+ const normalizedLengthFeet =
+ rawLengthFeet !== undefined && rawLengthFeet !== null && rawLengthFeet !== "Null" && rawLengthFeet !== " "
+ ? rawLengthFeet
+ : "";
+ identifyLength.push(normalizedLengthFeet);
+ });
+
+ const carouselItems = [];
+ for (let i = 0; i < identifyResult.length; i++) {
+ const element = identifyResult[i];
+ const itemIsTrail = isTrailResult(element);
+ const attrs = element.attributes || {};
+
+ 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] && (
+
+ {identifyLayer[i].split(" ")[0] !== "Existing"
+ ? identifyLayer[i]
+ : identifyLayer[i].split(" ").slice(1, identifyLayer[i].split(" ").length).join(" ")}
+
+ ))}
+
+ {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) {
+ setCarouselIndex(event);
+ handleCarousel(event);
+ }
+
+ return (
+ handleShowPopup(false)}>
+ 1}
+ activeIndex={carouselIndex}
+ onSelect={handleSelect}
+ >
+ {carouselItems}
+
+ {/* Edit button only for trail tooltips */}
+ {identifyResult.length > 0 && isTrailResult(identifyResult[carouselIndex]) && (
+
+ )}
+
+ );
+};
+
+export default CommunityIdentify;
diff --git a/src/components/Map/tooltip/EnvironmentalJusticePopupContent.js b/src/components/Map/tooltip/EnvironmentalJusticePopupContent.js
new file mode 100644
index 0000000..5c17b76
--- /dev/null
+++ b/src/components/Map/tooltip/EnvironmentalJusticePopupContent.js
@@ -0,0 +1,78 @@
+import React from "react";
+import styled from "styled-components";
+
+const EJPopupContainer = styled.div`
+ min-width: 200px;
+ max-width: 300px;
+ color: #2774bd;
+ font-size: 12px;
+ word-wrap: break-word;
+ overflow-wrap: break-word;
+`;
+
+const EJPopupTitle = styled.div`
+ font-weight: 600;
+ margin-bottom: 8px;
+ font-size: 14px;
+ word-wrap: break-word;
+ overflow-wrap: break-word;
+`;
+
+const EJPopupRow = styled.div`
+ margin-bottom: 4px;
+ word-wrap: break-word;
+ overflow-wrap: break-word;
+ font-size: ${(props) => (props.$muted ? "11px" : "inherit")};
+ color: ${(props) => (props.$muted ? "#666" : "inherit")};
+`;
+
+// Supports both UPPER_SNAKE_CASE (RegionalTrailsProfile) and PascalCase (CommunityTrailsProfile) property names
+const EnvironmentalJusticePopupContent = ({ properties }) => {
+ const p = properties || {};
+ const area = p.GEOGRAPHICAREANAME || p.Geographic_Area_Name;
+ const municipality = p.MUNICIPALITY || p.Municipality;
+ const ejCritDesc = p.EJ_CRIT_DESC || p.EJ_Crit_Desc;
+ const ej = p.EJ || p.Ej;
+ const totalPop = p.TOTAL_POP;
+ const pctMinority = p.PCT_MINORITY;
+ const limEnghHPct = p.LIMENGHHPCT;
+ const bgMhhi = p.BG_MHHI;
+ const geoid = p.GEOID;
+ const hasData = area || municipality || ejCritDesc;
+
+ return (
+
+ Environmental Justice
+ {area && (
+ Area: {area}
+ )}
+ {municipality && (
+ Municipality: {municipality}
+ )}
+ {ejCritDesc && (
+ EJ Criteria: {ejCritDesc}
+ )}
+ {ej && (
+ EJ Designated: {ej}
+ )}
+ {totalPop !== undefined && totalPop !== null && (
+ Total Population: {parseFloat(totalPop).toLocaleString()}
+ )}
+ {pctMinority !== undefined && pctMinority !== null && (
+ Percent Minority: {parseFloat(pctMinority).toFixed(1)}%
+ )}
+ {limEnghHPct !== undefined && limEnghHPct !== null && (
+ Limited English Households: {parseFloat(limEnghHPct).toFixed(1)}%
+ )}
+ {bgMhhi !== undefined && bgMhhi !== null && (
+ Median Household Income: ${parseFloat(bgMhhi).toLocaleString()}
+ )}
+ {geoid && (
+ GEOID: {geoid}
+ )}
+ {!hasData && No data available}
+
+ );
+};
+
+export default EnvironmentalJusticePopupContent;
diff --git a/src/components/Map/Identify.js b/src/components/Map/tooltip/Identify.js
similarity index 86%
rename from src/components/Map/Identify.js
rename to src/components/Map/tooltip/Identify.js
index eeb7430..1701d57 100644
--- a/src/components/Map/Identify.js
+++ b/src/components/Map/tooltip/Identify.js
@@ -2,27 +2,14 @@ import React, { useContext, useState } from "react";
import { Popup } from "react-map-gl";
import Button from "react-bootstrap/Button";
import Carousel from "react-bootstrap/Carousel";
-import editIcon from "../../assets/icons/edit-icon.svg";
-import { ModalContext } from "../../App";
-import muniKeys from "../../data/ma_muni_keys.json";
+import editIcon from "../../../assets/icons/edit-icon.svg";
+import { ModalContext } from "../../../App";
+import { getMunicipalityName } from "../utils/municipalityUtils";
const Identify = ({ point, identifyResult, handleShowPopup, handleCarousel }) => {
const { showEditModal, toggleEditModal } = useContext(ModalContext);
const [carouselIndex, setCarouselIndex] = useState(0);
- // Function to get municipality name by muni_id
- const getMunicipalityName = (muniId) => {
- if (!muniId || muniId === "Null" || muniId === "") return "";
-
- // Handle both string and numeric muni_id
- 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 : "";
- };
-
const identifyLayer = [];
const identifyAttributes = [];
const identifyTrailName = [];
@@ -43,8 +30,7 @@ const Identify = ({ point, identifyResult, handleShowPopup, handleCarousel }) =>
: ""
);
identifyMunicipality.push(
- getMunicipalityName(element.attributes["muni_id"] || element.attributes["Municipal ID"])
-
+ getMunicipalityName(element.attributes["muni_id"] || element.attributes["Municipal ID"]) ?? ""
);
identifyDate.push(
element.attributes["Facility Opening Date"] !== "Null" ? element.attributes["Facility Opening Date"] : ""
diff --git a/src/components/Map/tooltip/OpenSpacePopupContent.js b/src/components/Map/tooltip/OpenSpacePopupContent.js
new file mode 100644
index 0000000..14f6075
--- /dev/null
+++ b/src/components/Map/tooltip/OpenSpacePopupContent.js
@@ -0,0 +1,58 @@
+import React from "react";
+import styled from "styled-components";
+
+const OpenSpacePopupContainer = styled.div`
+ min-width: 200px;
+ max-width: 300px;
+ color: #2774bd;
+ font-size: 12px;
+ word-wrap: break-word;
+ overflow-wrap: break-word;
+`;
+
+const OpenSpacePopupTitle = styled.div`
+ font-weight: 600;
+ margin-bottom: 6px;
+ word-wrap: break-word;
+ overflow-wrap: break-word;
+`;
+
+const OpenSpacePopupRow = styled.div`
+ margin-bottom: ${(props) => (props.$spacing === "sm" ? "2px" : "4px")};
+ font-weight: ${(props) => (props.$bold ? 500 : "inherit")};
+ word-wrap: break-word;
+ overflow-wrap: break-word;
+`;
+
+const OpenSpacePopupContent = ({ properties }) => {
+ const p = properties || {};
+
+ return (
+
+ OpenSpace
+ {p.SITE_NAME && (
+ {p.SITE_NAME}
+ )}
+ {p.FEE_OWNER && (
+ Owner: {p.FEE_OWNER}
+ )}
+ {p.OWNER_TYPE && (
+ Owner Type: {p.OWNER_TYPE}
+ )}
+ {p.PRIM_PURP && (
+ Primary Purpose: {p.PRIM_PURP}
+ )}
+ {p.PUB_ACCESS && (
+ Public Access: {p.PUB_ACCESS}
+ )}
+ {p.GIS_ACRES !== null && p.GIS_ACRES !== undefined && (
+ Acres: {parseFloat(p.GIS_ACRES).toFixed(2)}
+ )}
+ {!p.SITE_NAME && !p.FEE_OWNER && (
+ No data available
+ )}
+
+ );
+};
+
+export default OpenSpacePopupContent;
diff --git a/src/components/Map/tooltip/TrailPopupContent.js b/src/components/Map/tooltip/TrailPopupContent.js
new file mode 100644
index 0000000..cda46eb
--- /dev/null
+++ b/src/components/Map/tooltip/TrailPopupContent.js
@@ -0,0 +1,91 @@
+import React from "react";
+import styled from "styled-components";
+import { TRAIL_FACILITY_TYPE_LABELS } from "../constants/mapConstants";
+import { getMunicipalityName } from "../utils/municipalityUtils";
+
+const TrailPopupContainer = styled.div`
+ min-width: 200px;
+ max-width: 300px;
+ color: #2774bd;
+ font-size: 12px;
+ word-wrap: break-word;
+ overflow-wrap: break-word;
+`;
+
+const TrailPopupTitle = styled.div`
+ font-weight: 600;
+ margin-bottom: 8px;
+ font-size: 14px;
+ color: #2774bd;
+ word-wrap: break-word;
+ overflow-wrap: break-word;
+`;
+
+const TrailPopupRow = styled.div`
+ margin-bottom: 4px;
+ word-wrap: break-word;
+ overflow-wrap: break-word;
+`;
+
+const TrailPopupLink = styled.a`
+ color: #2774bd;
+ word-break: break-all;
+`;
+
+const getTrailTypeLabel = (segType, facStat) =>
+ TRAIL_FACILITY_TYPE_LABELS[`${segType},${facStat}`] || null;
+
+const getStatusLabel = (facStat) => {
+ if (facStat === 1 || facStat === "1") return "Existing";
+ if (facStat === 2 || facStat === "2") return "Design/Construction";
+ return "Envisioned";
+};
+
+/**
+ * Reusable trail popup content for Major Trail and Regular Trail click popups.
+ * @param {object} props
+ * @param {object} props.properties - Feature properties from the clicked trail
+ * @param {'grouped_reg_name'|'reg_name'} props.titleKey - Key for the trail name (grouped_reg_name for major trails, reg_name for regular)
+ */
+const TrailPopupContent = ({ properties, titleKey = "reg_name" }) => {
+ const p = properties || {};
+ const segType = p.seg_type;
+ const facStat = p.fac_stat;
+ const trailTypeLabel = getTrailTypeLabel(segType, facStat);
+ const muniId = p.muni_id || p.MUNI_ID || p.muniId;
+ const municipalityName = muniId ? getMunicipalityName(muniId) : null;
+ const title = p[titleKey];
+
+ return (
+
+ {title && {title}}
+ {trailTypeLabel && (
+ Type: {trailTypeLabel}
+ )}
+ {municipalityName && (
+ Municipality: {municipalityName}
+ )}
+ {p.steward && (
+ Steward: {p.steward}
+ )}
+ {p.website && (
+
+ Website:{" "}
+
+ {p.website.length > 40 ? p.website.substring(0, 40) + "..." : p.website}
+
+
+ )}
+ {p.length_ft && (
+
+ Length: {(parseFloat(p.length_ft) / 5280).toFixed(2)} miles
+
+ )}
+ {p.fac_stat != null && (
+ Status: {getStatusLabel(p.fac_stat)}
+ )}
+
+ );
+};
+
+export default TrailPopupContent;
diff --git a/src/components/Map/utils/arcgisPointQuery.js b/src/components/Map/utils/arcgisPointQuery.js
new file mode 100644
index 0000000..eb729dc
--- /dev/null
+++ b/src/components/Map/utils/arcgisPointQuery.js
@@ -0,0 +1,48 @@
+/**
+ * Query an ArcGIS MapServer/FeatureServer layer at a point (point-in-polygon).
+ * Useful for raster layers where client doesn't have vector data - query server for attributes at click location.
+ *
+ * @param {string} layerUrl - ArcGIS layer URL (e.g. https://.../MapServer/0)
+ * @param {number} lng - Longitude (EPSG:4326)
+ * @param {number} lat - Latitude (EPSG:4326)
+ * @returns {Promise