diff --git a/src/app/map/[id]/atoms/mapStateAtoms.ts b/src/app/map/[id]/atoms/mapStateAtoms.ts index fce5aa5e..ab3c6336 100644 --- a/src/app/map/[id]/atoms/mapStateAtoms.ts +++ b/src/app/map/[id]/atoms/mapStateAtoms.ts @@ -1,11 +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 compareGeographiesAtom = atom(false); 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]/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/AreaInfo.tsx b/src/app/map/[id]/components/AreaInfo.tsx new file mode 100644 index 00000000..c136bf1b --- /dev/null +++ b/src/app/map/[id]/components/AreaInfo.tsx @@ -0,0 +1,381 @@ +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 { useMemo, useState } from "react"; + +import { ColumnType } from "@/server/models/DataSource"; +import { CalculationType, ColorScheme } from "@/server/models/MapView"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/shadcn/ui/table"; +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"; +import { useMapViews } from "../hooks/useMapViews"; + +const getDisplayValue = ( + calculationType: CalculationType | null | undefined, + areaStats: + | { + columnType: ColumnType; + minValue: number; + maxValue: number; + } + | undefined + | null, + areaStatValue: unknown, +): string => { + if ( + areaStatValue === undefined || + areaStatValue === null || + areaStatValue === "" + ) { + return calculationType === CalculationType.Count ? "0" : "-"; + } + if (areaStats?.columnType !== ColumnType.Number) { + return String(areaStatValue); + } + const value = Number(areaStatValue); + if (isNaN(value)) { + return "-"; + } + if (areaStats?.minValue >= 0 && areaStats?.maxValue <= 1) { + return `${Math.round(value * 1000) / 10}%`; + } + return formatNumber(value); +}; + +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 [hoveredRowArea, setHoveredRowArea] = useState<{ + code: string; + areaSetCode: string; + name: string; + coordinates: [number, number]; + } | null>(null); + const [selectedAreas, setSelectedAreas] = useAtom(selectedAreasAtom); + const areaStatsQuery = useAreaStats(); + const areaStats = areaStatsQuery.data; + const choroplethDataSource = useChoroplethDataSource(); + const { viewConfig } = useMapViews(); + + const fillColor = useFillColor({ + areaStats, + scheme: viewConfig.colorScheme || ColorScheme.RedBlue, + isReversed: Boolean(viewConfig.reverseColorScheme), + selectedBivariateBucket: null, + }); + + // Combine selected areas and hover area, avoiding duplicates + // Memoized to prevent downstream recalculations (especially color expressions) + const areasToDisplay = useMemo(() => { + const areas = []; + + // 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 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 + ? areaStats.calculationType === CalculationType.Count + ? `${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, + ); + } + + // Memoize color calculations for all areas to improve performance + const areaColors = useMemo(() => { + const colors = new Map(); + + if (result !== "success" || !areaStats) { + 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, + }, + ); + + colors.set(`${area.areaSetCode}-${area.code}`, toRGBA(colorResult)); + } + + return colors; + }, [areasToDisplay, areaStats, fillColorExpression, result]); + + // 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)" + ); + }; + + // Early return after all hooks have been called + if (!areaStats) { + return null; + } + + return ( + + {areasToDisplay.length > 0 && ( + + {selectedAreas.length > 0 && ( + + )} + + {multipleAreas && ( + + + + + {statLabel} + + {hasSecondaryData && ( + + {viewConfig.areaDataSecondaryColumn} + + )} + + + )} + + {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 ( + { + if (!area.isSelected) { + setHoveredRowArea(area); + } + }} + 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, + }, + ]); + } + }} + > + +
+
+ {area.name} +
+ + {!multipleAreas && ( + +
+ + )} + + {!multipleAreas ? ( +
+ + {statLabel}: + + {primaryValue} +
+ ) : ( + primaryValue + )} +
+ {hasSecondaryData && ( + + {!multipleAreas ? ( +
+ + {viewConfig.areaDataSecondaryColumn}: + + {secondaryValue} +
+ ) : ( + secondaryValue + )} +
+ )} + + ); + })} + +
+
+ )} +
+ ); +} 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); -}; diff --git a/src/app/map/[id]/components/Choropleth/index.tsx b/src/app/map/[id]/components/Choropleth/index.tsx index 4f0dc6df..8612933c 100644 --- a/src/app/map/[id]/components/Choropleth/index.tsx +++ b/src/app/map/[id]/components/Choropleth/index.tsx @@ -1,8 +1,11 @@ +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"; export default function Choropleth() { @@ -12,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 @@ -81,26 +86,71 @@ export default function Choropleth() { /> {/* Line Layer - show for both boundary-only and choropleth */} - { + + + {/* Selected areas outline (green) - only when not active */} + {hasSelectedAreas && ( - } + )} - {/* Active outline drawn above other lines */} + {/* Active outline - only when not selected */} + {/* Active + Selected combined outline layers */} + {hasSelectedAreas && ( + <> + + + + + )} + {/* Symbol Layer (Labels) */} {viewConfig.mapType !== MapType.Hex && viewConfig.showLabels && ( >({}); + // 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; 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 c2f292b8..cd942eaf 100644 --- a/src/app/map/[id]/components/Map.tsx +++ b/src/app/map/[id]/components/Map.tsx @@ -16,17 +16,23 @@ 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 { useDraw } from "../hooks/useDraw"; import { useSetZoom } from "../hooks/useMapCamera"; import { getClickedPolygonFeature, useMapClickEffect, } from "../hooks/useMapClick"; -import { usePinDropMode, useShowControls } from "../hooks/useMapControls"; +import { + useEditAreaMode, + useMapControlsEscapeKeyEffect, + 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, useWatchDrawModeEffect } from "../hooks/useTurfState"; import { CONTROL_PANEL_WIDTH, mapColors } from "../styles"; -import AreaPopup from "./AreaPopup"; import Choropleth from "./Choropleth"; import { MAPBOX_SOURCE_IDS } from "./Choropleth/configs"; import FilterMarkers from "./FilterMarkers"; @@ -49,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); @@ -59,7 +66,7 @@ export default function Map({ const markerQueries = useMarkerQueries(); const [styleLoaded, setStyleLoaded] = useState(false); - const [draw, setDraw] = useState(null); + const [draw, setDraw] = useDraw(); const [currentMode, setCurrentMode] = useState(""); const [didInitialFit, setDidInitialFit] = useState(false); @@ -80,6 +87,8 @@ export default function Map({ useMapClickEffect({ markerLayers, draw, currentMode, ready }); useMapHoverEffect({ markerLayers, draw, ready }); + useWatchDrawModeEffect(); + useMapControlsEscapeKeyEffect(); // draw existing turfs useEffect(() => { @@ -118,6 +127,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) => { @@ -481,7 +499,6 @@ export default function Map({ - )} diff --git a/src/app/map/[id]/components/MapMarkerAndAreaControls.tsx b/src/app/map/[id]/components/MapMarkerAndAreaControls.tsx index d43786c3..7ef79049 100644 --- a/src/app/map/[id]/components/MapMarkerAndAreaControls.tsx +++ b/src/app/map/[id]/components/MapMarkerAndAreaControls.tsx @@ -1,37 +1,83 @@ -import { MapPin } from "lucide-react"; +import { ChartBar, MapPin } from "lucide-react"; import VectorSquare from "@/components/icons/VectorSquare"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/shadcn/ui/tooltip"; +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 { handleAddArea, cancelDrawMode } = useTurfState(); + const { + pinDropMode, + editAreaMode, + compareGeographiesMode, + togglePinDrop, + toggleAddArea, + toggleCompareGeographies, + } = useMapControls(); + + const handlePinDropClick = (e: React.MouseEvent) => { + e.stopPropagation(); + togglePinDrop({ cancelDrawMode, handleDropPin }); + }; + + const handleAddAreaClick = (e: React.MouseEvent) => { + e.stopPropagation(); + toggleAddArea({ cancelDrawMode, handleAddArea }); + }; return (
- + Add marker - + Add area +
+ + + + + Compare geographies +
); } diff --git a/src/app/map/[id]/components/MapWrapper.tsx b/src/app/map/[id]/components/MapWrapper.tsx index 4251efe2..042d920e 100644 --- a/src/app/map/[id]/components/MapWrapper.tsx +++ b/src/app/map/[id]/components/MapWrapper.tsx @@ -1,8 +1,15 @@ +import { XIcon } from "lucide-react"; import { useEffect, useState } from "react"; import { MapType } from "@/server/models/MapView"; -import { useShowControls } from "../hooks/useMapControls"; +import { useInspector } from "../hooks/useInspector"; +import { + useCompareGeographiesMode, + useMapControls, + useShowControls, +} from "../hooks/useMapControls"; import { useMapViews } from "../hooks/useMapViews"; import { CONTROL_PANEL_WIDTH, mapColors } from "../styles"; +import AreaInfo from "./AreaInfo"; import InspectorPanel from "./inspector/InspectorPanel"; import MapMarkerAndAreaControls from "./MapMarkerAndAreaControls"; import MapStyleSelector from "./MapStyleSelector"; @@ -20,24 +27,49 @@ export default function MapWrapper({ }) { const showControls = useShowControls(); const { viewConfig } = useMapViews(); + const { inspectorContent } = useInspector(); + const inspectorVisible = Boolean(inspectorContent); + const compareGeographiesMode = useCompareGeographiesMode(); + 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 (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 (compareGeographiesMode) { + setIndicatorColor(mapColors.geography.color); // green-500 + setMessage("Compare mode active. Click geographies to select/deselect."); } else { setIndicatorColor(""); setMessage(""); } - }, [currentMode]); + }, [currentMode, compareGeographiesMode, pinDropMode, editAreaMode]); const absolutelyCenter = { transform: showControls @@ -55,6 +87,20 @@ export default function MapWrapper({
{children} +
+ +
+
)} - {indicatorColor && ( -
- )} {message && (
-

- {message} -

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

{message}

+ +
)} 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..b06cc914 100644 --- a/src/app/map/[id]/components/controls/TurfsControl/TurfsControl.tsx +++ b/src/app/map/[id]/components/controls/TurfsControl/TurfsControl.tsx @@ -2,7 +2,12 @@ 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 { + useEditAreaMode, + useSetEditAreaMode, +} from "../../../hooks/useMapControls"; +import { useTurfsQuery } from "../../../hooks/useTurfsQuery"; +import { useTurfState } from "../../../hooks/useTurfState"; import LayerControlWrapper from "../LayerControlWrapper"; import EmptyLayer from "../LayerEmptyMessage"; import LayerHeader from "../LayerHeader"; @@ -10,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 ( @@ -32,7 +38,7 @@ export default function AreasControl() { setExpanded={setExpanded} enableVisibilityToggle={Boolean(turfs?.length)} > - {!isAddingArea ? ( + {!editAreaMode ? ( onAddArea()}> 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/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/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 3f1dbc88..a3c7c127 100644 --- a/src/app/map/[id]/hooks/useMapClick.ts +++ b/src/app/map/[id]/hooks/useMapClick.ts @@ -1,10 +1,14 @@ import { point as turfPoint } from "@turf/helpers"; import { booleanPointInPolygon } from "@turf/turf"; +import { useAtom } from "jotai"; import { useEffect, useRef } from "react"; - +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 { @@ -38,6 +42,8 @@ export function useMapClickEffect({ setSelectedRecords, setSelectedTurf, } = useInspector(); + const [selectedAreas, setSelectedAreas] = useAtom(selectedAreasAtom); + const compareGeographiesMode = useCompareGeographiesMode(); const { mapbox: { sourceId, layerId, featureCodeProperty, featureNameProperty }, @@ -45,36 +51,116 @@ export function useMapClickEffect({ } = choroplethLayerConfig; const activeFeatureId = useRef(undefined); + const selectedAreasRef = useRef(selectedAreas); + const prevSelectedAreasRef = useRef([]); - /* Handle clicks to set active state */ + // 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(() => { + 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) { + 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; } - if (handleMarkerClick(e)) { - return; - } + const prevSelectedAreas = prevSelectedAreasRef.current; - if (handleTurfClick(e)) { - return; - } + // Update previous selected areas before processing to ensure it's always set + prevSelectedAreasRef.current = selectedAreas; - if (handleAreaClick(e)) { - return; + // 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 + // 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 && + e.sourceDataType === "metadata" + ) { + applyFeatureStates(); } + }; - resetInspector(); + map.on("sourcedata", onSourceData); + + return () => { + map.off("sourcedata", onSourceData); }; + }, [selectedAreas, mapRef, ready, sourceId, layerId, areaSetCode]); + + /* Handle clicks to set active state */ + useEffect(() => { + if (!mapRef?.current || !ready) { + return; + } + + const map = mapRef.current; + const fillLayerId = `${sourceId}-fill`; + const lineLayerId = `${sourceId}-line`; const handleMarkerClick = (e: mapboxgl.MapMouseEvent): boolean => { const validMarkerLayers = markerLayers.filter((l) => map.getLayer(l)); @@ -174,6 +260,22 @@ export function useMapClickEffect({ const areaName = feature.properties?.[featureNameProperty] as string; if (areaCode && areaName && feature.id !== undefined) { + // Check if clicking the same active area + if (activeFeatureId.current === areaCode) { + // Deactivate the current area + map.setFeatureState( + { + source: sourceId, + sourceLayer: layerId, + id: activeFeatureId.current, + }, + { active: false }, + ); + activeFeatureId.current = undefined; + resetInspector(); + return true; + } + // Remove active state from previous feature if (activeFeatureId.current !== undefined) { map.setFeatureState( @@ -210,25 +312,85 @@ export function useMapClickEffect({ return false; }; - map.on("click", onClick); + const handleCtrlAreaClick = (e: mapboxgl.MapMouseEvent): boolean => { + if (!map.getLayer(fillLayerId) && !map.getLayer(lineLayerId)) { + return false; + } - return () => { - // Clean up active state on unmount - if (activeFeatureId.current !== undefined) { - try { - map?.setFeatureState( - { - source: sourceId, - sourceLayer: layerId, - id: activeFeatureId.current, - }, - { active: 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, ); - } catch { - // Ignore error clearing feature state + + 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 (currentModeRef.current === "draw_polygon" || pinDropModeRef.current) { + return; + } + + // Check if compare areas mode is active + if (compareGeographiesModeRef.current) { + if (handleCtrlAreaClick(e)) { + return; + } + } + + if (handleMarkerClick(e)) { + return; + } + + if (handleTurfClick(e)) { + return; + } + + if (handleAreaClick(e)) { + return; + } + + resetInspector(); + }; + + map.on("click", onClick); + + return () => { + // Only clean up the event listener, not the active state + // The active state should persist across mode changes map.off("click", onClick); }; }, [ @@ -242,11 +404,10 @@ export function useMapClickEffect({ setSelectedBoundary, markerLayers, draw, - currentMode, - pinDropMode, setSelectedTurf, ready, setSelectedRecords, + setSelectedAreas, ]); // 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..29b6464f 100644 --- a/src/app/map/[id]/hooks/useMapControls.ts +++ b/src/app/map/[id]/hooks/useMapControls.ts @@ -1,24 +1,134 @@ import { useAtom, useAtomValue, useSetAtom } from "jotai"; -import { pinDropModeAtom, showControlsAtom } from "../atoms/mapStateAtoms"; +import { useCallback, useEffect } from "react"; +import { + compareGeographiesAtom, + 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), + * editAreaMode (area editing interaction), and compareGeographiesMode (multi-select geographies) */ 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); + const [editAreaMode, setEditAreaMode] = useAtom(editAreaModeAtom); + const [compareGeographiesMode, setCompareGeographiesMode] = useAtom( + 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) { + // 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, setEditAreaMode], + ); + + const toggleCompareGeographies = useCallback( + ({ cancelDrawMode }: { cancelDrawMode: () => void }) => { + if (!compareGeographiesMode) { + setPinDropMode(false); + cancelDrawMode(); + } + + setCompareGeographiesMode(!compareGeographiesMode); + }, + [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) => { + 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, + ]); +} + // Individual hooks for granular access export function useShowControls() { return useAtomValue(showControlsAtom); @@ -43,3 +153,27 @@ 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); +} + +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 abc0e52a..51d53435 100644 --- a/src/app/map/[id]/hooks/useMapHover.ts +++ b/src/app/map/[id]/hooks/useMapHover.ts @@ -1,8 +1,13 @@ 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"; +import { + useCompareGeographiesModeAtom, + useEditAreaMode, + usePinDropMode, +} from "./useMapControls"; import { useMapRef } from "./useMapCore"; import type MapboxDraw from "@mapbox/mapbox-gl-draw"; @@ -24,6 +29,21 @@ export function useMapHoverEffect({ const [, setHoverArea] = useHoverArea(); const [, setHoverMarker] = useHoverMarker(); + const [compareGeographiesMode, setCompareGeographiesMode] = + useCompareGeographiesModeAtom(); + 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(() => { @@ -48,7 +68,35 @@ export function useMapHoverEffect({ } }; + const onKeyDown = (e: KeyboardEvent) => { + if ((e.key === "c" || e.key === "C") && !e.repeat) { + setCompareGeographiesMode(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") { + setCompareGeographiesMode(false); + const canvas = map.getCanvas(); + if (canvas.style.cursor === "copy") { + canvas.style.cursor = "pointer"; + } + } + }; + const onMouseMove = (e: mapboxgl.MapMouseEvent) => { + if (pinDropModeRef.current || editAreaModeRef.current) { + // In draw/pin modes, ignore hover effects and keep crosshair + map.getCanvas().style.cursor = "crosshair"; + clearAreaHover(); + setHoverMarker(null); + return; + } + if (handleHoverMarker(e)) { clearAreaHover(); return; @@ -62,19 +110,26 @@ export function useMapHoverEffect({ if (handleHoverArea(e)) { return; } + + // Clear area hover if mouse is not over any feature + clearAreaHover(); }; const onMouseLeave = () => { - if (hoveredFeatureId !== undefined) { - map.setFeatureState( - { source: sourceId, sourceLayer: layerId, id: hoveredFeatureId }, - { hover: false }, - ); - hoveredFeatureId = undefined; + clearAreaHover(); + setHoverMarker(null); + if (pinDropModeRef.current || editAreaModeRef.current) { + map.getCanvas().style.cursor = "crosshair"; + } else { + map.getCanvas().style.cursor = prevPointer.cursor; } - map.getCanvas().style.cursor = prevPointer.cursor; }; + // Reset cursor when exiting pin/edit modes + if (!(pinDropModeRef.current || editAreaModeRef.current)) { + map.getCanvas().style.cursor = prevPointer.cursor; + } + const handleHoverMarker = (e: mapboxgl.MapMouseEvent): boolean => { const map = mapRef?.current; @@ -129,35 +184,47 @@ 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 (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 = compareGeographiesModeRef.current + ? "copy" + : "pointer"; return true; } @@ -170,7 +237,10 @@ export function useMapHoverEffect({ 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 +249,8 @@ export function useMapHoverEffect({ map.on("mousemove", onMouseMove); map.on("mouseleave", onMouseLeave); + window.addEventListener("keydown", onKeyDown); + window.addEventListener("keyup", onKeyUp); return () => { // Clean up hover state on unmount @@ -194,7 +266,9 @@ export function useMapHoverEffect({ } map.off("mousemove", onMouseMove); - map.off("mouseleave", onMouseLeave); + map.off("mouseout", onMouseLeave); + window.removeEventListener("keydown", onKeyDown); + window.removeEventListener("keyup", onKeyUp); }; }, [ mapRef, @@ -207,6 +281,7 @@ export function useMapHoverEffect({ setHoverArea, featureNameProperty, areaSetCode, + setCompareGeographiesMode, ]); } diff --git a/src/app/map/[id]/hooks/usePlacedMarkers.ts b/src/app/map/[id]/hooks/usePlacedMarkers.ts index 577177be..ddee1ce9 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"; @@ -246,10 +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 @@ -259,6 +283,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..875e17d8 --- /dev/null +++ b/src/app/map/[id]/hooks/useTurfState.ts @@ -0,0 +1,135 @@ +"use client"; + +import { useAtom } from "jotai"; +import { useCallback, useEffect, useMemo } from "react"; +import { turfVisibilityAtom } from "../atoms/turfAtoms"; +import { useDraw } from "./useDraw"; +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] = useDraw(); + + 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]); + + 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 + 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 + setEditAreaMode(false); + }, [draw, mapRef, setEditAreaMode]); + + return { + editAreaMode, + setEditAreaMode, + visibleTurfs, + handleAddArea, + cancelDrawMode, + turfVisibility, + setTurfVisibility, + 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]); +} 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 }; +} 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",