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(),
}),