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}
+
{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 && (
- setSelectedAreas([])}
- >
-
-
- )}
-
- {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 && (
+ setSelectedAreas([])}
+ >
+
+
+ )}
+
+ {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,
- },
- ]);
- }
- }}
- >
-
-
-
-
- {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,
+ },
+ ]);
+ }
+ }}
+ >
+
+
+
+
+ {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
+
+
+
+ setCompareAreasMode(!compareAreasMode)}
+ >
+
+
+
+ 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 (
-
+
Add marker
-
+
Add area
-
+
{
- if (hoveredFeatureId !== undefined) {
- map.setFeatureState(
- { source: sourceId, sourceLayer: layerId, id: hoveredFeatureId },
- { hover: false },
- );
- hoveredFeatureId = undefined;
- }
+ clearAreaHover();
+ setHoverMarker(null);
map.getCanvas().style.cursor = prevPointer.cursor;
};
@@ -206,7 +204,7 @@ export function useMapHoverEffect({
};
map.on("mousemove", onMouseMove);
- map.on("mouseleave", onMouseLeave);
+ map.on("mouseout", onMouseLeave);
window.addEventListener("keydown", onKeyDown);
window.addEventListener("keyup", onKeyUp);
@@ -224,7 +222,7 @@ export function useMapHoverEffect({
}
map.off("mousemove", onMouseMove);
- map.off("mouseleave", onMouseLeave);
+ map.off("mouseout", onMouseLeave);
window.removeEventListener("keydown", onKeyDown);
window.removeEventListener("keyup", onKeyUp);
};
@@ -239,6 +237,8 @@ export function useMapHoverEffect({
setHoverArea,
featureNameProperty,
areaSetCode,
+ compareAreasMode,
+ setCompareAreasMode,
]);
}
From e734de576c935832a1d295087ce74c17d751be03 Mon Sep 17 00:00:00 2001
From: ev <4164774+ev-sc@users.noreply.github.com>
Date: Thu, 11 Dec 2025 21:41:38 +0100
Subject: [PATCH 12/45] feat: implement debounced hover area handling and
enhance area display logic
---
src/app/map/[id]/components/AreaInfo.tsx | 92 ++++++++++++++++++++----
1 file changed, 80 insertions(+), 12 deletions(-)
diff --git a/src/app/map/[id]/components/AreaInfo.tsx b/src/app/map/[id]/components/AreaInfo.tsx
index efb417d8..46e14806 100644
--- a/src/app/map/[id]/components/AreaInfo.tsx
+++ b/src/app/map/[id]/components/AreaInfo.tsx
@@ -2,6 +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 { ColumnType } from "@/server/models/DataSource";
import { CalculationType, ColorScheme } from "@/server/models/MapView";
@@ -15,6 +16,7 @@ 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";
@@ -69,12 +71,29 @@ const toRGBA = (expressionResult: unknown) => {
export default function AreaInfo() {
const [hoverArea] = useHoverArea();
+ const [debouncedHoverArea, setDebouncedHoverArea] =
+ useState(null);
+ const [hoveredRowArea, setHoveredRowArea] = useState<{
+ code: string;
+ areaSetCode: string;
+ name: string;
+ 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();
const { viewConfig } = useMapViews();
+ // Debounce hoverArea changes
+ useEffect(() => {
+ const timer = setTimeout(() => {
+ setDebouncedHoverArea(hoverArea);
+ }, 5);
+ return () => clearTimeout(timer);
+ }, [hoverArea]);
+
const fillColor = useFillColor({
areaStats,
scheme: viewConfig.colorScheme || ColorScheme.RedBlue,
@@ -88,6 +107,7 @@ export default function AreaInfo() {
// Combine selected areas and hover area, avoiding duplicates
const areasToDisplay = [];
+ const multipleAreas = selectedAreas.length > 1;
// Add all selected areas
for (const selectedArea of selectedAreas) {
@@ -101,22 +121,60 @@ export default function AreaInfo() {
}
// Add hover area only if it's not already in selected areas
- if (hoverArea) {
+ if (debouncedHoverArea) {
const isHoverAreaSelected = selectedAreas.some(
(a) =>
- a.code === hoverArea.code && a.areaSetCode === hoverArea.areaSetCode,
+ a.code === debouncedHoverArea.code &&
+ a.areaSetCode === debouncedHoverArea.areaSetCode,
);
if (!isHoverAreaSelected) {
areasToDisplay.push({
- code: hoverArea.code,
- name: hoverArea.name,
- areaSetCode: hoverArea.areaSetCode,
- coordinates: hoverArea.coordinates,
+ code: debouncedHoverArea.code,
+ name: debouncedHoverArea.name,
+ areaSetCode: debouncedHoverArea.areaSetCode,
+ coordinates: debouncedHoverArea.coordinates,
isSelected: false,
});
}
}
+ // 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,
+ });
+ }
+ }
+
+ // 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`
@@ -188,7 +246,7 @@ export default function AreaInfo() {
className="border-none"
style={{ tableLayout: "fixed", width: "100%" }}
>
- {areasToDisplay.length > 1 && (
+ {multipleAreas && (
@@ -223,17 +281,27 @@ export default function AreaInfo() {
)
: "-";
- const isSingleRow = areasToDisplay.length === 1;
-
return (
{
+ if (!area.isSelected) {
+ setHoveredRowArea(area);
+ }
+ }}
+ onMouseLeave={() => {
+ setHoveredRowArea(null);
+ }}
onClick={() => {
if (area.isSelected) {
// Remove from selected areas
@@ -270,7 +338,7 @@ export default function AreaInfo() {
- {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() {
setCompareAreasMode(!compareAreasMode)}
+ onClick={() => {
+ if (!compareGeographiesMode) {
+ // Disable other modes first
+ setPinDropMode(false);
+ cancelDrawMode();
+ }
+ setCompareGeographiesMode(!compareGeographiesMode);
+ }}
>
- 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 && (