From fe1c48e349d27c848a7163bfeb536a354c62893b Mon Sep 17 00:00:00 2001 From: Arbyhisenaj <41119392+Arbyhisenaj@users.noreply.github.com> Date: Wed, 14 Jan 2026 13:31:36 +0000 Subject: [PATCH 01/19] cp1-context-menu --- .../components/controls/DataSourceItem.tsx | 245 +++++++++++++++--- .../MarkersControl/SortableMarkerItem.tsx | 172 +++++++++--- .../controls/TurfsControl/TurfItem.tsx | 145 ++++++++--- src/server/trpc/routers/dataSource.ts | 19 +- 4 files changed, 477 insertions(+), 104 deletions(-) diff --git a/src/app/map/[id]/components/controls/DataSourceItem.tsx b/src/app/map/[id]/components/controls/DataSourceItem.tsx index bad6a92f..b10dbe1b 100644 --- a/src/app/map/[id]/components/controls/DataSourceItem.tsx +++ b/src/app/map/[id]/components/controls/DataSourceItem.tsx @@ -1,6 +1,31 @@ +"use client"; + +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { EyeIcon, EyeOffIcon, PencilIcon, TrashIcon } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; +import { toast } from "sonner"; import DataSourceIcon from "@/components/DataSourceIcon"; +import ContextMenuContentWithFocus from "@/components/ContextMenuContentWithFocus"; +import { useTRPC } from "@/services/trpc/react"; +import { + ContextMenu, + ContextMenuItem, + ContextMenuSeparator, + ContextMenuTrigger, +} from "@/shadcn/ui/context-menu"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/shadcn/ui/alert-dialog"; import { LayerType } from "@/types"; import { useLayers } from "../../hooks/useLayers"; +import { useMapConfig } from "../../hooks/useMapConfig"; import { mapColors } from "../../styles"; import ControlWrapper from "./ControlWrapper"; import type { DataSourceType } from "@/server/models/DataSource"; @@ -23,6 +48,15 @@ export default function DataSourceItem({ layerType: LayerType; }) { const { setDataSourceVisibility, getDataSourceVisibility } = useLayers(); + const { mapConfig, updateMapConfig } = useMapConfig(); + const trpc = useTRPC(); + const queryClient = useQueryClient(); + const [isRenaming, setIsRenaming] = useState(false); + const [editName, setEditName] = useState(dataSource.name); + const [showRemoveDialog, setShowRemoveDialog] = useState(false); + const inputRef = useRef(null); + const isFocusing = useRef(false); + const layerColor = layerType === LayerType.Member ? mapColors.member.color @@ -30,42 +64,189 @@ export default function DataSourceItem({ const isVisible = getDataSourceVisibility(dataSource?.id); + // Focus management for rename input + useEffect(() => { + if (isRenaming) { + isFocusing.current = true; + setTimeout(() => { + inputRef.current?.focus(); + inputRef.current?.select(); + }, 10); + setTimeout(() => { + isFocusing.current = false; + }, 500); + } + }, [isRenaming]); + + // Update editName when dataSource.name changes + useEffect(() => { + setEditName(dataSource.name); + }, [dataSource.name]); + + // Update name mutation + const { mutate: updateName } = useMutation( + trpc.dataSource.updateConfig.mutationOptions({ + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: trpc.dataSource.listReadable.queryKey(), + }); + toast.success("Data source renamed successfully"); + setIsRenaming(false); + }, + onError: () => { + toast.error("Failed to rename data source"); + setEditName(dataSource.name); // Reset on error + }, + }) + ); + + const handleSaveRename = () => { + if (editName.trim() && editName !== dataSource.name) { + updateName({ + dataSourceId: dataSource.id, + name: editName.trim(), + }); + } else { + setEditName(dataSource.name); + setIsRenaming(false); + } + }; + + const handleRemoveFromMap = () => { + if (layerType === LayerType.Member) { + // Remove members data source + updateMapConfig({ membersDataSourceId: null }); + toast.success("Data source removed from map"); + } else if (layerType === LayerType.Marker) { + // Remove from marker data sources array + updateMapConfig({ + markerDataSourceIds: mapConfig.markerDataSourceIds.filter( + (id) => id !== dataSource.id + ), + }); + toast.success("Data source removed from map"); + } + setShowRemoveDialog(false); + }; + return ( - - setDataSourceVisibility(dataSource?.id, !isVisible) - } - > - + + + setIsRenaming(true)}> + + Rename + + setDataSourceVisibility(dataSource.id, !isVisible)} + > + {isVisible ? ( + <> + + Hide + ) : ( -

No records

- )} - {dataSource.createdAt && ( -

- Created {new Date(dataSource?.createdAt).toLocaleDateString()} -

+ <> + + Show + )} - - - - -
+ + + setShowRemoveDialog(true)} + > + + Remove from map + + + + + + + + + Remove data source from map? + + This will remove "{dataSource.name}" from this map. The data + source will not be deleted and can be added back later. + + + + Cancel + + Remove from map + + + + + ); } diff --git a/src/app/map/[id]/components/controls/MarkersControl/SortableMarkerItem.tsx b/src/app/map/[id]/components/controls/MarkersControl/SortableMarkerItem.tsx index 5fe0017c..07c0c8e1 100644 --- a/src/app/map/[id]/components/controls/MarkersControl/SortableMarkerItem.tsx +++ b/src/app/map/[id]/components/controls/MarkersControl/SortableMarkerItem.tsx @@ -1,6 +1,27 @@ +"use client"; + import { useSortable } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; -import { useState } from "react"; +import { EyeIcon, EyeOffIcon, PencilIcon, TrashIcon } from "lucide-react"; +import { useEffect, useState } from "react"; +import { toast } from "sonner"; +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuSeparator, + ContextMenuTrigger, +} from "@/shadcn/ui/context-menu"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/shadcn/ui/alert-dialog"; import { LayerType } from "@/types"; import { useMapRef } from "../../../hooks/useMapCore"; import { @@ -8,7 +29,6 @@ import { usePlacedMarkerState, } from "../../../hooks/usePlacedMarkers"; import ControlEditForm from "../ControlEditForm"; -import ControlHoverMenu from "../ControlHoverMenu"; import ControlWrapper from "../ControlWrapper"; import type { PlacedMarker } from "@/server/models/PlacedMarker"; @@ -39,6 +59,7 @@ export default function SortableMarkerItem({ const mapRef = useMapRef(); const [isEditing, setEditing] = useState(false); const [editText, setEditText] = useState(marker.label); + const [showDeleteDialog, setShowDeleteDialog] = useState(false); // Check if this marker is the one being dragged (even outside its container) const isCurrentlyDragging = isDragging || activeId === `marker-${marker.id}`; @@ -64,6 +85,11 @@ export default function SortableMarkerItem({ }); }; + // Update editText when marker.label changes + useEffect(() => { + setEditText(marker.label); + }, [marker.label]); + const onEdit = () => { setEditText(marker.label); setEditing(true); @@ -71,50 +97,116 @@ export default function SortableMarkerItem({ }; const onSubmit = () => { - updatePlacedMarker({ - ...marker, - label: editText, - }); + if (editText.trim() && editText !== marker.label) { + updatePlacedMarker({ + ...marker, + label: editText.trim(), + }); + toast.success("Marker renamed successfully"); + } setEditing(false); setKeyboardCapture(false); }; + const handleDelete = () => { + deletePlacedMarker(marker.id); + setShowDeleteDialog(false); + toast.success("Marker deleted successfully"); + }; + return ( -
- - setPlacedMarkerVisibility(marker.id, !isVisible) - } + <> +
- {isEditing ? ( - - ) : ( - onEdit()} - onDelete={() => deletePlacedMarker(marker.id)} - > - + + + + + Rename + + setPlacedMarkerVisibility(marker.id, !isVisible)} + > + {isVisible ? ( + <> + + Hide + + ) : ( + <> + + Show + + )} + + + setShowDeleteDialog(true)} + > + + Delete + + + + )} + +
+ + + + + Are you absolutely sure? + + This action cannot be undone. This will permanently delete the + marker "{marker.label}". + + + + Cancel + - {marker.label} - - - )} -
-
+ Delete + + + + + ); } diff --git a/src/app/map/[id]/components/controls/TurfsControl/TurfItem.tsx b/src/app/map/[id]/components/controls/TurfsControl/TurfItem.tsx index 4dd9522c..68f31056 100644 --- a/src/app/map/[id]/components/controls/TurfsControl/TurfItem.tsx +++ b/src/app/map/[id]/components/controls/TurfsControl/TurfItem.tsx @@ -1,5 +1,26 @@ +"use client"; + import * as turfLib from "@turf/turf"; -import { useState } from "react"; +import { EyeIcon, EyeOffIcon, PencilIcon, TrashIcon } from "lucide-react"; +import { useEffect, useState } from "react"; +import { toast } from "sonner"; +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuSeparator, + ContextMenuTrigger, +} from "@/shadcn/ui/context-menu"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/shadcn/ui/alert-dialog"; import { LayerType } from "@/types"; import { useShowControls } from "../../../hooks/useMapControls"; import { useMapRef } from "../../../hooks/useMapCore"; @@ -7,7 +28,6 @@ import { useTurfMutations } from "../../../hooks/useTurfMutations"; import { useTurfState } from "../../../hooks/useTurfState"; import { CONTROL_PANEL_WIDTH } from "../../../styles"; import ControlEditForm from "../ControlEditForm"; -import ControlHoverMenu from "../ControlHoverMenu"; import ControlWrapper from "../ControlWrapper"; import type { Turf } from "@/server/models/Turf"; @@ -19,6 +39,7 @@ export default function TurfItem({ turf }: { turf: Turf }) { const [isEditing, setEditing] = useState(false); const [editText, setEditText] = useState(turf.label); + const [showDeleteDialog, setShowDeleteDialog] = useState(false); const handleFlyTo = (turf: Turf) => { const map = mapRef?.current; @@ -47,41 +68,107 @@ export default function TurfItem({ turf }: { turf: Turf }) { const isVisible = getTurfVisibility(turf.id); + // Update editText when turf.label changes + useEffect(() => { + setEditText(turf.label); + }, [turf.label]); + const onEdit = () => { + setEditText(turf.label); setEditing(true); }; const onSubmit = () => { - updateTurf({ ...turf, label: editText }); + if (editText.trim() && editText !== turf.label) { + updateTurf({ ...turf, label: editText.trim() }); + toast.success("Area renamed successfully"); + } setEditing(false); }; + const handleDelete = () => { + deleteTurf(turf.id); + setShowDeleteDialog(false); + toast.success("Area deleted successfully"); + }; + return ( - setTurfVisibility(turf.id, !isVisible)} - > - {isEditing ? ( - - ) : ( - deleteTurf(turf.id)} - onEdit={() => onEdit()} - > - - - )} - + <> + setTurfVisibility(turf.id, !isVisible)} + > + {isEditing ? ( + + ) : ( + + + + + + + + Rename + + setTurfVisibility(turf.id, !isVisible)} + > + {isVisible ? ( + <> + + Hide + + ) : ( + <> + + Show + + )} + + + setShowDeleteDialog(true)} + > + + Delete + + + + )} + + + + + + Are you absolutely sure? + + This action cannot be undone. This will permanently delete the + area "{turf.label || `Area: ${turf.area?.toFixed(2)}m²`}". + + + + Cancel + + Delete + + + + + ); } diff --git a/src/server/trpc/routers/dataSource.ts b/src/server/trpc/routers/dataSource.ts index 1d4d8d1f..e84d46ac 100644 --- a/src/server/trpc/routers/dataSource.ts +++ b/src/server/trpc/routers/dataSource.ts @@ -201,6 +201,7 @@ export const dataSourceRouter = router({ const adaptor = getDataSourceAdaptor(ctx.dataSource); const update = { + name: input.name, columnRoles: input.columnRoles, enrichments: input.enrichments, geocodingConfig: input.geocodingConfig, @@ -239,9 +240,21 @@ export const dataSourceRouter = router({ `Updated ${ctx.dataSource.config.type} data source config: ${ctx.dataSource.id}`, ); - await enqueue("importDataSource", ctx.dataSource.id, { - dataSourceId: ctx.dataSource.id, - }); + // Only trigger import if config fields changed (not just name) + const configFieldsChanged = + input.columnRoles !== undefined || + input.enrichments !== undefined || + input.geocodingConfig !== undefined || + input.dateFormat !== undefined || + input.public !== undefined || + typeof input.autoEnrich === "boolean" || + typeof input.autoImport === "boolean"; + + if (configFieldsChanged) { + await enqueue("importDataSource", ctx.dataSource.id, { + dataSourceId: ctx.dataSource.id, + }); + } return true; }), From cbdfbb5241ac242a5e637b7a27b56e4bcef93a1a Mon Sep 17 00:00:00 2001 From: Arbyhisenaj <41119392+Arbyhisenaj@users.noreply.github.com> Date: Wed, 14 Jan 2026 13:38:27 +0000 Subject: [PATCH 02/19] cp2-consolidating members and markers --- .../MarkersControl/MarkersControl.tsx | 54 ++++++++++++++++--- .../controls/MarkersControl/MarkersList.tsx | 24 +++++++-- .../controls/PrivateMapControls.tsx | 2 - 3 files changed, 69 insertions(+), 11 deletions(-) diff --git a/src/app/map/[id]/components/controls/MarkersControl/MarkersControl.tsx b/src/app/map/[id]/components/controls/MarkersControl/MarkersControl.tsx index 852e0c0c..599a4ec5 100644 --- a/src/app/map/[id]/components/controls/MarkersControl/MarkersControl.tsx +++ b/src/app/map/[id]/components/controls/MarkersControl/MarkersControl.tsx @@ -3,7 +3,10 @@ import { useRouter } from "next/navigation"; import { useState } from "react"; import { v4 as uuidv4 } from "uuid"; -import { useDataSources } from "@/app/map/[id]/hooks/useDataSources"; +import { + useDataSources, + useMembersDataSource, +} from "@/app/map/[id]/hooks/useDataSources"; import { useFolderMutations, useFoldersQuery, @@ -60,7 +63,30 @@ export default function MarkersControl() { }, 200); }; - const getDataSourceDropdownItems = () => { + const membersDataSource = useMembersDataSource(); + + const getMemberDataSourceDropdownItems = () => { + const memberDataSources = + dataSources?.filter((dataSource) => { + return dataSource.recordType === DataSourceRecordType.Members; + }) || []; + + return memberDataSources.map((dataSource) => { + const selected = dataSource.id === mapConfig.membersDataSourceId; + return { + type: "item" as const, + icon: selected ? : null, + label: dataSource.name, + onClick: () => { + updateMapConfig({ + membersDataSourceId: selected ? null : dataSource.id, + }); + }, + }; + }); + }; + + const getMarkerDataSourceDropdownItems = () => { const markerDataSources = dataSources?.filter((dataSource) => { return dataSource.recordType !== DataSourceRecordType.Members; @@ -110,13 +136,29 @@ export default function MarkersControl() { }, { type: "submenu" as const, - label: "Add Marker Collection", - icon: , + label: "Add Member Collection", + icon: , items: [ - ...getDataSourceDropdownItems(), + ...getMemberDataSourceDropdownItems(), + ...(getMemberDataSourceDropdownItems().length > 0 + ? [{ type: "separator" as const }] + : []), { - type: "separator" as const, + type: "item" as const, + label: "Add new data source", + onClick: () => router.push("/data-sources/new"), }, + ], + }, + { + type: "submenu" as const, + label: "Add Marker Collection", + icon: , + items: [ + ...getMarkerDataSourceDropdownItems(), + ...(getMarkerDataSourceDropdownItems().length > 0 + ? [{ type: "separator" as const }] + : []), { type: "item" as const, label: "Add new data source", diff --git a/src/app/map/[id]/components/controls/MarkersControl/MarkersList.tsx b/src/app/map/[id]/components/controls/MarkersControl/MarkersList.tsx index 060d4c92..f6022e18 100644 --- a/src/app/map/[id]/components/controls/MarkersControl/MarkersList.tsx +++ b/src/app/map/[id]/components/controls/MarkersControl/MarkersList.tsx @@ -16,7 +16,10 @@ import { import { useQueryClient } from "@tanstack/react-query"; import { useCallback, useMemo, useState } from "react"; import { createPortal } from "react-dom"; -import { useMarkerDataSources } from "@/app/map/[id]/hooks/useDataSources"; +import { + useMarkerDataSources, + useMembersDataSource, +} from "@/app/map/[id]/hooks/useDataSources"; import { useFolderMutations, useFoldersQuery, @@ -61,6 +64,7 @@ export default function MarkersList() { const { updatePlacedMarker } = usePlacedMarkerMutations(); const { selectedDataSourceId, handleDataSourceSelect } = useTable(); const markerDataSources = useMarkerDataSources(); + const membersDataSource = useMembersDataSource(); const [activeId, setActiveId] = useState(null); @@ -410,13 +414,27 @@ export default function MarkersList() { className={`${viewConfig.showLocations ? "opacity-100" : "opacity-50"} `} >
- {markerDataSources && + {!membersDataSource && + markerDataSources && markerDataSources.length === 0 && placedMarkers.length === 0 && ( )} - {/* Data sources */} + {/* Member data source */} + {membersDataSource && ( +
+ +
+ )} + + {/* Marker data sources */} {markerDataSources && markerDataSources.length > 0 && (
{markerDataSources.map((dataSource) => ( diff --git a/src/app/map/[id]/components/controls/PrivateMapControls.tsx b/src/app/map/[id]/components/controls/PrivateMapControls.tsx index 0a4449b8..7144b670 100644 --- a/src/app/map/[id]/components/controls/PrivateMapControls.tsx +++ b/src/app/map/[id]/components/controls/PrivateMapControls.tsx @@ -9,7 +9,6 @@ import { CONTROL_PANEL_WIDTH } from "../../styles"; import BoundariesControl from "./BoundariesControl/BoundariesControl"; import MarkersControl from "./MarkersControl/MarkersControl"; -import MembersControl from "./MembersControl/MembersControl"; import TurfsControl from "./TurfsControl/TurfsControl"; export default function PrivateMapControls() { @@ -67,7 +66,6 @@ export default function PrivateMapControls() { > {viewConfig.mapType !== MapType.Hex && ( <> - From bd36727ed2da569c1620dbdd8213c9c91404f054 Mon Sep 17 00:00:00 2001 From: Arbyhisenaj <41119392+Arbyhisenaj@users.noreply.github.com> Date: Wed, 14 Jan 2026 13:50:56 +0000 Subject: [PATCH 03/19] cp3-icon-change --- package-lock.json | 6 ++++-- package.json | 2 +- src/app/map/[id]/components/LayerTypeIcon.tsx | 7 ++++--- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/package-lock.json b/package-lock.json index 66cc8bea..292dda1b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -68,7 +68,7 @@ "jotai": "^2.16.0", "kysely": "^0.27.6", "lucide": "^0.544.0", - "lucide-react": "^0.484.0", + "lucide-react": "^0.562.0", "mapbox-gl": "^3.10.0", "minio": "^8.0.5", "next": "^15.5.7", @@ -22018,7 +22018,9 @@ "license": "ISC" }, "node_modules/lucide-react": { - "version": "0.484.0", + "version": "0.562.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.562.0.tgz", + "integrity": "sha512-82hOAu7y0dbVuFfmO4bYF1XEwYk/mEbM5E+b1jgci/udUBEE/R7LF5Ip0CCEmXe8AybRM8L+04eP+LGZeDvkiw==", "license": "ISC", "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" diff --git a/package.json b/package.json index 78e71c71..d6cd21a2 100644 --- a/package.json +++ b/package.json @@ -79,7 +79,7 @@ "jotai": "^2.16.0", "kysely": "^0.27.6", "lucide": "^0.544.0", - "lucide-react": "^0.484.0", + "lucide-react": "^0.562.0", "mapbox-gl": "^3.10.0", "minio": "^8.0.5", "next": "^15.5.7", diff --git a/src/app/map/[id]/components/LayerTypeIcon.tsx b/src/app/map/[id]/components/LayerTypeIcon.tsx index 8cf1cb9a..4cc76ee8 100644 --- a/src/app/map/[id]/components/LayerTypeIcon.tsx +++ b/src/app/map/[id]/components/LayerTypeIcon.tsx @@ -1,8 +1,9 @@ import { LayoutDashboardIcon, - MapPinIcon, + CircleIcon, SquareIcon, UsersIcon, + VectorSquareIcon, } from "lucide-react"; import { cn } from "@/shadcn/utils"; import { LayerType } from "@/types"; @@ -21,9 +22,9 @@ export default function LayerTypeIcon({ case LayerType.Member: return ; case LayerType.Marker: - return ; + return ; case LayerType.Turf: - return ; + return ; case LayerType.Boundary: return ; default: From 65050211db9876c567210f5e4643ebb52b5747ae Mon Sep 17 00:00:00 2001 From: Arbyhisenaj <41119392+Arbyhisenaj@users.noreply.github.com> Date: Thu, 15 Jan 2026 00:37:29 +0000 Subject: [PATCH 04/19] linear changes --- src/app/map/[id]/colors.ts | 21 ++- src/app/map/[id]/components/AreaInfo.tsx | 1 + .../Choropleth/useChoroplethAreaStats.ts | 1 + src/app/map/[id]/components/Legend.tsx | 107 ++++---------- .../components/controls/LayerEmptyMessage.tsx | 17 ++- .../controls/MarkersControl/MarkersList.tsx | 4 +- .../MarkersControl/SortableFolderItem.tsx | 129 +++++++++++++---- .../controls/TurfsControl/TurfItem.tsx | 4 +- .../controls/TurfsControl/TurfsControl.tsx | 2 +- .../CategoryColorEditor.tsx | 133 ++++++++++++++++++ .../VisualisationPanel/VisualisationPanel.tsx | 67 ++++++--- src/app/map/[id]/hooks/useMapConfig.ts | 6 +- src/server/models/MapView.ts | 1 + 13 files changed, 346 insertions(+), 147 deletions(-) create mode 100644 src/app/map/[id]/components/controls/VisualisationPanel/CategoryColorEditor.tsx diff --git a/src/app/map/[id]/colors.ts b/src/app/map/[id]/colors.ts index 2066efae..2ced5ac5 100644 --- a/src/app/map/[id]/colors.ts +++ b/src/app/map/[id]/colors.ts @@ -97,25 +97,29 @@ export const useColorScheme = ({ areaStats, scheme, isReversed, + categoryColors, }: { areaStats: CombinedAreaStats | null; scheme: ColorScheme; isReversed: boolean; + categoryColors?: Record; }): CategoricColorScheme | NumericColorScheme | null => { // useMemo to cache calculated scales return useMemo(() => { - return getColorScheme({ areaStats, scheme, isReversed }); - }, [areaStats, scheme, isReversed]); + return getColorScheme({ areaStats, scheme, isReversed, categoryColors }); + }, [areaStats, scheme, isReversed, categoryColors]); }; const getColorScheme = ({ areaStats, scheme, isReversed, + categoryColors, }: { areaStats: CombinedAreaStats | null; scheme: ColorScheme; isReversed: boolean; + categoryColors?: Record; }): CategoricColorScheme | NumericColorScheme | null => { if (!areaStats || !areaStats.stats.length) { return null; @@ -129,7 +133,9 @@ const getColorScheme = ({ const colorScale = scaleOrdinal(schemeCategory10).domain(distinctValues); const colorMap: Record = {}; distinctValues.forEach((v) => { - colorMap[v] = getCategoricalColor(v, colorScale); + // Use custom color if provided, otherwise use default + colorMap[v] = + categoryColors?.[v] ?? getCategoricalColor(v, colorScale); }); return { columnType: ColumnType.String, @@ -190,11 +196,13 @@ export const useFillColor = ({ scheme, isReversed, selectedBivariateBucket, + categoryColors, }: { areaStats: CombinedAreaStats | null; scheme: ColorScheme; isReversed: boolean; selectedBivariateBucket: string | null; + categoryColors?: Record; }): DataDrivenPropertyValueSpecification => { // useMemo to cache calculated fillColor return useMemo(() => { @@ -203,7 +211,12 @@ export const useFillColor = ({ } const isCount = areaStats?.calculationType === CalculationType.Count; - const colorScheme = getColorScheme({ areaStats, scheme, isReversed }); + const colorScheme = getColorScheme({ + areaStats, + scheme, + isReversed, + categoryColors, + }); if (!colorScheme) { return DEFAULT_FILL_COLOR; } diff --git a/src/app/map/[id]/components/AreaInfo.tsx b/src/app/map/[id]/components/AreaInfo.tsx index c136bf1b..dc414664 100644 --- a/src/app/map/[id]/components/AreaInfo.tsx +++ b/src/app/map/[id]/components/AreaInfo.tsx @@ -87,6 +87,7 @@ export default function AreaInfo() { scheme: viewConfig.colorScheme || ColorScheme.RedBlue, isReversed: Boolean(viewConfig.reverseColorScheme), selectedBivariateBucket: null, + categoryColors: viewConfig.categoryColors, }); // Combine selected areas and hover area, avoiding duplicates diff --git a/src/app/map/[id]/components/Choropleth/useChoroplethAreaStats.ts b/src/app/map/[id]/components/Choropleth/useChoroplethAreaStats.ts index 24af2852..ae71c9de 100644 --- a/src/app/map/[id]/components/Choropleth/useChoroplethAreaStats.ts +++ b/src/app/map/[id]/components/Choropleth/useChoroplethAreaStats.ts @@ -31,6 +31,7 @@ export function useChoroplethAreaStats() { scheme: viewConfig.colorScheme || ColorScheme.RedBlue, isReversed: Boolean(viewConfig.reverseColorScheme), selectedBivariateBucket, + categoryColors: viewConfig.categoryColors, }); useEffect(() => { diff --git a/src/app/map/[id]/components/Legend.tsx b/src/app/map/[id]/components/Legend.tsx index b43692fc..218ed811 100644 --- a/src/app/map/[id]/components/Legend.tsx +++ b/src/app/map/[id]/components/Legend.tsx @@ -1,27 +1,19 @@ import { Database } from "lucide-react"; +import { useChoropleth } from "@/app/map/[id]/hooks/useChoropleth"; import { useChoroplethDataSource } from "@/app/map/[id]/hooks/useDataSources"; import { useMapViews } from "@/app/map/[id]/hooks/useMapViews"; import { MAX_COLUMN_KEY } from "@/constants"; import { ColumnType } from "@/server/models/DataSource"; import { CalculationType, ColorScheme } from "@/server/models/MapView"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuLabel, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from "@/shadcn/ui/dropdown-menu"; -import { Switch } from "@/shadcn/ui/switch"; -import { cn } from "@/shadcn/utils"; import { formatNumber } from "@/utils/text"; -import { CHOROPLETH_COLOR_SCHEMES, useColorScheme } from "../colors"; +import { useColorScheme } from "../colors"; import { useAreaStats } from "../data"; import BivariateLegend from "./BivariateLagend"; export default function Legend() { const { viewConfig, updateViewConfig } = useMapViews(); const dataSource = useChoroplethDataSource(); + const { setBoundariesPanelOpen } = useChoropleth(); const areaStatsQuery = useAreaStats(); const areaStats = areaStatsQuery?.data; @@ -30,6 +22,7 @@ export default function Legend() { areaStats, scheme: viewConfig.colorScheme || ColorScheme.RedBlue, isReversed: Boolean(viewConfig.reverseColorScheme), + categoryColors: viewConfig.categoryColors, }); if (!colorScheme) { return null; @@ -117,82 +110,32 @@ export default function Legend() { return (
-

- - Locality Data Legend -

{areaStats?.calculationType !== CalculationType.Count && viewConfig.areaDataColumn && viewConfig.areaDataSecondaryColumn ? ( -
+
+ ) : ( - - -
-

- {dataSource?.name} -

-

- {viewConfig.areaDataColumn === MAX_COLUMN_KEY - ? "Highest-value column" - : viewConfig.calculationType === CalculationType.Count - ? "Count" - : viewConfig.areaDataColumn} -

-
{makeBars()}
-
-
- {areaStats?.primary?.columnType === ColumnType.Number && ( - - Choose colour scheme - - {CHOROPLETH_COLOR_SCHEMES.map((option, index) => ( - - updateViewConfig({ colorScheme: option.value }) - } - className="flex items-center gap-2" - > -
- {option.label} - - ))} - -
- - updateViewConfig({ - reverseColorScheme: !viewConfig?.reverseColorScheme, - }) - } - onCheckedChange={() => - updateViewConfig({ - reverseColorScheme: !viewConfig?.reverseColorScheme, - }) - } - /> - -
- - )} - + )}
); diff --git a/src/app/map/[id]/components/controls/LayerEmptyMessage.tsx b/src/app/map/[id]/components/controls/LayerEmptyMessage.tsx index d459294f..5b9f8614 100644 --- a/src/app/map/[id]/components/controls/LayerEmptyMessage.tsx +++ b/src/app/map/[id]/components/controls/LayerEmptyMessage.tsx @@ -1,14 +1,25 @@ import { CornerRightUp } from "lucide-react"; import React from "react"; +import { cn } from "@/shadcn/utils"; export default function LayerEmptyMessage({ message, + onClick, }: { message: React.ReactNode; + onClick?: () => void; }) { + const Component = onClick ? "button" : "div"; return ( -
- {message} -
+ + {message} + ); } diff --git a/src/app/map/[id]/components/controls/MarkersControl/MarkersList.tsx b/src/app/map/[id]/components/controls/MarkersControl/MarkersList.tsx index f6022e18..08a6788f 100644 --- a/src/app/map/[id]/components/controls/MarkersControl/MarkersList.tsx +++ b/src/app/map/[id]/components/controls/MarkersControl/MarkersList.tsx @@ -26,6 +26,7 @@ import { } from "@/app/map/[id]/hooks/useFolders"; import { useMapViews } from "@/app/map/[id]/hooks/useMapViews"; import { + useHandleDropPin, usePlacedMarkerMutations, usePlacedMarkersQuery, } from "@/app/map/[id]/hooks/usePlacedMarkers"; @@ -62,6 +63,7 @@ export default function MarkersList() { const { updateFolder } = useFolderMutations(); const { data: placedMarkers = [] } = usePlacedMarkersQuery(); const { updatePlacedMarker } = usePlacedMarkerMutations(); + const { handleDropPin } = useHandleDropPin(); const { selectedDataSourceId, handleDataSourceSelect } = useTable(); const markerDataSources = useMarkerDataSources(); const membersDataSource = useMembersDataSource(); @@ -418,7 +420,7 @@ export default function MarkersList() { markerDataSources && markerDataSources.length === 0 && placedMarkers.length === 0 && ( - + )} {/* Member data source */} diff --git a/src/app/map/[id]/components/controls/MarkersControl/SortableFolderItem.tsx b/src/app/map/[id]/components/controls/MarkersControl/SortableFolderItem.tsx index 1ac972c1..cbd84b38 100644 --- a/src/app/map/[id]/components/controls/MarkersControl/SortableFolderItem.tsx +++ b/src/app/map/[id]/components/controls/MarkersControl/SortableFolderItem.tsx @@ -7,10 +7,31 @@ import { import { CSS } from "@dnd-kit/utilities"; import { CornerDownRightIcon, + EyeIcon, + EyeOffIcon, Folder as FolderClosed, FolderOpen, + PencilIcon, + TrashIcon, } from "lucide-react"; import { useMemo, useState } from "react"; +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuSeparator, + ContextMenuTrigger, +} from "@/shadcn/ui/context-menu"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/shadcn/ui/alert-dialog"; import { sortByPositionAndId } from "@/app/map/[id]/utils"; import { cn } from "@/shadcn/utils"; @@ -18,7 +39,6 @@ import { LayerType } from "@/types"; import { useFolderMutations } from "../../../hooks/useFolders"; import { usePlacedMarkerState } from "../../../hooks/usePlacedMarkers"; import ControlEditForm from "../ControlEditForm"; -import ControlHoverMenu from "../ControlHoverMenu"; import ControlWrapper from "../ControlWrapper"; import SortableMarkerItem from "./SortableMarkerItem"; import type { Folder } from "@/server/models/Folder"; @@ -72,6 +92,7 @@ export default function SortableFolderItem({ const [isExpanded, setExpanded] = useState(false); const [isEditing, setEditing] = useState(false); const [editText, setEditText] = useState(folder.name); + const [showDeleteDialog, setShowDeleteDialog] = useState(false); const sortedMarkers = useMemo(() => { return sortByPositionAndId(markers); @@ -85,15 +106,9 @@ export default function SortableFolderItem({ setExpanded(!isExpanded); }; - const onDelete = () => { - if ( - !window.confirm( - "Are you sure you want to delete this folder? This action cannot be undone, and any markers in the folder will be lost.", - ) - ) { - return; - } + const handleDelete = () => { deleteFolder(folder.id); + setShowDeleteDialog(false); }; const onEdit = () => { @@ -138,26 +153,61 @@ export default function SortableFolderItem({ onSubmit={onSubmit} /> ) : ( - onDelete()} onEdit={() => onEdit()}> - - + + + + + + + + Rename + + + {isFolderVisible ? ( + <> + + Hide + + ) : ( + <> + + Show + + )} + + + setShowDeleteDialog(true)} + > + + Delete + + + )} @@ -199,6 +249,27 @@ export default function SortableFolderItem({ /> )} + + + + + Are you absolutely sure? + + This action cannot be undone. This will permanently delete the + folder "{folder.name}" and any markers in the folder will be lost. + + + + Cancel + + Delete + + + +
); } diff --git a/src/app/map/[id]/components/controls/TurfsControl/TurfItem.tsx b/src/app/map/[id]/components/controls/TurfsControl/TurfItem.tsx index 68f31056..83f7e823 100644 --- a/src/app/map/[id]/components/controls/TurfsControl/TurfItem.tsx +++ b/src/app/map/[id]/components/controls/TurfsControl/TurfItem.tsx @@ -62,7 +62,7 @@ export default function TurfItem({ turf }: { turf: Turf }) { bottom: padding, }, duration: 1000, - }, + } ); }; @@ -162,7 +162,7 @@ export default function TurfItem({ turf }: { turf: Turf }) { Cancel Delete diff --git a/src/app/map/[id]/components/controls/TurfsControl/TurfsControl.tsx b/src/app/map/[id]/components/controls/TurfsControl/TurfsControl.tsx index be00020d..05462463 100644 --- a/src/app/map/[id]/components/controls/TurfsControl/TurfsControl.tsx +++ b/src/app/map/[id]/components/controls/TurfsControl/TurfsControl.tsx @@ -53,7 +53,7 @@ export default function AreasControl() { {expanded && (
{turfs && turfs.length === 0 && ( - + )}
    {turfs.map((turf) => ( diff --git a/src/app/map/[id]/components/controls/VisualisationPanel/CategoryColorEditor.tsx b/src/app/map/[id]/components/controls/VisualisationPanel/CategoryColorEditor.tsx new file mode 100644 index 00000000..34490a6e --- /dev/null +++ b/src/app/map/[id]/components/controls/VisualisationPanel/CategoryColorEditor.tsx @@ -0,0 +1,133 @@ +import { useMemo, useState } from "react"; +import { useMapViews } from "@/app/map/[id]/hooks/useMapViews"; +import { useAreaStats } from "@/app/map/[id]/data"; +import { useColorScheme } from "@/app/map/[id]/colors"; +import { ColorScheme } from "@/server/models/MapView"; +import { ColumnType } from "@/server/models/DataSource"; +import { Button } from "@/shadcn/ui/button"; +import { Label } from "@/shadcn/ui/label"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/shadcn/ui/dialog"; +import { X } from "lucide-react"; + +export default function CategoryColorEditor() { + const { viewConfig, updateViewConfig } = useMapViews(); + const areaStatsQuery = useAreaStats(); + const areaStats = areaStatsQuery?.data; + const [isOpen, setIsOpen] = useState(false); + + const colorScheme = useColorScheme({ + areaStats, + scheme: viewConfig.colorScheme || ColorScheme.RedBlue, + isReversed: Boolean(viewConfig.reverseColorScheme), + categoryColors: viewConfig.categoryColors, + }); + + // Get unique categories from areaStats + const categories = useMemo(() => { + if (!areaStats || !colorScheme || colorScheme.columnType === ColumnType.Number) { + return []; + } + return Object.keys(colorScheme.colorMap).sort(); + }, [areaStats, colorScheme]); + + const handleColorChange = (category: string, color: string) => { + const currentColors = viewConfig.categoryColors || {}; + updateViewConfig({ + categoryColors: { + ...currentColors, + [category]: color, + }, + }); + }; + + const handleResetColor = (category: string) => { + const currentColors = viewConfig.categoryColors || {}; + const newColors = { ...currentColors }; + delete newColors[category]; + updateViewConfig({ + categoryColors: Object.keys(newColors).length > 0 ? newColors : undefined, + }); + }; + + if (!colorScheme || colorScheme.columnType === ColumnType.Number || categories.length === 0) { + return null; + } + + return ( + + + + + + + Set category colors + +
    + {categories.map((category) => { + const currentColor = + viewConfig.categoryColors?.[category] || + colorScheme.colorMap[category]; + return ( +
    +
    + +
    + {viewConfig.categoryColors?.[category] && ( + + )} +
    + ); + })} + {Object.keys(viewConfig.categoryColors || {}).length > 0 && ( +
    + +
    + )} +
    +
    +
    + ); +} diff --git a/src/app/map/[id]/components/controls/VisualisationPanel/VisualisationPanel.tsx b/src/app/map/[id]/components/controls/VisualisationPanel/VisualisationPanel.tsx index ec979064..a664695f 100644 --- a/src/app/map/[id]/components/controls/VisualisationPanel/VisualisationPanel.tsx +++ b/src/app/map/[id]/components/controls/VisualisationPanel/VisualisationPanel.tsx @@ -5,6 +5,7 @@ import { PieChart, PlusIcon, X, + RotateCwIcon, } from "lucide-react"; import { useMemo, useState } from "react"; import { useChoropleth } from "@/app/map/[id]/hooks/useChoropleth"; @@ -43,6 +44,7 @@ import { getValidAreaSetGroupCodes, } from "../../Choropleth/areas"; import { DataSourceItem } from "./DataSourceItem"; +import CategoryColorEditor from "./CategoryColorEditor"; import type { AreaSetGroupCode } from "@/server/models/AreaSet"; export default function VisualisationPanel({ @@ -59,7 +61,7 @@ export default function VisualisationPanel({ const [searchQuery, setSearchQuery] = useState(""); const [isModalOpen, setIsModalOpen] = useState(false); const [invalidDataSourceId, setInvalidDataSourceId] = useState( - null, + null ); // Update the filtering logic to include search @@ -71,8 +73,8 @@ export default function VisualisationPanel({ (ds) => ds.name.toLowerCase().includes(searchQuery.toLowerCase()) || ds.columnDefs.some((col) => - col.name.toLowerCase().includes(searchQuery.toLowerCase()), - ), + col.name.toLowerCase().includes(searchQuery.toLowerCase()) + ) ); } @@ -97,7 +99,7 @@ export default function VisualisationPanel({
    {/* Data Source Selection */} -
    +
    {viewConfig.areaDataSourceId && dataSource ? ( // Show selected data source as a card - + className="group-hover:bg-neutral-100 transition-colors cursor-pointer rounded-lg" + > + + + +
    ) : ( // Show button to open modal when no data source selected @@ -187,7 +200,7 @@ export default function VisualisationPanel({ {AreaSetGroupCodeLabels[code as AreaSetGroupCode]} - ), + ) )} @@ -278,7 +291,7 @@ export default function VisualisationPanel({ ...(dataSources ?.find((ds) => ds.id === viewConfig.areaDataSourceId) ?.columnDefs.filter( - (col) => col.type === ColumnType.Number, + (col) => col.type === ColumnType.Number ) .map((col) => ({ value: col.name, @@ -301,7 +314,7 @@ export default function VisualisationPanel({ columnOneIsNumber && dataRecordsWillAggregate( dataSource?.geocodingConfig, - viewConfig.areaSetGroupCode, + viewConfig.areaSetGroupCode ) && ( <>
    ); } else { - bars = Object.keys(colorScheme.colorMap) - .toSorted() - .map((key) => ( -
    - {key} -
    - )); + // Filter to only show categories that actually appear in the data + const categoriesInData = new Set( + areaStats?.stats + .map((stat) => String(stat.primary)) + .filter((v) => v && v !== "null" && v !== "undefined") + ); + + bars = ( +
    + {Object.keys(colorScheme.colorMap) + .filter((key) => categoriesInData.has(key)) + .toSorted() + .map((key) => ( +
    +
    + {key} +
    + ))} +
    + ); } return bars; }; @@ -111,8 +126,8 @@ export default function Legend() { return (
    {areaStats?.calculationType !== CalculationType.Count && - viewConfig.areaDataColumn && - viewConfig.areaDataSecondaryColumn ? ( + viewConfig.areaDataColumn && + viewConfig.areaDataSecondaryColumn ? ( + +
    + + + Select columns to include + +
    +

    + Only selected columns will be considered when determining the highest + value column for each area. Leave empty to use all numeric columns. +

    +
    + {numericColumns.map((column) => ( +
    + + handleToggle(column.name, checked === true) + } + /> + +
    + ))} +
    + {numericColumns.length === 0 && ( +

    + No numeric columns found in this data source. +

    + )} +
    +
    + + ); +} export default function VisualisationPanel({ positionLeft, @@ -366,22 +449,25 @@ export default function VisualisationPanel({
    )} - {/* Exclude Columns Input - only show when MAX_COLUMN_KEY is selected */} - {viewConfig.areaDataColumn === MAX_COLUMN_KEY && ( -
    - - - updateViewConfig({ - excludeColumnsString: e.target.value, - }) - } - placeholder="Comma-separated columns to exclude" - value={viewConfig.excludeColumnsString} - className="w-full px-3 py-2 text-sm border border-neutral-200 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" - /> -
    + {/* Include Columns Modal - only show when MAX_COLUMN_KEY is selected */} + {viewConfig.areaDataColumn === MAX_COLUMN_KEY && dataSource && ( + v.trim()) + .filter(Boolean) + : [] + } + onColumnsChange={(columns) => { + updateViewConfig({ + includeColumnsString: + columns.length > 0 ? columns.join(",") : undefined, + }); + }} + /> )}
    diff --git a/src/app/map/[id]/data.ts b/src/app/map/[id]/data.ts index 118d7097..1a7453b6 100644 --- a/src/app/map/[id]/data.ts +++ b/src/app/map/[id]/data.ts @@ -95,6 +95,15 @@ export const useAreaStats = () => { .filter(Boolean); }, [viewConfig.excludeColumnsString]); + const includeColumns = useMemo(() => { + if (!viewConfig.includeColumnsString) return null; + const columns = viewConfig.includeColumnsString + .split(",") + .map((v) => v.trim()) + .filter(Boolean); + return columns.length > 0 ? columns : null; + }, [viewConfig.includeColumnsString]); + // The results of this query aren't used directly, as data for different // bounding boxes needs to be added together. Instead, useEffect is used // to add incoming data to the dedupedAreaStats state. @@ -107,6 +116,7 @@ export const useAreaStats = () => { column: columnOrCount, secondaryColumn: secondaryColumn, excludeColumns, + includeColumns, boundingBox: requiresBoundingBox ? boundingBox : null, }, { enabled: !skipCondition }, @@ -122,6 +132,7 @@ export const useAreaStats = () => { column, secondaryColumn, excludeColumns, + includeColumns, ]); useEffect(() => { diff --git a/src/server/models/MapView.ts b/src/server/models/MapView.ts index 9239c028..ad67ae16 100644 --- a/src/server/models/MapView.ts +++ b/src/server/models/MapView.ts @@ -106,6 +106,7 @@ export const mapViewConfigSchema = z.object({ areaSetGroupCode: areaSetGroupCode.nullish(), choroplethOpacityPct: z.number().optional(), excludeColumnsString: z.string(), + includeColumnsString: z.string().optional(), mapStyleName: z.nativeEnum(MapStyleName), mapType: z.nativeEnum(MapType).optional(), showBoundaryOutline: z.boolean(), diff --git a/src/server/stats/index.ts b/src/server/stats/index.ts index a01b8f9c..88cb9a1d 100644 --- a/src/server/stats/index.ts +++ b/src/server/stats/index.ts @@ -46,6 +46,7 @@ export const getAreaStats = async ({ column, secondaryColumn, excludeColumns, + includeColumns, boundingBox = null, }: { areaSetCode: AreaSetCode; @@ -54,6 +55,7 @@ export const getAreaStats = async ({ column: string; secondaryColumn?: string; excludeColumns: string[]; + includeColumns?: string[] | null; boundingBox?: BoundingBox | null; }): Promise => { const areaStats: AreaStats = { @@ -67,6 +69,7 @@ export const getAreaStats = async ({ areaSetCode, dataSourceId, excludeColumns, + includeColumns, boundingBox, ); const { maxValue, minValue } = getValueRange(stats); @@ -143,16 +146,25 @@ export const getMaxColumnByArea = async ( areaSetCode: string, dataSourceId: string, excludeColumns: string[], + includeColumns: string[] | null = null, boundingBox: BoundingBox | null = null, ) => { const dataSource = await findDataSourceById(dataSourceId); if (!dataSource) return []; const columnNames = dataSource.columnDefs - .filter( - ({ name, type }) => - !excludeColumns.includes(name) && type === ColumnType.Number, - ) + .filter(({ name, type }) => { + // Must be a number column + if (type !== ColumnType.Number) return false; + + // If includeColumns is specified, only include those columns + if (includeColumns && includeColumns.length > 0) { + return includeColumns.includes(name); + } + + // Otherwise, exclude columns in excludeColumns list + return !excludeColumns.includes(name); + }) .map((c) => c.name); if (!columnNames.length) { @@ -168,14 +180,15 @@ export const getMaxColumnByArea = async ( | CaseWhenBuilder, column: string, ) => { + // Cast to float for numeric comparison, not text comparison return caseBuilder .when( db.fn( "GREATEST", - columnNames.map((c) => sql`json->>${c}`), + columnNames.map((c) => sql`(json->>${c})::float`), ), "=", - sql`json->>${column}`, + sql`(json->>${column})::float`, ) .then(column); }; @@ -198,6 +211,14 @@ export const getMaxColumnByArea = async ( // // Finally, SELECT DISTINCT ON ("area_code") to return only this first row // for each area. + // + // We filter out records where all columns are NULL by checking that + // at least one column has a non-NULL numeric value. + const hasNonNullValueCondition = sql`(${sql.join( + columnNames.map((c) => sql`(json->>${c})::float IS NOT NULL`), + sql` OR ` + )})`; + const q = sql` WITH data_record_with_max_column AS ( SELECT @@ -206,6 +227,8 @@ export const getMaxColumnByArea = async ( FROM data_record WHERE data_source_id = ${dataSourceId} AND ${getBoundingBoxSQL(boundingBox)} + AND ${hasNonNullValueCondition} + AND ${caseWhen.end()} IS NOT NULL ) SELECT DISTINCT ON (area_code) area_code as "areaCode", @@ -213,6 +236,7 @@ export const getMaxColumnByArea = async ( FROM ( SELECT area_code, max_column, COUNT(*) AS count FROM data_record_with_max_column + WHERE max_column IS NOT NULL GROUP BY area_code, max_column ORDER BY area_code, count DESC ) subquery; diff --git a/src/server/trpc/routers/area.ts b/src/server/trpc/routers/area.ts index c4bcf754..a16e7795 100644 --- a/src/server/trpc/routers/area.ts +++ b/src/server/trpc/routers/area.ts @@ -25,6 +25,7 @@ export const areaRouter = router({ column: z.string(), secondaryColumn: z.string().optional(), excludeColumns: z.array(z.string()), + includeColumns: z.array(z.string()).optional().nullable(), boundingBox: boundingBoxSchema.nullish(), }), ) From f756155d1e70001f66049902434183ee0bdb3a37 Mon Sep 17 00:00:00 2001 From: Arbyhisenaj <41119392+Arbyhisenaj@users.noreply.github.com> Date: Thu, 15 Jan 2026 11:21:57 +0000 Subject: [PATCH 06/19] centered areainfo --- src/app/map/[id]/components/AreaInfo.tsx | 33 +++++++++++----------- src/app/map/[id]/components/MapWrapper.tsx | 9 +++--- 2 files changed, 20 insertions(+), 22 deletions(-) diff --git a/src/app/map/[id]/components/AreaInfo.tsx b/src/app/map/[id]/components/AreaInfo.tsx index dc414664..4adbbae5 100644 --- a/src/app/map/[id]/components/AreaInfo.tsx +++ b/src/app/map/[id]/components/AreaInfo.tsx @@ -27,10 +27,10 @@ const getDisplayValue = ( calculationType: CalculationType | null | undefined, areaStats: | { - columnType: ColumnType; - minValue: number; - maxValue: number; - } + columnType: ColumnType; + minValue: number; + maxValue: number; + } | undefined | null, areaStatValue: unknown, @@ -270,27 +270,26 @@ export default function AreaInfo() { const primaryValue = areaStat ? getDisplayValue( - areaStats.calculationType, - areaStats.primary, - areaStat.primary, - ) + areaStats.calculationType, + areaStats.primary, + areaStat.primary, + ) : "-"; const secondaryValue = areaStat ? getDisplayValue( - areaStats.calculationType, - areaStats.secondary, - areaStat.secondary, - ) + areaStats.calculationType, + areaStats.secondary, + areaStat.secondary, + ) : "-"; return ( From 4ba87732004600bdee7b067333092ece6fa3e9d9 Mon Sep 17 00:00:00 2001 From: Arbyhisenaj <41119392+Arbyhisenaj@users.noreply.github.com> Date: Thu, 15 Jan 2026 12:43:13 +0000 Subject: [PATCH 07/19] empty state + delete layer item alert dialogue component --- .../data-sources/[id]/DataSourceDashboard.tsx | 53 ++----- src/app/map/[id]/colors.ts | 2 +- .../map/[id]/components/Choropleth/index.tsx | 1 + src/app/map/[id]/components/Legend.tsx | 137 ++++++++++++++++-- .../components/MapMarkerAndAreaControls.tsx | 20 +-- .../BoundariesControl/BoundariesControl.tsx | 9 ++ .../BoundariesControl/LegendControl.tsx | 16 +- .../components/controls/DataSourceItem.tsx | 2 +- .../components/controls/LayerEmptyMessage.tsx | 64 +++++++- .../[id]/components/controls/LayerHeader.tsx | 7 +- .../MarkersControl/MarkersControl.tsx | 20 ++- .../controls/MarkersControl/MarkersList.tsx | 15 +- .../MarkersControl/SortableFolderItem.tsx | 37 +---- .../MarkersControl/SortableMarkerItem.tsx | 37 +---- .../controls/TurfsControl/TurfItem.tsx | 37 +---- .../controls/TurfsControl/TurfsControl.tsx | 4 +- .../VisualisationPanel/VisualisationPanel.tsx | 43 ++++-- src/app/map/[id]/hooks/useLayers.ts | 18 ++- src/components/DeleteConfirmationDialog.tsx | 54 +++++++ src/components/icons/VectorSquare.tsx | 25 ---- 20 files changed, 372 insertions(+), 229 deletions(-) create mode 100644 src/components/DeleteConfirmationDialog.tsx delete mode 100644 src/components/icons/VectorSquare.tsx diff --git a/src/app/(private)/data-sources/[id]/DataSourceDashboard.tsx b/src/app/(private)/data-sources/[id]/DataSourceDashboard.tsx index df44d8bf..296bcb40 100644 --- a/src/app/(private)/data-sources/[id]/DataSourceDashboard.tsx +++ b/src/app/(private)/data-sources/[id]/DataSourceDashboard.tsx @@ -14,17 +14,7 @@ import { Link } from "@/components/Link"; import { DataSourceConfigLabels } from "@/labels"; import { JobStatus } from "@/server/models/DataSource"; import { useTRPC } from "@/services/trpc/react"; -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, - AlertDialogTrigger, -} from "@/shadcn/ui/alert-dialog"; +import DeleteConfirmationDialog from "@/components/DeleteConfirmationDialog"; import { Breadcrumb, BreadcrumbItem, @@ -251,6 +241,7 @@ function DeleteDataSourceButton({ }) { const router = useRouter(); const trpc = useTRPC(); + const [open, setOpen] = useState(false); const { mutate, isPending } = useMutation( trpc.dataSource.delete.mutationOptions({ onSuccess: () => { @@ -264,31 +255,19 @@ function DeleteDataSourceButton({ ); return ( - - - - - - - Are you absolutely sure? - - This action cannot be undone. This will permanently delete your data - source. - - - - Cancel - mutate({ dataSourceId: dataSource.id })} - > - {isPending ? "Deleting..." : "Continue"} - - - - + <> + + mutate({ dataSourceId: dataSource.id })} + isPending={isPending} + confirmButtonText="Continue" + /> + ); } diff --git a/src/app/map/[id]/colors.ts b/src/app/map/[id]/colors.ts index 2ced5ac5..b1b178fd 100644 --- a/src/app/map/[id]/colors.ts +++ b/src/app/map/[id]/colors.ts @@ -270,7 +270,7 @@ export const useFillColor = ({ : ["feature-state", "value"], ...interpolateColorStops, ]; - }, [areaStats, isReversed, scheme, selectedBivariateBucket]); + }, [areaStats, isReversed, scheme, selectedBivariateBucket, categoryColors]); }; const getBivariateFillColor = ( diff --git a/src/app/map/[id]/components/Choropleth/index.tsx b/src/app/map/[id]/components/Choropleth/index.tsx index 62eb1398..5450bfbc 100644 --- a/src/app/map/[id]/components/Choropleth/index.tsx +++ b/src/app/map/[id]/components/Choropleth/index.tsx @@ -49,6 +49,7 @@ export default function Choropleth() { > {/* Fill Layer - only show for choropleth */} { + if (isLayerVisible) { + hideLayer(LayerType.Boundary); + } else { + showLayer(LayerType.Boundary); + } + }; + + // Show legend if data source is selected, even if no column is selected yet + const hasDataSource = Boolean(viewConfig.areaDataSourceId); + const hasColumn = Boolean( + viewConfig.areaDataColumn || viewConfig.calculationType === CalculationType.Count + ); + + if (!hasDataSource) { return null; } + if (!hasColumn || !colorScheme) { + return ( +
    + +
    + ); + } + const makeBars = () => { let bars; if (colorScheme.columnType === ColumnType.Number) { @@ -124,32 +185,76 @@ export default function Legend() { }; return ( -
    +
    {areaStats?.calculationType !== CalculationType.Count && viewConfig.areaDataColumn && viewConfig.areaDataSecondaryColumn ? ( ) : ( )}
    diff --git a/src/app/map/[id]/components/MapMarkerAndAreaControls.tsx b/src/app/map/[id]/components/MapMarkerAndAreaControls.tsx index 7ef79049..8b8b4867 100644 --- a/src/app/map/[id]/components/MapMarkerAndAreaControls.tsx +++ b/src/app/map/[id]/components/MapMarkerAndAreaControls.tsx @@ -1,5 +1,4 @@ -import { ChartBar, MapPin } from "lucide-react"; -import VectorSquare from "@/components/icons/VectorSquare"; +import { ChartBar, MapPin, VectorSquareIcon } from "lucide-react"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/shadcn/ui/tooltip"; import { useMapControls } from "../hooks/useMapControls"; import { useHandleDropPin } from "../hooks/usePlacedMarkers"; @@ -32,11 +31,10 @@ export default function MapMarkerAndAreaControls() { Add area @@ -63,11 +60,10 @@ export default function MapMarkerAndAreaControls() {
    )} diff --git a/src/app/map/[id]/components/controls/BoundariesControl/LegendControl.tsx b/src/app/map/[id]/components/controls/BoundariesControl/LegendControl.tsx index 683a8a6b..ebe76a43 100644 --- a/src/app/map/[id]/components/controls/BoundariesControl/LegendControl.tsx +++ b/src/app/map/[id]/components/controls/BoundariesControl/LegendControl.tsx @@ -1,16 +1,11 @@ -import { Eye } from "lucide-react"; import Legend from "@/app/map/[id]/components/Legend"; import { useMapViews } from "@/app/map/[id]/hooks/useMapViews"; import { useBoundariesControl } from "./useBoundariesControl"; export default function LegendControl() { - const { viewConfig, updateViewConfig } = useMapViews(); + const { viewConfig } = useMapViews(); const { hasShape } = useBoundariesControl(); - const toggleChoropleth = () => { - updateViewConfig({ showChoropleth: !viewConfig.showChoropleth }); - }; - return (
    -
diff --git a/src/app/map/[id]/components/controls/DataSourceItem.tsx b/src/app/map/[id]/components/controls/DataSourceItem.tsx index b10dbe1b..759d9db5 100644 --- a/src/app/map/[id]/components/controls/DataSourceItem.tsx +++ b/src/app/map/[id]/components/controls/DataSourceItem.tsx @@ -240,7 +240,7 @@ export default function DataSourceItem({ Cancel Remove from map diff --git a/src/app/map/[id]/components/controls/LayerEmptyMessage.tsx b/src/app/map/[id]/components/controls/LayerEmptyMessage.tsx index 5b9f8614..523f011c 100644 --- a/src/app/map/[id]/components/controls/LayerEmptyMessage.tsx +++ b/src/app/map/[id]/components/controls/LayerEmptyMessage.tsx @@ -1,14 +1,74 @@ -import { CornerRightUp } from "lucide-react"; +import { CornerRightUp, PlusIcon } from "lucide-react"; import React from "react"; import { cn } from "@/shadcn/utils"; +import MultiDropdownMenu from "@/components/MultiDropdownMenu"; +import type { DropdownMenuItemType } from "@/components/MultiDropdownMenu"; +import { Button } from "@/shadcn/ui/button"; + +// Shared button content component to ensure consistent styling +const AddLayerButtonContent = ({ message }: { message: React.ReactNode }) => ( +
+ {message} +
+); + +// Shared button styling classes - must match exactly +const sharedButtonClasses = "text-sm text-neutral-400 w-full hover:text-neutral-600 hover:bg-neutral-50 transition-colors"; + +// Shared button wrapper component that both cases use +const AddLayerButton = ({ + message, + onClick, + dropdownItems, +}: { + message: React.ReactNode; + onClick?: () => void; + dropdownItems?: DropdownMenuItemType[]; +}) => { + const buttonContent = ; + + if (dropdownItems) { + return ( + + {buttonContent} + + ); + } + + return ( + + ); +}; export default function LayerEmptyMessage({ message, onClick, + dropdownItems, + showAsButton = false, }: { message: React.ReactNode; onClick?: () => void; + dropdownItems?: DropdownMenuItemType[]; + showAsButton?: boolean; }) { + if (dropdownItems || showAsButton) { + return ; + } + const Component = onClick ? "button" : "div"; return ( {message} diff --git a/src/app/map/[id]/components/controls/LayerHeader.tsx b/src/app/map/[id]/components/controls/LayerHeader.tsx index d4198445..ff34e373 100644 --- a/src/app/map/[id]/components/controls/LayerHeader.tsx +++ b/src/app/map/[id]/components/controls/LayerHeader.tsx @@ -37,7 +37,10 @@ export default function LayerHeader({ }; return ( -
+
@@ -224,14 +223,30 @@ export default function VisualisationPanel({ }} /> - +
+ + +
) : ( // Show button to open modal when no data source selected @@ -456,9 +471,9 @@ export default function VisualisationPanel({ selectedColumns={ viewConfig.includeColumnsString ? viewConfig.includeColumnsString - .split(",") - .map((v) => v.trim()) - .filter(Boolean) + .split(",") + .map((v) => v.trim()) + .filter(Boolean) : [] } onColumnsChange={(columns) => { diff --git a/src/app/map/[id]/hooks/useLayers.ts b/src/app/map/[id]/hooks/useLayers.ts index 23d1d060..02ffd85c 100644 --- a/src/app/map/[id]/hooks/useLayers.ts +++ b/src/app/map/[id]/hooks/useLayers.ts @@ -8,11 +8,15 @@ import { hiddenLayersAtom } from "../atoms/layerAtoms"; import { dataSourceVisibilityAtom } from "../atoms/markerAtoms"; import { useTurfsQuery } from "./useTurfsQuery"; import { useTurfState } from "./useTurfState"; +import { usePlacedMarkersQuery } from "./usePlacedMarkers"; +import { usePlacedMarkerState } from "./usePlacedMarkers"; export function useLayers() { const { mapConfig } = useMapConfig(); const { data: turfs = [] } = useTurfsQuery(); const { setTurfVisibility, visibleTurfs } = useTurfState(); + const { data: placedMarkers = [] } = usePlacedMarkersQuery(); + const { setPlacedMarkerVisibility } = usePlacedMarkerState(); const [hiddenLayers, setHiddenLayers] = useAtom(hiddenLayersAtom); const [dataSourceVisibility, _setDataSourceVisibility] = useAtom( @@ -40,11 +44,14 @@ export function useLayers() { (layer: LayerType) => { setHiddenLayers((prev) => prev.filter((l) => l !== layer)); - // TODO: add logic for markers if (layer === LayerType.Member) { if (mapConfig.membersDataSourceId) { setDataSourceVisibility(mapConfig.membersDataSourceId, true); } + } else if (layer === LayerType.Marker) { + placedMarkers.forEach((marker) => + setPlacedMarkerVisibility(marker.id, true), + ); } else if (layer === LayerType.Turf) { turfs.forEach((t) => setTurfVisibility(t.id, true)); } @@ -53,6 +60,8 @@ export function useLayers() { setHiddenLayers, mapConfig.membersDataSourceId, setDataSourceVisibility, + placedMarkers, + setPlacedMarkerVisibility, turfs, setTurfVisibility, ], @@ -62,11 +71,14 @@ export function useLayers() { (layer: LayerType) => { setHiddenLayers((prev) => [...prev, layer]); - // TODO: add logic for markers if (layer === LayerType.Member) { if (mapConfig.membersDataSourceId) { setDataSourceVisibility(mapConfig.membersDataSourceId, false); } + } else if (layer === LayerType.Marker) { + placedMarkers.forEach((marker) => + setPlacedMarkerVisibility(marker.id, false), + ); } else if (layer === LayerType.Turf) { turfs.forEach((t) => setTurfVisibility(t.id, false)); } @@ -75,6 +87,8 @@ export function useLayers() { setHiddenLayers, mapConfig.membersDataSourceId, setDataSourceVisibility, + placedMarkers, + setPlacedMarkerVisibility, turfs, setTurfVisibility, ], diff --git a/src/components/DeleteConfirmationDialog.tsx b/src/components/DeleteConfirmationDialog.tsx new file mode 100644 index 00000000..f5ad8682 --- /dev/null +++ b/src/components/DeleteConfirmationDialog.tsx @@ -0,0 +1,54 @@ +"use client"; + +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/shadcn/ui/alert-dialog"; + +interface DeleteConfirmationDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + title?: string; + description: string; + onConfirm: () => void; + isPending?: boolean; + confirmButtonText?: string; +} + +export default function DeleteConfirmationDialog({ + open, + onOpenChange, + title = "Are you absolutely sure?", + description, + onConfirm, + isPending = false, + confirmButtonText = "Delete", +}: DeleteConfirmationDialogProps) { + return ( + + + + {title} + {description} + + + Cancel + + {isPending ? "Deleting..." : confirmButtonText} + + + + + ); +} + diff --git a/src/components/icons/VectorSquare.tsx b/src/components/icons/VectorSquare.tsx deleted file mode 100644 index df95cd01..00000000 --- a/src/components/icons/VectorSquare.tsx +++ /dev/null @@ -1,25 +0,0 @@ -export default function VectorSquare({ size = 24 }) { - return ( - - - - - - - - - - - ); -} From 565ced9509d129b61a7345e0a691ce7e167b4ea3 Mon Sep 17 00:00:00 2001 From: Arbyhisenaj <41119392+Arbyhisenaj@users.noreply.github.com> Date: Thu, 15 Jan 2026 13:08:18 +0000 Subject: [PATCH 08/19] legend upgrade --- .../data-sources/[id]/DataSourceDashboard.tsx | 6 +- src/app/map/[id]/components/Legend.tsx | 192 +++++++----------- .../components/controls/LayerEmptyMessage.tsx | 2 +- 3 files changed, 81 insertions(+), 119 deletions(-) diff --git a/src/app/(private)/data-sources/[id]/DataSourceDashboard.tsx b/src/app/(private)/data-sources/[id]/DataSourceDashboard.tsx index 296bcb40..2b0ceecb 100644 --- a/src/app/(private)/data-sources/[id]/DataSourceDashboard.tsx +++ b/src/app/(private)/data-sources/[id]/DataSourceDashboard.tsx @@ -257,9 +257,9 @@ function DeleteDataSourceButton({ return ( <> + + Delete data source + - -
- ); - } + const getColumnLabel = () => { + if (!hasColumn) { + return "No column selected"; + } + if (viewConfig.areaDataColumn === MAX_COLUMN_KEY) { + return "Highest-value column"; + } + if (viewConfig.calculationType === CalculationType.Count) { + return "Count"; + } + if (viewConfig.areaDataSecondaryColumn) { + return `${viewConfig.areaDataColumn} vs ${viewConfig.areaDataSecondaryColumn}`; + } + return viewConfig.areaDataColumn; + }; const makeBars = () => { - let bars; + if (!colorScheme) return null; + if (colorScheme.columnType === ColumnType.Number) { const numStops = 24; const stops = new Array(numStops + 1) @@ -108,7 +87,7 @@ export default function Legend() { const numTicks = 5 as number; // number of numeric step labels const denom = Math.max(numTicks - 1, 1); - bars = ( + return (
v && v !== "null" && v !== "undefined") ); - bars = ( + return (
{Object.keys(colorScheme.colorMap) .filter((key) => categoriesInData.has(key)) @@ -181,82 +160,65 @@ export default function Legend() {
); } - return bars; }; + const VisibilityToggle = () => ( +
+
{ + e.stopPropagation(); + toggleLayerVisibility(); + }} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + e.stopPropagation(); + toggleLayerVisibility(); + } + }} + > + {isLayerVisible ? : } +
+
+ ); + return (
- {areaStats?.calculationType !== CalculationType.Count && - viewConfig.areaDataColumn && - viewConfig.areaDataSecondaryColumn ? ( - - ) : ( - - )} + ) : hasColumn && colorScheme ? ( +
{makeBars()}
+ ) : null} +
+
); } + diff --git a/src/app/map/[id]/components/controls/LayerEmptyMessage.tsx b/src/app/map/[id]/components/controls/LayerEmptyMessage.tsx index 523f011c..fc49f782 100644 --- a/src/app/map/[id]/components/controls/LayerEmptyMessage.tsx +++ b/src/app/map/[id]/components/controls/LayerEmptyMessage.tsx @@ -76,7 +76,7 @@ export default function LayerEmptyMessage({ className={cn( "text-sm text-neutral-400 p-2 text-right rounded bg-transparent flex items-center gap-2 justify-end", onClick && - "cursor-pointer hover:text-neutral-600 hover:bg-neutral-50 transition-colors" + "cursor-pointer hover:text-neutral-600 hover:bg-neutral-50 transition-colors" )} > {message} From d36e62c4163fea6a2dd2d4d858ba0a9357ecbf12 Mon Sep 17 00:00:00 2001 From: Arbyhisenaj <41119392+Arbyhisenaj@users.noreply.github.com> Date: Thu, 15 Jan 2026 13:12:11 +0000 Subject: [PATCH 09/19] lint fixes --- .../data-sources/[id]/DataSourceDashboard.tsx | 8 +- src/app/map/[id]/colors.ts | 3 +- src/app/map/[id]/components/AreaInfo.tsx | 33 +++---- src/app/map/[id]/components/LayerTypeIcon.tsx | 3 +- src/app/map/[id]/components/Legend.tsx | 2 +- .../components/MapMarkerAndAreaControls.tsx | 15 ++-- .../BoundariesControl/BoundariesControl.tsx | 2 +- .../components/controls/DataSourceItem.tsx | 20 ++--- .../components/controls/LayerEmptyMessage.tsx | 17 ++-- .../MarkersControl/MarkersControl.tsx | 8 +- .../controls/MarkersControl/MarkersList.tsx | 4 +- .../MarkersControl/SortableFolderItem.tsx | 4 +- .../MarkersControl/SortableMarkerItem.tsx | 6 +- .../controls/TurfsControl/TurfItem.tsx | 4 +- .../controls/TurfsControl/TurfsControl.tsx | 6 +- .../CategoryColorEditor.tsx | 34 +++++--- .../VisualisationPanel/VisualisationPanel.tsx | 44 +++++----- .../components/inspector/InspectorPanel.tsx | 4 +- src/app/map/[id]/hooks/useLayers.ts | 4 +- src/app/map/[id]/hooks/useMapConfig.ts | 6 +- src/components/DeleteConfirmationDialog.tsx | 85 +++++++++---------- src/server/stats/index.ts | 8 +- 22 files changed, 172 insertions(+), 148 deletions(-) diff --git a/src/app/(private)/data-sources/[id]/DataSourceDashboard.tsx b/src/app/(private)/data-sources/[id]/DataSourceDashboard.tsx index 2b0ceecb..579f7dcb 100644 --- a/src/app/(private)/data-sources/[id]/DataSourceDashboard.tsx +++ b/src/app/(private)/data-sources/[id]/DataSourceDashboard.tsx @@ -10,11 +10,11 @@ import { toast } from "sonner"; import DataSourceBadge from "@/components/DataSourceBadge"; import DataSourceRecordTypeIcon from "@/components/DataSourceRecordTypeIcon"; import DefinitionList from "@/components/DefinitionList"; +import DeleteConfirmationDialog from "@/components/DeleteConfirmationDialog"; import { Link } from "@/components/Link"; import { DataSourceConfigLabels } from "@/labels"; import { JobStatus } from "@/server/models/DataSource"; import { useTRPC } from "@/services/trpc/react"; -import DeleteConfirmationDialog from "@/components/DeleteConfirmationDialog"; import { Breadcrumb, BreadcrumbItem, @@ -257,9 +257,9 @@ function DeleteDataSourceButton({ return ( <> + + Delete data source + = {}; distinctValues.forEach((v) => { // Use custom color if provided, otherwise use default - colorMap[v] = - categoryColors?.[v] ?? getCategoricalColor(v, colorScale); + colorMap[v] = categoryColors?.[v] ?? getCategoricalColor(v, colorScale); }); return { columnType: ColumnType.String, diff --git a/src/app/map/[id]/components/AreaInfo.tsx b/src/app/map/[id]/components/AreaInfo.tsx index 4adbbae5..dc414664 100644 --- a/src/app/map/[id]/components/AreaInfo.tsx +++ b/src/app/map/[id]/components/AreaInfo.tsx @@ -27,10 +27,10 @@ const getDisplayValue = ( calculationType: CalculationType | null | undefined, areaStats: | { - columnType: ColumnType; - minValue: number; - maxValue: number; - } + columnType: ColumnType; + minValue: number; + maxValue: number; + } | undefined | null, areaStatValue: unknown, @@ -270,26 +270,27 @@ export default function AreaInfo() { const primaryValue = areaStat ? getDisplayValue( - areaStats.calculationType, - areaStats.primary, - areaStat.primary, - ) + areaStats.calculationType, + areaStats.primary, + areaStat.primary, + ) : "-"; const secondaryValue = areaStat ? getDisplayValue( - areaStats.calculationType, - areaStats.secondary, - areaStat.secondary, - ) + areaStats.calculationType, + areaStats.secondary, + areaStat.secondary, + ) : "-"; return ( diff --git a/src/app/map/[id]/components/controls/VisualisationPanel/VisualisationPanel.tsx b/src/app/map/[id]/components/controls/VisualisationPanel/VisualisationPanel.tsx index fe7926b6..a4634c05 100644 --- a/src/app/map/[id]/components/controls/VisualisationPanel/VisualisationPanel.tsx +++ b/src/app/map/[id]/components/controls/VisualisationPanel/VisualisationPanel.tsx @@ -4,8 +4,8 @@ import { Palette, PieChart, PlusIcon, - X, RotateCwIcon, + X, } from "lucide-react"; import { useMemo, useState } from "react"; import { useChoropleth } from "@/app/map/[id]/hooks/useChoropleth"; @@ -19,6 +19,7 @@ import { AreaSetGroupCodeLabels } from "@/labels"; import { ColumnType } from "@/server/models/DataSource"; import { CalculationType, ColorScheme } from "@/server/models/MapView"; import { Button } from "@/shadcn/ui/button"; +import { Checkbox } from "@/shadcn/ui/checkbox"; import { Combobox } from "@/shadcn/ui/combobox"; import { Dialog, @@ -26,6 +27,7 @@ import { DialogHeader, DialogTitle, } from "@/shadcn/ui/dialog"; +import { DialogTrigger } from "@/shadcn/ui/dialog"; import { Input } from "@/shadcn/ui/input"; import { Label } from "@/shadcn/ui/label"; import { @@ -43,12 +45,10 @@ import { dataRecordsWillAggregate, getValidAreaSetGroupCodes, } from "../../Choropleth/areas"; -import { DataSourceItem } from "./DataSourceItem"; import CategoryColorEditor from "./CategoryColorEditor"; +import { DataSourceItem } from "./DataSourceItem"; import type { AreaSetGroupCode } from "@/server/models/AreaSet"; import type { DataSource } from "@/server/models/DataSource"; -import { Checkbox } from "@/shadcn/ui/checkbox"; -import { DialogTrigger } from "@/shadcn/ui/dialog"; function IncludeColumnsModal({ dataSource, @@ -61,7 +61,7 @@ function IncludeColumnsModal({ }) { const [isOpen, setIsOpen] = useState(false); const numericColumns = dataSource.columnDefs.filter( - (c) => c.type === ColumnType.Number + (c) => c.type === ColumnType.Number, ); const handleToggle = (columnName: string, checked: boolean) => { @@ -81,8 +81,9 @@ function IncludeColumnsModal({ @@ -93,8 +94,9 @@ function IncludeColumnsModal({

- Only selected columns will be considered when determining the highest - value column for each area. Leave empty to use all numeric columns. + Only selected columns will be considered when determining the + highest value column for each area. Leave empty to use all numeric + columns.

{numericColumns.map((column) => ( @@ -143,7 +145,7 @@ export default function VisualisationPanel({ const [searchQuery, setSearchQuery] = useState(""); const [isModalOpen, setIsModalOpen] = useState(false); const [invalidDataSourceId, setInvalidDataSourceId] = useState( - null + null, ); // Update the filtering logic to include search @@ -155,8 +157,8 @@ export default function VisualisationPanel({ (ds) => ds.name.toLowerCase().includes(searchQuery.toLowerCase()) || ds.columnDefs.some((col) => - col.name.toLowerCase().includes(searchQuery.toLowerCase()) - ) + col.name.toLowerCase().includes(searchQuery.toLowerCase()), + ), ); } @@ -181,7 +183,7 @@ export default function VisualisationPanel({
{AreaSetGroupCodeLabels[code as AreaSetGroupCode]} - ) + ), )} @@ -389,7 +391,7 @@ export default function VisualisationPanel({ ...(dataSources ?.find((ds) => ds.id === viewConfig.areaDataSourceId) ?.columnDefs.filter( - (col) => col.type === ColumnType.Number + (col) => col.type === ColumnType.Number, ) .map((col) => ({ value: col.name, @@ -412,7 +414,7 @@ export default function VisualisationPanel({ columnOneIsNumber && dataRecordsWillAggregate( dataSource?.geocodingConfig, - viewConfig.areaSetGroupCode + viewConfig.areaSetGroupCode, ) && ( <>