diff --git a/src/app/map/[id]/components/AreaPopup.tsx b/src/app/map/[id]/components/AreaPopup.tsx index 7d8c2c67..5acb4759 100644 --- a/src/app/map/[id]/components/AreaPopup.tsx +++ b/src/app/map/[id]/components/AreaPopup.tsx @@ -33,6 +33,7 @@ function WrappedAreaPopup({ }) { const { viewConfig } = useMapViews(); const choroplethDataSource = useChoroplethDataSource(); + const [, setHoverArea] = useHoverArea(); const areaStatsQuery = useAreaStats(); const areaStats = areaStatsQuery.data; @@ -104,6 +105,7 @@ function WrappedAreaPopup({ longitude={coordinates[0]} latitude={coordinates[1]} closeButton={false} + onClose={() => setHoverArea(null)} >

{name}

diff --git a/src/app/map/[id]/components/Choropleth/index.tsx b/src/app/map/[id]/components/Choropleth/index.tsx index 4f0dc6df..fdb2ff69 100644 --- a/src/app/map/[id]/components/Choropleth/index.tsx +++ b/src/app/map/[id]/components/Choropleth/index.tsx @@ -1,8 +1,8 @@ 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 { getMapStyle } from "../../utils/map"; import { useChoroplethAreaStats } from "./useChoroplethAreaStats"; export default function Choropleth() { diff --git a/src/app/map/[id]/components/Map.tsx b/src/app/map/[id]/components/Map.tsx index c2f292b8..ffcf155e 100644 --- a/src/app/map/[id]/components/Map.tsx +++ b/src/app/map/[id]/components/Map.tsx @@ -4,10 +4,6 @@ import * as turf from "@turf/turf"; import { useCallback, useEffect, useMemo, useState } from "react"; import MapGL from "react-map-gl/mapbox"; import { v4 as uuidv4 } from "uuid"; -import { - getDataSourceIds, - getMapStyle, -} from "@/app/map/[id]/context/MapContext"; import { useMapBounds } from "@/app/map/[id]/hooks/useMapBounds"; import { useMapConfig } from "@/app/map/[id]/hooks/useMapConfig"; import { useMapViews } from "@/app/map/[id]/hooks/useMapViews"; @@ -26,6 +22,7 @@ import { useMapRef } from "../hooks/useMapCore"; import { useMapHoverEffect } from "../hooks/useMapHover"; import { useTurfMutations, useTurfState } from "../hooks/useTurfs"; import { CONTROL_PANEL_WIDTH, mapColors } from "../styles"; +import { getDataSourceIds, getMapStyle } from "../utils/map"; import AreaPopup from "./AreaPopup"; import Choropleth from "./Choropleth"; import { MAPBOX_SOURCE_IDS } from "./Choropleth/configs"; diff --git a/src/app/map/[id]/components/MapViews.tsx b/src/app/map/[id]/components/MapViews.tsx index 5b110be5..1acb8d4f 100644 --- a/src/app/map/[id]/components/MapViews.tsx +++ b/src/app/map/[id]/components/MapViews.tsx @@ -20,7 +20,6 @@ import { CSS } from "@dnd-kit/utilities"; import { Check, Layers, Plus, X } from "lucide-react"; import React, { useEffect, useMemo, useRef, useState } from "react"; import { v4 as uuidv4 } from "uuid"; -import { createNewViewConfig } from "@/app/map/[id]/context/MapContext"; import { useMapViews } from "@/app/map/[id]/hooks/useMapViews"; import ContextMenuContentWithFocus from "@/components/ContextMenuContentWithFocus"; import { Button } from "@/shadcn/ui/button"; @@ -35,12 +34,13 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/shadcn/ui/tooltip"; import { cn } from "@/shadcn/utils"; import { useMapId } from "../hooks/useMapCore"; import { useDirtyViewIds, useSetViewId } from "../hooks/useMapViews"; +import { createNewViewConfig } from "../utils/mapView"; import { compareByPositionAndId, getNewPositionAfter, getNewPositionBefore, sortByPositionAndId, -} from "../utils"; +} from "../utils/position"; import type { View } from "../types"; import type { DragEndEvent } from "@dnd-kit/core"; diff --git a/src/app/map/[id]/components/controls/MarkersControl/MarkersList.tsx b/src/app/map/[id]/components/controls/MarkersControl/MarkersList.tsx index 060d4c92..b6cb8fd3 100644 --- a/src/app/map/[id]/components/controls/MarkersControl/MarkersList.tsx +++ b/src/app/map/[id]/components/controls/MarkersControl/MarkersList.tsx @@ -34,7 +34,7 @@ import { getNewPositionAfter, getNewPositionBefore, sortByPositionAndId, -} from "@/app/map/[id]/utils"; +} from "@/app/map/[id]/utils/position"; import { useTRPC } from "@/services/trpc/react"; import { LayerType } from "@/types"; import { useMapId } from "../../../hooks/useMapCore"; diff --git a/src/app/map/[id]/components/controls/MarkersControl/SortableFolderItem.tsx b/src/app/map/[id]/components/controls/MarkersControl/SortableFolderItem.tsx index 30991560..8f5f0e6a 100644 --- a/src/app/map/[id]/components/controls/MarkersControl/SortableFolderItem.tsx +++ b/src/app/map/[id]/components/controls/MarkersControl/SortableFolderItem.tsx @@ -13,7 +13,7 @@ import { } from "lucide-react"; import { useMemo, useState } from "react"; -import { sortByPositionAndId } from "@/app/map/[id]/utils"; +import { sortByPositionAndId } from "@/app/map/[id]/utils/position"; import { ContextMenu } from "@/shadcn/ui/context-menu"; import { cn } from "@/shadcn/utils"; import { LayerType } from "@/types"; diff --git a/src/app/map/[id]/components/controls/MarkersControl/UnassignedFolder.tsx b/src/app/map/[id]/components/controls/MarkersControl/UnassignedFolder.tsx index 1a3cbd01..cf33e545 100644 --- a/src/app/map/[id]/components/controls/MarkersControl/UnassignedFolder.tsx +++ b/src/app/map/[id]/components/controls/MarkersControl/UnassignedFolder.tsx @@ -4,7 +4,7 @@ import { verticalListSortingStrategy, } from "@dnd-kit/sortable"; import { useMemo } from "react"; -import { sortByPositionAndId } from "@/app/map/[id]/utils"; +import { sortByPositionAndId } from "@/app/map/[id]/utils/position"; import SortableMarkerItem from "./SortableMarkerItem"; import type { Folder } from "@/server/models/Folder"; import type { PlacedMarker } from "@/server/models/PlacedMarker"; diff --git a/src/app/map/[id]/components/controls/VisualisationPanel/VisualisationPanel.tsx b/src/app/map/[id]/components/controls/VisualisationPanel/VisualisationPanel.tsx index ec979064..e37037c1 100644 --- a/src/app/map/[id]/components/controls/VisualisationPanel/VisualisationPanel.tsx +++ b/src/app/map/[id]/components/controls/VisualisationPanel/VisualisationPanel.tsx @@ -331,6 +331,26 @@ export default function VisualisationPanel({ )} + + {viewConfig.calculationType !== CalculationType.Count && + columnOneIsNumber && ( + <> + + + + updateViewConfig({ areaDataNullIsZero: v }) + } + /> + + )}
{!viewConfig.areaDataSourceId && (
diff --git a/src/app/map/[id]/components/inspector/TurfMarkersList.tsx b/src/app/map/[id]/components/inspector/TurfMarkersList.tsx index 9dc1d985..6d68f589 100644 --- a/src/app/map/[id]/components/inspector/TurfMarkersList.tsx +++ b/src/app/map/[id]/components/inspector/TurfMarkersList.tsx @@ -1,7 +1,6 @@ import { useQueries } from "@tanstack/react-query"; import { useMemo } from "react"; -import { getDataSourceIds } from "@/app/map/[id]/context/MapContext"; import { useDataSources } from "@/app/map/[id]/hooks/useDataSources"; import { useFoldersQuery } from "@/app/map/[id]/hooks/useFolders"; import { useInspector } from "@/app/map/[id]/hooks/useInspector"; @@ -11,6 +10,7 @@ import { DataSourceRecordType } from "@/server/models/DataSource"; import { FilterType } from "@/server/models/MapView"; import { useTRPC } from "@/services/trpc/react"; import { buildName } from "@/utils/dataRecord"; +import { getDataSourceIds } from "../../utils/map"; import { getMarkersInsidePolygon, groupPlacedMarkersByFolder, diff --git a/src/app/map/[id]/data.ts b/src/app/map/[id]/data.ts index 118d7097..eae666f0 100644 --- a/src/app/map/[id]/data.ts +++ b/src/app/map/[id]/data.ts @@ -47,6 +47,7 @@ export const useAreaStats = () => { areaDataColumn: column, areaDataSecondaryColumn: secondaryColumn, areaDataSourceId: dataSourceId, + areaDataNullIsZero: nullIsZero, areaSetGroupCode, } = viewConfig; @@ -106,6 +107,7 @@ export const useAreaStats = () => { dataSourceId, column: columnOrCount, secondaryColumn: secondaryColumn, + nullIsZero, excludeColumns, boundingBox: requiresBoundingBox ? boundingBox : null, }, @@ -121,6 +123,7 @@ export const useAreaStats = () => { dataSourceId, column, secondaryColumn, + nullIsZero, excludeColumns, ]); diff --git a/src/app/map/[id]/hooks/useFolders.ts b/src/app/map/[id]/hooks/useFolders.ts index 0db7dcef..81f3ac98 100644 --- a/src/app/map/[id]/hooks/useFolders.ts +++ b/src/app/map/[id]/hooks/useFolders.ts @@ -5,7 +5,7 @@ import { useIsMutating } from "@tanstack/react-query"; import { useCallback } from "react"; import { toast } from "sonner"; import { useTRPC } from "@/services/trpc/react"; -import { getNewLastPosition } from "../utils"; +import { getNewLastPosition } from "../utils/position"; import { useMapId } from "./useMapCore"; import { useMapQuery } from "./useMapQuery"; import type { Folder } from "@/server/models/Folder"; diff --git a/src/app/map/[id]/hooks/useInitialMapView.ts b/src/app/map/[id]/hooks/useInitialMapView.ts index 15535ba4..bcb7459b 100644 --- a/src/app/map/[id]/hooks/useInitialMapView.ts +++ b/src/app/map/[id]/hooks/useInitialMapView.ts @@ -1,9 +1,9 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useEffect, useRef } from "react"; import { v4 as uuidv4 } from "uuid"; -import { createNewViewConfig } from "@/app/map/[id]/context/MapContext"; import { useTRPC } from "@/services/trpc/react"; -import { getNewLastPosition } from "../utils"; +import { createNewViewConfig } from "../utils/mapView"; +import { getNewLastPosition } from "../utils/position"; import { useMapId } from "./useMapCore"; import { useMapQuery } from "./useMapQuery"; import { useViewIdAtom } from "./useMapViews"; diff --git a/src/app/map/[id]/hooks/useMapViews.ts b/src/app/map/[id]/hooks/useMapViews.ts index eef6d74a..9465c6e9 100644 --- a/src/app/map/[id]/hooks/useMapViews.ts +++ b/src/app/map/[id]/hooks/useMapViews.ts @@ -4,12 +4,12 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useAtom, useAtomValue, useSetAtom } from "jotai"; import { useCallback, useContext, useMemo } from "react"; import { toast } from "sonner"; -import { createNewViewConfig } from "@/app/map/[id]/context/MapContext"; import { AreaSetGroupCode } from "@/server/models/AreaSet"; import { MapType, type MapViewConfig } from "@/server/models/MapView"; import { useTRPC } from "@/services/trpc/react"; import { dirtyViewIdsAtom, viewIdAtom } from "../atoms/mapStateAtoms"; -import { getNewLastPosition } from "../utils"; +import { createNewViewConfig } from "../utils/mapView"; +import { getNewLastPosition } from "../utils/position"; import { PublicMapContext } from "../view/[viewIdOrHost]/publish/context/PublicMapContext"; import { useMapId } from "./useMapCore"; import { useMapQuery } from "./useMapQuery"; diff --git a/src/app/map/[id]/hooks/useMarkerQueries.ts b/src/app/map/[id]/hooks/useMarkerQueries.ts index ade5d100..95ce4d41 100644 --- a/src/app/map/[id]/hooks/useMarkerQueries.ts +++ b/src/app/map/[id]/hooks/useMarkerQueries.ts @@ -2,9 +2,9 @@ import { useQueries } from "@tanstack/react-query"; import { useContext, useMemo } from "react"; -import { getDataSourceIds } from "@/app/map/[id]/context/MapContext"; import { useMapConfig } from "@/app/map/[id]/hooks/useMapConfig"; import { useMapViews } from "@/app/map/[id]/hooks/useMapViews"; +import { getDataSourceIds } from "../utils/map"; import { PublicMapContext } from "../view/[viewIdOrHost]/publish/context/PublicMapContext"; import type { MarkerFeatureWithoutDataSourceId } from "@/types"; diff --git a/src/app/map/[id]/hooks/usePlacedMarkers.ts b/src/app/map/[id]/hooks/usePlacedMarkers.ts index 577177be..411d468c 100644 --- a/src/app/map/[id]/hooks/usePlacedMarkers.ts +++ b/src/app/map/[id]/hooks/usePlacedMarkers.ts @@ -15,7 +15,7 @@ import { searchMarkerAtom, selectedPlacedMarkerIdAtom, } from "../atoms/markerAtoms"; -import { getNewLastPosition } from "../utils"; +import { getNewLastPosition } from "../utils/position"; import { useSetPinDropMode } from "./useMapControls"; import { useMapId, useMapRef } from "./useMapCore"; import { useMapQuery } from "./useMapQuery"; diff --git a/src/app/map/[id]/utils/map.ts b/src/app/map/[id]/utils/map.ts new file mode 100644 index 00000000..7246fe81 --- /dev/null +++ b/src/app/map/[id]/utils/map.ts @@ -0,0 +1,21 @@ +import { MapType } from "@/server/models/MapView"; +import mapStyles, { hexMapStyle } from "../styles"; +import type { MapConfig } from "@/server/models/Map"; +import type { MapViewConfig } from "@/server/models/MapView"; + +export const getDataSourceIds = (mapConfig: MapConfig) => { + return new Set( + [mapConfig.membersDataSourceId] + .concat(mapConfig.markerDataSourceIds) + .filter(Boolean), + ) + .values() + .toArray(); +}; + +export const getMapStyle = (viewConfig: MapViewConfig) => { + if (viewConfig.mapType === MapType.Hex) { + return hexMapStyle; + } + return mapStyles[viewConfig.mapStyleName] || Object.values(mapStyles)[0]; +}; diff --git a/src/app/map/[id]/context/MapContext.tsx b/src/app/map/[id]/utils/mapView.ts similarity index 52% rename from src/app/map/[id]/context/MapContext.tsx rename to src/app/map/[id]/utils/mapView.ts index fc53f76c..12358b27 100644 --- a/src/app/map/[id]/context/MapContext.tsx +++ b/src/app/map/[id]/utils/mapView.ts @@ -2,16 +2,14 @@ import { CalculationType, ColorScheme, MapStyleName, - MapType, } from "@/server/models/MapView"; -import mapStyles, { hexMapStyle } from "../styles"; -import type { MapConfig } from "@/server/models/Map"; import type { MapViewConfig } from "@/server/models/MapView"; export const createNewViewConfig = (): MapViewConfig => { return { areaDataSourceId: "", areaDataColumn: "", + areaDataNullIsZero: true, areaSetGroupCode: null, excludeColumnsString: "", mapStyleName: MapStyleName.Light, @@ -25,20 +23,3 @@ export const createNewViewConfig = (): MapViewConfig => { reverseColorScheme: false, }; }; - -export const getDataSourceIds = (mapConfig: MapConfig) => { - return new Set( - [mapConfig.membersDataSourceId] - .concat(mapConfig.markerDataSourceIds) - .filter(Boolean), - ) - .values() - .toArray(); -}; - -export const getMapStyle = (viewConfig: MapViewConfig) => { - if (viewConfig.mapType === MapType.Hex) { - return hexMapStyle; - } - return mapStyles[viewConfig.mapStyleName] || Object.values(mapStyles)[0]; -}; diff --git a/src/app/map/[id]/utils.ts b/src/app/map/[id]/utils/position.ts similarity index 100% rename from src/app/map/[id]/utils.ts rename to src/app/map/[id]/utils/position.ts diff --git a/src/app/map/[id]/view/[viewIdOrHost]/publish/components/DataSourcesSelect.tsx b/src/app/map/[id]/view/[viewIdOrHost]/publish/components/DataSourcesSelect.tsx index d463c274..aa3f30ed 100644 --- a/src/app/map/[id]/view/[viewIdOrHost]/publish/components/DataSourcesSelect.tsx +++ b/src/app/map/[id]/view/[viewIdOrHost]/publish/components/DataSourcesSelect.tsx @@ -1,8 +1,8 @@ import { Database } from "lucide-react"; import { useContext } from "react"; -import { getDataSourceIds } from "@/app/map/[id]/context/MapContext"; import { useDataSources } from "@/app/map/[id]/hooks/useDataSources"; import { useMapConfig } from "@/app/map/[id]/hooks/useMapConfig"; +import { getDataSourceIds } from "@/app/map/[id]/utils/map"; import { Button } from "@/shadcn/ui/button"; import { DropdownMenu, diff --git a/src/app/map/[id]/view/[viewIdOrHost]/publish/providers/PublicMapProvider.tsx b/src/app/map/[id]/view/[viewIdOrHost]/publish/providers/PublicMapProvider.tsx index 301b687c..fd0628bb 100644 --- a/src/app/map/[id]/view/[viewIdOrHost]/publish/providers/PublicMapProvider.tsx +++ b/src/app/map/[id]/view/[viewIdOrHost]/publish/providers/PublicMapProvider.tsx @@ -1,9 +1,9 @@ "use client"; import { useEffect, useState } from "react"; -import { getDataSourceIds } from "@/app/map/[id]/context/MapContext"; import { useDataSources } from "@/app/map/[id]/hooks/useDataSources"; import { useMapConfig } from "@/app/map/[id]/hooks/useMapConfig"; +import { getDataSourceIds } from "@/app/map/[id]/utils/map"; import { createDataSourceConfig } from "../components/DataSourcesSelect"; import { PublicMapContext } from "../context/PublicMapContext"; import type { diff --git a/src/server/models/MapView.ts b/src/server/models/MapView.ts index b67bf7bc..59f7fa3f 100644 --- a/src/server/models/MapView.ts +++ b/src/server/models/MapView.ts @@ -103,6 +103,7 @@ export const mapViewConfigSchema = z.object({ areaDataSourceId: z.string(), areaDataColumn: z.string(), areaDataSecondaryColumn: z.string().optional(), + areaDataNullIsZero: z.boolean().optional(), areaSetGroupCode: areaSetGroupCode.nullish(), choroplethOpacityPct: z.number().optional(), excludeColumnsString: z.string(), diff --git a/src/server/stats/index.ts b/src/server/stats/index.ts index a01b8f9c..630cd327 100644 --- a/src/server/stats/index.ts +++ b/src/server/stats/index.ts @@ -45,6 +45,7 @@ export const getAreaStats = async ({ calculationType, column, secondaryColumn, + nullIsZero, excludeColumns, boundingBox = null, }: { @@ -53,6 +54,7 @@ export const getAreaStats = async ({ calculationType: CalculationType; column: string; secondaryColumn?: string; + nullIsZero?: boolean; excludeColumns: string[]; boundingBox?: BoundingBox | null; }): Promise => { @@ -103,13 +105,14 @@ export const getAreaStats = async ({ throw new Error(`Data source not found: ${dataSourceId}`); } - const primaryStats = await getColumnValueByArea( + const primaryStats = await getColumnValueByArea({ dataSource, areaSetCode, calculationType, column, + nullIsZero, boundingBox, - ); + }); const valueRange = getValueRange(primaryStats.stats); areaStats.primary = { ...primaryStats, @@ -120,13 +123,14 @@ export const getAreaStats = async ({ return areaStats; } - const secondaryStats = await getColumnValueByArea( + const secondaryStats = await getColumnValueByArea({ dataSource, areaSetCode, calculationType, - secondaryColumn, + column: secondaryColumn, + nullIsZero, boundingBox, - ); + }); const secondaryValueRange = getValueRange(secondaryStats.stats); areaStats.secondary = { ...secondaryStats, @@ -226,23 +230,36 @@ export const getMaxColumnByArea = async ( return []; }; -const getColumnValueByArea = async ( - dataSource: DataSource, - areaSetCode: AreaSetCode, - calculationType: CalculationType, - column: string, - boundingBox: BoundingBox | null, -) => { +const getColumnValueByArea = async ({ + dataSource, + areaSetCode, + calculationType, + column, + nullIsZero, + boundingBox, +}: { + dataSource: DataSource; + areaSetCode: AreaSetCode; + calculationType: CalculationType; + column: string; + nullIsZero: boolean | undefined; + boundingBox: BoundingBox | null; +}) => { const columnDef = dataSource.columnDefs.find((c) => c.name === column); if (!columnDef) { throw new Error(`Data source column not found: ${column}`); } + // Coalesce empty values to 0 if set + const numberSelect = nullIsZero + ? sql`(COALESCE(NULLIF(json->>${column}, ''), '0'))::float` + : sql`(NULLIF(json->>${column}, ''))::float`; + // Select is always MODE for ColumnType !== Number const valueSelect = columnDef.type !== ColumnType.Number ? sql`MODE () WITHIN GROUP (ORDER BY json->>${column})`.as("value") - : db.fn(calculationType, [sql`(json->>${column})::float`]).as("value"); + : db.fn(calculationType, [numberSelect]).as("value"); const query = db .selectFrom("dataRecord") diff --git a/src/server/trpc/routers/area.ts b/src/server/trpc/routers/area.ts index c4bcf754..0a24a919 100644 --- a/src/server/trpc/routers/area.ts +++ b/src/server/trpc/routers/area.ts @@ -24,6 +24,7 @@ export const areaRouter = router({ calculationType: z.nativeEnum(CalculationType), column: z.string(), secondaryColumn: z.string().optional(), + nullIsZero: z.boolean().optional(), excludeColumns: z.array(z.string()), boundingBox: boundingBoxSchema.nullish(), }),