diff --git a/src/components/Map/ClickPopover.jsx b/src/components/Map/ClickPopover.jsx
index e3f83bf..fad6efa 100644
--- a/src/components/Map/ClickPopover.jsx
+++ b/src/components/Map/ClickPopover.jsx
@@ -1,5 +1,6 @@
// components/Map/ClickPopover.jsx
import React, { useMemo } from 'react';
+import { area as turfArea } from '@turf/turf';
function computeQuantileEdges(sortedVals, bins) {
const n = sortedVals.length;
@@ -82,13 +83,49 @@ const MiniHistogram = ({ values = [], currentValue = null, bins = 10, barWidth =
);
};
-const ClickPopover = ({ tooltip, stats, distributions, onFocus, onClose }) => {
+const ClickPopover = ({ tooltip, stats, distributions, geometry, dimensionUnits = 'ft', onFocus, onClose }) => {
if (!tooltip || !tooltip.visible || !tooltip.content) return null;
const avgValues = distributions?.avg || [];
const totalValues = distributions?.total || [];
const hasAvg = stats && typeof stats.a === 'number' && isFinite(stats.a);
const hasTotal = stats && typeof stats.t === 'number' && isFinite(stats.t);
+ // Calculate area using turf.js
+ const areaDisplay = useMemo(() => {
+ if (!geometry) return null;
+ try {
+ const areaMetersSquared = turfArea({ type: 'Feature', geometry });
+ if (!isFinite(areaMetersSquared) || areaMetersSquared <= 0) return null;
+
+ if (dimensionUnits === 'ft') {
+ // Convert square meters to square feet (1 m² = 10.7639 ft²)
+ const areaFtSquared = areaMetersSquared * 10.7639;
+ if (areaFtSquared >= 43560) {
+ // Convert to acres (1 acre = 43,560 ft²)
+ const acres = areaFtSquared / 43560;
+ return { value: acres, unit: 'ac', label: 'Area', fullText: `${acres.toFixed(2)} ac` };
+ } else if (areaFtSquared >= 1) {
+ return { value: areaFtSquared, unit: 'ft²', label: 'Area', fullText: `${Math.round(areaFtSquared).toLocaleString()} ft²` };
+ } else {
+ return { value: areaFtSquared * 144, unit: 'in²', label: 'Area', fullText: `${Math.round(areaFtSquared * 144).toLocaleString()} in²` };
+ }
+ } else {
+ // Metric units
+ if (areaMetersSquared >= 1000000) {
+ // Convert to square kilometers (1 km² = 1,000,000 m²)
+ const areaKmSquared = areaMetersSquared / 1000000;
+ return { value: areaKmSquared, unit: 'km²', label: 'Area', fullText: `${areaKmSquared.toFixed(3)} km²` };
+ } else if (areaMetersSquared >= 1) {
+ return { value: areaMetersSquared, unit: 'm²', label: 'Area', fullText: `${Math.round(areaMetersSquared).toLocaleString()} m²` };
+ } else {
+ return { value: areaMetersSquared * 10000, unit: 'cm²', label: 'Area', fullText: `${Math.round(areaMetersSquared * 10000).toLocaleString()} cm²` };
+ }
+ }
+ } catch (_) {
+ return null;
+ }
+ }, [geometry, dimensionUnits]);
+
return (
- Park usage stats are not available for this zone.
+
+ {areaDisplay ? (
+
+
+ {areaDisplay.label}
+ {areaDisplay.fullText}
+
+
+ ) : (
+
+ Park usage stats are not available for this zone.
+
+ )}
)}
diff --git a/src/components/Map/MapContainer.jsx b/src/components/Map/MapContainer.jsx
index 1b4d7e3..13b6752 100644
--- a/src/components/Map/MapContainer.jsx
+++ b/src/components/Map/MapContainer.jsx
@@ -52,7 +52,8 @@ const MapContainer = forwardRef(({
isLoading,
responsive,
isSitePlanMode = false,
- isRightSidebarOpen = false
+ isRightSidebarOpen = false,
+ exportOptions
}, ref) => {
const safeResponsive = responsive || { sidebarMode: 'expanded' };
const {
@@ -1635,6 +1636,8 @@ const MapContainer = forwardRef(({
tooltip={permitAreas.clickedTooltip}
stats={permitAreas.clickedTooltip.stats}
distributions={permitAreas.clickedTooltip.distributions}
+ geometry={permitAreas.clickedTooltip.geometry}
+ dimensionUnits={exportOptions?.dimensionUnits || 'ft'}
onClose={permitAreas.dismissClickedTooltip}
onFocus={permitAreas.focusClickedTooltipArea}
/>
diff --git a/src/components/SpaceStager.jsx b/src/components/SpaceStager.jsx
index bbb1292..18c526b 100644
--- a/src/components/SpaceStager.jsx
+++ b/src/components/SpaceStager.jsx
@@ -954,6 +954,7 @@ const SpaceStager = () => {
responsive={responsive}
isSitePlanMode={isSitePlanMode}
isRightSidebarOpen={responsive.sidebarMode !== 'icon-rail' || isRightDrawerOpen}
+ exportOptions={exportOptions}
/>
{/* Center-bottom contextual nudges */}
diff --git a/src/hooks/usePermitAreas.js b/src/hooks/usePermitAreas.js
index ab16854..0e8510b 100644
--- a/src/hooks/usePermitAreas.js
+++ b/src/hooks/usePermitAreas.js
@@ -76,7 +76,7 @@ export const usePermitAreas = (map, mapLoaded, options = {}) => {
const [showFocusInfo, setShowFocusInfo] = useState(false);
const [tooltip, setTooltip] = useState({ visible: false, x: 0, y: 0, content: null });
// Persistent click popover for parks mode (single at a time)
- const [clickedTooltip, setClickedTooltip] = useState({ visible: false, x: 0, y: 0, lngLat: null, content: null, featureId: null });
+ const [clickedTooltip, setClickedTooltip] = useState({ visible: false, x: 0, y: 0, lngLat: null, content: null, featureId: null, geometry: null });
const [overlappingAreas, setOverlappingAreas] = useState([]);
const [selectedOverlapIndex, setSelectedOverlapIndex] = useState(0);
const [showOverlapSelector, setShowOverlapSelector] = useState(false);
@@ -268,7 +268,7 @@ export const usePermitAreas = (map, mapLoaded, options = {}) => {
clearGeoFeatureState(map, prevIdPrefix);
} catch (_) {}
// Dismiss any open click popover when mode changes
- setClickedTooltip({ visible: false, x: 0, y: 0, lngLat: null, content: null, featureId: null });
+ setClickedTooltip({ visible: false, x: 0, y: 0, lngLat: null, content: null, featureId: null, geometry: null });
try {
// Abort any in-flight fetches
if (abortControllerRef.current) { try { abortControllerRef.current.abort(); } catch (_) {} }
@@ -1196,6 +1196,15 @@ export const usePermitAreas = (map, mapLoaded, options = {}) => {
try { console.debug('PERMIT: handleClickPermitFill start', { prevented: !!e?.defaultPrevented, feats: e?.features?.length, x: e?.point?.x, y: e?.point?.y }); } catch (_) {}
if (e?.defaultPrevented) { try { console.debug('PERMIT: bail defaultPrevented'); } catch (_) {} return; }
if (e.features.length === 0) return;
+
+ // Lock popover: if a popover is already visible, ignore clicks on other park zones
+ // User must dismiss it first before clicking another park
+ const activeMode = options.mode || mode;
+ if (activeMode === 'parks' && clickedTooltipVisibleRef.current) {
+ try { console.debug('PERMIT: bail popover already visible, ignoring click'); } catch (_) {}
+ return;
+ }
+
// Ignore clicks that intersect annotation layers to avoid clashing with annotation popup
try {
const pt = [e.point.x, e.point.y];
@@ -1203,7 +1212,6 @@ export const usePermitAreas = (map, mapLoaded, options = {}) => {
const annHits = map.queryRenderedFeatures && map.queryRenderedFeatures(pt, { layers });
if (annHits && annHits.length) { try { console.debug('PERMIT: bail annotation hit', { hits: annHits.length }); } catch (_) {} return; }
} catch (_) {}
- const activeMode = options.mode || mode;
if (activeMode === 'intersections') return;
const drawControl = map.getControl && map.getControl('MapboxDraw');
if (drawControl && drawControl.getMode && drawControl.getMode() !== 'simple_select') { try { console.debug('PERMIT: bail drawing active'); } catch (_) {} return; }
@@ -1221,7 +1229,7 @@ export const usePermitAreas = (map, mapLoaded, options = {}) => {
try {
const lngLat = e.lngLat || map.unproject([e.point.x, e.point.y]);
const content = buildTooltipContent(top.properties, { includeStats: false });
- setClickedTooltip({ visible: !!content, x: e.point.x, y: e.point.y, lngLat: lngLat ? { lng: lngLat.lng, lat: lngLat.lat } : null, content, featureId: (top.properties?.system ?? null), stats: (() => { const id = (top.properties?.CEMSID || top.properties?.cemsid || top.properties?.CEMS_ID || top.properties?.cems_id || '').toString(); const dict = eventsByCemsidRef.current || {}; return id && dict[id] ? dict[id] : null; })(), distributions: eventsDistributionsRef.current });
+ setClickedTooltip({ visible: !!content, x: e.point.x, y: e.point.y, lngLat: lngLat ? { lng: lngLat.lng, lat: lngLat.lat } : null, content, featureId: (top.properties?.system ?? null), geometry: top.geometry || null, stats: (() => { const id = (top.properties?.CEMSID || top.properties?.cemsid || top.properties?.CEMS_ID || top.properties?.cems_id || '').toString(); const dict = eventsByCemsidRef.current || {}; return id && dict[id] ? dict[id] : null; })(), distributions: eventsDistributionsRef.current });
setTooltip(prev => ({ ...prev, visible: false }));
} catch (_) {}
setShowOverlapSelector(false);
@@ -1252,7 +1260,7 @@ export const usePermitAreas = (map, mapLoaded, options = {}) => {
focusOnPermitArea(feature);
setShowOverlapSelector(false);
if (activeMode === 'parks') clearOverlapHighlights(map);
- setClickedTooltip({ visible: false, x: 0, y: 0, lngLat: null, content: null, featureId: null });
+ setClickedTooltip({ visible: false, x: 0, y: 0, lngLat: null, content: null, featureId: null, geometry: null });
}, [map, mode, options.mode, focusOnPermitArea]);
const handleClickGeneral = useCallback((e) => {
@@ -1274,7 +1282,7 @@ export const usePermitAreas = (map, mapLoaded, options = {}) => {
setShowOverlapSelector(false);
if (mode === 'parks') {
clearOverlapHighlights(map);
- setClickedTooltip({ visible: false, x: 0, y: 0, lngLat: null, content: null, featureId: null });
+ setClickedTooltip({ visible: false, x: 0, y: 0, lngLat: null, content: null, featureId: null, geometry: null });
}
}
}, [map, mode]);
@@ -1902,7 +1910,7 @@ export const usePermitAreas = (map, mapLoaded, options = {}) => {
// Expose helpers for popover UX
const dismissClickedTooltip = useCallback(() => {
- setClickedTooltip({ visible: false, x: 0, y: 0, lngLat: null, content: null, featureId: null });
+ setClickedTooltip({ visible: false, x: 0, y: 0, lngLat: null, content: null, featureId: null, geometry: null });
}, []);
const focusClickedTooltipArea = useCallback(() => {