From eebaabb18d97d7696c5ba10c42cda051b33e8f28 Mon Sep 17 00:00:00 2001 From: ev <4164774+ev-sc@users.noreply.github.com> Date: Thu, 11 Dec 2025 09:51:52 +0100 Subject: [PATCH 01/45] feat: add AreaInfo component to display area statistics in MapWrapper --- src/app/map/[id]/components/AreaInfo.tsx | 93 ++++++++++++++++++++++ src/app/map/[id]/components/Map.tsx | 2 - src/app/map/[id]/components/MapWrapper.tsx | 15 ++++ 3 files changed, 108 insertions(+), 2 deletions(-) create mode 100644 src/app/map/[id]/components/AreaInfo.tsx diff --git a/src/app/map/[id]/components/AreaInfo.tsx b/src/app/map/[id]/components/AreaInfo.tsx new file mode 100644 index 00000000..5e8fadeb --- /dev/null +++ b/src/app/map/[id]/components/AreaInfo.tsx @@ -0,0 +1,93 @@ +import { ColumnType } from "@/server/models/DataSource"; +import { CalculationType } from "@/server/models/MapView"; +import { formatNumber } from "@/utils/text"; +import { useAreaStats } from "../data"; +import { useChoroplethDataSource } from "../hooks/useDataSources"; +import { useHoverArea } from "../hooks/useMapHover"; +import { useMapViews } from "../hooks/useMapViews"; + +const getDisplayValue = ( + calculationType: CalculationType | null | undefined, + areaStats: + | { + columnType: ColumnType; + minValue: number; + maxValue: number; + } + | undefined + | null, + areaStatValue: unknown, +): string => { + if ( + areaStatValue === undefined || + areaStatValue === null || + areaStatValue === "" + ) { + return calculationType === CalculationType.Count ? "0" : "-"; + } + if (areaStats?.columnType !== ColumnType.Number) { + return String(areaStatValue); + } + const value = Number(areaStatValue); + if (isNaN(value)) { + return "-"; + } + if (areaStats?.minValue >= 0 && areaStats?.maxValue <= 1) { + return `${Math.round(value * 1000) / 10}%`; + } + return formatNumber(value); +}; + +export default function AreaInfo() { + const [hoverArea] = useHoverArea(); + const areaStatsQuery = useAreaStats(); + const areaStats = areaStatsQuery.data; + const choroplethDataSource = useChoroplethDataSource(); + const { viewConfig } = useMapViews(); + + if (!hoverArea || !areaStats) { + return null; + } + + const areaStat = + areaStats.areaSetCode === hoverArea.areaSetCode + ? areaStats.stats.find((s) => s.areaCode === hoverArea.code) + : null; + + if (!areaStat) { + return null; + } + + const statLabel = + areaStats.calculationType === CalculationType.Count + ? `${choroplethDataSource?.name || "Unknown"} count` + : viewConfig.areaDataColumn; + + const primaryValue = getDisplayValue( + areaStats.calculationType, + areaStats.primary, + areaStat.primary, + ); + const secondaryValue = getDisplayValue( + areaStats.calculationType, + areaStats.secondary, + areaStat.secondary, + ); + + return ( +
+
+ {hoverArea.name} +
+
+ {primaryValue} + {secondaryValue !== "-" && ( + + / {secondaryValue} + + )} +
+
{statLabel}
+
+ ); +} diff --git a/src/app/map/[id]/components/Map.tsx b/src/app/map/[id]/components/Map.tsx index e43e9964..7202f0d7 100644 --- a/src/app/map/[id]/components/Map.tsx +++ b/src/app/map/[id]/components/Map.tsx @@ -21,7 +21,6 @@ import { getClickedPolygonFeature, useMapClick } from "../hooks/useMapClick"; import { useMapHover } from "../hooks/useMapHover"; import { useTurfMutations, useTurfState } from "../hooks/useTurfs"; import { CONTROL_PANEL_WIDTH, mapColors } from "../styles"; -import AreaPopup from "./AreaPopup"; import Choropleth from "./Choropleth"; import { MAPBOX_SOURCE_IDS } from "./Choropleth/configs"; import FilterMarkers from "./FilterMarkers"; @@ -473,7 +472,6 @@ export default function Map({ - )} diff --git a/src/app/map/[id]/components/MapWrapper.tsx b/src/app/map/[id]/components/MapWrapper.tsx index b45f9bd5..10e11c51 100644 --- a/src/app/map/[id]/components/MapWrapper.tsx +++ b/src/app/map/[id]/components/MapWrapper.tsx @@ -3,6 +3,7 @@ import { MapContext } from "@/app/map/[id]/context/MapContext"; import { MapType } from "@/server/models/MapView"; import { useMapViews } from "../hooks/useMapViews"; import { CONTROL_PANEL_WIDTH, mapColors } from "../styles"; +import AreaInfo from "./AreaInfo"; import InspectorPanel from "./inspector/InspectorPanel"; import MapMarkerAndAreaControls from "./MapMarkerAndAreaControls"; import MapStyleSelector from "./MapStyleSelector"; @@ -55,6 +56,20 @@ export default function MapWrapper({
{children} +
+ +
+
Date: Thu, 11 Dec 2025 09:52:31 +0100 Subject: [PATCH 02/45] refactor: remove AreaPopup component and its related logic --- src/app/map/[id]/components/AreaPopup.tsx | 180 ---------------------- 1 file changed, 180 deletions(-) delete mode 100644 src/app/map/[id]/components/AreaPopup.tsx diff --git a/src/app/map/[id]/components/AreaPopup.tsx b/src/app/map/[id]/components/AreaPopup.tsx deleted file mode 100644 index 7d8c2c67..00000000 --- a/src/app/map/[id]/components/AreaPopup.tsx +++ /dev/null @@ -1,180 +0,0 @@ -import { expression } from "mapbox-gl/dist/style-spec/index.cjs"; -import { Popup } from "react-map-gl/mapbox"; -import { ColumnType } from "@/server/models/DataSource"; -import { CalculationType, ColorScheme } from "@/server/models/MapView"; -import { formatNumber } from "@/utils/text"; -import { useFillColor } from "../colors"; -import { useAreaStats } from "../data"; -import { useChoroplethDataSource } from "../hooks/useDataSources"; -import { useHoverArea } from "../hooks/useMapHover"; -import { useMapViews } from "../hooks/useMapViews"; -import type { AreaSetCode } from "@/server/models/AreaSet"; - -export default function AreaPopup() { - const [hoverArea] = useHoverArea(); - - if (!hoverArea) { - return null; - } - - return ; -} - -function WrappedAreaPopup({ - areaSetCode, - code, - name, - coordinates, -}: { - areaSetCode: AreaSetCode; - code: string; - name: string; - coordinates: [number, number]; -}) { - const { viewConfig } = useMapViews(); - const choroplethDataSource = useChoroplethDataSource(); - - const areaStatsQuery = useAreaStats(); - const areaStats = areaStatsQuery.data; - - const areaStat = - areaStats?.areaSetCode === areaSetCode - ? areaStats?.stats.find((s) => s.areaCode === code) - : null; - - const primaryDisplayValue = getDisplayValue( - areaStats?.calculationType, - areaStats?.primary, - areaStat?.primary, - ); - const secondaryDisplayValue = getDisplayValue( - areaStats?.calculationType, - areaStats?.secondary, - areaStat?.secondary, - ); - - const fillColor = useFillColor({ - areaStats, - scheme: viewConfig.colorScheme || ColorScheme.RedBlue, - isReversed: Boolean(viewConfig.reverseColorScheme), - selectedBivariateBucket: null, - }); - - const { result, value: fillColorExpression } = expression.createExpression([ - "to-rgba", - fillColor, - ]); - - if (result !== "success") { - console.error( - "Attempted to parse invalid MapboxGL expression", - JSON.stringify(fillColor), - fillColorExpression, - ); - return null; - } - - // If using a bivariate color scheme, separate out the colors - // here so the user can easily see the value along each dimension - const primaryColor = fillColorExpression.evaluate( - { zoom: 0 }, - { type: "Polygon", properties: {} }, - { - value: areaStat?.primary || 0, - secondaryValue: 0, // Only look at primary stat - }, - ); - - const secondaryColor = fillColorExpression.evaluate( - { zoom: 0 }, - { type: "Polygon", properties: {} }, - { - value: 0, // Only look at secondary stat - secondaryValue: areaStat?.secondary, - }, - ); - - const statLabel = - areaStats?.calculationType === CalculationType.Count - ? `${choroplethDataSource?.name || "Unknown"} count` - : viewConfig.areaDataColumn; - - return ( - -
-

{name}

- {areaStats?.primary && ( -
-
-

- {statLabel}: {primaryDisplayValue} -

-
- )} - {areaStats?.secondary && ( -
-
-

- {viewConfig.areaDataSecondaryColumn}: {secondaryDisplayValue} -

-
- )} -
-
- ); -} - -const toRGBA = (expressionResult: unknown) => { - if ( - !expressionResult || - !Array.isArray(expressionResult) || - expressionResult.length < 3 - ) { - return `rgba(0, 0, 0, 0)`; - } - const [r, g, b, ...rest] = expressionResult; - const a = rest.length ? rest[0] : 1; - return `rgba(${r}, ${g}, ${b}, ${a})`; -}; - -const getDisplayValue = ( - calculationType: CalculationType | null | undefined, - areaStats: - | { - columnType: ColumnType; - minValue: number; - maxValue: number; - } - | undefined - | null, - areaStatValue: unknown, -): string => { - if ( - areaStatValue === undefined || - areaStatValue === null || - areaStatValue === "" - ) { - return calculationType === CalculationType.Count ? "0" : "-"; - } - if (areaStats?.columnType !== ColumnType.Number) { - return String(areaStatValue); - } - const value = Number(areaStatValue); - if (isNaN(value)) { - return "-"; - } - if (areaStats.minValue >= 0 && areaStats.maxValue <= 1) { - return `${Math.round(value * 1000) / 10}%`; - } - return formatNumber(value); -}; From d2571964dab4beee8b4b1fa1ec85e3e1659800a0 Mon Sep 17 00:00:00 2001 From: ev <4164774+ev-sc@users.noreply.github.com> Date: Thu, 11 Dec 2025 10:22:35 +0100 Subject: [PATCH 03/45] feat: enhance AreaInfo component with table layout for better data presentation --- src/app/map/[id]/components/AreaInfo.tsx | 51 ++++++++++++++++------ src/app/map/[id]/components/MapWrapper.tsx | 12 +++-- 2 files changed, 46 insertions(+), 17 deletions(-) diff --git a/src/app/map/[id]/components/AreaInfo.tsx b/src/app/map/[id]/components/AreaInfo.tsx index 5e8fadeb..2cee0a16 100644 --- a/src/app/map/[id]/components/AreaInfo.tsx +++ b/src/app/map/[id]/components/AreaInfo.tsx @@ -1,5 +1,13 @@ import { ColumnType } from "@/server/models/DataSource"; import { CalculationType } from "@/server/models/MapView"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/shadcn/ui/table"; import { formatNumber } from "@/utils/text"; import { useAreaStats } from "../data"; import { useChoroplethDataSource } from "../hooks/useDataSources"; @@ -75,19 +83,36 @@ export default function AreaInfo() { ); return ( -
-
- {hoverArea.name} -
-
- {primaryValue} - {secondaryValue !== "-" && ( - - / {secondaryValue} - - )} -
-
{statLabel}
+
+ + + + + + {statLabel} + + + {viewConfig.areaDataSecondaryColumn || "Secondary"} + + + + + + + {hoverArea.name} + + + {primaryValue} + + + {secondaryValue} + + + +
); } diff --git a/src/app/map/[id]/components/MapWrapper.tsx b/src/app/map/[id]/components/MapWrapper.tsx index 10e11c51..b37dc0b7 100644 --- a/src/app/map/[id]/components/MapWrapper.tsx +++ b/src/app/map/[id]/components/MapWrapper.tsx @@ -1,6 +1,7 @@ import { useContext, useEffect, useState } from "react"; import { MapContext } from "@/app/map/[id]/context/MapContext"; import { MapType } from "@/server/models/MapView"; +import { useInspector } from "../hooks/useInspector"; import { useMapViews } from "../hooks/useMapViews"; import { CONTROL_PANEL_WIDTH, mapColors } from "../styles"; import AreaInfo from "./AreaInfo"; @@ -21,6 +22,8 @@ export default function MapWrapper({ }) { const { showControls } = useContext(MapContext); const { viewConfig } = useMapViews(); + const { inspectorContent } = useInspector(); + const inspectorVisible = Boolean(inspectorContent); const [message, setMessage] = useState(""); const [indicatorColor, setIndicatorColor] = useState(""); @@ -57,14 +60,15 @@ export default function MapWrapper({ {children}
From 379f15717f612a0b0a7af1f318c88893b54ca0f1 Mon Sep 17 00:00:00 2001 From: ev <4164774+ev-sc@users.noreply.github.com> Date: Thu, 11 Dec 2025 11:54:43 +0100 Subject: [PATCH 04/45] feat: implement selected areas functionality with hover and click interactions --- src/app/map/[id]/atoms/selectedAreasAtom.ts | 10 + src/app/map/[id]/components/AreaInfo.tsx | 114 +++++++--- .../map/[id]/components/Choropleth/index.tsx | 215 +++++++++++++++++- src/app/map/[id]/hooks/useMapClick.ts | 150 ++++++++++-- src/app/map/[id]/hooks/useMapHover.ts | 37 ++- 5 files changed, 471 insertions(+), 55 deletions(-) create mode 100644 src/app/map/[id]/atoms/selectedAreasAtom.ts diff --git a/src/app/map/[id]/atoms/selectedAreasAtom.ts b/src/app/map/[id]/atoms/selectedAreasAtom.ts new file mode 100644 index 00000000..1054e1c5 --- /dev/null +++ b/src/app/map/[id]/atoms/selectedAreasAtom.ts @@ -0,0 +1,10 @@ +import { atom } from "jotai"; + +export interface SelectedArea { + areaSetCode: string; + code: string; + name: string; + coordinates: [number, number]; +} + +export const selectedAreasAtom = atom([]); diff --git a/src/app/map/[id]/components/AreaInfo.tsx b/src/app/map/[id]/components/AreaInfo.tsx index 2cee0a16..1a5770b7 100644 --- a/src/app/map/[id]/components/AreaInfo.tsx +++ b/src/app/map/[id]/components/AreaInfo.tsx @@ -1,3 +1,5 @@ +import { useAtom } from "jotai"; + import { ColumnType } from "@/server/models/DataSource"; import { CalculationType } from "@/server/models/MapView"; import { @@ -9,6 +11,8 @@ import { TableRow, } from "@/shadcn/ui/table"; import { formatNumber } from "@/utils/text"; + +import { selectedAreasAtom } from "../atoms/selectedAreasAtom"; import { useAreaStats } from "../data"; import { useChoroplethDataSource } from "../hooks/useDataSources"; import { useHoverArea } from "../hooks/useMapHover"; @@ -48,21 +52,46 @@ const getDisplayValue = ( export default function AreaInfo() { const [hoverArea] = useHoverArea(); + const [selectedAreas] = useAtom(selectedAreasAtom); const areaStatsQuery = useAreaStats(); const areaStats = areaStatsQuery.data; const choroplethDataSource = useChoroplethDataSource(); const { viewConfig } = useMapViews(); - if (!hoverArea || !areaStats) { + if (!areaStats) { return null; } - const areaStat = - areaStats.areaSetCode === hoverArea.areaSetCode - ? areaStats.stats.find((s) => s.areaCode === hoverArea.code) - : null; + // Combine selected areas and hover area, avoiding duplicates + const areasToDisplay = []; + + // Add all selected areas + for (const selectedArea of selectedAreas) { + areasToDisplay.push({ + code: selectedArea.code, + name: selectedArea.name, + areaSetCode: selectedArea.areaSetCode, + isSelected: true, + }); + } + + // Add hover area only if it's not already in selected areas + if (hoverArea) { + const isHoverAreaSelected = selectedAreas.some( + (a) => + a.code === hoverArea.code && a.areaSetCode === hoverArea.areaSetCode, + ); + if (!isHoverAreaSelected) { + areasToDisplay.push({ + code: hoverArea.code, + name: hoverArea.name, + areaSetCode: hoverArea.areaSetCode, + isSelected: false, + }); + } + } - if (!areaStat) { + if (areasToDisplay.length === 0) { return null; } @@ -71,46 +100,67 @@ export default function AreaInfo() { ? `${choroplethDataSource?.name || "Unknown"} count` : viewConfig.areaDataColumn; - const primaryValue = getDisplayValue( - areaStats.calculationType, - areaStats.primary, - areaStat.primary, - ); - const secondaryValue = getDisplayValue( - areaStats.calculationType, - areaStats.secondary, - areaStat.secondary, - ); - return ( -
+
- - + + {statLabel} - + {viewConfig.areaDataSecondaryColumn || "Secondary"} - - - {hoverArea.name} - - - {primaryValue} - - - {secondaryValue} - - + {areasToDisplay.map((area) => { + const areaStat = + areaStats.areaSetCode === area.areaSetCode + ? areaStats.stats.find((s) => s.areaCode === area.code) + : null; + + const primaryValue = areaStat + ? getDisplayValue( + areaStats.calculationType, + areaStats.primary, + areaStat.primary, + ) + : "-"; + const secondaryValue = areaStat + ? getDisplayValue( + areaStats.calculationType, + areaStats.secondary, + areaStat.secondary, + ) + : "-"; + + return ( + + + {area.name} + + + {primaryValue} + + + {secondaryValue} + + + ); + })}
diff --git a/src/app/map/[id]/components/Choropleth/index.tsx b/src/app/map/[id]/components/Choropleth/index.tsx index 4f0dc6df..1d773717 100644 --- a/src/app/map/[id]/components/Choropleth/index.tsx +++ b/src/app/map/[id]/components/Choropleth/index.tsx @@ -100,7 +100,52 @@ export default function Choropleth() { /> } - {/* Active outline drawn above other lines */} + {/* Selected areas outline (green) - only when not active */} + + + {/* Active outline - only when not selected */} + + {/* Active + Selected outline: blue outside, green inside offset */} + + {/* Active + Selected inner outline (green offset inside) */} + + + {/* Active + Selected outer outline (blue offset outside with dashes) */} + + {/* Symbol Layer (Labels) */} {viewConfig.mapType !== MapType.Hex && viewConfig.showLabels && ( (undefined); + const selectedAreasRef = useRef(selectedAreas); - /* Handle clicks to set active state */ + // Keep ref in sync with latest selectedAreas + useEffect(() => { + selectedAreasRef.current = selectedAreas; + }, [selectedAreas]); + + // Update feature states for selected areas useEffect(() => { if (!mapRef?.current || !ready) { return; } const map = mapRef.current; - const fillLayerId = `${sourceId}-fill`; - const lineLayerId = `${sourceId}-line`; - const onClick = (e: mapboxgl.MapMouseEvent) => { - if (currentMode === "draw_polygon" || pinDropMode) { - return; + // Set selected state for all selected areas + selectedAreas.forEach((area) => { + if (area.areaSetCode === areaSetCode) { + try { + map.setFeatureState( + { source: sourceId, sourceLayer: layerId, id: area.code }, + { selected: true }, + ); + } catch { + // Ignore errors + } } + }); - if (handleMarkerClick(e)) { - return; - } + // Clean up: remove selected state from areas no longer selected + return () => { + selectedAreas.forEach((area) => { + if (area.areaSetCode === areaSetCode) { + try { + map.setFeatureState( + { source: sourceId, sourceLayer: layerId, id: area.code }, + { selected: false }, + ); + } catch { + // Ignore errors + } + } + }); + }; + }, [selectedAreas, mapRef, ready, sourceId, layerId, areaSetCode]); - if (handleTurfClick(e)) { - return; - } + /* Handle clicks to set active state */ + useEffect(() => { + if (!mapRef?.current || !ready) { + return; + } - if (handleAreaClick(e)) { - return; + const map = mapRef.current; + const fillLayerId = `${sourceId}-fill`; + const lineLayerId = `${sourceId}-line`; + let isCKeyPressed = false; + + const onKeyDown = (e: KeyboardEvent) => { + if ((e.key === "c" || e.key === "C") && !e.repeat) { + isCKeyPressed = true; } + }; - resetInspector(); + const onKeyUp = (e: KeyboardEvent) => { + if (e.key === "c" || e.key === "C") { + isCKeyPressed = false; + } }; const handleMarkerClick = (e: mapboxgl.MapMouseEvent): boolean => { @@ -208,7 +249,83 @@ export function useMapClick({ return false; }; + const handleCtrlAreaClick = (e: mapboxgl.MapMouseEvent): boolean => { + if (!map.getLayer(fillLayerId) && !map.getLayer(lineLayerId)) { + return false; + } + + const features = map.queryRenderedFeatures(e.point, { + layers: [fillLayerId, lineLayerId].filter((l) => map.getLayer(l)), + }); + + if (features.length > 0) { + const feature = features[0]; + const areaCode = feature.properties?.[featureCodeProperty] as string; + const areaName = feature.properties?.[featureNameProperty] as string; + + if (areaCode && areaName) { + // Use ref to get the latest selectedAreas value + const currentSelectedAreas = selectedAreasRef.current; + + // Check if area already exists in selection + const existingIndex = currentSelectedAreas.findIndex( + (a) => a.code === areaCode && a.areaSetCode === areaSetCode, + ); + + if (existingIndex !== -1) { + // Remove area from selection + const newSelectedAreas = currentSelectedAreas.filter( + (_, index) => index !== existingIndex, + ); + setSelectedAreas(newSelectedAreas); + return true; + } else { + // Add area to selected areas + const newArea = { + areaSetCode, + code: areaCode, + name: areaName, + coordinates: [e.lngLat.lng, e.lngLat.lat] as [number, number], + }; + setSelectedAreas([...currentSelectedAreas, newArea]); + return true; + } + } + } + + return false; + }; + + const onClick = (e: mapboxgl.MapMouseEvent) => { + if (currentMode === "draw_polygon" || pinDropMode) { + return; + } + + // Check if 'c' key is pressed + if (isCKeyPressed) { + if (handleCtrlAreaClick(e)) { + return; + } + } + + if (handleMarkerClick(e)) { + return; + } + + if (handleTurfClick(e)) { + return; + } + + if (handleAreaClick(e)) { + return; + } + + resetInspector(); + }; + map.on("click", onClick); + window.addEventListener("keydown", onKeyDown); + window.addEventListener("keyup", onKeyUp); return () => { // Clean up active state on unmount @@ -228,6 +345,8 @@ export function useMapClick({ } map.off("click", onClick); + window.removeEventListener("keydown", onKeyDown); + window.removeEventListener("keyup", onKeyUp); }; }, [ mapRef, @@ -245,6 +364,7 @@ export function useMapClick({ setSelectedTurf, ready, setSelectedRecords, + setSelectedAreas, ]); // Clear active feature state when selectedBoundary is cleared (resetInspector called from outside) diff --git a/src/app/map/[id]/hooks/useMapHover.ts b/src/app/map/[id]/hooks/useMapHover.ts index 4871ef03..820bfb5e 100644 --- a/src/app/map/[id]/hooks/useMapHover.ts +++ b/src/app/map/[id]/hooks/useMapHover.ts @@ -36,6 +36,7 @@ export function useMapHover({ const lineLayerId = `${sourceId}-line`; const prevPointer = { cursor: "" }; let hoveredFeatureId: string | number | undefined; + let isCtrlPressed = false; const clearAreaHover = () => { if (hoveredFeatureId !== undefined) { @@ -48,6 +49,26 @@ export function useMapHover({ } }; + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === "c" || e.key === "C") { + isCtrlPressed = true; + const canvas = map.getCanvas(); + if (canvas.style.cursor === "pointer") { + canvas.style.cursor = "copy"; + } + } + }; + + const onKeyUp = (e: KeyboardEvent) => { + if (e.key === "c" || e.key === "C") { + isCtrlPressed = false; + const canvas = map.getCanvas(); + if (canvas.style.cursor === "copy") { + canvas.style.cursor = "pointer"; + } + } + }; + const onMouseMove = (e: mapboxgl.MapMouseEvent) => { if (handleHoverMarker(e)) { clearAreaHover(); @@ -154,10 +175,13 @@ export function useMapHover({ }); } - if (map.getCanvas().style.cursor !== "pointer") { + if ( + map.getCanvas().style.cursor !== "pointer" && + map.getCanvas().style.cursor !== "copy" + ) { prevPointer.cursor = map.getCanvas().style.cursor || ""; } - map.getCanvas().style.cursor = "pointer"; + map.getCanvas().style.cursor = isCtrlPressed ? "copy" : "pointer"; return true; } @@ -170,7 +194,10 @@ export function useMapHover({ setHoverArea(null); } - if (map.getCanvas().style.cursor === "pointer") { + if ( + map.getCanvas().style.cursor === "pointer" || + map.getCanvas().style.cursor === "copy" + ) { map.getCanvas().style.cursor = prevPointer.cursor; } @@ -179,6 +206,8 @@ export function useMapHover({ map.on("mousemove", onMouseMove); map.on("mouseleave", onMouseLeave); + window.addEventListener("keydown", onKeyDown); + window.addEventListener("keyup", onKeyUp); return () => { // Clean up hover state on unmount @@ -195,6 +224,8 @@ export function useMapHover({ map.off("mousemove", onMouseMove); map.off("mouseleave", onMouseLeave); + window.removeEventListener("keydown", onKeyDown); + window.removeEventListener("keyup", onKeyUp); }; }, [ mapRef, From 0ba33ef546ffe3a5b5208a8d4f274906388e08fb Mon Sep 17 00:00:00 2001 From: ev <4164774+ev-sc@users.noreply.github.com> Date: Thu, 11 Dec 2025 12:31:02 +0100 Subject: [PATCH 05/45] feat: add clear selection button and toggle functionality for selected areas in AreaInfo component --- src/app/map/[id]/components/AreaInfo.tsx | 46 ++++++++++++++++++++++-- 1 file changed, 43 insertions(+), 3 deletions(-) diff --git a/src/app/map/[id]/components/AreaInfo.tsx b/src/app/map/[id]/components/AreaInfo.tsx index 1a5770b7..2ad175eb 100644 --- a/src/app/map/[id]/components/AreaInfo.tsx +++ b/src/app/map/[id]/components/AreaInfo.tsx @@ -1,4 +1,5 @@ import { useAtom } from "jotai"; +import { XIcon } from "lucide-react"; import { ColumnType } from "@/server/models/DataSource"; import { CalculationType } from "@/server/models/MapView"; @@ -52,7 +53,7 @@ const getDisplayValue = ( export default function AreaInfo() { const [hoverArea] = useHoverArea(); - const [selectedAreas] = useAtom(selectedAreasAtom); + const [selectedAreas, setSelectedAreas] = useAtom(selectedAreasAtom); const areaStatsQuery = useAreaStats(); const areaStats = areaStatsQuery.data; const choroplethDataSource = useChoroplethDataSource(); @@ -71,6 +72,7 @@ export default function AreaInfo() { code: selectedArea.code, name: selectedArea.name, areaSetCode: selectedArea.areaSetCode, + coordinates: selectedArea.coordinates, isSelected: true, }); } @@ -86,6 +88,7 @@ export default function AreaInfo() { code: hoverArea.code, name: hoverArea.name, areaSetCode: hoverArea.areaSetCode, + coordinates: hoverArea.coordinates, isSelected: false, }); } @@ -101,7 +104,19 @@ export default function AreaInfo() { : viewConfig.areaDataColumn; return ( -
+
+ {selectedAreas.length > 0 && ( + + )} { + if (area.isSelected) { + // Remove from selected areas + setSelectedAreas( + selectedAreas.filter( + (a) => + !( + a.code === area.code && + a.areaSetCode === area.areaSetCode + ), + ), + ); + } else { + // Add to selected areas + setSelectedAreas([ + ...selectedAreas, + { + code: area.code, + name: area.name, + areaSetCode: area.areaSetCode, + coordinates: area.coordinates, + }, + ]); + } + }} > {area.name} From dcd3dca4d521673e6cb2ed5ba7ba5894fe081e09 Mon Sep 17 00:00:00 2001 From: ev <4164774+ev-sc@users.noreply.github.com> Date: Thu, 11 Dec 2025 20:14:31 +0100 Subject: [PATCH 06/45] feat: adjust AreaInfo component layout for improved single area display --- src/app/map/[id]/components/AreaInfo.tsx | 52 +++++++++++++++++------- 1 file changed, 37 insertions(+), 15 deletions(-) diff --git a/src/app/map/[id]/components/AreaInfo.tsx b/src/app/map/[id]/components/AreaInfo.tsx index 2ad175eb..6b0f15f2 100644 --- a/src/app/map/[id]/components/AreaInfo.tsx +++ b/src/app/map/[id]/components/AreaInfo.tsx @@ -104,10 +104,10 @@ export default function AreaInfo() { : viewConfig.areaDataColumn; return ( -
+
{selectedAreas.length > 0 && (
-
+ + )} + ); } From 4b27b95e7fe3fdd69cb00d0275568daf2d859923 Mon Sep 17 00:00:00 2001 From: ev <4164774+ev-sc@users.noreply.github.com> Date: Thu, 11 Dec 2025 20:47:23 +0100 Subject: [PATCH 09/45] feat: enhance AreaInfo component with dynamic area color rendering based on fillColor expression --- src/app/map/[id]/components/AreaInfo.tsx | 78 ++++++++++++++++++++++-- 1 file changed, 73 insertions(+), 5 deletions(-) diff --git a/src/app/map/[id]/components/AreaInfo.tsx b/src/app/map/[id]/components/AreaInfo.tsx index 74b2aa43..a79f7d57 100644 --- a/src/app/map/[id]/components/AreaInfo.tsx +++ b/src/app/map/[id]/components/AreaInfo.tsx @@ -1,9 +1,10 @@ import { AnimatePresence, motion } from "framer-motion"; import { useAtom } from "jotai"; import { XIcon } from "lucide-react"; +import { expression } from "mapbox-gl/dist/style-spec/index.cjs"; import { ColumnType } from "@/server/models/DataSource"; -import { CalculationType } from "@/server/models/MapView"; +import { CalculationType, ColorScheme } from "@/server/models/MapView"; import { Table, TableBody, @@ -15,6 +16,7 @@ import { import { formatNumber } from "@/utils/text"; import { selectedAreasAtom } from "../atoms/selectedAreasAtom"; +import { useFillColor } from "../colors"; import { useAreaStats } from "../data"; import { useChoroplethDataSource } from "../hooks/useDataSources"; import { useHoverArea } from "../hooks/useMapHover"; @@ -52,6 +54,19 @@ const getDisplayValue = ( return formatNumber(value); }; +const toRGBA = (expressionResult: unknown) => { + if ( + !expressionResult || + !Array.isArray(expressionResult) || + expressionResult.length < 3 + ) { + return `rgba(0, 0, 0, 0)`; + } + const [r, g, b, ...rest] = expressionResult; + const a = rest.length ? rest[0] : 1; + return `rgba(${r}, ${g}, ${b}, ${a})`; +}; + export default function AreaInfo() { const [hoverArea] = useHoverArea(); const [selectedAreas, setSelectedAreas] = useAtom(selectedAreasAtom); @@ -60,6 +75,13 @@ export default function AreaInfo() { const choroplethDataSource = useChoroplethDataSource(); const { viewConfig } = useMapViews(); + const fillColor = useFillColor({ + areaStats, + scheme: viewConfig.colorScheme || ColorScheme.RedBlue, + isReversed: Boolean(viewConfig.reverseColorScheme), + selectedBivariateBucket: null, + }); + if (!areaStats) { return null; } @@ -100,6 +122,46 @@ export default function AreaInfo() { ? `${choroplethDataSource?.name || "Unknown"} count` : viewConfig.areaDataColumn; + const { result, value: fillColorExpression } = expression.createExpression([ + "to-rgba", + fillColor, + ]); + + if (result !== "success") { + console.error( + "Attempted to parse invalid MapboxGL expression", + JSON.stringify(fillColor), + fillColorExpression, + ); + } + + // Helper to get color for an area based on fillColor expression + const getAreaColor = (area: { + code: string; + areaSetCode: string; + }): string => { + const areaStat = + areaStats.areaSetCode === area.areaSetCode + ? areaStats.stats.find((s) => s.areaCode === area.code) + : null; + + if (!areaStat || result !== "success") { + return "rgba(200, 200, 200, 1)"; + } + + // For bivariate color schemes, evaluate with both primary and secondary values + const colorResult = fillColorExpression.evaluate( + { zoom: 0 }, + { type: "Polygon", properties: {} }, + { + value: areaStat.primary || 0, + secondaryValue: areaStat.secondary || 0, + }, + ); + + return toRGBA(colorResult); + }; + return ( {areasToDisplay.length > 0 && ( @@ -129,7 +191,7 @@ export default function AreaInfo() { {areasToDisplay.length > 1 && ( - + {statLabel} @@ -199,11 +261,17 @@ export default function AreaInfo() { }} > - {area.name} +
+
+ {area.name} +
{isSingleRow ? ( -
+
{statLabel}: @@ -215,7 +283,7 @@ export default function AreaInfo() { {isSingleRow ? ( -
+
{viewConfig.areaDataSecondaryColumn || "Secondary"}: From 4098948a52abbff959dcd62ba1e09068d0033df3 Mon Sep 17 00:00:00 2001 From: ev <4164774+ev-sc@users.noreply.github.com> Date: Thu, 11 Dec 2025 21:01:23 +0100 Subject: [PATCH 10/45] feat: implement compare areas functionality with UI controls and state management --- src/app/map/[id]/atoms/mapStateAtoms.ts | 1 + src/app/map/[id]/components/AreaInfo.tsx | 256 +++++++++--------- .../components/MapMarkerAndAreaControls.tsx | 21 +- src/app/map/[id]/hooks/useMapClick.ts | 26 +- src/app/map/[id]/hooks/useMapControls.ts | 6 +- src/app/map/[id]/hooks/useMapHover.ts | 11 +- 6 files changed, 163 insertions(+), 158 deletions(-) diff --git a/src/app/map/[id]/atoms/mapStateAtoms.ts b/src/app/map/[id]/atoms/mapStateAtoms.ts index fce5aa5e..714cecbb 100644 --- a/src/app/map/[id]/atoms/mapStateAtoms.ts +++ b/src/app/map/[id]/atoms/mapStateAtoms.ts @@ -9,3 +9,4 @@ export const dirtyViewIdsAtom = atom([]); export const zoomAtom = atom(DEFAULT_ZOOM); export const pinDropModeAtom = atom(false); export const showControlsAtom = atom(true); +export const compareAreasAtom = atom(false); diff --git a/src/app/map/[id]/components/AreaInfo.tsx b/src/app/map/[id]/components/AreaInfo.tsx index a79f7d57..efb417d8 100644 --- a/src/app/map/[id]/components/AreaInfo.tsx +++ b/src/app/map/[id]/components/AreaInfo.tsx @@ -75,12 +75,12 @@ export default function AreaInfo() { const choroplethDataSource = useChoroplethDataSource(); const { viewConfig } = useMapViews(); - const fillColor = useFillColor({ - areaStats, - scheme: viewConfig.colorScheme || ColorScheme.RedBlue, - isReversed: Boolean(viewConfig.reverseColorScheme), - selectedBivariateBucket: null, - }); + const fillColor = useFillColor({ + areaStats, + scheme: viewConfig.colorScheme || ColorScheme.RedBlue, + isReversed: Boolean(viewConfig.reverseColorScheme), + selectedBivariateBucket: null, + }); if (!areaStats) { return null; @@ -172,132 +172,132 @@ export default function AreaInfo() { transition={{ duration: 0.15, type: "tween" }} className="bg-white rounded shadow-lg py-1 pr-8 relative pointer-events-auto" > - {selectedAreas.length > 0 && ( - - )} - - {areasToDisplay.length > 1 && ( - - - - - {statLabel} - - - {viewConfig.areaDataSecondaryColumn || "Secondary"} - - - - )} - - {areasToDisplay.map((area) => { - const areaStat = - areaStats.areaSetCode === area.areaSetCode - ? areaStats.stats.find((s) => s.areaCode === area.code) - : null; + {selectedAreas.length > 0 && ( + + )} +
+ {areasToDisplay.length > 1 && ( + + + + + {statLabel} + + + {viewConfig.areaDataSecondaryColumn || "Secondary"} + + + + )} + + {areasToDisplay.map((area) => { + const areaStat = + areaStats.areaSetCode === area.areaSetCode + ? areaStats.stats.find((s) => s.areaCode === area.code) + : null; - const primaryValue = areaStat - ? getDisplayValue( - areaStats.calculationType, - areaStats.primary, - areaStat.primary, - ) - : "-"; - const secondaryValue = areaStat - ? getDisplayValue( - areaStats.calculationType, - areaStats.secondary, - areaStat.secondary, - ) - : "-"; + const primaryValue = areaStat + ? getDisplayValue( + areaStats.calculationType, + areaStats.primary, + areaStat.primary, + ) + : "-"; + const secondaryValue = areaStat + ? getDisplayValue( + areaStats.calculationType, + areaStats.secondary, + areaStat.secondary, + ) + : "-"; - const isSingleRow = areasToDisplay.length === 1; + const isSingleRow = areasToDisplay.length === 1; - return ( - { - if (area.isSelected) { - // Remove from selected areas - setSelectedAreas( - selectedAreas.filter( - (a) => - !( - a.code === area.code && - a.areaSetCode === area.areaSetCode + return ( + { + if (area.isSelected) { + // Remove from selected areas + setSelectedAreas( + selectedAreas.filter( + (a) => + !( + a.code === area.code && + a.areaSetCode === area.areaSetCode + ), ), - ), - ); - } else { - // Add to selected areas - setSelectedAreas([ - ...selectedAreas, - { - code: area.code, - name: area.name, - areaSetCode: area.areaSetCode, - coordinates: area.coordinates, - }, - ]); - } - }} - > - -
-
- {area.name} -
- - - {isSingleRow ? ( -
- - {statLabel}: - - {primaryValue} -
- ) : ( - primaryValue - )} -
- - {isSingleRow ? ( -
- - {viewConfig.areaDataSecondaryColumn || "Secondary"}: - - {secondaryValue} -
- ) : ( - secondaryValue - )} -
- - ); - })} - -
+ ); + } else { + // Add to selected areas + setSelectedAreas([ + ...selectedAreas, + { + code: area.code, + name: area.name, + areaSetCode: area.areaSetCode, + coordinates: area.coordinates, + }, + ]); + } + }} + > + +
+
+ {area.name} +
+ + + {isSingleRow ? ( +
+ + {statLabel}: + + {primaryValue} +
+ ) : ( + primaryValue + )} +
+ + {isSingleRow ? ( +
+ + {viewConfig.areaDataSecondaryColumn || "Secondary"}: + + {secondaryValue} +
+ ) : ( + secondaryValue + )} +
+ + ); + })} + + )} diff --git a/src/app/map/[id]/components/MapMarkerAndAreaControls.tsx b/src/app/map/[id]/components/MapMarkerAndAreaControls.tsx index d43786c3..460fab4b 100644 --- a/src/app/map/[id]/components/MapMarkerAndAreaControls.tsx +++ b/src/app/map/[id]/components/MapMarkerAndAreaControls.tsx @@ -1,12 +1,15 @@ -import { MapPin } from "lucide-react"; +import { useAtom } from "jotai"; +import { ChartBar, MapPin } from "lucide-react"; import VectorSquare from "@/components/icons/VectorSquare"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/shadcn/ui/tooltip"; +import { compareAreasAtom } from "../atoms/mapStateAtoms"; import { useHandleDropPin } from "../hooks/usePlacedMarkers"; import { useTurfState } from "../hooks/useTurfs"; export default function MapMarkerAndAreaControls() { const { handleDropPin } = useHandleDropPin(); const { handleAddArea } = useTurfState(); + const [compareAreasMode, setCompareAreasMode] = useAtom(compareAreasAtom); return (
@@ -32,6 +35,22 @@ export default function MapMarkerAndAreaControls() { Add area +
+ + + + + Compare areas +
); } diff --git a/src/app/map/[id]/hooks/useMapClick.ts b/src/app/map/[id]/hooks/useMapClick.ts index 0b10233b..bbb00936 100644 --- a/src/app/map/[id]/hooks/useMapClick.ts +++ b/src/app/map/[id]/hooks/useMapClick.ts @@ -1,7 +1,8 @@ import { point as turfPoint } from "@turf/helpers"; import { booleanPointInPolygon } from "@turf/turf"; -import { useAtom } from "jotai"; +import { useAtom, useAtomValue } from "jotai"; import { useEffect, useRef } from "react"; +import { compareAreasAtom } from "@/app/map/[id]/atoms/mapStateAtoms"; import { selectedAreasAtom } from "@/app/map/[id]/atoms/selectedAreasAtom"; import { useChoropleth } from "@/app/map/[id]/hooks/useChoropleth"; import { useInspector } from "@/app/map/[id]/hooks/useInspector"; @@ -40,6 +41,7 @@ export function useMapClickEffect({ setSelectedTurf, } = useInspector(); const [selectedAreas, setSelectedAreas] = useAtom(selectedAreasAtom); + const compareAreasMode = useAtomValue(compareAreasAtom); const { mapbox: { sourceId, layerId, featureCodeProperty, featureNameProperty }, @@ -102,19 +104,6 @@ export function useMapClickEffect({ const map = mapRef.current; const fillLayerId = `${sourceId}-fill`; const lineLayerId = `${sourceId}-line`; - let isCKeyPressed = false; - - const onKeyDown = (e: KeyboardEvent) => { - if ((e.key === "c" || e.key === "C") && !e.repeat) { - isCKeyPressed = true; - } - }; - - const onKeyUp = (e: KeyboardEvent) => { - if (e.key === "c" || e.key === "C") { - isCKeyPressed = false; - } - }; const handleMarkerClick = (e: mapboxgl.MapMouseEvent): boolean => { const validMarkerLayers = markerLayers.filter((l) => map.getLayer(l)); @@ -318,8 +307,8 @@ export function useMapClickEffect({ return; } - // Check if 'c' key is pressed - if (isCKeyPressed) { + // Check if compare areas mode is active + if (compareAreasMode) { if (handleCtrlAreaClick(e)) { return; } @@ -341,8 +330,6 @@ export function useMapClickEffect({ }; map.on("click", onClick); - window.addEventListener("keydown", onKeyDown); - window.addEventListener("keyup", onKeyUp); return () => { // Clean up active state on unmount @@ -362,8 +349,6 @@ export function useMapClickEffect({ } map.off("click", onClick); - window.removeEventListener("keydown", onKeyDown); - window.removeEventListener("keyup", onKeyUp); }; }, [ mapRef, @@ -382,6 +367,7 @@ export function useMapClickEffect({ ready, setSelectedRecords, setSelectedAreas, + compareAreasMode, ]); // Clear active feature state when selectedBoundary is cleared (resetInspector called from outside) diff --git a/src/app/map/[id]/hooks/useMapControls.ts b/src/app/map/[id]/hooks/useMapControls.ts index f93fbe65..1e633503 100644 --- a/src/app/map/[id]/hooks/useMapControls.ts +++ b/src/app/map/[id]/hooks/useMapControls.ts @@ -6,10 +6,8 @@ import { pinDropModeAtom, showControlsAtom } from "../atoms/mapStateAtoms"; * Includes showControls (sidebar visibility) and pinDropMode (pin dropping interaction) */ export function useMapControls() { - const showControls = useAtomValue(showControlsAtom); - const setShowControls = useSetAtom(showControlsAtom); - const pinDropMode = useAtomValue(pinDropModeAtom); - const setPinDropMode = useSetAtom(pinDropModeAtom); + const [showControls, setShowControls] = useAtom(showControlsAtom); + const [pinDropMode, setPinDropMode] = useAtom(pinDropModeAtom); return { showControls, diff --git a/src/app/map/[id]/hooks/useMapHover.ts b/src/app/map/[id]/hooks/useMapHover.ts index 3606ee0b..5c14e44b 100644 --- a/src/app/map/[id]/hooks/useMapHover.ts +++ b/src/app/map/[id]/hooks/useMapHover.ts @@ -2,6 +2,7 @@ import { useAtom } from "jotai"; import { useEffect } from "react"; import { useChoropleth } from "@/app/map/[id]/hooks/useChoropleth"; import { hoverAreaAtom, hoverMarkerAtom } from "../atoms/hoverAtoms"; +import { compareAreasAtom } from "../atoms/mapStateAtoms"; import { getClickedPolygonFeature } from "./useMapClick"; import { useMapRef } from "./useMapCore"; import type MapboxDraw from "@mapbox/mapbox-gl-draw"; @@ -24,6 +25,7 @@ export function useMapHoverEffect({ const [, setHoverArea] = useHoverArea(); const [, setHoverMarker] = useHoverMarker(); + const [compareAreasMode, setCompareAreasMode] = useAtom(compareAreasAtom); /* Set cursor to pointer and darken fill on hover over choropleth areas */ useEffect(() => { @@ -36,7 +38,6 @@ export function useMapHoverEffect({ const lineLayerId = `${sourceId}-line`; const prevPointer = { cursor: "" }; let hoveredFeatureId: string | number | undefined; - let isCtrlPressed = false; const clearAreaHover = () => { if (hoveredFeatureId !== undefined) { @@ -50,8 +51,8 @@ export function useMapHoverEffect({ }; const onKeyDown = (e: KeyboardEvent) => { - if (e.key === "c" || e.key === "C") { - isCtrlPressed = true; + if ((e.key === "c" || e.key === "C") && !e.repeat) { + setCompareAreasMode(true); const canvas = map.getCanvas(); if (canvas.style.cursor === "pointer") { canvas.style.cursor = "copy"; @@ -61,7 +62,7 @@ export function useMapHoverEffect({ const onKeyUp = (e: KeyboardEvent) => { if (e.key === "c" || e.key === "C") { - isCtrlPressed = false; + setCompareAreasMode(false); const canvas = map.getCanvas(); if (canvas.style.cursor === "copy") { canvas.style.cursor = "pointer"; @@ -181,7 +182,7 @@ export function useMapHoverEffect({ ) { prevPointer.cursor = map.getCanvas().style.cursor || ""; } - map.getCanvas().style.cursor = isCtrlPressed ? "copy" : "pointer"; + map.getCanvas().style.cursor = compareAreasMode ? "copy" : "pointer"; return true; } From 4b15bdc0845b832e2edbc5235ee6b3b7f7329748 Mon Sep 17 00:00:00 2001 From: ev <4164774+ev-sc@users.noreply.github.com> Date: Thu, 11 Dec 2025 21:06:49 +0100 Subject: [PATCH 11/45] feat: enhance tooltip functionality and improve hover effect handling --- .../components/MapMarkerAndAreaControls.tsx | 6 +++--- src/app/map/[id]/hooks/useMapHover.ts | 18 +++++++++--------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/app/map/[id]/components/MapMarkerAndAreaControls.tsx b/src/app/map/[id]/components/MapMarkerAndAreaControls.tsx index 460fab4b..9fcbd01c 100644 --- a/src/app/map/[id]/components/MapMarkerAndAreaControls.tsx +++ b/src/app/map/[id]/components/MapMarkerAndAreaControls.tsx @@ -13,7 +13,7 @@ export default function MapMarkerAndAreaControls() { return (
- +
- {isSingleRow ? ( + {!multipleAreas ? (
{statLabel}: @@ -282,7 +350,7 @@ export default function AreaInfo() { )} - {isSingleRow ? ( + {!multipleAreas ? (
{viewConfig.areaDataSecondaryColumn || "Secondary"}: From b83ded4a616d86e4800bb6e376e27699acd5dd63 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 12 Dec 2025 18:00:25 +0000 Subject: [PATCH 13/45] Initial plan From 6dede99372e4f33b5d1301b0395728429325dff8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 12 Dec 2025 18:04:53 +0000 Subject: [PATCH 14/45] Fix Tailwind classes, className spacing, and keyboard accessibility in AreaInfo Co-authored-by: ev-sc <4164774+ev-sc@users.noreply.github.com> --- src/app/map/[id]/components/AreaInfo.tsx | 74 +++++++++++++++--------- src/app/map/[id]/hooks/useMapClick.ts | 47 ++++++++++----- 2 files changed, 79 insertions(+), 42 deletions(-) diff --git a/src/app/map/[id]/components/AreaInfo.tsx b/src/app/map/[id]/components/AreaInfo.tsx index 46e14806..494df141 100644 --- a/src/app/map/[id]/components/AreaInfo.tsx +++ b/src/app/map/[id]/components/AreaInfo.tsx @@ -247,13 +247,13 @@ export default function AreaInfo() { style={{ tableLayout: "fixed", width: "100%" }} > {multipleAreas && ( - + - + {statLabel} - + {viewConfig.areaDataSecondaryColumn || "Secondary"} @@ -281,6 +281,32 @@ export default function AreaInfo() { ) : "-"; + const handleToggleSelection = () => { + if (area.isSelected) { + // Remove from selected areas + setSelectedAreas( + selectedAreas.filter( + (a) => + !( + a.code === area.code && + a.areaSetCode === area.areaSetCode + ), + ), + ); + } else { + // Add to selected areas + setSelectedAreas([ + ...selectedAreas, + { + code: area.code, + name: area.name, + areaSetCode: area.areaSetCode, + coordinates: area.coordinates, + }, + ]); + } + }; + return ( { if (!area.isSelected) { setHoveredRowArea(area); @@ -302,29 +335,14 @@ export default function AreaInfo() { onMouseLeave={() => { setHoveredRowArea(null); }} - onClick={() => { - if (area.isSelected) { - // Remove from selected areas - setSelectedAreas( - selectedAreas.filter( - (a) => - !( - a.code === area.code && - a.areaSetCode === area.areaSetCode - ), - ), - ); - } else { - // Add to selected areas - setSelectedAreas([ - ...selectedAreas, - { - code: area.code, - name: area.name, - areaSetCode: area.areaSetCode, - coordinates: area.coordinates, - }, - ]); + onClick={area.isSelected ? handleToggleSelection : undefined} + onKeyDown={(e) => { + if ( + area.isSelected && + (e.key === "Enter" || e.key === " ") + ) { + e.preventDefault(); + handleToggleSelection(); } }} > @@ -337,7 +355,7 @@ export default function AreaInfo() { {area.name}
- + {!multipleAreas ? (
@@ -349,7 +367,7 @@ export default function AreaInfo() { primaryValue )} - + {!multipleAreas ? (
diff --git a/src/app/map/[id]/hooks/useMapClick.ts b/src/app/map/[id]/hooks/useMapClick.ts index bbb00936..6a3d5557 100644 --- a/src/app/map/[id]/hooks/useMapClick.ts +++ b/src/app/map/[id]/hooks/useMapClick.ts @@ -50,6 +50,7 @@ export function useMapClickEffect({ const activeFeatureId = useRef(undefined); const selectedAreasRef = useRef(selectedAreas); + const prevSelectedAreasRef = useRef([]); // Keep ref in sync with latest selectedAreas useEffect(() => { @@ -63,8 +64,33 @@ export function useMapClickEffect({ } const map = mapRef.current; + const prevSelectedAreas = prevSelectedAreasRef.current; + + // Find areas that were removed from selection + const removedAreas = prevSelectedAreas.filter( + (prevArea) => + !selectedAreas.some( + (area) => + area.code === prevArea.code && + area.areaSetCode === prevArea.areaSetCode, + ), + ); + + // Remove selected state from removed areas + removedAreas.forEach((area) => { + if (area.areaSetCode === areaSetCode) { + try { + map.setFeatureState( + { source: sourceId, sourceLayer: layerId, id: area.code }, + { selected: false }, + ); + } catch { + // Ignore errors + } + } + }); - // Set selected state for all selected areas + // Set selected state for all currently selected areas selectedAreas.forEach((area) => { if (area.areaSetCode === areaSetCode) { try { @@ -78,20 +104,13 @@ export function useMapClickEffect({ } }); - // Clean up: remove selected state from areas no longer selected + // Update previous selected areas for next comparison + prevSelectedAreasRef.current = selectedAreas; + + // Clean up: only remove selected state on unmount return () => { - selectedAreas.forEach((area) => { - if (area.areaSetCode === areaSetCode) { - try { - map.setFeatureState( - { source: sourceId, sourceLayer: layerId, id: area.code }, - { selected: false }, - ); - } catch { - // Ignore errors - } - } - }); + // Only clear if this is actually unmounting (selectedAreas ref will be stale) + // On re-renders, the effect will handle adding/removing as needed above }; }, [selectedAreas, mapRef, ready, sourceId, layerId, areaSetCode]); From 9177cc9aff7bc76c9c5a2c95dad79a9639b8ed67 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 12 Dec 2025 18:05:45 +0000 Subject: [PATCH 15/45] Add visual hint for compare areas mode keyboard shortcut Co-authored-by: ev-sc <4164774+ev-sc@users.noreply.github.com> --- src/app/map/[id]/components/MapWrapper.tsx | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/app/map/[id]/components/MapWrapper.tsx b/src/app/map/[id]/components/MapWrapper.tsx index 56b57d1b..cbe02a70 100644 --- a/src/app/map/[id]/components/MapWrapper.tsx +++ b/src/app/map/[id]/components/MapWrapper.tsx @@ -1,5 +1,7 @@ +import { useAtomValue } from "jotai"; import { useEffect, useState } from "react"; import { MapType } from "@/server/models/MapView"; +import { compareAreasAtom } from "../atoms/mapStateAtoms"; import { useInspector } from "../hooks/useInspector"; import { useShowControls } from "../hooks/useMapControls"; import { useMapViews } from "../hooks/useMapViews"; @@ -24,6 +26,7 @@ export default function MapWrapper({ const { viewConfig } = useMapViews(); const { inspectorContent } = useInspector(); const inspectorVisible = Boolean(inspectorContent); + const compareAreasMode = useAtomValue(compareAreasAtom); const [message, setMessage] = useState(""); const [indicatorColor, setIndicatorColor] = useState(""); @@ -37,11 +40,16 @@ export default function MapWrapper({ } else if (currentMode === "pin_drop") { setIndicatorColor(mapColors.markers.color); setMessage("Click on the map to drop a pin."); + } else if (compareAreasMode) { + setIndicatorColor("#10b981"); // green-500 + setMessage( + "Compare mode active. Click areas to select/deselect. Release 'C' to exit.", + ); } else { setIndicatorColor(""); setMessage(""); } - }, [currentMode]); + }, [currentMode, compareAreasMode]); const absolutelyCenter = { transform: showControls From d93f320ea2b44a9017d55b65439fab35ce610eda Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 12 Dec 2025 18:07:36 +0000 Subject: [PATCH 16/45] Fix TableRow accessibility and remove empty cleanup function Co-authored-by: ev-sc <4164774+ev-sc@users.noreply.github.com> --- src/app/map/[id]/components/AreaInfo.tsx | 19 ++++++------------- src/app/map/[id]/hooks/useMapClick.ts | 6 ------ 2 files changed, 6 insertions(+), 19 deletions(-) diff --git a/src/app/map/[id]/components/AreaInfo.tsx b/src/app/map/[id]/components/AreaInfo.tsx index 494df141..7695cc4e 100644 --- a/src/app/map/[id]/components/AreaInfo.tsx +++ b/src/app/map/[id]/components/AreaInfo.tsx @@ -310,22 +310,18 @@ export default function AreaInfo() { return ( { if (!area.isSelected) { @@ -335,12 +331,9 @@ export default function AreaInfo() { onMouseLeave={() => { setHoveredRowArea(null); }} - onClick={area.isSelected ? handleToggleSelection : undefined} + onClick={handleToggleSelection} onKeyDown={(e) => { - if ( - area.isSelected && - (e.key === "Enter" || e.key === " ") - ) { + if (e.key === "Enter" || e.key === " ") { e.preventDefault(); handleToggleSelection(); } diff --git a/src/app/map/[id]/hooks/useMapClick.ts b/src/app/map/[id]/hooks/useMapClick.ts index 6a3d5557..39aa5593 100644 --- a/src/app/map/[id]/hooks/useMapClick.ts +++ b/src/app/map/[id]/hooks/useMapClick.ts @@ -106,12 +106,6 @@ export function useMapClickEffect({ // Update previous selected areas for next comparison prevSelectedAreasRef.current = selectedAreas; - - // Clean up: only remove selected state on unmount - return () => { - // Only clear if this is actually unmounting (selectedAreas ref will be stale) - // On re-renders, the effect will handle adding/removing as needed above - }; }, [selectedAreas, mapRef, ready, sourceId, layerId, areaSetCode]); /* Handle clicks to set active state */ From c1d38f19e18168441f20e88757fe8fe3371db833 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 12 Dec 2025 18:08:45 +0000 Subject: [PATCH 17/45] Address code review feedback: simplify ref types and improve keyboard handling Co-authored-by: ev-sc <4164774+ev-sc@users.noreply.github.com> --- src/app/map/[id]/components/AreaInfo.tsx | 2 +- src/app/map/[id]/hooks/useMapClick.ts | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/app/map/[id]/components/AreaInfo.tsx b/src/app/map/[id]/components/AreaInfo.tsx index 7695cc4e..5468d2f5 100644 --- a/src/app/map/[id]/components/AreaInfo.tsx +++ b/src/app/map/[id]/components/AreaInfo.tsx @@ -333,7 +333,7 @@ export default function AreaInfo() { }} onClick={handleToggleSelection} onKeyDown={(e) => { - if (e.key === "Enter" || e.key === " ") { + if (e.key === "Enter" || e.code === "Space") { e.preventDefault(); handleToggleSelection(); } diff --git a/src/app/map/[id]/hooks/useMapClick.ts b/src/app/map/[id]/hooks/useMapClick.ts index 39aa5593..c81e1f00 100644 --- a/src/app/map/[id]/hooks/useMapClick.ts +++ b/src/app/map/[id]/hooks/useMapClick.ts @@ -50,7 +50,7 @@ export function useMapClickEffect({ const activeFeatureId = useRef(undefined); const selectedAreasRef = useRef(selectedAreas); - const prevSelectedAreasRef = useRef([]); + const prevSelectedAreasRef = useRef([]); // Keep ref in sync with latest selectedAreas useEffect(() => { @@ -66,6 +66,9 @@ export function useMapClickEffect({ const map = mapRef.current; const prevSelectedAreas = prevSelectedAreasRef.current; + // Update previous selected areas before processing to ensure it's always set + prevSelectedAreasRef.current = selectedAreas; + // Find areas that were removed from selection const removedAreas = prevSelectedAreas.filter( (prevArea) => @@ -103,9 +106,6 @@ export function useMapClickEffect({ } } }); - - // Update previous selected areas for next comparison - prevSelectedAreasRef.current = selectedAreas; }, [selectedAreas, mapRef, ready, sourceId, layerId, areaSetCode]); /* Handle clicks to set active state */ From 8f2e1e7f9217dfa03044327dd478dd4c942fba69 Mon Sep 17 00:00:00 2001 From: ev <4164774+ev-sc@users.noreply.github.com> Date: Fri, 12 Dec 2025 20:15:58 +0100 Subject: [PATCH 18/45] Refactor AreaInfo and MapWrapper components to improve code clarity and update indicator colors; enhance map color configuration with geography color. --- src/app/map/[id]/components/AreaInfo.tsx | 98 +++++++------------ .../map/[id]/components/Choropleth/index.tsx | 5 +- src/app/map/[id]/components/MapWrapper.tsx | 31 +++--- src/app/map/[id]/hooks/useMapClick.ts | 7 +- src/app/map/[id]/styles.ts | 6 +- 5 files changed, 62 insertions(+), 85 deletions(-) diff --git a/src/app/map/[id]/components/AreaInfo.tsx b/src/app/map/[id]/components/AreaInfo.tsx index 5468d2f5..c8728b2d 100644 --- a/src/app/map/[id]/components/AreaInfo.tsx +++ b/src/app/map/[id]/components/AreaInfo.tsx @@ -16,7 +16,6 @@ import { } from "@/shadcn/ui/table"; import { formatNumber } from "@/utils/text"; -import { compareAreasAtom } from "../atoms/mapStateAtoms"; import { selectedAreasAtom } from "../atoms/selectedAreasAtom"; import { useFillColor } from "../colors"; import { useAreaStats } from "../data"; @@ -80,7 +79,6 @@ export default function AreaInfo() { coordinates: [number, number]; } | null>(null); const [selectedAreas, setSelectedAreas] = useAtom(selectedAreasAtom); - const compareAreasMode = useAtom(compareAreasAtom)[0]; const areaStatsQuery = useAreaStats(); const areaStats = areaStatsQuery.data; const choroplethDataSource = useChoroplethDataSource(); @@ -156,25 +154,6 @@ export default function AreaInfo() { } } - // Show empty state if compare mode is on but no areas to display - if (compareAreasMode && areasToDisplay.length === 0) { - return ( - - -

- Click on areas to compare their data -

-
-
- ); - } - const statLabel = areaStats.calculationType === CalculationType.Count ? `${choroplethDataSource?.name || "Unknown"} count` @@ -247,13 +226,13 @@ export default function AreaInfo() { style={{ tableLayout: "fixed", width: "100%" }} > {multipleAreas && ( - + - + {statLabel} - + {viewConfig.areaDataSecondaryColumn || "Secondary"} @@ -281,48 +260,19 @@ export default function AreaInfo() { ) : "-"; - const handleToggleSelection = () => { - if (area.isSelected) { - // Remove from selected areas - setSelectedAreas( - selectedAreas.filter( - (a) => - !( - a.code === area.code && - a.areaSetCode === area.areaSetCode - ), - ), - ); - } else { - // Add to selected areas - setSelectedAreas([ - ...selectedAreas, - { - code: area.code, - name: area.name, - areaSetCode: area.areaSetCode, - coordinates: area.coordinates, - }, - ]); - } - }; - return ( { if (!area.isSelected) { setHoveredRowArea(area); @@ -331,11 +281,29 @@ export default function AreaInfo() { onMouseLeave={() => { setHoveredRowArea(null); }} - onClick={handleToggleSelection} - onKeyDown={(e) => { - if (e.key === "Enter" || e.code === "Space") { - e.preventDefault(); - handleToggleSelection(); + onClick={() => { + if (area.isSelected) { + // Remove from selected areas + setSelectedAreas( + selectedAreas.filter( + (a) => + !( + a.code === area.code && + a.areaSetCode === area.areaSetCode + ), + ), + ); + } else { + // Add to selected areas + setSelectedAreas([ + ...selectedAreas, + { + code: area.code, + name: area.name, + areaSetCode: area.areaSetCode, + coordinates: area.coordinates, + }, + ]); } }} > @@ -348,7 +316,7 @@ export default function AreaInfo() { {area.name}
- + {!multipleAreas ? (
@@ -360,7 +328,7 @@ export default function AreaInfo() { primaryValue )} - + {!multipleAreas ? (
diff --git a/src/app/map/[id]/components/Choropleth/index.tsx b/src/app/map/[id]/components/Choropleth/index.tsx index 1d773717..3cff7a82 100644 --- a/src/app/map/[id]/components/Choropleth/index.tsx +++ b/src/app/map/[id]/components/Choropleth/index.tsx @@ -3,6 +3,7 @@ import { getMapStyle } from "@/app/map/[id]/context/MapContext"; import { useChoropleth } from "@/app/map/[id]/hooks/useChoropleth"; import { useMapViews } from "@/app/map/[id]/hooks/useMapViews"; import { MapType } from "@/server/models/MapView"; +import { mapColors } from "../../styles"; import { useChoroplethAreaStats } from "./useChoroplethAreaStats"; export default function Choropleth() { @@ -115,7 +116,7 @@ export default function Choropleth() { ["==", ["feature-state", "selected"], true], ["!=", ["feature-state", "active"], true], ], - "#30a46c", + mapColors.geography.color, "rgba(0, 0, 0, 0)", ], "line-width": [ @@ -250,7 +251,7 @@ export default function Choropleth() { ["==", ["feature-state", "active"], true], ["==", ["feature-state", "selected"], true], ], - "#30a46c", + mapColors.geography.color, "rgba(0, 0, 0, 0)", ], "line-width": [ diff --git a/src/app/map/[id]/components/MapWrapper.tsx b/src/app/map/[id]/components/MapWrapper.tsx index cbe02a70..3ecc9c46 100644 --- a/src/app/map/[id]/components/MapWrapper.tsx +++ b/src/app/map/[id]/components/MapWrapper.tsx @@ -41,10 +41,8 @@ export default function MapWrapper({ setIndicatorColor(mapColors.markers.color); setMessage("Click on the map to drop a pin."); } else if (compareAreasMode) { - setIndicatorColor("#10b981"); // green-500 - setMessage( - "Compare mode active. Click areas to select/deselect. Release 'C' to exit.", - ); + setIndicatorColor(mapColors.geography.color); // green-500 + setMessage("Compare mode active. Click geographies to select/deselect."); } else { setIndicatorColor(""); setMessage(""); @@ -105,21 +103,24 @@ export default function MapWrapper({
)} - {indicatorColor && ( -
- )} {message && (
-

- {message} -

+
+ {indicatorColor && ( +
+ )} +

{message}

+
)} diff --git a/src/app/map/[id]/hooks/useMapClick.ts b/src/app/map/[id]/hooks/useMapClick.ts index c81e1f00..39b0eca1 100644 --- a/src/app/map/[id]/hooks/useMapClick.ts +++ b/src/app/map/[id]/hooks/useMapClick.ts @@ -3,7 +3,10 @@ import { booleanPointInPolygon } from "@turf/turf"; import { useAtom, useAtomValue } from "jotai"; import { useEffect, useRef } from "react"; import { compareAreasAtom } from "@/app/map/[id]/atoms/mapStateAtoms"; -import { selectedAreasAtom } from "@/app/map/[id]/atoms/selectedAreasAtom"; +import { + type SelectedArea, + selectedAreasAtom, +} from "@/app/map/[id]/atoms/selectedAreasAtom"; import { useChoropleth } from "@/app/map/[id]/hooks/useChoropleth"; import { useInspector } from "@/app/map/[id]/hooks/useInspector"; import { usePinDropMode } from "./useMapControls"; @@ -50,7 +53,7 @@ export function useMapClickEffect({ const activeFeatureId = useRef(undefined); const selectedAreasRef = useRef(selectedAreas); - const prevSelectedAreasRef = useRef([]); + const prevSelectedAreasRef = useRef([]); // Keep ref in sync with latest selectedAreas useEffect(() => { diff --git a/src/app/map/[id]/styles.ts b/src/app/map/[id]/styles.ts index fa324e75..b4fa3371 100644 --- a/src/app/map/[id]/styles.ts +++ b/src/app/map/[id]/styles.ts @@ -64,7 +64,7 @@ export interface mapColor { } export const mapColors: Record< - "member" | "dataSource" | "markers" | "areas", + "member" | "dataSource" | "markers" | "areas" | "geography", mapColor > = { member: { @@ -75,6 +75,10 @@ export const mapColors: Record< name: "Data Source", color: "#FF6B6B", }, + geography: { + name: "Geography", + color: "#30a46c", + }, markers: { name: "Markers", color: "#FF6B6B", From 4f1dbd338d044567ed106ab98b95f83009fba42e Mon Sep 17 00:00:00 2001 From: ev <4164774+ev-sc@users.noreply.github.com> Date: Mon, 15 Dec 2025 12:08:59 +0100 Subject: [PATCH 19/45] Refactor map state management: introduce draw and edit area modes; rename compareAreasAtom to compareGeographiesAtom; update related components and hooks for consistency --- src/app/map/[id]/atoms/mapStateAtoms.ts | 5 +- src/app/map/[id]/atoms/turfAtoms.ts | 2 - src/app/map/[id]/components/FilterMarkers.tsx | 2 +- src/app/map/[id]/components/Map.tsx | 8 +- .../components/MapMarkerAndAreaControls.tsx | 67 ++++++++-- src/app/map/[id]/components/MapWrapper.tsx | 15 ++- .../controls/TurfsControl/TurfItem.tsx | 3 +- .../controls/TurfsControl/TurfsControl.tsx | 3 +- .../[id]/components/table/MapTableFilter.tsx | 2 +- src/app/map/[id]/hooks/useLayers.ts | 3 +- src/app/map/[id]/hooks/useMapClick.ts | 8 +- src/app/map/[id]/hooks/useMapControls.ts | 24 +++- src/app/map/[id]/hooks/useMapHover.ts | 18 ++- src/app/map/[id]/hooks/usePlacedMarkers.ts | 30 ++++- .../{useTurfs.ts => useTurfMutations.ts} | 64 +-------- src/app/map/[id]/hooks/useTurfState.ts | 122 ++++++++++++++++++ src/app/map/[id]/hooks/useTurfsQuery.ts | 10 ++ 17 files changed, 282 insertions(+), 104 deletions(-) rename src/app/map/[id]/hooks/{useTurfs.ts => useTurfMutations.ts} (70%) create mode 100644 src/app/map/[id]/hooks/useTurfState.ts create mode 100644 src/app/map/[id]/hooks/useTurfsQuery.ts diff --git a/src/app/map/[id]/atoms/mapStateAtoms.ts b/src/app/map/[id]/atoms/mapStateAtoms.ts index 714cecbb..ab3c6336 100644 --- a/src/app/map/[id]/atoms/mapStateAtoms.ts +++ b/src/app/map/[id]/atoms/mapStateAtoms.ts @@ -1,12 +1,15 @@ import { atom } from "jotai"; import { DEFAULT_ZOOM } from "@/constants"; +import type MapboxDraw from "@mapbox/mapbox-gl-draw"; import type { MapRef } from "react-map-gl/mapbox"; export const mapIdAtom = atom(null); export const mapRefAtom = atom<{ current: MapRef | null }>({ current: null }); +export const drawAtom = atom(null); export const viewIdAtom = atom(null); export const dirtyViewIdsAtom = atom([]); export const zoomAtom = atom(DEFAULT_ZOOM); export const pinDropModeAtom = atom(false); +export const editAreaModeAtom = atom(false); export const showControlsAtom = atom(true); -export const compareAreasAtom = atom(false); +export const compareGeographiesAtom = atom(false); diff --git a/src/app/map/[id]/atoms/turfAtoms.ts b/src/app/map/[id]/atoms/turfAtoms.ts index dce02798..4e2156dc 100644 --- a/src/app/map/[id]/atoms/turfAtoms.ts +++ b/src/app/map/[id]/atoms/turfAtoms.ts @@ -1,5 +1,3 @@ import { atom } from "jotai"; -import type { Turf } from "@/server/models/Turf"; -export const editingTurfAtom = atom(null); export const turfVisibilityAtom = atom>({}); diff --git a/src/app/map/[id]/components/FilterMarkers.tsx b/src/app/map/[id]/components/FilterMarkers.tsx index e52fe2c5..7afe98fc 100644 --- a/src/app/map/[id]/components/FilterMarkers.tsx +++ b/src/app/map/[id]/components/FilterMarkers.tsx @@ -6,7 +6,7 @@ import { useMapViews } from "@/app/map/[id]/hooks/useMapViews"; import { useMarkerQueries } from "@/app/map/[id]/hooks/useMarkerQueries"; import { usePlacedMarkersQuery } from "@/app/map/[id]/hooks/usePlacedMarkers"; import { useTable } from "@/app/map/[id]/hooks/useTable"; -import { useTurfsQuery } from "@/app/map/[id]/hooks/useTurfs"; +import { useTurfsQuery } from "@/app/map/[id]/hooks/useTurfsQuery"; import { useMapRef } from "../hooks/useMapCore"; import { mapColors } from "../styles"; import type { RecordFilterInput } from "@/server/models/MapView"; diff --git a/src/app/map/[id]/components/Map.tsx b/src/app/map/[id]/components/Map.tsx index 4b81820c..fc75a3d4 100644 --- a/src/app/map/[id]/components/Map.tsx +++ b/src/app/map/[id]/components/Map.tsx @@ -1,6 +1,7 @@ import MapboxDraw from "@mapbox/mapbox-gl-draw"; import "@mapbox/mapbox-gl-draw/dist/mapbox-gl-draw.css"; import * as turf from "@turf/turf"; +import { useSetAtom } from "jotai"; import { useCallback, useEffect, useMemo, useState } from "react"; import MapGL from "react-map-gl/mapbox"; import { v4 as uuidv4 } from "uuid"; @@ -16,6 +17,7 @@ import { usePlacedMarkersQuery } from "@/app/map/[id]/hooks/usePlacedMarkers"; import { DEFAULT_ZOOM } from "@/constants"; import { useIsMobile } from "@/hooks/useIsMobile"; import { MapType } from "@/server/models/MapView"; +import { drawAtom } from "../atoms/mapStateAtoms"; import { useSetZoom } from "../hooks/useMapCamera"; import { getClickedPolygonFeature, @@ -24,7 +26,8 @@ import { import { usePinDropMode, useShowControls } from "../hooks/useMapControls"; import { useMapRef } from "../hooks/useMapCore"; import { useMapHoverEffect } from "../hooks/useMapHover"; -import { useTurfMutations, useTurfState } from "../hooks/useTurfs"; +import { useTurfMutations } from "../hooks/useTurfMutations"; +import { useTurfState } from "../hooks/useTurfState"; import { CONTROL_PANEL_WIDTH, mapColors } from "../styles"; import Choropleth from "./Choropleth"; import { MAPBOX_SOURCE_IDS } from "./Choropleth/configs"; @@ -59,6 +62,7 @@ export default function Map({ const [styleLoaded, setStyleLoaded] = useState(false); const [draw, setDraw] = useState(null); + const setDrawAtom = useSetAtom(drawAtom); const [currentMode, setCurrentMode] = useState(""); const [didInitialFit, setDidInitialFit] = useState(false); @@ -362,6 +366,7 @@ export default function Map({ ], }); setDraw(newDraw); + setDrawAtom(newDraw); const mapInstance = map.getMap(); mapInstance.addControl(newDraw, "bottom-right"); @@ -468,6 +473,7 @@ export default function Map({ mapRef.current.getMap().removeControl(draw); } setDraw(null); + setDrawAtom(null); setReady(false); setStyleLoaded(false); }} diff --git a/src/app/map/[id]/components/MapMarkerAndAreaControls.tsx b/src/app/map/[id]/components/MapMarkerAndAreaControls.tsx index 9fcbd01c..452fcc82 100644 --- a/src/app/map/[id]/components/MapMarkerAndAreaControls.tsx +++ b/src/app/map/[id]/components/MapMarkerAndAreaControls.tsx @@ -2,22 +2,56 @@ import { useAtom } from "jotai"; import { ChartBar, MapPin } from "lucide-react"; import VectorSquare from "@/components/icons/VectorSquare"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/shadcn/ui/tooltip"; -import { compareAreasAtom } from "../atoms/mapStateAtoms"; +import { compareGeographiesAtom } from "../atoms/mapStateAtoms"; +import { useMapControls } from "../hooks/useMapControls"; import { useHandleDropPin } from "../hooks/usePlacedMarkers"; -import { useTurfState } from "../hooks/useTurfs"; +import { useTurfState } from "../hooks/useTurfState"; export default function MapMarkerAndAreaControls() { const { handleDropPin } = useHandleDropPin(); - const { handleAddArea } = useTurfState(); - const [compareAreasMode, setCompareAreasMode] = useAtom(compareAreasAtom); + const { handleAddArea, cancelDrawMode } = useTurfState(); + const { pinDropMode, setPinDropMode, editAreaMode } = useMapControls(); + const [compareGeographiesMode, setCompareGeographiesMode] = useAtom( + compareGeographiesAtom, + ); + + const handlePinDropClick = () => { + if (pinDropMode) { + // If already in pin drop mode, cancel it + setPinDropMode(false); + } else { + // Disable other modes first + cancelDrawMode(); + setCompareGeographiesMode(false); + // Then activate pin drop + handleDropPin(); + } + }; + + const handleAddAreaClick = () => { + if (editAreaMode) { + // If already in edit area mode, cancel it + cancelDrawMode(); + } else { + // Disable other modes first + setPinDropMode(false); + setCompareGeographiesMode(false); + // Then activate area drawing + handleAddArea(); + } + }; return (
@@ -27,8 +61,12 @@ export default function MapMarkerAndAreaControls() { @@ -40,16 +78,23 @@ export default function MapMarkerAndAreaControls() { - Compare areas + Compare geographies
); diff --git a/src/app/map/[id]/components/MapWrapper.tsx b/src/app/map/[id]/components/MapWrapper.tsx index 3ecc9c46..44844c22 100644 --- a/src/app/map/[id]/components/MapWrapper.tsx +++ b/src/app/map/[id]/components/MapWrapper.tsx @@ -1,9 +1,9 @@ import { useAtomValue } from "jotai"; import { useEffect, useState } from "react"; import { MapType } from "@/server/models/MapView"; -import { compareAreasAtom } from "../atoms/mapStateAtoms"; +import { compareGeographiesAtom } from "../atoms/mapStateAtoms"; import { useInspector } from "../hooks/useInspector"; -import { useShowControls } from "../hooks/useMapControls"; +import { useMapControls, useShowControls } from "../hooks/useMapControls"; import { useMapViews } from "../hooks/useMapViews"; import { CONTROL_PANEL_WIDTH, mapColors } from "../styles"; import AreaInfo from "./AreaInfo"; @@ -26,28 +26,29 @@ export default function MapWrapper({ const { viewConfig } = useMapViews(); const { inspectorContent } = useInspector(); const inspectorVisible = Boolean(inspectorContent); - const compareAreasMode = useAtomValue(compareAreasAtom); + const compareGeographiesMode = useAtomValue(compareGeographiesAtom); + const { pinDropMode, editAreaMode } = useMapControls(); const [message, setMessage] = useState(""); const [indicatorColor, setIndicatorColor] = useState(""); useEffect(() => { - if (currentMode === "draw_polygon") { + if (editAreaMode || currentMode === "draw_polygon") { setIndicatorColor(mapColors.areas.color); setMessage( "You are in draw mode. Click to add points. Double click to finish drawing.", ); - } else if (currentMode === "pin_drop") { + } else if (pinDropMode || currentMode === "pin_drop") { setIndicatorColor(mapColors.markers.color); setMessage("Click on the map to drop a pin."); - } else if (compareAreasMode) { + } else if (compareGeographiesMode) { setIndicatorColor(mapColors.geography.color); // green-500 setMessage("Compare mode active. Click geographies to select/deselect."); } else { setIndicatorColor(""); setMessage(""); } - }, [currentMode, compareAreasMode]); + }, [currentMode, compareGeographiesMode, pinDropMode, editAreaMode]); const absolutelyCenter = { transform: showControls diff --git a/src/app/map/[id]/components/controls/TurfsControl/TurfItem.tsx b/src/app/map/[id]/components/controls/TurfsControl/TurfItem.tsx index 4d6d0ca8..830b5560 100644 --- a/src/app/map/[id]/components/controls/TurfsControl/TurfItem.tsx +++ b/src/app/map/[id]/components/controls/TurfsControl/TurfItem.tsx @@ -4,7 +4,8 @@ import { ContextMenu, ContextMenuTrigger } from "@/shadcn/ui/context-menu"; import { LayerType } from "@/types"; import { useShowControls } from "../../../hooks/useMapControls"; import { useMapRef } from "../../../hooks/useMapCore"; -import { useTurfMutations, useTurfState } from "../../../hooks/useTurfs"; +import { useTurfMutations } from "../../../hooks/useTurfMutations"; +import { useTurfState } from "../../../hooks/useTurfState"; import { CONTROL_PANEL_WIDTH } from "../../../styles"; import ControlContextMenuContent from "../ControlContextMenuContent"; import ControlEditForm from "../ControlEditForm"; diff --git a/src/app/map/[id]/components/controls/TurfsControl/TurfsControl.tsx b/src/app/map/[id]/components/controls/TurfsControl/TurfsControl.tsx index 25a64a88..4d042c5e 100644 --- a/src/app/map/[id]/components/controls/TurfsControl/TurfsControl.tsx +++ b/src/app/map/[id]/components/controls/TurfsControl/TurfsControl.tsx @@ -2,7 +2,8 @@ import { ArrowRight, PlusIcon } from "lucide-react"; import { useState } from "react"; import IconButtonWithTooltip from "@/components/IconButtonWithTooltip"; import { LayerType } from "@/types"; -import { useTurfState, useTurfsQuery } from "../../../hooks/useTurfs"; +import { useTurfsQuery } from "../../../hooks/useTurfsQuery"; +import { useTurfState } from "../../../hooks/useTurfState"; import LayerControlWrapper from "../LayerControlWrapper"; import EmptyLayer from "../LayerEmptyMessage"; import LayerHeader from "../LayerHeader"; diff --git a/src/app/map/[id]/components/table/MapTableFilter.tsx b/src/app/map/[id]/components/table/MapTableFilter.tsx index 012a2a19..152d2a27 100644 --- a/src/app/map/[id]/components/table/MapTableFilter.tsx +++ b/src/app/map/[id]/components/table/MapTableFilter.tsx @@ -5,7 +5,7 @@ import { useDataSources } from "@/app/map/[id]/hooks/useDataSources"; import { useMapConfig } from "@/app/map/[id]/hooks/useMapConfig"; import { usePlacedMarkersQuery } from "@/app/map/[id]/hooks/usePlacedMarkers"; import { useTable } from "@/app/map/[id]/hooks/useTable"; -import { useTurfsQuery } from "@/app/map/[id]/hooks/useTurfs"; +import { useTurfsQuery } from "@/app/map/[id]/hooks/useTurfsQuery"; import MultiDropdownMenu from "@/components/MultiDropdownMenu"; import { FilterOperator, FilterType } from "@/server/models/MapView"; import { useTRPC } from "@/services/trpc/react"; diff --git a/src/app/map/[id]/hooks/useLayers.ts b/src/app/map/[id]/hooks/useLayers.ts index 72e75384..23d1d060 100644 --- a/src/app/map/[id]/hooks/useLayers.ts +++ b/src/app/map/[id]/hooks/useLayers.ts @@ -6,7 +6,8 @@ import { useMapConfig } from "@/app/map/[id]/hooks/useMapConfig"; import { LayerType } from "@/types"; import { hiddenLayersAtom } from "../atoms/layerAtoms"; import { dataSourceVisibilityAtom } from "../atoms/markerAtoms"; -import { useTurfState, useTurfsQuery } from "./useTurfs"; +import { useTurfsQuery } from "./useTurfsQuery"; +import { useTurfState } from "./useTurfState"; export function useLayers() { const { mapConfig } = useMapConfig(); diff --git a/src/app/map/[id]/hooks/useMapClick.ts b/src/app/map/[id]/hooks/useMapClick.ts index 39b0eca1..feb9a5c5 100644 --- a/src/app/map/[id]/hooks/useMapClick.ts +++ b/src/app/map/[id]/hooks/useMapClick.ts @@ -2,7 +2,7 @@ import { point as turfPoint } from "@turf/helpers"; import { booleanPointInPolygon } from "@turf/turf"; import { useAtom, useAtomValue } from "jotai"; import { useEffect, useRef } from "react"; -import { compareAreasAtom } from "@/app/map/[id]/atoms/mapStateAtoms"; +import { compareGeographiesAtom } from "@/app/map/[id]/atoms/mapStateAtoms"; import { type SelectedArea, selectedAreasAtom, @@ -44,7 +44,7 @@ export function useMapClickEffect({ setSelectedTurf, } = useInspector(); const [selectedAreas, setSelectedAreas] = useAtom(selectedAreasAtom); - const compareAreasMode = useAtomValue(compareAreasAtom); + const compareGeographiesMode = useAtomValue(compareGeographiesAtom); const { mapbox: { sourceId, layerId, featureCodeProperty, featureNameProperty }, @@ -324,7 +324,7 @@ export function useMapClickEffect({ } // Check if compare areas mode is active - if (compareAreasMode) { + if (compareGeographiesMode) { if (handleCtrlAreaClick(e)) { return; } @@ -383,7 +383,7 @@ export function useMapClickEffect({ ready, setSelectedRecords, setSelectedAreas, - compareAreasMode, + compareGeographiesMode, ]); // Clear active feature state when selectedBoundary is cleared (resetInspector called from outside) diff --git a/src/app/map/[id]/hooks/useMapControls.ts b/src/app/map/[id]/hooks/useMapControls.ts index 1e633503..061e7266 100644 --- a/src/app/map/[id]/hooks/useMapControls.ts +++ b/src/app/map/[id]/hooks/useMapControls.ts @@ -1,19 +1,27 @@ import { useAtom, useAtomValue, useSetAtom } from "jotai"; -import { pinDropModeAtom, showControlsAtom } from "../atoms/mapStateAtoms"; +import { + editAreaModeAtom, + pinDropModeAtom, + showControlsAtom, +} from "../atoms/mapStateAtoms"; /** * Hook for managing map UI control states - * Includes showControls (sidebar visibility) and pinDropMode (pin dropping interaction) + * Includes showControls (sidebar visibility), pinDropMode (pin dropping interaction), + * and editAreaMode (area editing interaction) */ export function useMapControls() { const [showControls, setShowControls] = useAtom(showControlsAtom); const [pinDropMode, setPinDropMode] = useAtom(pinDropModeAtom); + const [editAreaMode, setEditAreaMode] = useAtom(editAreaModeAtom); return { showControls, setShowControls, pinDropMode, setPinDropMode, + editAreaMode, + setEditAreaMode, }; } @@ -41,3 +49,15 @@ export function usePinDropModeAtom() { export function useSetPinDropMode() { return useSetAtom(pinDropModeAtom); } + +export function useEditAreaMode() { + return useAtomValue(editAreaModeAtom); +} + +export function useEditAreaModeAtom() { + return useAtom(editAreaModeAtom); +} + +export function useSetEditAreaMode() { + return useSetAtom(editAreaModeAtom); +} diff --git a/src/app/map/[id]/hooks/useMapHover.ts b/src/app/map/[id]/hooks/useMapHover.ts index 3df8a79e..60db8cc6 100644 --- a/src/app/map/[id]/hooks/useMapHover.ts +++ b/src/app/map/[id]/hooks/useMapHover.ts @@ -2,7 +2,7 @@ import { useAtom } from "jotai"; import { useEffect } from "react"; import { useChoropleth } from "@/app/map/[id]/hooks/useChoropleth"; import { hoverAreaAtom, hoverMarkerAtom } from "../atoms/hoverAtoms"; -import { compareAreasAtom } from "../atoms/mapStateAtoms"; +import { compareGeographiesAtom } from "../atoms/mapStateAtoms"; import { getClickedPolygonFeature } from "./useMapClick"; import { useMapRef } from "./useMapCore"; import type MapboxDraw from "@mapbox/mapbox-gl-draw"; @@ -25,7 +25,9 @@ export function useMapHoverEffect({ const [, setHoverArea] = useHoverArea(); const [, setHoverMarker] = useHoverMarker(); - const [compareAreasMode, setCompareAreasMode] = useAtom(compareAreasAtom); + const [compareGeographiesMode, setCompareGeographiesMode] = useAtom( + compareGeographiesAtom, + ); /* Set cursor to pointer and darken fill on hover over choropleth areas */ useEffect(() => { @@ -52,7 +54,7 @@ export function useMapHoverEffect({ const onKeyDown = (e: KeyboardEvent) => { if ((e.key === "c" || e.key === "C") && !e.repeat) { - setCompareAreasMode(true); + setCompareGeographiesMode(true); const canvas = map.getCanvas(); if (canvas.style.cursor === "pointer") { canvas.style.cursor = "copy"; @@ -62,7 +64,7 @@ export function useMapHoverEffect({ const onKeyUp = (e: KeyboardEvent) => { if (e.key === "c" || e.key === "C") { - setCompareAreasMode(false); + setCompareGeographiesMode(false); const canvas = map.getCanvas(); if (canvas.style.cursor === "copy") { canvas.style.cursor = "pointer"; @@ -180,7 +182,9 @@ export function useMapHoverEffect({ ) { prevPointer.cursor = map.getCanvas().style.cursor || ""; } - map.getCanvas().style.cursor = compareAreasMode ? "copy" : "pointer"; + map.getCanvas().style.cursor = compareGeographiesMode + ? "copy" + : "pointer"; return true; } @@ -237,8 +241,8 @@ export function useMapHoverEffect({ setHoverArea, featureNameProperty, areaSetCode, - compareAreasMode, - setCompareAreasMode, + compareGeographiesMode, + setCompareGeographiesMode, ]); } diff --git a/src/app/map/[id]/hooks/usePlacedMarkers.ts b/src/app/map/[id]/hooks/usePlacedMarkers.ts index 577177be..d50130c7 100644 --- a/src/app/map/[id]/hooks/usePlacedMarkers.ts +++ b/src/app/map/[id]/hooks/usePlacedMarkers.ts @@ -6,7 +6,7 @@ import { useQueryClient, } from "@tanstack/react-query"; import { useAtom } from "jotai"; -import { useCallback } from "react"; +import { useCallback, useEffect, useRef } from "react"; import { toast } from "sonner"; import { v4 as uuidv4 } from "uuid"; import { useTRPC } from "@/services/trpc/react"; @@ -16,7 +16,7 @@ import { selectedPlacedMarkerIdAtom, } from "../atoms/markerAtoms"; import { getNewLastPosition } from "../utils"; -import { useSetPinDropMode } from "./useMapControls"; +import { usePinDropMode, useSetPinDropMode } from "./useMapControls"; import { useMapId, useMapRef } from "./useMapCore"; import { useMapQuery } from "./useMapQuery"; import type { PlacedMarker } from "@/server/models/PlacedMarker"; @@ -229,11 +229,35 @@ export const useHandleDropPin = () => { const mapRef = useMapRef(); const mapId = useMapId(); const setPinDropMode = useSetPinDropMode(); + const pinDropMode = usePinDropMode(); const { insertPlacedMarker } = usePlacedMarkerMutations(); + const clickHandlerRef = useRef<((e: mapboxgl.MapMouseEvent) => void) | null>( + null, + ); + + // Cleanup effect when pinDropMode is disabled + useEffect(() => { + const map = mapRef?.current; + if (!map) return; + + if (!pinDropMode && clickHandlerRef.current) { + // Remove the click handler if it exists + map.off("click", clickHandlerRef.current); + clickHandlerRef.current = null; + // Reset cursor + map.getCanvas().style.cursor = ""; + } + }, [pinDropMode, mapRef]); const handleDropPin = useCallback(() => { const map = mapRef?.current; if (!map || !mapId) return; + + // Clear any existing handler first + if (clickHandlerRef.current) { + map.off("click", clickHandlerRef.current); + } + setPinDropMode(true); map.getCanvas().style.cursor = "crosshair"; @@ -249,6 +273,7 @@ export const useHandleDropPin = () => { // Reset cursor map.getCanvas().style.cursor = ""; map.off("click", clickHandler); + clickHandlerRef.current = null; setPinDropMode(false); @@ -259,6 +284,7 @@ export const useHandleDropPin = () => { }); }; + clickHandlerRef.current = clickHandler; map.once("click", clickHandler); }, [mapRef, mapId, setPinDropMode, insertPlacedMarker]); diff --git a/src/app/map/[id]/hooks/useTurfs.ts b/src/app/map/[id]/hooks/useTurfMutations.ts similarity index 70% rename from src/app/map/[id]/hooks/useTurfs.ts rename to src/app/map/[id]/hooks/useTurfMutations.ts index c5ce9476..18f6faed 100644 --- a/src/app/map/[id]/hooks/useTurfs.ts +++ b/src/app/map/[id]/hooks/useTurfMutations.ts @@ -1,21 +1,12 @@ "use client"; import { useMutation, useQueryClient } from "@tanstack/react-query"; -import { useAtom } from "jotai"; -import { useCallback, useMemo } from "react"; +import { useCallback } from "react"; import { toast } from "sonner"; import { useTRPC } from "@/services/trpc/react"; -import { editingTurfAtom, turfVisibilityAtom } from "../atoms/turfAtoms"; -import { useMapId, useMapRef } from "./useMapCore"; -import { useMapQuery } from "./useMapQuery"; +import { useMapId } from "./useMapCore"; import type { Turf } from "@/server/models/Turf"; -export function useTurfsQuery() { - const mapId = useMapId(); - const { data: mapData, isFetching } = useMapQuery(mapId); - return { data: mapData?.turfs, isFetching }; -} - export function useTurfMutations() { const mapId = useMapId(); const trpc = useTRPC(); @@ -154,54 +145,3 @@ export function useTurfMutations() { loading: upsertLoading, }; } - -export function useTurfState() { - const mapRef = useMapRef(); - const { data: turfs = [] } = useTurfsQuery(); - - const [editingTurf, setEditingTurf] = useAtom(editingTurfAtom); - const [turfVisibility, _setTurfVisibility] = useAtom(turfVisibilityAtom); - - const setTurfVisibility = useCallback( - (turfId: string, isVisible: boolean) => { - _setTurfVisibility((prev) => ({ ...prev, [turfId]: isVisible })); - }, - [_setTurfVisibility], - ); - - const getTurfVisibility = useCallback( - (turfId: string): boolean => { - return turfVisibility[turfId] ?? true; - }, - [turfVisibility], - ); - - const visibleTurfs = useMemo(() => { - return turfs.filter((turf) => { - return getTurfVisibility(turf.id); - }); - }, [turfs, getTurfVisibility]); - - const handleAddArea = useCallback(() => { - const map = mapRef?.current; - if (map) { - // Find the polygon draw button and click it - const drawButton = document.querySelector( - ".mapbox-gl-draw_polygon", - ) as HTMLButtonElement; - if (drawButton) { - drawButton.click(); - } - } - }, [mapRef]); - - return { - editingTurf, - setEditingTurf, - visibleTurfs, - handleAddArea, - turfVisibility, - setTurfVisibility, - getTurfVisibility, - }; -} diff --git a/src/app/map/[id]/hooks/useTurfState.ts b/src/app/map/[id]/hooks/useTurfState.ts new file mode 100644 index 00000000..8c3efdbc --- /dev/null +++ b/src/app/map/[id]/hooks/useTurfState.ts @@ -0,0 +1,122 @@ +"use client"; + +import { useAtom, useAtomValue } from "jotai"; +import { useCallback, useEffect, useMemo } from "react"; +import { drawAtom } from "../atoms/mapStateAtoms"; +import { turfVisibilityAtom } from "../atoms/turfAtoms"; +import { useMapControls } from "./useMapControls"; +import { useMapRef } from "./useMapCore"; +import { useTurfsQuery } from "./useTurfsQuery"; +import type { DrawModeChangeEvent } from "@/types"; + +export function useTurfState() { + const mapRef = useMapRef(); + const { data: turfs = [] } = useTurfsQuery(); + const { editAreaMode, setEditAreaMode } = useMapControls(); + const draw = useAtomValue(drawAtom); + + const [turfVisibility, _setTurfVisibility] = useAtom(turfVisibilityAtom); + + // Listen to draw mode changes and update editAreaMode accordingly + useEffect(() => { + const map = mapRef?.current; + if (!map) return; + + const handleModeChange = (e: DrawModeChangeEvent) => { + // Set editAreaMode to true if in draw_polygon mode, false otherwise + setEditAreaMode(e.mode === "draw_polygon"); + }; + + map.on("draw.modechange", handleModeChange); + + return () => { + map.off("draw.modechange", handleModeChange); + }; + }, [mapRef, setEditAreaMode]); + + // When edit mode is turned off elsewhere, forcibly return draw to a neutral state + useEffect(() => { + if (editAreaMode) return; + + const map = mapRef?.current; + if (map) { + map.getCanvas().style.cursor = ""; + } + + if (draw) { + (draw.changeMode as (mode: string, opts?: object) => void)( + "simple_select", + { featureIds: [] }, + ); + } + + const drawButton = document.querySelector( + ".mapbox-gl-draw_polygon", + ) as HTMLButtonElement | null; + if (drawButton) { + drawButton.classList.remove("active"); + drawButton.setAttribute("aria-pressed", "false"); + } + }, [draw, editAreaMode, mapRef]); + + const setTurfVisibility = useCallback( + (turfId: string, isVisible: boolean) => { + _setTurfVisibility((prev) => ({ ...prev, [turfId]: isVisible })); + }, + [_setTurfVisibility], + ); + + const getTurfVisibility = useCallback( + (turfId: string): boolean => { + return turfVisibility[turfId] ?? true; + }, + [turfVisibility], + ); + + const visibleTurfs = useMemo(() => { + return turfs.filter((turf) => { + return getTurfVisibility(turf.id); + }); + }, [turfs, getTurfVisibility]); + + const handleAddArea = useCallback(() => { + const map = mapRef?.current; + if (map) { + // Find the polygon draw button and click it + const drawButton = document.querySelector( + ".mapbox-gl-draw_polygon", + ) as HTMLButtonElement; + if (drawButton) { + drawButton.click(); + } + } + }, [mapRef]); + + const cancelDrawMode = useCallback(() => { + const map = mapRef?.current; + if (map) { + // Reset the map cursor first + map.getCanvas().style.cursor = ""; + } + + if (draw) { + // Use MapboxDraw's changeMode to exit draw_polygon mode + // This should fire draw.modechange, but also set the state directly as a fallback + (draw.changeMode as (mode: string) => void)("simple_select"); + } + + // Explicitly reset the edit mode flag in case the modechange event does not fire + setEditAreaMode(false); + }, [draw, mapRef, setEditAreaMode]); + + return { + editAreaMode, + setEditAreaMode, + visibleTurfs, + handleAddArea, + cancelDrawMode, + turfVisibility, + setTurfVisibility, + getTurfVisibility, + }; +} diff --git a/src/app/map/[id]/hooks/useTurfsQuery.ts b/src/app/map/[id]/hooks/useTurfsQuery.ts new file mode 100644 index 00000000..557de777 --- /dev/null +++ b/src/app/map/[id]/hooks/useTurfsQuery.ts @@ -0,0 +1,10 @@ +"use client"; + +import { useMapId } from "./useMapCore"; +import { useMapQuery } from "./useMapQuery"; + +export function useTurfsQuery() { + const mapId = useMapId(); + const { data: mapData, isFetching } = useMapQuery(mapId); + return { data: mapData?.turfs, isFetching }; +} From 94ef87ab9d6c785d5166ec995080f2666703a6f1 Mon Sep 17 00:00:00 2001 From: ev <4164774+ev-sc@users.noreply.github.com> Date: Mon, 15 Dec 2025 12:11:37 +0100 Subject: [PATCH 20/45] Refactor map state management: consolidate compareGeographiesAtom usage into useMapControls hook; update related components and hooks for improved clarity and consistency --- .../components/MapMarkerAndAreaControls.tsx | 13 ++++++------ src/app/map/[id]/components/MapWrapper.tsx | 10 ++++++---- src/app/map/[id]/hooks/useMapClick.ts | 10 ++++++---- src/app/map/[id]/hooks/useMapControls.ts | 20 ++++++++++++++++++- src/app/map/[id]/hooks/useMapHover.ts | 7 +++---- 5 files changed, 41 insertions(+), 19 deletions(-) diff --git a/src/app/map/[id]/components/MapMarkerAndAreaControls.tsx b/src/app/map/[id]/components/MapMarkerAndAreaControls.tsx index 452fcc82..e9aa2bd4 100644 --- a/src/app/map/[id]/components/MapMarkerAndAreaControls.tsx +++ b/src/app/map/[id]/components/MapMarkerAndAreaControls.tsx @@ -1,8 +1,6 @@ -import { useAtom } from "jotai"; import { ChartBar, MapPin } from "lucide-react"; import VectorSquare from "@/components/icons/VectorSquare"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/shadcn/ui/tooltip"; -import { compareGeographiesAtom } from "../atoms/mapStateAtoms"; import { useMapControls } from "../hooks/useMapControls"; import { useHandleDropPin } from "../hooks/usePlacedMarkers"; import { useTurfState } from "../hooks/useTurfState"; @@ -10,10 +8,13 @@ import { useTurfState } from "../hooks/useTurfState"; export default function MapMarkerAndAreaControls() { const { handleDropPin } = useHandleDropPin(); const { handleAddArea, cancelDrawMode } = useTurfState(); - const { pinDropMode, setPinDropMode, editAreaMode } = useMapControls(); - const [compareGeographiesMode, setCompareGeographiesMode] = useAtom( - compareGeographiesAtom, - ); + const { + pinDropMode, + setPinDropMode, + editAreaMode, + compareGeographiesMode, + setCompareGeographiesMode, + } = useMapControls(); const handlePinDropClick = () => { if (pinDropMode) { diff --git a/src/app/map/[id]/components/MapWrapper.tsx b/src/app/map/[id]/components/MapWrapper.tsx index 44844c22..a99968a0 100644 --- a/src/app/map/[id]/components/MapWrapper.tsx +++ b/src/app/map/[id]/components/MapWrapper.tsx @@ -1,9 +1,11 @@ -import { useAtomValue } from "jotai"; import { useEffect, useState } from "react"; import { MapType } from "@/server/models/MapView"; -import { compareGeographiesAtom } from "../atoms/mapStateAtoms"; import { useInspector } from "../hooks/useInspector"; -import { useMapControls, useShowControls } from "../hooks/useMapControls"; +import { + useCompareGeographiesMode, + useMapControls, + useShowControls, +} from "../hooks/useMapControls"; import { useMapViews } from "../hooks/useMapViews"; import { CONTROL_PANEL_WIDTH, mapColors } from "../styles"; import AreaInfo from "./AreaInfo"; @@ -26,7 +28,7 @@ export default function MapWrapper({ const { viewConfig } = useMapViews(); const { inspectorContent } = useInspector(); const inspectorVisible = Boolean(inspectorContent); - const compareGeographiesMode = useAtomValue(compareGeographiesAtom); + const compareGeographiesMode = useCompareGeographiesMode(); const { pinDropMode, editAreaMode } = useMapControls(); const [message, setMessage] = useState(""); diff --git a/src/app/map/[id]/hooks/useMapClick.ts b/src/app/map/[id]/hooks/useMapClick.ts index feb9a5c5..55a99d15 100644 --- a/src/app/map/[id]/hooks/useMapClick.ts +++ b/src/app/map/[id]/hooks/useMapClick.ts @@ -1,15 +1,17 @@ import { point as turfPoint } from "@turf/helpers"; import { booleanPointInPolygon } from "@turf/turf"; -import { useAtom, useAtomValue } from "jotai"; +import { useAtom } from "jotai"; import { useEffect, useRef } from "react"; -import { compareGeographiesAtom } from "@/app/map/[id]/atoms/mapStateAtoms"; import { type SelectedArea, selectedAreasAtom, } from "@/app/map/[id]/atoms/selectedAreasAtom"; import { useChoropleth } from "@/app/map/[id]/hooks/useChoropleth"; import { useInspector } from "@/app/map/[id]/hooks/useInspector"; -import { usePinDropMode } from "./useMapControls"; +import { + useCompareGeographiesMode, + usePinDropMode, +} from "./useMapControls"; import { useMapRef } from "./useMapCore"; import type MapboxDraw from "@mapbox/mapbox-gl-draw"; import type { @@ -44,7 +46,7 @@ export function useMapClickEffect({ setSelectedTurf, } = useInspector(); const [selectedAreas, setSelectedAreas] = useAtom(selectedAreasAtom); - const compareGeographiesMode = useAtomValue(compareGeographiesAtom); + const compareGeographiesMode = useCompareGeographiesMode(); const { mapbox: { sourceId, layerId, featureCodeProperty, featureNameProperty }, diff --git a/src/app/map/[id]/hooks/useMapControls.ts b/src/app/map/[id]/hooks/useMapControls.ts index 061e7266..7d46b40f 100644 --- a/src/app/map/[id]/hooks/useMapControls.ts +++ b/src/app/map/[id]/hooks/useMapControls.ts @@ -1,5 +1,6 @@ import { useAtom, useAtomValue, useSetAtom } from "jotai"; import { + compareGeographiesAtom, editAreaModeAtom, pinDropModeAtom, showControlsAtom, @@ -8,12 +9,15 @@ import { /** * Hook for managing map UI control states * Includes showControls (sidebar visibility), pinDropMode (pin dropping interaction), - * and editAreaMode (area editing interaction) + * editAreaMode (area editing interaction), and compareGeographiesMode (multi-select geographies) */ export function useMapControls() { const [showControls, setShowControls] = useAtom(showControlsAtom); const [pinDropMode, setPinDropMode] = useAtom(pinDropModeAtom); const [editAreaMode, setEditAreaMode] = useAtom(editAreaModeAtom); + const [compareGeographiesMode, setCompareGeographiesMode] = useAtom( + compareGeographiesAtom, + ); return { showControls, @@ -22,6 +26,8 @@ export function useMapControls() { setPinDropMode, editAreaMode, setEditAreaMode, + compareGeographiesMode, + setCompareGeographiesMode, }; } @@ -61,3 +67,15 @@ export function useEditAreaModeAtom() { export function useSetEditAreaMode() { return useSetAtom(editAreaModeAtom); } + +export function useCompareGeographiesMode() { + return useAtomValue(compareGeographiesAtom); +} + +export function useCompareGeographiesModeAtom() { + return useAtom(compareGeographiesAtom); +} + +export function useSetCompareGeographiesMode() { + return useSetAtom(compareGeographiesAtom); +} diff --git a/src/app/map/[id]/hooks/useMapHover.ts b/src/app/map/[id]/hooks/useMapHover.ts index 60db8cc6..78a908f6 100644 --- a/src/app/map/[id]/hooks/useMapHover.ts +++ b/src/app/map/[id]/hooks/useMapHover.ts @@ -2,8 +2,8 @@ import { useAtom } from "jotai"; import { useEffect } from "react"; import { useChoropleth } from "@/app/map/[id]/hooks/useChoropleth"; import { hoverAreaAtom, hoverMarkerAtom } from "../atoms/hoverAtoms"; -import { compareGeographiesAtom } from "../atoms/mapStateAtoms"; import { getClickedPolygonFeature } from "./useMapClick"; +import { useCompareGeographiesModeAtom } from "./useMapControls"; import { useMapRef } from "./useMapCore"; import type MapboxDraw from "@mapbox/mapbox-gl-draw"; @@ -25,9 +25,8 @@ export function useMapHoverEffect({ const [, setHoverArea] = useHoverArea(); const [, setHoverMarker] = useHoverMarker(); - const [compareGeographiesMode, setCompareGeographiesMode] = useAtom( - compareGeographiesAtom, - ); + const [compareGeographiesMode, setCompareGeographiesMode] = + useCompareGeographiesModeAtom(); /* Set cursor to pointer and darken fill on hover over choropleth areas */ useEffect(() => { From 3bade24fce51811e7fd30e5962d61c00d8358de3 Mon Sep 17 00:00:00 2001 From: ev <4164774+ev-sc@users.noreply.github.com> Date: Mon, 15 Dec 2025 12:18:31 +0100 Subject: [PATCH 21/45] Refactor map controls: implement toggle functions for pin drop, add area, and compare geographies modes; streamline state management in MapMarkerAndAreaControls component --- .../components/MapMarkerAndAreaControls.tsx | 34 ++--------- src/app/map/[id]/hooks/useMapControls.ts | 57 +++++++++++++++++++ 2 files changed, 63 insertions(+), 28 deletions(-) diff --git a/src/app/map/[id]/components/MapMarkerAndAreaControls.tsx b/src/app/map/[id]/components/MapMarkerAndAreaControls.tsx index e9aa2bd4..4150d63d 100644 --- a/src/app/map/[id]/components/MapMarkerAndAreaControls.tsx +++ b/src/app/map/[id]/components/MapMarkerAndAreaControls.tsx @@ -10,36 +10,19 @@ export default function MapMarkerAndAreaControls() { const { handleAddArea, cancelDrawMode } = useTurfState(); const { pinDropMode, - setPinDropMode, editAreaMode, compareGeographiesMode, - setCompareGeographiesMode, + togglePinDrop, + toggleAddArea, + toggleCompareGeographies, } = useMapControls(); const handlePinDropClick = () => { - if (pinDropMode) { - // If already in pin drop mode, cancel it - setPinDropMode(false); - } else { - // Disable other modes first - cancelDrawMode(); - setCompareGeographiesMode(false); - // Then activate pin drop - handleDropPin(); - } + togglePinDrop({ cancelDrawMode, handleDropPin }); }; const handleAddAreaClick = () => { - if (editAreaMode) { - // If already in edit area mode, cancel it - cancelDrawMode(); - } else { - // Disable other modes first - setPinDropMode(false); - setCompareGeographiesMode(false); - // Then activate area drawing - handleAddArea(); - } + toggleAddArea({ cancelDrawMode, handleAddArea }); }; return ( @@ -84,12 +67,7 @@ export default function MapMarkerAndAreaControls() { : "text-primary hover:bg-muted" }`} onClick={() => { - if (!compareGeographiesMode) { - // Disable other modes first - setPinDropMode(false); - cancelDrawMode(); - } - setCompareGeographiesMode(!compareGeographiesMode); + toggleCompareGeographies({ cancelDrawMode }); }} > diff --git a/src/app/map/[id]/hooks/useMapControls.ts b/src/app/map/[id]/hooks/useMapControls.ts index 7d46b40f..5993e4c1 100644 --- a/src/app/map/[id]/hooks/useMapControls.ts +++ b/src/app/map/[id]/hooks/useMapControls.ts @@ -1,4 +1,5 @@ import { useAtom, useAtomValue, useSetAtom } from "jotai"; +import { useCallback } from "react"; import { compareGeographiesAtom, editAreaModeAtom, @@ -19,6 +20,59 @@ export function useMapControls() { compareGeographiesAtom, ); + const togglePinDrop = useCallback( + ({ + cancelDrawMode, + handleDropPin, + }: { + cancelDrawMode: () => void; + handleDropPin: () => void; + }) => { + if (pinDropMode) { + setPinDropMode(false); + return; + } + + // Turn off other modes first, then activate pin drop + cancelDrawMode(); + setCompareGeographiesMode(false); + handleDropPin(); + }, + [pinDropMode, setPinDropMode, setCompareGeographiesMode], + ); + + const toggleAddArea = useCallback( + ({ + cancelDrawMode, + handleAddArea, + }: { + cancelDrawMode: () => void; + handleAddArea: () => void; + }) => { + if (editAreaMode) { + cancelDrawMode(); + return; + } + + setPinDropMode(false); + setCompareGeographiesMode(false); + handleAddArea(); + }, + [editAreaMode, setPinDropMode, setCompareGeographiesMode], + ); + + const toggleCompareGeographies = useCallback( + ({ cancelDrawMode }: { cancelDrawMode: () => void }) => { + if (!compareGeographiesMode) { + setPinDropMode(false); + cancelDrawMode(); + } + + setCompareGeographiesMode(!compareGeographiesMode); + }, + [compareGeographiesMode, setCompareGeographiesMode, setPinDropMode], + ); + return { showControls, setShowControls, @@ -28,6 +82,9 @@ export function useMapControls() { setEditAreaMode, compareGeographiesMode, setCompareGeographiesMode, + togglePinDrop, + toggleAddArea, + toggleCompareGeographies, }; } From bc1d3a3dd0f46a6167122f51ccf001ec42342a55 Mon Sep 17 00:00:00 2001 From: ev <4164774+ev-sc@users.noreply.github.com> Date: Mon, 15 Dec 2025 12:28:34 +0100 Subject: [PATCH 22/45] Refactor map controls: integrate edit area mode into useMapControls hook; ensure proper state management when toggling edit mode in Map component --- src/app/map/[id]/components/Map.tsx | 16 +++++++++++++++- src/app/map/[id]/hooks/useMapControls.ts | 6 +++++- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/src/app/map/[id]/components/Map.tsx b/src/app/map/[id]/components/Map.tsx index fc75a3d4..2519df73 100644 --- a/src/app/map/[id]/components/Map.tsx +++ b/src/app/map/[id]/components/Map.tsx @@ -23,7 +23,11 @@ import { getClickedPolygonFeature, useMapClickEffect, } from "../hooks/useMapClick"; -import { usePinDropMode, useShowControls } from "../hooks/useMapControls"; +import { + useEditAreaMode, + usePinDropMode, + useShowControls, +} from "../hooks/useMapControls"; import { useMapRef } from "../hooks/useMapCore"; import { useMapHoverEffect } from "../hooks/useMapHover"; import { useTurfMutations } from "../hooks/useTurfMutations"; @@ -51,6 +55,7 @@ export default function Map({ const mapRef = useMapRef(); const setZoom = useSetZoom(); const pinDropMode = usePinDropMode(); + const editAreaMode = useEditAreaMode(); const showControls = useShowControls(); const { setBoundingBox } = useMapBounds(); const [ready, setReady] = useState(false); @@ -121,6 +126,15 @@ export default function Map({ }; }, [mapRef, ready]); + // Fallback: if UI says edit mode is off but draw thinks it's still on, force exit + useEffect(() => { + if (!draw) return; + if (!editAreaMode && currentMode === "draw_polygon") { + (draw.changeMode as (mode: string) => void)("simple_select"); + setCurrentMode("simple_select"); + } + }, [draw, editAreaMode, currentMode]); + // Show/Hide labels const toggleLabelVisibility = useCallback( (show: boolean) => { diff --git a/src/app/map/[id]/hooks/useMapControls.ts b/src/app/map/[id]/hooks/useMapControls.ts index 5993e4c1..c9c0f083 100644 --- a/src/app/map/[id]/hooks/useMapControls.ts +++ b/src/app/map/[id]/hooks/useMapControls.ts @@ -50,15 +50,19 @@ export function useMapControls() { handleAddArea: () => void; }) => { if (editAreaMode) { + // Turning off edit mode: force cancel and clear flag cancelDrawMode(); + setEditAreaMode(false); return; } + // Turning on: disable other modes, start draw, set flag as fallback setPinDropMode(false); setCompareGeographiesMode(false); handleAddArea(); + setEditAreaMode(true); }, - [editAreaMode, setPinDropMode, setCompareGeographiesMode], + [editAreaMode, setPinDropMode, setCompareGeographiesMode, setEditAreaMode], ); const toggleCompareGeographies = useCallback( From 887e08d8a1db321fb7f580fd9985c3aa05251942 Mon Sep 17 00:00:00 2001 From: ev <4164774+ev-sc@users.noreply.github.com> Date: Mon, 15 Dec 2025 12:45:45 +0100 Subject: [PATCH 23/45] Refactor AreaInfo and MapMarkerAndAreaControls components: enhance secondary data handling and improve event propagation; update useMapHover and usePlacedMarkers hooks for better mode management and cursor behavior --- .../components/MapMarkerAndAreaControls.tsx | 9 ++++-- src/app/map/[id]/hooks/useMapClick.ts | 23 ++------------- src/app/map/[id]/hooks/useMapHover.ts | 29 +++++++++++++++++-- src/app/map/[id]/hooks/usePlacedMarkers.ts | 3 +- 4 files changed, 37 insertions(+), 27 deletions(-) diff --git a/src/app/map/[id]/components/MapMarkerAndAreaControls.tsx b/src/app/map/[id]/components/MapMarkerAndAreaControls.tsx index 4150d63d..7ef79049 100644 --- a/src/app/map/[id]/components/MapMarkerAndAreaControls.tsx +++ b/src/app/map/[id]/components/MapMarkerAndAreaControls.tsx @@ -17,11 +17,13 @@ export default function MapMarkerAndAreaControls() { toggleCompareGeographies, } = useMapControls(); - const handlePinDropClick = () => { + const handlePinDropClick = (e: React.MouseEvent) => { + e.stopPropagation(); togglePinDrop({ cancelDrawMode, handleDropPin }); }; - const handleAddAreaClick = () => { + const handleAddAreaClick = (e: React.MouseEvent) => { + e.stopPropagation(); toggleAddArea({ cancelDrawMode, handleAddArea }); }; @@ -66,7 +68,8 @@ export default function MapMarkerAndAreaControls() { ? "bg-muted-foreground/30 text-primary" : "text-primary hover:bg-muted" }`} - onClick={() => { + onClick={(e) => { + e.stopPropagation(); toggleCompareGeographies({ cancelDrawMode }); }} > diff --git a/src/app/map/[id]/hooks/useMapClick.ts b/src/app/map/[id]/hooks/useMapClick.ts index 55a99d15..3728a5fb 100644 --- a/src/app/map/[id]/hooks/useMapClick.ts +++ b/src/app/map/[id]/hooks/useMapClick.ts @@ -8,10 +8,7 @@ import { } from "@/app/map/[id]/atoms/selectedAreasAtom"; import { useChoropleth } from "@/app/map/[id]/hooks/useChoropleth"; import { useInspector } from "@/app/map/[id]/hooks/useInspector"; -import { - useCompareGeographiesMode, - usePinDropMode, -} from "./useMapControls"; +import { useCompareGeographiesMode, usePinDropMode } from "./useMapControls"; import { useMapRef } from "./useMapCore"; import type MapboxDraw from "@mapbox/mapbox-gl-draw"; import type { @@ -350,22 +347,8 @@ export function useMapClickEffect({ map.on("click", onClick); return () => { - // Clean up active state on unmount - if (activeFeatureId.current !== undefined) { - try { - map?.setFeatureState( - { - source: sourceId, - sourceLayer: layerId, - id: activeFeatureId.current, - }, - { active: false }, - ); - } catch { - // Ignore error clearing feature state - } - } - + // Only clean up the event listener, not the active state + // The active state should persist across mode changes map.off("click", onClick); }; }, [ diff --git a/src/app/map/[id]/hooks/useMapHover.ts b/src/app/map/[id]/hooks/useMapHover.ts index 78a908f6..9d7d05aa 100644 --- a/src/app/map/[id]/hooks/useMapHover.ts +++ b/src/app/map/[id]/hooks/useMapHover.ts @@ -3,7 +3,11 @@ import { useEffect } from "react"; import { useChoropleth } from "@/app/map/[id]/hooks/useChoropleth"; import { hoverAreaAtom, hoverMarkerAtom } from "../atoms/hoverAtoms"; import { getClickedPolygonFeature } from "./useMapClick"; -import { useCompareGeographiesModeAtom } from "./useMapControls"; +import { + useCompareGeographiesModeAtom, + useEditAreaMode, + usePinDropMode, +} from "./useMapControls"; import { useMapRef } from "./useMapCore"; import type MapboxDraw from "@mapbox/mapbox-gl-draw"; @@ -27,6 +31,8 @@ export function useMapHoverEffect({ const [, setHoverMarker] = useHoverMarker(); const [compareGeographiesMode, setCompareGeographiesMode] = useCompareGeographiesModeAtom(); + const pinDropMode = usePinDropMode(); + const editAreaMode = useEditAreaMode(); /* Set cursor to pointer and darken fill on hover over choropleth areas */ useEffect(() => { @@ -72,6 +78,14 @@ export function useMapHoverEffect({ }; const onMouseMove = (e: mapboxgl.MapMouseEvent) => { + if (pinDropMode || editAreaMode) { + // In draw/pin modes, ignore hover effects and keep crosshair + map.getCanvas().style.cursor = "crosshair"; + clearAreaHover(); + setHoverMarker(null); + return; + } + if (handleHoverMarker(e)) { clearAreaHover(); return; @@ -93,9 +107,18 @@ export function useMapHoverEffect({ const onMouseLeave = () => { clearAreaHover(); setHoverMarker(null); - map.getCanvas().style.cursor = prevPointer.cursor; + if (pinDropMode || editAreaMode) { + map.getCanvas().style.cursor = "crosshair"; + } else { + map.getCanvas().style.cursor = prevPointer.cursor; + } }; + // Reset cursor when exiting pin/edit modes + if (!(pinDropMode || editAreaMode)) { + map.getCanvas().style.cursor = prevPointer.cursor; + } + const handleHoverMarker = (e: mapboxgl.MapMouseEvent): boolean => { const map = mapRef?.current; @@ -242,6 +265,8 @@ export function useMapHoverEffect({ areaSetCode, compareGeographiesMode, setCompareGeographiesMode, + pinDropMode, + editAreaMode, ]); } diff --git a/src/app/map/[id]/hooks/usePlacedMarkers.ts b/src/app/map/[id]/hooks/usePlacedMarkers.ts index d50130c7..ddee1ce9 100644 --- a/src/app/map/[id]/hooks/usePlacedMarkers.ts +++ b/src/app/map/[id]/hooks/usePlacedMarkers.ts @@ -270,11 +270,10 @@ export const useHandleDropPin = () => { folderId: null, }); - // Reset cursor - map.getCanvas().style.cursor = ""; map.off("click", clickHandler); clickHandlerRef.current = null; + // Set pinDropMode to false; hover effect will reset cursor setPinDropMode(false); // Fly to the new marker From 09678c8a2b3841f8714fdbe9da1cbc12056c5745 Mon Sep 17 00:00:00 2001 From: ev <4164774+ev-sc@users.noreply.github.com> Date: Mon, 15 Dec 2025 14:22:41 +0100 Subject: [PATCH 24/45] Refactor AreaInfo and MapWrapper components: enhance secondary data display logic and improve table layout responsiveness --- src/app/map/[id]/components/AreaInfo.tsx | 45 ++++++++++++---------- src/app/map/[id]/components/MapWrapper.tsx | 9 ++--- 2 files changed, 29 insertions(+), 25 deletions(-) diff --git a/src/app/map/[id]/components/AreaInfo.tsx b/src/app/map/[id]/components/AreaInfo.tsx index c8728b2d..646e9708 100644 --- a/src/app/map/[id]/components/AreaInfo.tsx +++ b/src/app/map/[id]/components/AreaInfo.tsx @@ -106,6 +106,7 @@ export default function AreaInfo() { // Combine selected areas and hover area, avoiding duplicates const areasToDisplay = []; const multipleAreas = selectedAreas.length > 1; + const hasSecondaryData = Boolean(viewConfig.areaDataSecondaryColumn); // Add all selected areas for (const selectedArea of selectedAreas) { @@ -223,18 +224,20 @@ export default function AreaInfo() { )} {multipleAreas && ( - - + + {statLabel} - - {viewConfig.areaDataSecondaryColumn || "Secondary"} - + {hasSecondaryData && ( + + {viewConfig.areaDataSecondaryColumn} + + )} )} @@ -307,7 +310,7 @@ export default function AreaInfo() { } }} > - +
{area.name}
- + {!multipleAreas ? (
@@ -328,18 +331,20 @@ export default function AreaInfo() { primaryValue )} - - {!multipleAreas ? ( -
- - {viewConfig.areaDataSecondaryColumn || "Secondary"}: - - {secondaryValue} -
- ) : ( - secondaryValue - )} -
+ {hasSecondaryData && ( + + {!multipleAreas ? ( +
+ + {viewConfig.areaDataSecondaryColumn}: + + {secondaryValue} +
+ ) : ( + secondaryValue + )} +
+ )} ); })} diff --git a/src/app/map/[id]/components/MapWrapper.tsx b/src/app/map/[id]/components/MapWrapper.tsx index a99968a0..46f2e462 100644 --- a/src/app/map/[id]/components/MapWrapper.tsx +++ b/src/app/map/[id]/components/MapWrapper.tsx @@ -72,12 +72,11 @@ export default function MapWrapper({ className="absolute top-5 z-10 transition-transform duration-300 hidden md:block" style={{ left: "32px", - right: inspectorVisible ? "280px" : "32px", ...positionLeft, - width: showControls - ? `calc(100% - 64px - ${CONTROL_PANEL_WIDTH}px - ${inspectorVisible ? "248px" : "0px"})` - : `calc(100% - 64px - ${inspectorVisible ? "248px" : "0px"})`, - transition: "right 0.3s, width 0.3s", + maxWidth: showControls + ? `calc(100% - 64px - ${CONTROL_PANEL_WIDTH}px - ${inspectorVisible ? "280px" : "32px"})` + : `calc(100% - 64px - ${inspectorVisible ? "280px" : "32px"})`, + transition: "max-width 0.3s", }} > From 092f64fe078f50644141b5c5af16563fac2fb3b9 Mon Sep 17 00:00:00 2001 From: ev <4164774+ev-sc@users.noreply.github.com> Date: Mon, 15 Dec 2025 14:52:31 +0100 Subject: [PATCH 25/45] Refactor useMapClickEffect and useTurfState hooks: improve feature state management and error handling during mode changes --- src/app/map/[id]/hooks/useMapClick.ts | 102 ++++++++++++++++--------- src/app/map/[id]/hooks/useTurfState.ts | 17 +++-- 2 files changed, 76 insertions(+), 43 deletions(-) diff --git a/src/app/map/[id]/hooks/useMapClick.ts b/src/app/map/[id]/hooks/useMapClick.ts index 3728a5fb..8265714f 100644 --- a/src/app/map/[id]/hooks/useMapClick.ts +++ b/src/app/map/[id]/hooks/useMapClick.ts @@ -66,48 +66,74 @@ export function useMapClickEffect({ } const map = mapRef.current; - const prevSelectedAreas = prevSelectedAreasRef.current; - - // Update previous selected areas before processing to ensure it's always set - prevSelectedAreasRef.current = selectedAreas; - - // Find areas that were removed from selection - const removedAreas = prevSelectedAreas.filter( - (prevArea) => - !selectedAreas.some( - (area) => - area.code === prevArea.code && - area.areaSetCode === prevArea.areaSetCode, - ), - ); - - // Remove selected state from removed areas - removedAreas.forEach((area) => { - if (area.areaSetCode === areaSetCode) { - try { - map.setFeatureState( - { source: sourceId, sourceLayer: layerId, id: area.code }, - { selected: false }, - ); - } catch { - // Ignore errors - } + + const applyFeatureStates = () => { + // Check if the source and layer exist before trying to set feature states + const source = map.getSource(sourceId); + if (!source || !map.getLayer(`${sourceId}-fill`)) { + // Layers not loaded yet, skip this update + return; } - }); - // Set selected state for all currently selected areas - selectedAreas.forEach((area) => { - if (area.areaSetCode === areaSetCode) { - try { - map.setFeatureState( - { source: sourceId, sourceLayer: layerId, id: area.code }, - { selected: true }, - ); - } catch { - // Ignore errors + const prevSelectedAreas = prevSelectedAreasRef.current; + + // Update previous selected areas before processing to ensure it's always set + prevSelectedAreasRef.current = selectedAreas; + + // Find areas that were removed from selection + const removedAreas = prevSelectedAreas.filter( + (prevArea) => + !selectedAreas.some( + (area) => + area.code === prevArea.code && + area.areaSetCode === prevArea.areaSetCode, + ), + ); + + // Remove selected state from removed areas + removedAreas.forEach((area) => { + if (area.areaSetCode === areaSetCode) { + try { + map.setFeatureState( + { source: sourceId, sourceLayer: layerId, id: area.code }, + { selected: false }, + ); + } catch { + // Ignore errors + } + } + }); + + // Set selected state for all currently selected areas + selectedAreas.forEach((area) => { + if (area.areaSetCode === areaSetCode) { + try { + map.setFeatureState( + { source: sourceId, sourceLayer: layerId, id: area.code }, + { selected: true }, + ); + } catch { + // Ignore errors + } } + }); + }; + + // Apply immediately if layers are ready + applyFeatureStates(); + + // Also listen for source data events to re-apply when layers are reloaded + const onSourceData = (e: mapboxgl.MapSourceDataEvent) => { + if (e.sourceId === sourceId && e.isSourceLoaded) { + applyFeatureStates(); } - }); + }; + + map.on("sourcedata", onSourceData); + + return () => { + map.off("sourcedata", onSourceData); + }; }, [selectedAreas, mapRef, ready, sourceId, layerId, areaSetCode]); /* Handle clicks to set active state */ diff --git a/src/app/map/[id]/hooks/useTurfState.ts b/src/app/map/[id]/hooks/useTurfState.ts index 8c3efdbc..116b6c97 100644 --- a/src/app/map/[id]/hooks/useTurfState.ts +++ b/src/app/map/[id]/hooks/useTurfState.ts @@ -44,10 +44,12 @@ export function useTurfState() { } if (draw) { - (draw.changeMode as (mode: string, opts?: object) => void)( - "simple_select", - { featureIds: [] }, - ); + try { + const changeMode = draw.changeMode as (mode: string) => void; + changeMode("simple_select"); + } catch { + // Ignore errors when draw is not fully initialized + } } const drawButton = document.querySelector( @@ -102,7 +104,12 @@ export function useTurfState() { if (draw) { // Use MapboxDraw's changeMode to exit draw_polygon mode // This should fire draw.modechange, but also set the state directly as a fallback - (draw.changeMode as (mode: string) => void)("simple_select"); + try { + const changeMode = draw.changeMode as (mode: string) => void; + changeMode("simple_select"); + } catch { + // Ignore errors when draw is not fully initialized + } } // Explicitly reset the edit mode flag in case the modechange event does not fire From 6d094b856feba0718113c6b2cbdb7cf7ad9be80d Mon Sep 17 00:00:00 2001 From: ev <4164774+ev-sc@users.noreply.github.com> Date: Mon, 15 Dec 2025 14:59:15 +0100 Subject: [PATCH 26/45] Tidy divider --- src/app/map/[id]/components/AreaInfo.tsx | 3 +++ src/app/map/[id]/hooks/useMapClick.ts | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/app/map/[id]/components/AreaInfo.tsx b/src/app/map/[id]/components/AreaInfo.tsx index 646e9708..9f27b886 100644 --- a/src/app/map/[id]/components/AreaInfo.tsx +++ b/src/app/map/[id]/components/AreaInfo.tsx @@ -319,6 +319,9 @@ export default function AreaInfo() { {area.name}
+ {!multipleAreas && ( +
+ )} {!multipleAreas ? (
diff --git a/src/app/map/[id]/hooks/useMapClick.ts b/src/app/map/[id]/hooks/useMapClick.ts index 8265714f..b83b4bd1 100644 --- a/src/app/map/[id]/hooks/useMapClick.ts +++ b/src/app/map/[id]/hooks/useMapClick.ts @@ -66,7 +66,7 @@ export function useMapClickEffect({ } const map = mapRef.current; - + const applyFeatureStates = () => { // Check if the source and layer exist before trying to set feature states const source = map.getSource(sourceId); From d484d400bdf538487725e5bbb9766269e0bf2ce0 Mon Sep 17 00:00:00 2001 From: ev <4164774+ev-sc@users.noreply.github.com> Date: Mon, 15 Dec 2025 20:34:16 +0100 Subject: [PATCH 27/45] Add escape key listener to cancel active map modes --- src/app/map/[id]/hooks/useMapControls.ts | 32 +++++++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/src/app/map/[id]/hooks/useMapControls.ts b/src/app/map/[id]/hooks/useMapControls.ts index c9c0f083..34a59c4b 100644 --- a/src/app/map/[id]/hooks/useMapControls.ts +++ b/src/app/map/[id]/hooks/useMapControls.ts @@ -1,5 +1,5 @@ import { useAtom, useAtomValue, useSetAtom } from "jotai"; -import { useCallback } from "react"; +import { useCallback, useEffect } from "react"; import { compareGeographiesAtom, editAreaModeAtom, @@ -77,6 +77,36 @@ export function useMapControls() { [compareGeographiesMode, setCompareGeographiesMode, setPinDropMode], ); + // Listen for escape key and cancel active modes + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") { + // Cancel any active mode + if (pinDropMode) { + setPinDropMode(false); + } + if (editAreaMode) { + setEditAreaMode(false); + } + if (compareGeographiesMode) { + setCompareGeographiesMode(false); + } + } + }; + + window.addEventListener("keydown", handleKeyDown); + return () => { + window.removeEventListener("keydown", handleKeyDown); + }; + }, [ + pinDropMode, + editAreaMode, + compareGeographiesMode, + setPinDropMode, + setEditAreaMode, + setCompareGeographiesMode, + ]); + return { showControls, setShowControls, From 474ffc20aac5381edea76bddff70bb8b75fb5856 Mon Sep 17 00:00:00 2001 From: ev <4164774+ev-sc@users.noreply.github.com> Date: Mon, 15 Dec 2025 20:35:31 +0100 Subject: [PATCH 28/45] Refactor AreaInfo component: update table cell structure for improved layout consistency --- src/app/map/[id]/components/AreaInfo.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/app/map/[id]/components/AreaInfo.tsx b/src/app/map/[id]/components/AreaInfo.tsx index 9f27b886..90a40c73 100644 --- a/src/app/map/[id]/components/AreaInfo.tsx +++ b/src/app/map/[id]/components/AreaInfo.tsx @@ -320,7 +320,9 @@ export default function AreaInfo() {
{!multipleAreas && ( -
+ +
+ )} {!multipleAreas ? ( From ff0f7a22bbe47ed82b0e96350b4740de5d31aed6 Mon Sep 17 00:00:00 2001 From: ev <4164774+ev-sc@users.noreply.github.com> Date: Mon, 15 Dec 2025 20:36:48 +0100 Subject: [PATCH 29/45] Update src/app/map/[id]/components/Choropleth/index.tsx Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/app/map/[id]/components/Choropleth/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/map/[id]/components/Choropleth/index.tsx b/src/app/map/[id]/components/Choropleth/index.tsx index 3cff7a82..bc186e33 100644 --- a/src/app/map/[id]/components/Choropleth/index.tsx +++ b/src/app/map/[id]/components/Choropleth/index.tsx @@ -293,7 +293,7 @@ export default function Choropleth() { {/* Active + Selected outer outline (blue offset outside with dashes) */} Date: Mon, 15 Dec 2025 20:39:47 +0100 Subject: [PATCH 30/45] Fix event listener for map hover: change 'mouseout' to 'mouseleave' --- src/app/map/[id]/hooks/useMapHover.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/map/[id]/hooks/useMapHover.ts b/src/app/map/[id]/hooks/useMapHover.ts index 9d7d05aa..0d7bbc04 100644 --- a/src/app/map/[id]/hooks/useMapHover.ts +++ b/src/app/map/[id]/hooks/useMapHover.ts @@ -230,7 +230,7 @@ export function useMapHoverEffect({ }; map.on("mousemove", onMouseMove); - map.on("mouseout", onMouseLeave); + map.on("mouseleave", onMouseLeave); window.addEventListener("keydown", onKeyDown); window.addEventListener("keyup", onKeyUp); From b075463234718ca3eeb7d418ddb2d38fb12ab2ef Mon Sep 17 00:00:00 2001 From: ev <4164774+ev-sc@users.noreply.github.com> Date: Mon, 15 Dec 2025 20:44:58 +0100 Subject: [PATCH 31/45] Add cancel button to MapWrapper for mode cancellation --- src/app/map/[id]/components/MapWrapper.tsx | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/src/app/map/[id]/components/MapWrapper.tsx b/src/app/map/[id]/components/MapWrapper.tsx index 46f2e462..dfcc8662 100644 --- a/src/app/map/[id]/components/MapWrapper.tsx +++ b/src/app/map/[id]/components/MapWrapper.tsx @@ -1,3 +1,4 @@ +import { XIcon } from "lucide-react"; import { useEffect, useState } from "react"; import { MapType } from "@/server/models/MapView"; import { useInspector } from "../hooks/useInspector"; @@ -29,11 +30,23 @@ export default function MapWrapper({ const { inspectorContent } = useInspector(); const inspectorVisible = Boolean(inspectorContent); const compareGeographiesMode = useCompareGeographiesMode(); - const { pinDropMode, editAreaMode } = useMapControls(); + const { pinDropMode, editAreaMode, setPinDropMode, setEditAreaMode, setCompareGeographiesMode } = useMapControls(); const [message, setMessage] = useState(""); const [indicatorColor, setIndicatorColor] = useState(""); + const handleCancelMode = () => { + if (pinDropMode) { + setPinDropMode(false); + } + if (editAreaMode) { + setEditAreaMode(false); + } + if (compareGeographiesMode) { + setCompareGeographiesMode(false); + } + }; + useEffect(() => { if (editAreaMode || currentMode === "draw_polygon") { setIndicatorColor(mapColors.areas.color); @@ -122,6 +135,13 @@ export default function MapWrapper({ /> )}

{message}

+
)} From 953feedac079bf78f06fa110fce1c68bcc92e1e6 Mon Sep 17 00:00:00 2001 From: ev <4164774+ev-sc@users.noreply.github.com> Date: Mon, 15 Dec 2025 23:34:02 +0100 Subject: [PATCH 32/45] Linting --- src/app/map/[id]/components/MapWrapper.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/app/map/[id]/components/MapWrapper.tsx b/src/app/map/[id]/components/MapWrapper.tsx index dfcc8662..042d920e 100644 --- a/src/app/map/[id]/components/MapWrapper.tsx +++ b/src/app/map/[id]/components/MapWrapper.tsx @@ -30,7 +30,13 @@ export default function MapWrapper({ const { inspectorContent } = useInspector(); const inspectorVisible = Boolean(inspectorContent); const compareGeographiesMode = useCompareGeographiesMode(); - const { pinDropMode, editAreaMode, setPinDropMode, setEditAreaMode, setCompareGeographiesMode } = useMapControls(); + const { + pinDropMode, + editAreaMode, + setPinDropMode, + setEditAreaMode, + setCompareGeographiesMode, + } = useMapControls(); const [message, setMessage] = useState(""); const [indicatorColor, setIndicatorColor] = useState(""); From c76c9d1e1c7bbd8bf9e1483f6deaa0bfc70f4a93 Mon Sep 17 00:00:00 2001 From: ev <4164774+ev-sc@users.noreply.github.com> Date: Thu, 18 Dec 2025 14:44:48 +0100 Subject: [PATCH 33/45] Increase debounce time for hover area updates and optimize event listener handling in useMapHoverEffect --- src/app/map/[id]/components/AreaInfo.tsx | 2 +- src/app/map/[id]/hooks/useMapClick.ts | 7 ++++++- src/app/map/[id]/hooks/useMapHover.ts | 24 ++++++++++++++++-------- 3 files changed, 23 insertions(+), 10 deletions(-) diff --git a/src/app/map/[id]/components/AreaInfo.tsx b/src/app/map/[id]/components/AreaInfo.tsx index 90a40c73..52a434b9 100644 --- a/src/app/map/[id]/components/AreaInfo.tsx +++ b/src/app/map/[id]/components/AreaInfo.tsx @@ -88,7 +88,7 @@ export default function AreaInfo() { useEffect(() => { const timer = setTimeout(() => { setDebouncedHoverArea(hoverArea); - }, 5); + }, 100); return () => clearTimeout(timer); }, [hoverArea]); diff --git a/src/app/map/[id]/hooks/useMapClick.ts b/src/app/map/[id]/hooks/useMapClick.ts index b83b4bd1..82500277 100644 --- a/src/app/map/[id]/hooks/useMapClick.ts +++ b/src/app/map/[id]/hooks/useMapClick.ts @@ -123,8 +123,13 @@ export function useMapClickEffect({ applyFeatureStates(); // Also listen for source data events to re-apply when layers are reloaded + // Only respond to 'metadata' events (when style changes) to avoid firing on every tile load const onSourceData = (e: mapboxgl.MapSourceDataEvent) => { - if (e.sourceId === sourceId && e.isSourceLoaded) { + if ( + e.sourceId === sourceId && + e.isSourceLoaded && + e.sourceDataType === "metadata" + ) { applyFeatureStates(); } }; diff --git a/src/app/map/[id]/hooks/useMapHover.ts b/src/app/map/[id]/hooks/useMapHover.ts index 0d7bbc04..d3eaf810 100644 --- a/src/app/map/[id]/hooks/useMapHover.ts +++ b/src/app/map/[id]/hooks/useMapHover.ts @@ -1,5 +1,5 @@ import { useAtom } from "jotai"; -import { useEffect } from "react"; +import { useEffect, useRef } from "react"; import { useChoropleth } from "@/app/map/[id]/hooks/useChoropleth"; import { hoverAreaAtom, hoverMarkerAtom } from "../atoms/hoverAtoms"; import { getClickedPolygonFeature } from "./useMapClick"; @@ -34,6 +34,17 @@ export function useMapHoverEffect({ const pinDropMode = usePinDropMode(); const editAreaMode = useEditAreaMode(); + // Use refs to avoid recreating event listeners when modes change + const compareGeographiesModeRef = useRef(compareGeographiesMode); + const pinDropModeRef = useRef(pinDropMode); + const editAreaModeRef = useRef(editAreaMode); + + useEffect(() => { + compareGeographiesModeRef.current = compareGeographiesMode; + pinDropModeRef.current = pinDropMode; + editAreaModeRef.current = editAreaMode; + }, [compareGeographiesMode, pinDropMode, editAreaMode]); + /* Set cursor to pointer and darken fill on hover over choropleth areas */ useEffect(() => { if (!mapRef?.current || !ready) { @@ -78,7 +89,7 @@ export function useMapHoverEffect({ }; const onMouseMove = (e: mapboxgl.MapMouseEvent) => { - if (pinDropMode || editAreaMode) { + if (pinDropModeRef.current || editAreaModeRef.current) { // In draw/pin modes, ignore hover effects and keep crosshair map.getCanvas().style.cursor = "crosshair"; clearAreaHover(); @@ -107,7 +118,7 @@ export function useMapHoverEffect({ const onMouseLeave = () => { clearAreaHover(); setHoverMarker(null); - if (pinDropMode || editAreaMode) { + if (pinDropModeRef.current || editAreaModeRef.current) { map.getCanvas().style.cursor = "crosshair"; } else { map.getCanvas().style.cursor = prevPointer.cursor; @@ -115,7 +126,7 @@ export function useMapHoverEffect({ }; // Reset cursor when exiting pin/edit modes - if (!(pinDropMode || editAreaMode)) { + if (!(pinDropModeRef.current || editAreaModeRef.current)) { map.getCanvas().style.cursor = prevPointer.cursor; } @@ -204,7 +215,7 @@ export function useMapHoverEffect({ ) { prevPointer.cursor = map.getCanvas().style.cursor || ""; } - map.getCanvas().style.cursor = compareGeographiesMode + map.getCanvas().style.cursor = compareGeographiesModeRef.current ? "copy" : "pointer"; return true; @@ -263,10 +274,7 @@ export function useMapHoverEffect({ setHoverArea, featureNameProperty, areaSetCode, - compareGeographiesMode, setCompareGeographiesMode, - pinDropMode, - editAreaMode, ]); } From 1fe244a9305c399fbcc01c23899f8005c6143979 Mon Sep 17 00:00:00 2001 From: ev <4164774+ev-sc@users.noreply.github.com> Date: Thu, 18 Dec 2025 14:46:45 +0100 Subject: [PATCH 34/45] Refactor useMapClickEffect to use refs for currentMode, pinDropMode, and compareGeographiesMode to prevent unnecessary re-renders --- src/app/map/[id]/hooks/useMapClick.ts | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/src/app/map/[id]/hooks/useMapClick.ts b/src/app/map/[id]/hooks/useMapClick.ts index 82500277..ea3d12bc 100644 --- a/src/app/map/[id]/hooks/useMapClick.ts +++ b/src/app/map/[id]/hooks/useMapClick.ts @@ -53,6 +53,17 @@ export function useMapClickEffect({ const activeFeatureId = useRef(undefined); const selectedAreasRef = useRef(selectedAreas); const prevSelectedAreasRef = useRef([]); + + // Use refs to avoid recreating click handler when modes change + const compareGeographiesModeRef = useRef(compareGeographiesMode); + const pinDropModeRef = useRef(pinDropMode); + const currentModeRef = useRef(currentMode); + + useEffect(() => { + compareGeographiesModeRef.current = compareGeographiesMode; + pinDropModeRef.current = pinDropMode; + currentModeRef.current = currentMode; + }, [compareGeographiesMode, pinDropMode, currentMode]); // Keep ref in sync with latest selectedAreas useEffect(() => { @@ -349,12 +360,12 @@ export function useMapClickEffect({ }; const onClick = (e: mapboxgl.MapMouseEvent) => { - if (currentMode === "draw_polygon" || pinDropMode) { + if (currentModeRef.current === "draw_polygon" || pinDropModeRef.current) { return; } // Check if compare areas mode is active - if (compareGeographiesMode) { + if (compareGeographiesModeRef.current) { if (handleCtrlAreaClick(e)) { return; } @@ -393,13 +404,10 @@ export function useMapClickEffect({ setSelectedBoundary, markerLayers, draw, - currentMode, - pinDropMode, setSelectedTurf, ready, setSelectedRecords, setSelectedAreas, - compareGeographiesMode, ]); // Clear active feature state when selectedBoundary is cleared (resetInspector called from outside) From e6344b3173df01fe249c3c75461628864332c685 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 18 Dec 2025 13:52:02 +0000 Subject: [PATCH 35/45] Initial plan From 8b407bd144cb703b113bc99d184cc691fbf1e0b6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 18 Dec 2025 13:56:16 +0000 Subject: [PATCH 36/45] Optimize hover performance: remove debounce delay and memoize color calculations Co-authored-by: ev-sc <4164774+ev-sc@users.noreply.github.com> --- src/app/map/[id]/components/AreaInfo.tsx | 64 +++++++++++++++--------- src/app/map/[id]/hooks/useMapHover.ts | 47 +++++++++-------- 2 files changed, 65 insertions(+), 46 deletions(-) diff --git a/src/app/map/[id]/components/AreaInfo.tsx b/src/app/map/[id]/components/AreaInfo.tsx index 52a434b9..ad3eb1be 100644 --- a/src/app/map/[id]/components/AreaInfo.tsx +++ b/src/app/map/[id]/components/AreaInfo.tsx @@ -2,7 +2,7 @@ import { AnimatePresence, motion } from "framer-motion"; import { useAtom } from "jotai"; import { XIcon } from "lucide-react"; import { expression } from "mapbox-gl/dist/style-spec/index.cjs"; -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { ColumnType } from "@/server/models/DataSource"; import { CalculationType, ColorScheme } from "@/server/models/MapView"; @@ -84,11 +84,11 @@ export default function AreaInfo() { const choroplethDataSource = useChoroplethDataSource(); const { viewConfig } = useMapViews(); - // Debounce hoverArea changes + // Debounce hoverArea changes - reduced from 100ms to 0ms for better responsiveness useEffect(() => { const timer = setTimeout(() => { setDebouncedHoverArea(hoverArea); - }, 100); + }, 0); return () => clearTimeout(timer); }, [hoverArea]); @@ -173,31 +173,47 @@ export default function AreaInfo() { ); } - // Helper to get color for an area based on fillColor expression - const getAreaColor = (area: { - code: string; - areaSetCode: string; - }): string => { - const areaStat = - areaStats.areaSetCode === area.areaSetCode - ? areaStats.stats.find((s) => s.areaCode === area.code) - : null; + // Memoize color calculations for all areas to improve performance + const areaColors = useMemo(() => { + const colors = new Map(); + + if (result !== "success") { + return colors; + } + + for (const area of areasToDisplay) { + const areaStat = + areaStats.areaSetCode === area.areaSetCode + ? areaStats.stats.find((s) => s.areaCode === area.code) + : null; + + if (!areaStat) { + colors.set(`${area.areaSetCode}-${area.code}`, "rgba(200, 200, 200, 1)"); + continue; + } + + // For bivariate color schemes, evaluate with both primary and secondary values + const colorResult = fillColorExpression.evaluate( + { zoom: 0 }, + { type: "Polygon", properties: {} }, + { + value: areaStat.primary || 0, + secondaryValue: areaStat.secondary || 0, + }, + ); - if (!areaStat || result !== "success") { - return "rgba(200, 200, 200, 1)"; + colors.set(`${area.areaSetCode}-${area.code}`, toRGBA(colorResult)); } - // For bivariate color schemes, evaluate with both primary and secondary values - const colorResult = fillColorExpression.evaluate( - { zoom: 0 }, - { type: "Polygon", properties: {} }, - { - value: areaStat.primary || 0, - secondaryValue: areaStat.secondary || 0, - }, - ); + return colors; + }, [areasToDisplay, areaStats, fillColorExpression, result]); - return toRGBA(colorResult); + // Helper to get color for an area based on memoized calculations + const getAreaColor = (area: { + code: string; + areaSetCode: string; + }): string => { + return areaColors.get(`${area.areaSetCode}-${area.code}`) || "rgba(200, 200, 200, 1)"; }; return ( diff --git a/src/app/map/[id]/hooks/useMapHover.ts b/src/app/map/[id]/hooks/useMapHover.ts index d3eaf810..abe5da81 100644 --- a/src/app/map/[id]/hooks/useMapHover.ts +++ b/src/app/map/[id]/hooks/useMapHover.ts @@ -184,29 +184,32 @@ export function useMapHoverEffect({ if (features?.length) { const feature = features[0]; - // Remove hover state from previous feature - if (hoveredFeatureId !== undefined) { - map.setFeatureState( - { source: sourceId, sourceLayer: layerId, id: hoveredFeatureId }, - { hover: false }, - ); - } - if (feature.id !== undefined) { - // Set hover state on current feature - hoveredFeatureId = feature.id; - map.setFeatureState( - { source: sourceId, sourceLayer: layerId, id: hoveredFeatureId }, - { hover: true }, - ); - setHoverArea({ - coordinates: [e.lngLat.lng, e.lngLat.lat], - areaSetCode, - code: String(feature.id), - name: String( - feature.properties?.[featureNameProperty] || feature.id, - ), - }); + // Only update if the feature has changed to reduce unnecessary state updates + if (hoveredFeatureId !== feature.id) { + // Remove hover state from previous feature + if (hoveredFeatureId !== undefined) { + map.setFeatureState( + { source: sourceId, sourceLayer: layerId, id: hoveredFeatureId }, + { hover: false }, + ); + } + + // Set hover state on new feature + hoveredFeatureId = feature.id; + map.setFeatureState( + { source: sourceId, sourceLayer: layerId, id: hoveredFeatureId }, + { hover: true }, + ); + setHoverArea({ + coordinates: [e.lngLat.lng, e.lngLat.lat], + areaSetCode, + code: String(feature.id), + name: String( + feature.properties?.[featureNameProperty] || feature.id, + ), + }); + } } if ( From 3755e0edf670f2bd0a423b2bfe36abb803f5df2e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 18 Dec 2025 13:56:56 +0000 Subject: [PATCH 37/45] Remove unnecessary debounce state for immediate hover response Co-authored-by: ev-sc <4164774+ev-sc@users.noreply.github.com> --- src/app/map/[id]/components/AreaInfo.tsx | 26 ++++++++---------------- 1 file changed, 8 insertions(+), 18 deletions(-) diff --git a/src/app/map/[id]/components/AreaInfo.tsx b/src/app/map/[id]/components/AreaInfo.tsx index ad3eb1be..4bf5143a 100644 --- a/src/app/map/[id]/components/AreaInfo.tsx +++ b/src/app/map/[id]/components/AreaInfo.tsx @@ -2,7 +2,7 @@ import { AnimatePresence, motion } from "framer-motion"; import { useAtom } from "jotai"; import { XIcon } from "lucide-react"; import { expression } from "mapbox-gl/dist/style-spec/index.cjs"; -import { useEffect, useMemo, useState } from "react"; +import { useMemo, useState } from "react"; import { ColumnType } from "@/server/models/DataSource"; import { CalculationType, ColorScheme } from "@/server/models/MapView"; @@ -70,8 +70,6 @@ const toRGBA = (expressionResult: unknown) => { export default function AreaInfo() { const [hoverArea] = useHoverArea(); - const [debouncedHoverArea, setDebouncedHoverArea] = - useState(null); const [hoveredRowArea, setHoveredRowArea] = useState<{ code: string; areaSetCode: string; @@ -84,14 +82,6 @@ export default function AreaInfo() { const choroplethDataSource = useChoroplethDataSource(); const { viewConfig } = useMapViews(); - // Debounce hoverArea changes - reduced from 100ms to 0ms for better responsiveness - useEffect(() => { - const timer = setTimeout(() => { - setDebouncedHoverArea(hoverArea); - }, 0); - return () => clearTimeout(timer); - }, [hoverArea]); - const fillColor = useFillColor({ areaStats, scheme: viewConfig.colorScheme || ColorScheme.RedBlue, @@ -120,18 +110,18 @@ export default function AreaInfo() { } // Add hover area only if it's not already in selected areas - if (debouncedHoverArea) { + if (hoverArea) { const isHoverAreaSelected = selectedAreas.some( (a) => - a.code === debouncedHoverArea.code && - a.areaSetCode === debouncedHoverArea.areaSetCode, + a.code === hoverArea.code && + a.areaSetCode === hoverArea.areaSetCode, ); if (!isHoverAreaSelected) { areasToDisplay.push({ - code: debouncedHoverArea.code, - name: debouncedHoverArea.name, - areaSetCode: debouncedHoverArea.areaSetCode, - coordinates: debouncedHoverArea.coordinates, + code: hoverArea.code, + name: hoverArea.name, + areaSetCode: hoverArea.areaSetCode, + coordinates: hoverArea.coordinates, isSelected: false, }); } From a7d9ac534d32f1a59151ba3a075cd0d3b40d8ed1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 18 Dec 2025 13:57:32 +0000 Subject: [PATCH 38/45] Memoize areasToDisplay to prevent unnecessary color recalculations Co-authored-by: ev-sc <4164774+ev-sc@users.noreply.github.com> --- src/app/map/[id]/components/AreaInfo.tsx | 97 +++++++++++++----------- 1 file changed, 51 insertions(+), 46 deletions(-) diff --git a/src/app/map/[id]/components/AreaInfo.tsx b/src/app/map/[id]/components/AreaInfo.tsx index 4bf5143a..87abf853 100644 --- a/src/app/map/[id]/components/AreaInfo.tsx +++ b/src/app/map/[id]/components/AreaInfo.tsx @@ -93,57 +93,62 @@ export default function AreaInfo() { return null; } - // Combine selected areas and hover area, avoiding duplicates - const areasToDisplay = []; - const multipleAreas = selectedAreas.length > 1; - const hasSecondaryData = Boolean(viewConfig.areaDataSecondaryColumn); + // Combine selected areas and hover area, avoiding duplicates - memoized for performance + const areasToDisplay = useMemo(() => { + const areas = []; - // Add all selected areas - for (const selectedArea of selectedAreas) { - areasToDisplay.push({ - code: selectedArea.code, - name: selectedArea.name, - areaSetCode: selectedArea.areaSetCode, - coordinates: selectedArea.coordinates, - isSelected: true, - }); - } - - // Add hover area only if it's not already in selected areas - if (hoverArea) { - const isHoverAreaSelected = selectedAreas.some( - (a) => - a.code === hoverArea.code && - a.areaSetCode === hoverArea.areaSetCode, - ); - if (!isHoverAreaSelected) { - areasToDisplay.push({ - code: hoverArea.code, - name: hoverArea.name, - areaSetCode: hoverArea.areaSetCode, - coordinates: hoverArea.coordinates, - isSelected: false, + // Add all selected areas + for (const selectedArea of selectedAreas) { + areas.push({ + code: selectedArea.code, + name: selectedArea.name, + areaSetCode: selectedArea.areaSetCode, + coordinates: selectedArea.coordinates, + isSelected: true, }); } - } - // Add hovered row area even if it's no longer in hoverArea - if (hoveredRowArea) { - const isAreaAlreadyDisplayed = areasToDisplay.some( - (a) => - a.code === hoveredRowArea.code && - a.areaSetCode === hoveredRowArea.areaSetCode, - ); - if (!isAreaAlreadyDisplayed) { - areasToDisplay.push({ - code: hoveredRowArea.code, - name: hoveredRowArea.name, - areaSetCode: hoveredRowArea.areaSetCode, - coordinates: hoveredRowArea.coordinates, - isSelected: false, - }); + // Add hover area only if it's not already in selected areas + if (hoverArea) { + const isHoverAreaSelected = selectedAreas.some( + (a) => + a.code === hoverArea.code && + a.areaSetCode === hoverArea.areaSetCode, + ); + if (!isHoverAreaSelected) { + areas.push({ + code: hoverArea.code, + name: hoverArea.name, + areaSetCode: hoverArea.areaSetCode, + coordinates: hoverArea.coordinates, + isSelected: false, + }); + } } - } + + // Add hovered row area even if it's no longer in hoverArea + if (hoveredRowArea) { + const isAreaAlreadyDisplayed = areas.some( + (a) => + a.code === hoveredRowArea.code && + a.areaSetCode === hoveredRowArea.areaSetCode, + ); + if (!isAreaAlreadyDisplayed) { + areas.push({ + code: hoveredRowArea.code, + name: hoveredRowArea.name, + areaSetCode: hoveredRowArea.areaSetCode, + coordinates: hoveredRowArea.coordinates, + isSelected: false, + }); + } + } + + return areas; + }, [selectedAreas, hoverArea, hoveredRowArea]); + + const multipleAreas = selectedAreas.length > 1; + const hasSecondaryData = Boolean(viewConfig.areaDataSecondaryColumn); const statLabel = areaStats.calculationType === CalculationType.Count From 0fa7b9c72ff7283678110ecb0da2eae479543d88 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 18 Dec 2025 13:58:33 +0000 Subject: [PATCH 39/45] Improve comment clarity for areasToDisplay memoization Co-authored-by: ev-sc <4164774+ev-sc@users.noreply.github.com> --- src/app/map/[id]/components/AreaInfo.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/app/map/[id]/components/AreaInfo.tsx b/src/app/map/[id]/components/AreaInfo.tsx index 87abf853..d9cb134c 100644 --- a/src/app/map/[id]/components/AreaInfo.tsx +++ b/src/app/map/[id]/components/AreaInfo.tsx @@ -93,7 +93,8 @@ export default function AreaInfo() { return null; } - // Combine selected areas and hover area, avoiding duplicates - memoized for performance + // Combine selected areas and hover area, avoiding duplicates + // Memoized to prevent downstream recalculations (especially color expressions) const areasToDisplay = useMemo(() => { const areas = []; From 02f1d2c08aa4ae579190cd9d55f7a7db7b4c61cd Mon Sep 17 00:00:00 2001 From: ev <4164774+ev-sc@users.noreply.github.com> Date: Thu, 18 Dec 2025 15:52:58 +0100 Subject: [PATCH 40/45] Refactor AreaInfo component: add early return for areaStats check and streamline statLabel assignment --- src/app/map/[id]/components/AreaInfo.tsx | 33 ++++++++++++++---------- src/app/map/[id]/hooks/useMapClick.ts | 2 +- src/app/map/[id]/hooks/useMapHover.ts | 6 ++++- 3 files changed, 26 insertions(+), 15 deletions(-) diff --git a/src/app/map/[id]/components/AreaInfo.tsx b/src/app/map/[id]/components/AreaInfo.tsx index d9cb134c..c136bf1b 100644 --- a/src/app/map/[id]/components/AreaInfo.tsx +++ b/src/app/map/[id]/components/AreaInfo.tsx @@ -89,10 +89,6 @@ export default function AreaInfo() { selectedBivariateBucket: null, }); - if (!areaStats) { - return null; - } - // Combine selected areas and hover area, avoiding duplicates // Memoized to prevent downstream recalculations (especially color expressions) const areasToDisplay = useMemo(() => { @@ -113,8 +109,7 @@ export default function AreaInfo() { if (hoverArea) { const isHoverAreaSelected = selectedAreas.some( (a) => - a.code === hoverArea.code && - a.areaSetCode === hoverArea.areaSetCode, + a.code === hoverArea.code && a.areaSetCode === hoverArea.areaSetCode, ); if (!isHoverAreaSelected) { areas.push({ @@ -151,10 +146,11 @@ export default function AreaInfo() { const multipleAreas = selectedAreas.length > 1; const hasSecondaryData = Boolean(viewConfig.areaDataSecondaryColumn); - const statLabel = - areaStats.calculationType === CalculationType.Count + const statLabel = areaStats + ? areaStats.calculationType === CalculationType.Count ? `${choroplethDataSource?.name || "Unknown"} count` - : viewConfig.areaDataColumn; + : viewConfig.areaDataColumn + : ""; const { result, value: fillColorExpression } = expression.createExpression([ "to-rgba", @@ -172,8 +168,8 @@ export default function AreaInfo() { // Memoize color calculations for all areas to improve performance const areaColors = useMemo(() => { const colors = new Map(); - - if (result !== "success") { + + if (result !== "success" || !areaStats) { return colors; } @@ -184,7 +180,10 @@ export default function AreaInfo() { : null; if (!areaStat) { - colors.set(`${area.areaSetCode}-${area.code}`, "rgba(200, 200, 200, 1)"); + colors.set( + `${area.areaSetCode}-${area.code}`, + "rgba(200, 200, 200, 1)", + ); continue; } @@ -209,9 +208,17 @@ export default function AreaInfo() { code: string; areaSetCode: string; }): string => { - return areaColors.get(`${area.areaSetCode}-${area.code}`) || "rgba(200, 200, 200, 1)"; + return ( + areaColors.get(`${area.areaSetCode}-${area.code}`) || + "rgba(200, 200, 200, 1)" + ); }; + // Early return after all hooks have been called + if (!areaStats) { + return null; + } + return ( {areasToDisplay.length > 0 && ( diff --git a/src/app/map/[id]/hooks/useMapClick.ts b/src/app/map/[id]/hooks/useMapClick.ts index ea3d12bc..a3c7c127 100644 --- a/src/app/map/[id]/hooks/useMapClick.ts +++ b/src/app/map/[id]/hooks/useMapClick.ts @@ -53,7 +53,7 @@ export function useMapClickEffect({ const activeFeatureId = useRef(undefined); const selectedAreasRef = useRef(selectedAreas); const prevSelectedAreasRef = useRef([]); - + // Use refs to avoid recreating click handler when modes change const compareGeographiesModeRef = useRef(compareGeographiesMode); const pinDropModeRef = useRef(pinDropMode); diff --git a/src/app/map/[id]/hooks/useMapHover.ts b/src/app/map/[id]/hooks/useMapHover.ts index abe5da81..51d53435 100644 --- a/src/app/map/[id]/hooks/useMapHover.ts +++ b/src/app/map/[id]/hooks/useMapHover.ts @@ -190,7 +190,11 @@ export function useMapHoverEffect({ // Remove hover state from previous feature if (hoveredFeatureId !== undefined) { map.setFeatureState( - { source: sourceId, sourceLayer: layerId, id: hoveredFeatureId }, + { + source: sourceId, + sourceLayer: layerId, + id: hoveredFeatureId, + }, { hover: false }, ); } From 0912bfb504ee007aa655c3bcaf3405a79857f179 Mon Sep 17 00:00:00 2001 From: Joaquim d'Souza Date: Thu, 18 Dec 2025 16:36:00 +0100 Subject: [PATCH 41/45] fix: remove unnecessary local draw state + move useEffect out of reusable useTurfState hook --- src/app/map/[id]/components/Map.tsx | 11 +-- src/app/map/[id]/hooks/useDraw.ts | 6 ++ src/app/map/[id]/hooks/useTurfState.ts | 100 +++++++++++++------------ 3 files changed, 63 insertions(+), 54 deletions(-) create mode 100644 src/app/map/[id]/hooks/useDraw.ts diff --git a/src/app/map/[id]/components/Map.tsx b/src/app/map/[id]/components/Map.tsx index 2519df73..95d58807 100644 --- a/src/app/map/[id]/components/Map.tsx +++ b/src/app/map/[id]/components/Map.tsx @@ -1,7 +1,6 @@ import MapboxDraw from "@mapbox/mapbox-gl-draw"; import "@mapbox/mapbox-gl-draw/dist/mapbox-gl-draw.css"; import * as turf from "@turf/turf"; -import { useSetAtom } from "jotai"; import { useCallback, useEffect, useMemo, useState } from "react"; import MapGL from "react-map-gl/mapbox"; import { v4 as uuidv4 } from "uuid"; @@ -17,7 +16,7 @@ import { usePlacedMarkersQuery } from "@/app/map/[id]/hooks/usePlacedMarkers"; import { DEFAULT_ZOOM } from "@/constants"; import { useIsMobile } from "@/hooks/useIsMobile"; import { MapType } from "@/server/models/MapView"; -import { drawAtom } from "../atoms/mapStateAtoms"; +import { useDraw } from "../hooks/useDraw"; import { useSetZoom } from "../hooks/useMapCamera"; import { getClickedPolygonFeature, @@ -31,7 +30,7 @@ import { import { useMapRef } from "../hooks/useMapCore"; import { useMapHoverEffect } from "../hooks/useMapHover"; import { useTurfMutations } from "../hooks/useTurfMutations"; -import { useTurfState } from "../hooks/useTurfState"; +import { useTurfState, useWatchDrawModeEffect } from "../hooks/useTurfState"; import { CONTROL_PANEL_WIDTH, mapColors } from "../styles"; import Choropleth from "./Choropleth"; import { MAPBOX_SOURCE_IDS } from "./Choropleth/configs"; @@ -66,8 +65,7 @@ export default function Map({ const markerQueries = useMarkerQueries(); const [styleLoaded, setStyleLoaded] = useState(false); - const [draw, setDraw] = useState(null); - const setDrawAtom = useSetAtom(drawAtom); + const [draw, setDraw] = useDraw(); const [currentMode, setCurrentMode] = useState(""); const [didInitialFit, setDidInitialFit] = useState(false); @@ -88,6 +86,7 @@ export default function Map({ useMapClickEffect({ markerLayers, draw, currentMode, ready }); useMapHoverEffect({ markerLayers, draw, ready }); + useWatchDrawModeEffect(); // draw existing turfs useEffect(() => { @@ -380,7 +379,6 @@ export default function Map({ ], }); setDraw(newDraw); - setDrawAtom(newDraw); const mapInstance = map.getMap(); mapInstance.addControl(newDraw, "bottom-right"); @@ -487,7 +485,6 @@ export default function Map({ mapRef.current.getMap().removeControl(draw); } setDraw(null); - setDrawAtom(null); setReady(false); setStyleLoaded(false); }} diff --git a/src/app/map/[id]/hooks/useDraw.ts b/src/app/map/[id]/hooks/useDraw.ts new file mode 100644 index 00000000..a39bcce9 --- /dev/null +++ b/src/app/map/[id]/hooks/useDraw.ts @@ -0,0 +1,6 @@ +import { useAtom } from "jotai"; +import { drawAtom } from "../atoms/mapStateAtoms"; + +export const useDraw = () => { + return useAtom(drawAtom); +}; diff --git a/src/app/map/[id]/hooks/useTurfState.ts b/src/app/map/[id]/hooks/useTurfState.ts index 116b6c97..875e17d8 100644 --- a/src/app/map/[id]/hooks/useTurfState.ts +++ b/src/app/map/[id]/hooks/useTurfState.ts @@ -1,9 +1,9 @@ "use client"; -import { useAtom, useAtomValue } from "jotai"; +import { useAtom } from "jotai"; import { useCallback, useEffect, useMemo } from "react"; -import { drawAtom } from "../atoms/mapStateAtoms"; import { turfVisibilityAtom } from "../atoms/turfAtoms"; +import { useDraw } from "./useDraw"; import { useMapControls } from "./useMapControls"; import { useMapRef } from "./useMapCore"; import { useTurfsQuery } from "./useTurfsQuery"; @@ -13,54 +13,10 @@ export function useTurfState() { const mapRef = useMapRef(); const { data: turfs = [] } = useTurfsQuery(); const { editAreaMode, setEditAreaMode } = useMapControls(); - const draw = useAtomValue(drawAtom); + const [draw] = useDraw(); const [turfVisibility, _setTurfVisibility] = useAtom(turfVisibilityAtom); - // Listen to draw mode changes and update editAreaMode accordingly - useEffect(() => { - const map = mapRef?.current; - if (!map) return; - - const handleModeChange = (e: DrawModeChangeEvent) => { - // Set editAreaMode to true if in draw_polygon mode, false otherwise - setEditAreaMode(e.mode === "draw_polygon"); - }; - - map.on("draw.modechange", handleModeChange); - - return () => { - map.off("draw.modechange", handleModeChange); - }; - }, [mapRef, setEditAreaMode]); - - // When edit mode is turned off elsewhere, forcibly return draw to a neutral state - useEffect(() => { - if (editAreaMode) return; - - const map = mapRef?.current; - if (map) { - map.getCanvas().style.cursor = ""; - } - - if (draw) { - try { - const changeMode = draw.changeMode as (mode: string) => void; - changeMode("simple_select"); - } catch { - // Ignore errors when draw is not fully initialized - } - } - - const drawButton = document.querySelector( - ".mapbox-gl-draw_polygon", - ) as HTMLButtonElement | null; - if (drawButton) { - drawButton.classList.remove("active"); - drawButton.setAttribute("aria-pressed", "false"); - } - }, [draw, editAreaMode, mapRef]); - const setTurfVisibility = useCallback( (turfId: string, isVisible: boolean) => { _setTurfVisibility((prev) => ({ ...prev, [turfId]: isVisible })); @@ -127,3 +83,53 @@ export function useTurfState() { getTurfVisibility, }; } + +export function useWatchDrawModeEffect() { + const mapRef = useMapRef(); + const [draw] = useDraw(); + const { editAreaMode, setEditAreaMode } = useMapControls(); + + // Listen to draw mode changes and update editAreaMode accordingly + useEffect(() => { + const map = mapRef?.current; + if (!map) return; + + const handleModeChange = (e: DrawModeChangeEvent) => { + // Set editAreaMode to true if in draw_polygon mode, false otherwise + setEditAreaMode(e.mode === "draw_polygon"); + }; + + map.on("draw.modechange", handleModeChange); + + return () => { + map.off("draw.modechange", handleModeChange); + }; + }, [mapRef, setEditAreaMode]); + + // When edit mode is turned off elsewhere, forcibly return draw to a neutral state + useEffect(() => { + if (editAreaMode) return; + + const map = mapRef?.current; + if (map) { + map.getCanvas().style.cursor = ""; + } + + if (draw) { + try { + const changeMode = draw.changeMode as (mode: string) => void; + changeMode("simple_select"); + } catch { + // Ignore errors when draw is not fully initialized + } + } + + const drawButton = document.querySelector( + ".mapbox-gl-draw_polygon", + ) as HTMLButtonElement | null; + if (drawButton) { + drawButton.classList.remove("active"); + drawButton.setAttribute("aria-pressed", "false"); + } + }, [draw, editAreaMode, mapRef]); +} From 6e0d2a5b9fb3663eaff5ac4ecd52e161fc021cd1 Mon Sep 17 00:00:00 2001 From: Joaquim d'Souza Date: Thu, 18 Dec 2025 16:38:50 +0100 Subject: [PATCH 42/45] fix: move useEffect from useMapControls into its own effect hook --- src/app/map/[id]/components/Map.tsx | 2 ++ src/app/map/[id]/hooks/useMapControls.ts | 43 ++++++++++++++---------- 2 files changed, 27 insertions(+), 18 deletions(-) diff --git a/src/app/map/[id]/components/Map.tsx b/src/app/map/[id]/components/Map.tsx index 95d58807..cd942eaf 100644 --- a/src/app/map/[id]/components/Map.tsx +++ b/src/app/map/[id]/components/Map.tsx @@ -24,6 +24,7 @@ import { } from "../hooks/useMapClick"; import { useEditAreaMode, + useMapControlsEscapeKeyEffect, usePinDropMode, useShowControls, } from "../hooks/useMapControls"; @@ -87,6 +88,7 @@ export default function Map({ useMapClickEffect({ markerLayers, draw, currentMode, ready }); useMapHoverEffect({ markerLayers, draw, ready }); useWatchDrawModeEffect(); + useMapControlsEscapeKeyEffect(); // draw existing turfs useEffect(() => { diff --git a/src/app/map/[id]/hooks/useMapControls.ts b/src/app/map/[id]/hooks/useMapControls.ts index 34a59c4b..448d8f07 100644 --- a/src/app/map/[id]/hooks/useMapControls.ts +++ b/src/app/map/[id]/hooks/useMapControls.ts @@ -17,7 +17,7 @@ export function useMapControls() { const [pinDropMode, setPinDropMode] = useAtom(pinDropModeAtom); const [editAreaMode, setEditAreaMode] = useAtom(editAreaModeAtom); const [compareGeographiesMode, setCompareGeographiesMode] = useAtom( - compareGeographiesAtom, + compareGeographiesAtom ); const togglePinDrop = useCallback( @@ -38,7 +38,7 @@ export function useMapControls() { setCompareGeographiesMode(false); handleDropPin(); }, - [pinDropMode, setPinDropMode, setCompareGeographiesMode], + [pinDropMode, setPinDropMode, setCompareGeographiesMode] ); const toggleAddArea = useCallback( @@ -62,7 +62,7 @@ export function useMapControls() { handleAddArea(); setEditAreaMode(true); }, - [editAreaMode, setPinDropMode, setCompareGeographiesMode, setEditAreaMode], + [editAreaMode, setPinDropMode, setCompareGeographiesMode, setEditAreaMode] ); const toggleCompareGeographies = useCallback( @@ -74,9 +74,30 @@ export function useMapControls() { setCompareGeographiesMode(!compareGeographiesMode); }, - [compareGeographiesMode, setCompareGeographiesMode, setPinDropMode], + [compareGeographiesMode, setCompareGeographiesMode, setPinDropMode] ); + return { + showControls, + setShowControls, + pinDropMode, + setPinDropMode, + editAreaMode, + setEditAreaMode, + compareGeographiesMode, + setCompareGeographiesMode, + togglePinDrop, + toggleAddArea, + toggleCompareGeographies, + }; +} + +export function useMapControlsEscapeKeyEffect() { + const [pinDropMode, setPinDropMode] = useAtom(pinDropModeAtom); + const [editAreaMode, setEditAreaMode] = useAtom(editAreaModeAtom); + const [compareGeographiesMode, setCompareGeographiesMode] = useAtom( + compareGeographiesAtom + ); // Listen for escape key and cancel active modes useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { @@ -106,20 +127,6 @@ export function useMapControls() { setEditAreaMode, setCompareGeographiesMode, ]); - - return { - showControls, - setShowControls, - pinDropMode, - setPinDropMode, - editAreaMode, - setEditAreaMode, - compareGeographiesMode, - setCompareGeographiesMode, - togglePinDrop, - toggleAddArea, - toggleCompareGeographies, - }; } // Individual hooks for granular access From 13a954e8ae86a95c09479c5f95ec53638d12da28 Mon Sep 17 00:00:00 2001 From: ev <4164774+ev-sc@users.noreply.github.com> Date: Thu, 18 Dec 2025 17:37:04 +0100 Subject: [PATCH 43/45] refactor: streamline area addition logic and integrate edit area mode hooks --- .../controls/TurfsControl/TurfsControl.tsx | 21 ++++++++++++------- src/app/map/[id]/hooks/useMapControls.ts | 10 ++++----- 2 files changed, 18 insertions(+), 13 deletions(-) diff --git a/src/app/map/[id]/components/controls/TurfsControl/TurfsControl.tsx b/src/app/map/[id]/components/controls/TurfsControl/TurfsControl.tsx index 4d042c5e..b06cc914 100644 --- a/src/app/map/[id]/components/controls/TurfsControl/TurfsControl.tsx +++ b/src/app/map/[id]/components/controls/TurfsControl/TurfsControl.tsx @@ -2,6 +2,10 @@ import { ArrowRight, PlusIcon } from "lucide-react"; import { useState } from "react"; import IconButtonWithTooltip from "@/components/IconButtonWithTooltip"; import { LayerType } from "@/types"; +import { + useEditAreaMode, + useSetEditAreaMode, +} from "../../../hooks/useMapControls"; import { useTurfsQuery } from "../../../hooks/useTurfsQuery"; import { useTurfState } from "../../../hooks/useTurfState"; import LayerControlWrapper from "../LayerControlWrapper"; @@ -11,17 +15,18 @@ import TurfItem from "./TurfItem"; export default function AreasControl() { const { handleAddArea } = useTurfState(); - const [isAddingArea, setAddingArea] = useState(false); + const editAreaMode = useEditAreaMode(); + const setEditAreaMode = useSetEditAreaMode(); const [expanded, setExpanded] = useState(true); const { data: turfs = [] } = useTurfsQuery(); const onAddArea = () => { - handleAddArea(); - setAddingArea(true); - - setTimeout(() => { - setAddingArea(false); - }, 5000); + if (editAreaMode) { + setEditAreaMode(false); + } else { + setEditAreaMode(true); + handleAddArea(); + } }; return ( @@ -33,7 +38,7 @@ export default function AreasControl() { setExpanded={setExpanded} enableVisibilityToggle={Boolean(turfs?.length)} > - {!isAddingArea ? ( + {!editAreaMode ? ( onAddArea()}> diff --git a/src/app/map/[id]/hooks/useMapControls.ts b/src/app/map/[id]/hooks/useMapControls.ts index 448d8f07..29b6464f 100644 --- a/src/app/map/[id]/hooks/useMapControls.ts +++ b/src/app/map/[id]/hooks/useMapControls.ts @@ -17,7 +17,7 @@ export function useMapControls() { const [pinDropMode, setPinDropMode] = useAtom(pinDropModeAtom); const [editAreaMode, setEditAreaMode] = useAtom(editAreaModeAtom); const [compareGeographiesMode, setCompareGeographiesMode] = useAtom( - compareGeographiesAtom + compareGeographiesAtom, ); const togglePinDrop = useCallback( @@ -38,7 +38,7 @@ export function useMapControls() { setCompareGeographiesMode(false); handleDropPin(); }, - [pinDropMode, setPinDropMode, setCompareGeographiesMode] + [pinDropMode, setPinDropMode, setCompareGeographiesMode], ); const toggleAddArea = useCallback( @@ -62,7 +62,7 @@ export function useMapControls() { handleAddArea(); setEditAreaMode(true); }, - [editAreaMode, setPinDropMode, setCompareGeographiesMode, setEditAreaMode] + [editAreaMode, setPinDropMode, setCompareGeographiesMode, setEditAreaMode], ); const toggleCompareGeographies = useCallback( @@ -74,7 +74,7 @@ export function useMapControls() { setCompareGeographiesMode(!compareGeographiesMode); }, - [compareGeographiesMode, setCompareGeographiesMode, setPinDropMode] + [compareGeographiesMode, setCompareGeographiesMode, setPinDropMode], ); return { @@ -96,7 +96,7 @@ export function useMapControlsEscapeKeyEffect() { const [pinDropMode, setPinDropMode] = useAtom(pinDropModeAtom); const [editAreaMode, setEditAreaMode] = useAtom(editAreaModeAtom); const [compareGeographiesMode, setCompareGeographiesMode] = useAtom( - compareGeographiesAtom + compareGeographiesAtom, ); // Listen for escape key and cancel active modes useEffect(() => { From 74710ad45d53a9274e2eed74c09274f39874a7e0 Mon Sep 17 00:00:00 2001 From: ev <4164774+ev-sc@users.noreply.github.com> Date: Thu, 18 Dec 2025 18:36:31 +0100 Subject: [PATCH 44/45] feat: enhance Choropleth component to conditionally render selected area outlines based on selection state --- .../map/[id]/components/Choropleth/index.tsx | 380 +++++++++--------- .../Choropleth/useChoroplethAreaStats.ts | 54 ++- 2 files changed, 233 insertions(+), 201 deletions(-) diff --git a/src/app/map/[id]/components/Choropleth/index.tsx b/src/app/map/[id]/components/Choropleth/index.tsx index bc186e33..a19b66ff 100644 --- a/src/app/map/[id]/components/Choropleth/index.tsx +++ b/src/app/map/[id]/components/Choropleth/index.tsx @@ -1,8 +1,10 @@ +import { useAtomValue } from "jotai"; import { Layer, Source } from "react-map-gl/mapbox"; import { getMapStyle } from "@/app/map/[id]/context/MapContext"; import { useChoropleth } from "@/app/map/[id]/hooks/useChoropleth"; import { useMapViews } from "@/app/map/[id]/hooks/useMapViews"; import { MapType } from "@/server/models/MapView"; +import { selectedAreasAtom } from "../../atoms/selectedAreasAtom"; import { mapColors } from "../../styles"; import { useChoroplethAreaStats } from "./useChoroplethAreaStats"; @@ -13,6 +15,8 @@ export default function Choropleth() { mapbox: { featureCodeProperty, featureNameProperty, sourceId, layerId }, }, } = useChoropleth(); + const selectedAreas = useAtomValue(selectedAreasAtom); + const hasSelectedAreas = selectedAreas.length > 0; const choroplethTopLayerId = "choropleth-top"; // Custom hooks for effects @@ -102,49 +106,51 @@ export default function Choropleth() { } {/* Selected areas outline (green) - only when not active */} - + }} + layout={{ + "line-cap": "round", + "line-join": "round", + }} + /> + )} {/* Active outline - only when not selected */} - {/* Active + Selected outline: blue outside, green inside offset */} - - - {/* Active + Selected inner outline (green offset inside) */} - - - {/* Active + Selected outer outline (blue offset outside with dashes) */} - + {/* Active + Selected combined outline layers */} + {hasSelectedAreas && ( + <> + + + + + )} {/* Symbol Layer (Labels) */} {viewConfig.mapType !== MapType.Hex && viewConfig.showLabels && ( diff --git a/src/app/map/[id]/components/Choropleth/useChoroplethAreaStats.ts b/src/app/map/[id]/components/Choropleth/useChoroplethAreaStats.ts index 322c00ca..24af2852 100644 --- a/src/app/map/[id]/components/Choropleth/useChoroplethAreaStats.ts +++ b/src/app/map/[id]/components/Choropleth/useChoroplethAreaStats.ts @@ -20,6 +20,10 @@ export function useChoroplethAreaStats() { // Keep track of area codes that have feature state, to clean if necessary const areaCodesToClean = useRef>({}); + // Track previous values to avoid re-setting feature state for unchanged areas + const prevAreaStatValues = useRef< + Map + >(new Map()); // Get fill color const fillColor = useFillColor({ @@ -30,42 +34,64 @@ export function useChoroplethAreaStats() { }); useEffect(() => { - if (!areaStats || !mapRef?.current) { + const map = mapRef?.current; + if (!areaStats || !map) { return; } // Check if the source exists before proceeding - const source = mapRef.current.getSource(sourceId); + const source = map.getSource(sourceId); if (!source) { return; } - // Overwrite previous feature states then remove any that weren't - // overwritten, to avoid flicker and a bug where gaps would appear const nextAreaCodesToClean: Record = {}; + const nextStatValues = new Map< + string, + { primary: number | null; secondary: number | null } + >(); + + // Only set feature state when the values actually change to avoid expensive re-renders areaStats.stats.forEach((stat) => { - mapRef.current?.setFeatureState( - { - source: sourceId, - sourceLayer: layerId, - id: stat.areaCode, - }, - { value: stat.primary, secondaryValue: stat.secondary }, - ); + const key = stat.areaCode; + const prev = prevAreaStatValues.current.get(key); + const next = { + primary: typeof stat.primary === "number" ? stat.primary : null, + secondary: typeof stat.secondary === "number" ? stat.secondary : null, + }; + nextStatValues.set(key, next); + + if ( + !prev || + prev.primary !== next.primary || + prev.secondary !== next.secondary + ) { + map.setFeatureState( + { + source: sourceId, + sourceLayer: layerId, + id: stat.areaCode, + }, + { value: stat.primary, secondaryValue: stat.secondary }, + ); + } + nextAreaCodesToClean[stat.areaCode] = true; }); - // Remove lingering feature states + // Remove lingering feature states for areas no longer present for (const areaCode of Object.keys(areaCodesToClean.current)) { if (!nextAreaCodesToClean[areaCode]) { - mapRef?.current?.removeFeatureState({ + map.removeFeatureState({ source: sourceId, sourceLayer: layerId, id: areaCode, }); } } + areaCodesToClean.current = nextAreaCodesToClean; + prevAreaStatValues.current = nextStatValues; }, [areaStats, lastLoadedSourceId, layerId, mapRef, sourceId]); return fillColor; From 5a06d803cd423d8439ae70943bcf9d0283667c7b Mon Sep 17 00:00:00 2001 From: ev <4164774+ev-sc@users.noreply.github.com> Date: Thu, 18 Dec 2025 18:39:59 +0100 Subject: [PATCH 45/45] fix: simplify line layer rendering in Choropleth component --- .../map/[id]/components/Choropleth/index.tsx | 34 +++++++++---------- 1 file changed, 16 insertions(+), 18 deletions(-) diff --git a/src/app/map/[id]/components/Choropleth/index.tsx b/src/app/map/[id]/components/Choropleth/index.tsx index a19b66ff..8612933c 100644 --- a/src/app/map/[id]/components/Choropleth/index.tsx +++ b/src/app/map/[id]/components/Choropleth/index.tsx @@ -86,24 +86,22 @@ export default function Choropleth() { /> {/* Line Layer - show for both boundary-only and choropleth */} - { - - } + {/* Selected areas outline (green) - only when not active */} {hasSelectedAreas && (