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 (
{
)} + {areaDisplay && ( +
+
+ {areaDisplay.label} + {areaDisplay.fullText} +
+
+ )} )} - {/* Data availability notice when no stats */} + {/* Area display when no stats */} {(!hasAvg && !hasTotal) && ( -
- 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(() => {