From c03e363b5dc28c978ef57d212a7ae7c8a9a83af2 Mon Sep 17 00:00:00 2001 From: Arbyhisenaj <41119392+Arbyhisenaj@users.noreply.github.com> Date: Wed, 14 Jan 2026 15:04:04 +0000 Subject: [PATCH 01/21] cp4-icon layers (requires back-end work) --- src/app/map/[id]/components/Icons.tsx | 27 ++- .../components/controls/ControlWrapper.tsx | 10 +- .../components/controls/DataSourceItem.tsx | 103 +++++----- .../[id]/components/controls/LayerIcon.tsx | 194 ++++++++++++++++++ .../MarkersControl/MarkersControl.tsx | 12 +- .../controls/MarkersControl/MarkersList.tsx | 58 ++++-- .../MarkersControl/SortableFolderItem.tsx | 182 +++++++++++++--- .../MarkersControl/SortableMarkerItem.tsx | 53 +++-- .../controls/TurfsControl/TurfItem.tsx | 27 ++- .../controls/TurfsControl/TurfsControl.tsx | 2 +- 10 files changed, 527 insertions(+), 141 deletions(-) create mode 100644 src/app/map/[id]/components/controls/LayerIcon.tsx diff --git a/src/app/map/[id]/components/Icons.tsx b/src/app/map/[id]/components/Icons.tsx index 5dc349261..0cc055834 100644 --- a/src/app/map/[id]/components/Icons.tsx +++ b/src/app/map/[id]/components/Icons.tsx @@ -1,11 +1,34 @@ import React from "react"; -export function CollectionIcon({ color = "currentColor" }: { color?: string }) { +export function MarkerCollectionIcon({ + color = "currentColor", +}: { + color?: string; +}) { return ( - + ); } + +export function MarkerIndividualIcon({ + color = "currentColor", +}: { + color?: string; +}) { + return ( +
+ ); +} diff --git a/src/app/map/[id]/components/controls/ControlWrapper.tsx b/src/app/map/[id]/components/controls/ControlWrapper.tsx index dc6979d68..adf249076 100644 --- a/src/app/map/[id]/components/controls/ControlWrapper.tsx +++ b/src/app/map/[id]/components/controls/ControlWrapper.tsx @@ -34,16 +34,10 @@ export default function ControlWrapper({
-
- -
{children}
- +
{children}
- + + JSX.Element; + +// Icon type mapping - shows all available icon types +const getIconRenderer = (iconType: IconType, color: string): IconRenderer => { + switch (iconType) { + case "folder": + return ({ className, style }) => ( + + ); + case "folder-open": + return ({ className, style }) => ( + + ); + case "marker-collection": + return ({ className, style }) => ; + case "marker-individual": + return ({ className, style }) => ; + case "turf": + return ({ className, style }) => ( + + ); + default: + return ({ className, style }) => ; + } +}; + +// Determine icon type based on props +const getIconType = ( + layerType: LayerType, + isDataSource: boolean, + isFolder: boolean, + isFolderExpanded: boolean +): IconType => { + if (isFolder) { + return isFolderExpanded ? "folder-open" : "folder"; + } + if (layerType === LayerType.Turf) { + return "turf"; + } + return isDataSource ? "marker-collection" : "marker-individual"; +}; + +export default function LayerIcon({ + layerType, + isDataSource = false, + layerColor, + onColorChange, + isFolder = false, + isFolderExpanded = false, +}: { + layerType: LayerType; + isDataSource?: boolean; + layerColor: string; + onColorChange?: (color: string) => void; + isFolder?: boolean; + isFolderExpanded?: boolean; +}) { + const [selectedColor, setSelectedColor] = useState(layerColor); + const [isOpen, setIsOpen] = useState(false); + + // Sync selectedColor with layerColor prop changes + useEffect(() => { + setSelectedColor(layerColor); + }, [layerColor]); + + const handleColorSelect = (color: string) => { + setSelectedColor(color); + onColorChange?.(color); + setIsOpen(false); + }; + + const currentColor = selectedColor || layerColor; + const iconType = getIconType( + layerType, + isDataSource, + isFolder, + isFolderExpanded + ); + const renderIcon = getIconRenderer(iconType, currentColor); + + return ( + + + + + e.stopPropagation()} + > +
+ {COLOR_PALETTE.map((color) => ( + + ))} +
+
+
+ ); +} diff --git a/src/app/map/[id]/components/controls/MarkersControl/MarkersControl.tsx b/src/app/map/[id]/components/controls/MarkersControl/MarkersControl.tsx index 599a4ec50..f0c44b680 100644 --- a/src/app/map/[id]/components/controls/MarkersControl/MarkersControl.tsx +++ b/src/app/map/[id]/components/controls/MarkersControl/MarkersControl.tsx @@ -20,7 +20,7 @@ import { mapColors } from "@/app/map/[id]/styles"; import IconButtonWithTooltip from "@/components/IconButtonWithTooltip"; import { DataSourceRecordType } from "@/server/models/DataSource"; import { LayerType } from "@/types"; -import { CollectionIcon } from "../../Icons"; +import { MarkerCollectionIcon } from "../../Icons"; import LayerControlWrapper from "../LayerControlWrapper"; import LayerHeader from "../LayerHeader"; import MarkersList from "./MarkersList"; @@ -47,7 +47,7 @@ export default function MarkersControl() { const handleManualSearch = () => { setTimeout(() => { const geocoderInput = document.querySelector( - 'mapbox-search-box [class$="--Input"]', + 'mapbox-search-box [class$="--Input"]' ) as HTMLInputElement; if (geocoderInput) { geocoderInput.focus(); @@ -57,7 +57,7 @@ export default function MarkersControl() { e.preventDefault(); geocoderInput.focus(); }, - { once: true }, + { once: true } ); } }, 200); @@ -102,7 +102,7 @@ export default function MarkersControl() { updateMapConfig({ markerDataSourceIds: selected ? mapConfig.markerDataSourceIds.filter( - (id) => id !== dataSource.id, + (id) => id !== dataSource.id ) : [...mapConfig.markerDataSourceIds, dataSource.id], }); @@ -137,7 +137,7 @@ export default function MarkersControl() { { type: "submenu" as const, label: "Add Member Collection", - icon: , + icon: , items: [ ...getMemberDataSourceDropdownItems(), ...(getMemberDataSourceDropdownItems().length > 0 @@ -153,7 +153,7 @@ export default function MarkersControl() { { type: "submenu" as const, label: "Add Marker Collection", - icon: , + icon: , items: [ ...getMarkerDataSourceDropdownItems(), ...(getMarkerDataSourceDropdownItems().length > 0 diff --git a/src/app/map/[id]/components/controls/MarkersControl/MarkersList.tsx b/src/app/map/[id]/components/controls/MarkersControl/MarkersList.tsx index f6022e185..682dee382 100644 --- a/src/app/map/[id]/components/controls/MarkersControl/MarkersList.tsx +++ b/src/app/map/[id]/components/controls/MarkersControl/MarkersList.tsx @@ -41,6 +41,7 @@ import { import { useTRPC } from "@/services/trpc/react"; import { LayerType } from "@/types"; import { useMapId } from "../../../hooks/useMapCore"; +import { mapColors } from "../../../styles"; import DataSourceControl from "../DataSourceItem"; import EmptyLayer from "../LayerEmptyMessage"; import MarkerDragOverlay from "./MarkerDragOverlay"; @@ -68,6 +69,9 @@ export default function MarkersList() { const [activeId, setActiveId] = useState(null); + // Track folder colors - map of folderId -> color + const [folderColors, setFolderColors] = useState>({}); + // Brief pulsing folder animation on some drag actions const [pulsingFolderId, _setPulsingFolderId] = useState(null); @@ -98,12 +102,12 @@ export default function MarkersList() { ...old, placedMarkers: old.placedMarkers?.map((m) => - m.id === placedMarker.id ? fullMarker : m, + m.id === placedMarker.id ? fullMarker : m ) || [], }; }); }, - [mapId, queryClient, trpc.map.byId], + [mapId, queryClient, trpc.map.byId] ); // DnD sensors @@ -119,7 +123,7 @@ export default function MarkersList() { keyboardCodes: keyboardCapture ? { start: [], cancel: [], end: [] } : undefined, - }), + }) ); // Drag and drop handlers @@ -143,7 +147,7 @@ export default function MarkersList() { // Get current cache data (reflects any previous drag over updates) const currentCacheData = queryClient.getQueryData( - trpc.map.byId.queryKey({ mapId }), + trpc.map.byId.queryKey({ mapId }) ); const currentMarkers = currentCacheData?.placedMarkers || []; @@ -166,7 +170,7 @@ export default function MarkersList() { // Get other markers in the target container const otherMarkers = currentMarkers.filter( (m) => - m.id !== activeMarker.id && m.folderId === overMarker.folderId, + m.id !== activeMarker.id && m.folderId === overMarker.folderId ); const newPosition = activeWasBeforeOver @@ -198,7 +202,7 @@ export default function MarkersList() { } const folderMarkers = currentMarkers.filter( - (m) => m.folderId === folderId, + (m) => m.folderId === folderId ); const newPosition = append @@ -215,7 +219,7 @@ export default function MarkersList() { // Only update cache if the marker is not already unassigned if (activeMarker.folderId !== null) { const unassignedMarkers = currentMarkers.filter( - (m) => m.folderId === null, + (m) => m.folderId === null ); const newPosition = getNewFirstPosition(unassignedMarkers); @@ -228,7 +232,7 @@ export default function MarkersList() { } } }, - [mapId, queryClient, trpc.map.byId, updateMarkerInCache], + [mapId, queryClient, trpc.map.byId, updateMarkerInCache] ); const handleDragEndMarker = useCallback( @@ -259,24 +263,30 @@ export default function MarkersList() { } const folderMarkers = placedMarkers.filter( - (m) => m.folderId === folderId, + (m) => m.folderId === folderId ); const newPosition = append ? getNewLastPosition(folderMarkers) : getNewFirstPosition(folderMarkers); + // Get the folder's color + const folderColor = folderColors[folderId] || mapColors.markers.color; + updatePlacedMarker({ ...activeMarker, folderId, position: newPosition, }); + // Update the marker's color in the UI by triggering a re-render + // The color will be applied via the folderColor prop passed to SortableMarkerItem + // Animate movement - pulse the folder that received the marker setPulsingFolderId(folderId); } else if (over && over.id === "unassigned") { const unassignedMarkers = placedMarkers.filter( - (m) => m.folderId === null, + (m) => m.folderId === null ); const newPosition = getNewFirstPosition(unassignedMarkers); updatePlacedMarker({ @@ -298,20 +308,20 @@ export default function MarkersList() { // Get other markers in the SAME container as the over marker const otherMarkers = placedMarkers.filter( (m) => - m.id !== activeMarker.id && m.folderId === overMarker.folderId, + m.id !== activeMarker.id && m.folderId === overMarker.folderId ); if (activeWasBeforeOver) { // If active marker was before, make it after newPosition = getNewPositionAfter( overMarker.position, - otherMarkers, + otherMarkers ); } else { // If active marker was after, make it before newPosition = getNewPositionBefore( overMarker.position, - otherMarkers, + otherMarkers ); } @@ -320,10 +330,12 @@ export default function MarkersList() { folderId: overMarker.folderId, // Move to the same folder as the marker we're dropping on position: newPosition, }); + + // The marker's color will be updated via the folderColor prop passed to SortableMarkerItem } } }, - [placedMarkers, updatePlacedMarker, setPulsingFolderId], + [placedMarkers, updatePlacedMarker, setPulsingFolderId] ); const handleDragEndFolder = useCallback( @@ -354,13 +366,13 @@ export default function MarkersList() { // If active folder was before, make it after newPosition = getNewPositionAfter( overFolder.position, - otherFolders, + otherFolders ); } else { // If active folder was after, make it before newPosition = getNewPositionBefore( overFolder.position, - otherFolders, + otherFolders ); } @@ -368,7 +380,7 @@ export default function MarkersList() { } } }, - [folders, updateFolder], + [folders, updateFolder] ); const handleDragEnd = useCallback( @@ -385,7 +397,7 @@ export default function MarkersList() { // Update UI AFTER handling the drag setActiveId(null); }, - [handleDragEndFolder, handleDragEndMarker], + [handleDragEndFolder, handleDragEndMarker] ); const sortedFolders = useMemo(() => { @@ -400,7 +412,7 @@ export default function MarkersList() { }; return ( -
+
p.folderId === folder.id, + (p) => p.folderId === folder.id )} activeId={activeId} setKeyboardCapture={setKeyboardCapture} isPulsing={pulsingFolderId === folder.id} + folderColor={folderColors[folder.id]} + onFolderColorChange={(color) => + setFolderColors((prev) => ({ ...prev, [folder.id]: color })) + } /> ))} @@ -485,7 +501,7 @@ export default function MarkersList() { )} , - document.body, + document.body )}
diff --git a/src/app/map/[id]/components/controls/MarkersControl/SortableFolderItem.tsx b/src/app/map/[id]/components/controls/MarkersControl/SortableFolderItem.tsx index 1ac972c19..6b2fbb6b6 100644 --- a/src/app/map/[id]/components/controls/MarkersControl/SortableFolderItem.tsx +++ b/src/app/map/[id]/components/controls/MarkersControl/SortableFolderItem.tsx @@ -6,20 +6,45 @@ import { } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; import { + ChevronDown, + ChevronRight, CornerDownRightIcon, + EyeIcon, + EyeOffIcon, Folder as FolderClosed, FolderOpen, + PencilIcon, + TrashIcon, } from "lucide-react"; -import { useMemo, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { sortByPositionAndId } from "@/app/map/[id]/utils"; import { cn } from "@/shadcn/utils"; import { LayerType } from "@/types"; +import { mapColors } from "../../../styles"; import { useFolderMutations } from "../../../hooks/useFolders"; import { usePlacedMarkerState } from "../../../hooks/usePlacedMarkers"; +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 { toast } from "sonner"; import ControlEditForm from "../ControlEditForm"; -import ControlHoverMenu from "../ControlHoverMenu"; import ControlWrapper from "../ControlWrapper"; +import LayerIcon from "../LayerIcon"; import SortableMarkerItem from "./SortableMarkerItem"; import type { Folder } from "@/server/models/Folder"; import type { PlacedMarker } from "@/server/models/PlacedMarker"; @@ -29,12 +54,16 @@ export default function SortableFolderItem({ markers, activeId, setKeyboardCapture, + folderColor, + onFolderColorChange, }: { folder: Folder; markers: PlacedMarker[]; activeId: string | null; isPulsing: boolean; setKeyboardCapture: (captured: boolean) => void; + folderColor?: string; + onFolderColorChange?: (color: string) => void; }) { const { setNodeRef: setHeaderNodeRef, isOver: isHeaderOver } = useDroppable({ id: `folder-${folder.id}`, @@ -72,6 +101,23 @@ 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 [layerColor, setLayerColor] = useState( + folderColor || mapColors.markers.color + ); + + // Sync layerColor with folderColor prop changes + useEffect(() => { + if (folderColor) { + setLayerColor(folderColor); + } + }, [folderColor]); + + // Notify parent when color changes + const handleColorChange = (color: string) => { + setLayerColor(color); + onFolderColorChange?.(color); + }; const sortedMarkers = useMemo(() => { return sortByPositionAndId(markers); @@ -85,15 +131,10 @@ 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); + toast.success("Folder deleted successfully"); }; const onEdit = () => { @@ -103,7 +144,10 @@ export default function SortableFolderItem({ }; const onSubmit = () => { - updateFolder({ ...folder, name: editText }); + if (editText.trim() && editText !== folder.name) { + updateFolder({ ...folder, name: editText.trim() }); + toast.success("Folder renamed successfully"); + } setEditing(false); setKeyboardCapture(false); }; @@ -111,7 +155,7 @@ export default function SortableFolderItem({ const visibleMarkers = useMemo( () => sortedMarkers.filter((marker) => getPlacedMarkerVisibility(marker.id)), - [sortedMarkers, getPlacedMarkerVisibility], + [sortedMarkers, getPlacedMarkerVisibility] ); const isFolderVisible = sortedMarkers?.length ? Boolean(visibleMarkers?.length) @@ -119,7 +163,7 @@ export default function SortableFolderItem({ const onVisibilityToggle = () => { sortedMarkers.forEach((marker) => - setPlacedMarkerVisibility(marker.id, !isFolderVisible), + setPlacedMarkerVisibility(marker.id, !isFolderVisible) ); }; @@ -138,26 +182,76 @@ export default function SortableFolderItem({ onSubmit={onSubmit} /> ) : ( - onDelete()} onEdit={() => onEdit()}> - - + + +
+ + +
+
+ + + + Rename + + onVisibilityToggle()}> + {isFolderVisible ? ( + <> + + Hide + + ) : ( + <> + + Show + + )} + + + setShowDeleteDialog(true)} + > + + Delete + + +
)} @@ -182,6 +276,7 @@ export default function SortableFolderItem({ marker={marker} activeId={activeId} setKeyboardCapture={setKeyboardCapture} + folderColor={layerColor} /> ))} @@ -199,6 +294,27 @@ export default function SortableFolderItem({ /> )} + + + + + Are you absolutely sure? + + This action cannot be undone. This will permanently delete the + folder "{folder.name}" and all markers inside it will be lost. + + + + Cancel + + Delete + + + +
); } diff --git a/src/app/map/[id]/components/controls/MarkersControl/SortableMarkerItem.tsx b/src/app/map/[id]/components/controls/MarkersControl/SortableMarkerItem.tsx index 07c0c8e12..1cb29b9ef 100644 --- a/src/app/map/[id]/components/controls/MarkersControl/SortableMarkerItem.tsx +++ b/src/app/map/[id]/components/controls/MarkersControl/SortableMarkerItem.tsx @@ -28,18 +28,22 @@ import { usePlacedMarkerMutations, usePlacedMarkerState, } from "../../../hooks/usePlacedMarkers"; +import { mapColors } from "../../../styles"; import ControlEditForm from "../ControlEditForm"; import ControlWrapper from "../ControlWrapper"; +import LayerIcon from "../LayerIcon"; import type { PlacedMarker } from "@/server/models/PlacedMarker"; export default function SortableMarkerItem({ marker, activeId, setKeyboardCapture, + folderColor, }: { marker: PlacedMarker; activeId: string | null; setKeyboardCapture: (captured: boolean) => void; + folderColor?: string; }) { const { attributes, @@ -60,6 +64,20 @@ export default function SortableMarkerItem({ const [isEditing, setEditing] = useState(false); const [editText, setEditText] = useState(marker.label); const [showDeleteDialog, setShowDeleteDialog] = useState(false); + const [layerColor, setLayerColor] = useState( + folderColor || mapColors.markers.color + ); + + // Update color when folderColor prop changes (e.g., when moved into a folder) + useEffect(() => { + if (folderColor) { + setLayerColor(folderColor); + } else if (marker.folderId) { + // If marker is in a folder but no folderColor prop, reset to default + // This handles the case when marker is moved out of a folder + setLayerColor(mapColors.markers.color); + } + }, [folderColor, marker.folderId]); // Check if this marker is the one being dragged (even outside its container) const isCurrentlyDragging = isDragging || activeId === `marker-${marker.id}`; @@ -140,18 +158,29 @@ export default function SortableMarkerItem({ ) : ( - +
+ + +
diff --git a/src/app/map/[id]/components/controls/TurfsControl/TurfItem.tsx b/src/app/map/[id]/components/controls/TurfsControl/TurfItem.tsx index 68f31056d..7f9de873d 100644 --- a/src/app/map/[id]/components/controls/TurfsControl/TurfItem.tsx +++ b/src/app/map/[id]/components/controls/TurfsControl/TurfItem.tsx @@ -26,9 +26,10 @@ import { useShowControls } from "../../../hooks/useMapControls"; import { useMapRef } from "../../../hooks/useMapCore"; import { useTurfMutations } from "../../../hooks/useTurfMutations"; import { useTurfState } from "../../../hooks/useTurfState"; -import { CONTROL_PANEL_WIDTH } from "../../../styles"; +import { CONTROL_PANEL_WIDTH, mapColors } from "../../../styles"; import ControlEditForm from "../ControlEditForm"; import ControlWrapper from "../ControlWrapper"; +import LayerIcon from "../LayerIcon"; import type { Turf } from "@/server/models/Turf"; export default function TurfItem({ turf }: { turf: Turf }) { @@ -40,6 +41,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 [layerColor, setLayerColor] = useState(mapColors.areas.color); const handleFlyTo = (turf: Turf) => { const map = mapRef?.current; @@ -109,12 +111,23 @@ export default function TurfItem({ turf }: { turf: Turf }) { ) : ( - +
+ + +
diff --git a/src/app/map/[id]/components/controls/TurfsControl/TurfsControl.tsx b/src/app/map/[id]/components/controls/TurfsControl/TurfsControl.tsx index be00020db..b3fdac5ba 100644 --- a/src/app/map/[id]/components/controls/TurfsControl/TurfsControl.tsx +++ b/src/app/map/[id]/components/controls/TurfsControl/TurfsControl.tsx @@ -51,7 +51,7 @@ export default function AreasControl() { {expanded && ( -
+
{turfs && turfs.length === 0 && ( )} From d6c40b352a3adc497ae3165a71f8cefd9d25c7f4 Mon Sep 17 00:00:00 2001 From: Arbyhisenaj <41119392+Arbyhisenaj@users.noreply.github.com> Date: Wed, 14 Jan 2026 15:43:51 +0000 Subject: [PATCH 02/21] cp5-add/remove marker layers --- .../components/UserDataSourcesList.tsx | 16 +- .../components/controls/DataSourceItem.tsx | 62 ++++- .../DataSourceSelectionModal.tsx | 242 ++++++++++++++++++ .../MarkersControl/MarkersControl.tsx | 120 +++------ 4 files changed, 348 insertions(+), 92 deletions(-) create mode 100644 src/app/map/[id]/components/controls/MarkersControl/DataSourceSelectionModal.tsx diff --git a/src/app/(private)/data-sources/components/UserDataSourcesList.tsx b/src/app/(private)/data-sources/components/UserDataSourcesList.tsx index dd0ededc4..b9b8ef138 100644 --- a/src/app/(private)/data-sources/components/UserDataSourcesList.tsx +++ b/src/app/(private)/data-sources/components/UserDataSourcesList.tsx @@ -2,7 +2,7 @@ import { Boxes, Database, PlusIcon, Users } from "lucide-react"; import { useMemo, useState } from "react"; -import { CollectionIcon } from "@/app/map/[id]/components/Icons"; +import { MarkerCollectionIcon } from "@/app/map/[id]/components/Icons"; import { mapColors } from "@/app/map/[id]/styles"; import { DataSourceItem } from "@/components/DataSourceItem"; import DataSourceRecordTypeIcon, { @@ -31,17 +31,17 @@ export default function UserDataSourcesList({ const memberDataSources = useMemo( () => dataSources?.filter( - (dataSource) => dataSource.recordType === DataSourceRecordType.Members, + (dataSource) => dataSource.recordType === DataSourceRecordType.Members ), - [dataSources], + [dataSources] ); const otherDataSources = useMemo( () => dataSources?.filter( - (dataSource) => dataSource.recordType !== DataSourceRecordType.Members, + (dataSource) => dataSource.recordType !== DataSourceRecordType.Members ), - [dataSources], + [dataSources] ); const filterOptions = useMemo(() => { @@ -97,7 +97,7 @@ export default function UserDataSourcesList({ {/* Member Collections Section */}

- + Member data sources

@@ -123,7 +123,7 @@ export default function UserDataSourcesList({

- + Other data sources

@@ -143,7 +143,7 @@ export default function UserDataSourcesList({ isDisabled && "pointer-events-none opacity-60", isSelected ? "bg-blue-100 border-blue-200" - : "bg-neutral-50 border-neutral-200 text-neutral-600 hover:bg-neutral-100", + : "bg-neutral-50 border-neutral-200 text-neutral-600 hover:bg-neutral-100" )} > {option.value === "all" ? null : ( diff --git a/src/app/map/[id]/components/controls/DataSourceItem.tsx b/src/app/map/[id]/components/controls/DataSourceItem.tsx index 912228d12..8f9461e8c 100644 --- a/src/app/map/[id]/components/controls/DataSourceItem.tsx +++ b/src/app/map/[id]/components/controls/DataSourceItem.tsx @@ -25,13 +25,51 @@ import { } from "@/shadcn/ui/alert-dialog"; import { DataSourceTypeLabels } from "@/labels"; import { LayerType } from "@/types"; +import { CircleIcon, SquareIcon, UsersIcon } from "lucide-react"; +import DataSourceRecordTypeIcon, { + dataSourceRecordTypeLabels, +} from "@/components/DataSourceRecordTypeIcon"; 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"; +import type { + DataSourceRecordType, + DataSourceType, +} from "@/server/models/DataSource"; import LayerIcon from "./LayerIcon"; +function getLayerTypeLabel(type: LayerType) { + switch (type) { + case LayerType.Member: + return "Members"; + case LayerType.Marker: + return "Markers"; + case LayerType.Turf: + return "Areas"; + case LayerType.Boundary: + return "Boundaries"; + default: + return "Layer"; + } +} + +function LayerTypeSubheadingIcon({ type }: { type: LayerType }) { + const common = "w-3 h-3"; + switch (type) { + case LayerType.Member: + return ; + case LayerType.Marker: + return ; + case LayerType.Turf: + return ; + case LayerType.Boundary: + return ; + default: + return null; + } +} + export default function DataSourceItem({ dataSource, isSelected, @@ -44,6 +82,7 @@ export default function DataSourceItem({ config: { type: DataSourceType }; recordCount?: number; createdAt?: Date; + recordType?: DataSourceRecordType; }; isSelected: boolean; handleDataSourceSelect: (id: string) => void; @@ -180,10 +219,27 @@ export default function DataSourceItem({
{dataSource.name}
-
+
{DataSourceTypeLabels[dataSource.config.type]} {Boolean(dataSource?.recordCount) && ( - <> · {dataSource.recordCount} records + <> + · + {dataSource.recordCount} + + )} + {dataSource.recordType && + dataSourceRecordTypeLabels[dataSource.recordType] ? ( + + { + dataSourceRecordTypeLabels[ + dataSource.recordType + ] + } + + ) : ( + Boolean(dataSource?.recordCount) && ( + records + ) )}
diff --git a/src/app/map/[id]/components/controls/MarkersControl/DataSourceSelectionModal.tsx b/src/app/map/[id]/components/controls/MarkersControl/DataSourceSelectionModal.tsx new file mode 100644 index 000000000..ecdc6b62b --- /dev/null +++ b/src/app/map/[id]/components/controls/MarkersControl/DataSourceSelectionModal.tsx @@ -0,0 +1,242 @@ +"use client"; + +import { Boxes, Check, Database, PlusIcon, Users } from "lucide-react"; +import { useMemo } from "react"; +import { useRouter } from "next/navigation"; +import { MarkerCollectionIcon } from "@/app/map/[id]/components/Icons"; +import { mapColors } from "@/app/map/[id]/styles"; +import { Link } from "@/components/Link"; +import { DataSourceRecordType } from "@/server/models/DataSource"; +import { Button } from "@/shadcn/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/shadcn/ui/dialog"; +import { cn } from "@/shadcn/utils"; +import type { RouterOutputs } from "@/services/trpc/react"; + +type DataSourceItemType = NonNullable< + RouterOutputs["dataSource"]["byOrganisation"] +>[0]; + +interface DataSourceSelectionModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; + dataSources: DataSourceItemType[]; + selectedMemberDataSourceId: string | null; + selectedMarkerDataSourceIds: string[]; + onSelectMemberDataSource: (dataSourceId: string | null) => void; + onSelectMarkerDataSource: (dataSourceId: string, selected: boolean) => void; +} + +export default function DataSourceSelectionModal({ + open, + onOpenChange, + dataSources, + selectedMemberDataSourceId, + selectedMarkerDataSourceIds, + onSelectMemberDataSource, + onSelectMarkerDataSource, +}: DataSourceSelectionModalProps) { + const router = useRouter(); + + const memberDataSources = useMemo( + () => + dataSources?.filter( + (dataSource) => dataSource.recordType === DataSourceRecordType.Members, + ), + [dataSources], + ); + + const otherDataSources = useMemo( + () => + dataSources?.filter( + (dataSource) => dataSource.recordType !== DataSourceRecordType.Members, + ), + [dataSources], + ); + + const groupedOtherDataSources = useMemo(() => { + const groups = Object.values(DataSourceRecordType) + .filter((rt) => rt !== DataSourceRecordType.Members) + .map((rt) => ({ + recordType: rt, + items: otherDataSources.filter((ds) => ds.recordType === rt), + })) + .filter((g) => g.items.length > 0); + + // Show record types with more items first + return groups.sort((a, b) => b.items.length - a.items.length); + }, [otherDataSources]); + + return ( + + + + Select Data Sources + + Choose which data sources to add to this map + + + +
+ {/* Empty state */} + {dataSources && dataSources.length === 0 && ( +
+ +

No sources yet

+

+ Create your first data source to get started +

+ + + +
+ )} + + {/* Member Collections Section */} + {memberDataSources && memberDataSources.length > 0 && ( +
+
+

+ + Member data sources +

+
Single select
+
+ +
+ {memberDataSources.map((dataSource) => { + const isSelected = + dataSource.id === selectedMemberDataSourceId; + return ( + + ); + })} +
+
+ )} + + {/* Other Data Sources Section */} + {otherDataSources && otherDataSources.length > 0 && ( +
+
+

+ + Markers from data sources +

+
Multi select
+
+ + {groupedOtherDataSources.length === 0 ? ( +
+ +

No other data sources yet

+
+ ) : ( +
+ {groupedOtherDataSources.map((group) => ( +
+
+ {group.recordType} +
+
+ {group.items.map((dataSource) => { + const isSelected = + selectedMarkerDataSourceIds.includes(dataSource.id); + return ( + + ); + })} +
+
+ ))} +
+ )} +
+ )} + + {/* Add new data source button */} +
+ +
+
+
+
+ ); +} diff --git a/src/app/map/[id]/components/controls/MarkersControl/MarkersControl.tsx b/src/app/map/[id]/components/controls/MarkersControl/MarkersControl.tsx index f0c44b680..540fee470 100644 --- a/src/app/map/[id]/components/controls/MarkersControl/MarkersControl.tsx +++ b/src/app/map/[id]/components/controls/MarkersControl/MarkersControl.tsx @@ -1,4 +1,10 @@ -import { Check, FolderPlusIcon, LoaderPinwheel, PlusIcon } from "lucide-react"; +import { + FolderPlusIcon, + LoaderPinwheel, + MapPin, + PlusIcon, + Search, +} from "lucide-react"; import { useRouter } from "next/navigation"; import { useState } from "react"; import { v4 as uuidv4 } from "uuid"; @@ -16,14 +22,13 @@ import { useHandleDropPin, usePlacedMarkerMutations, } from "@/app/map/[id]/hooks/usePlacedMarkers"; -import { mapColors } from "@/app/map/[id]/styles"; import IconButtonWithTooltip from "@/components/IconButtonWithTooltip"; -import { DataSourceRecordType } from "@/server/models/DataSource"; import { LayerType } from "@/types"; -import { MarkerCollectionIcon } from "../../Icons"; +import { MarkerCollectionIcon, MarkerIndividualIcon } from "../../Icons"; import LayerControlWrapper from "../LayerControlWrapper"; import LayerHeader from "../LayerHeader"; import MarkersList from "./MarkersList"; +import DataSourceSelectionModal from "./DataSourceSelectionModal"; export default function MarkersControl() { const router = useRouter(); @@ -34,6 +39,7 @@ export default function MarkersControl() { const { handleDropPin } = useHandleDropPin(); const { data: dataSources } = useDataSources(); const [expanded, setExpanded] = useState(true); + const [isDataSourceModalOpen, setIsDataSourceModalOpen] = useState(false); const createFolder = () => { const newFolder = { @@ -65,49 +71,20 @@ export default function MarkersControl() { 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 handleSelectMemberDataSource = (dataSourceId: string | null) => { + updateMapConfig({ + membersDataSourceId: dataSourceId, }); }; - const getMarkerDataSourceDropdownItems = () => { - const markerDataSources = - dataSources?.filter((dataSource) => { - return dataSource.recordType !== DataSourceRecordType.Members; - }) || []; - - return markerDataSources.map((dataSource) => { - const selected = mapConfig.markerDataSourceIds.includes(dataSource.id); - return { - type: "item" as const, - icon: selected ? : null, - label: dataSource.name, - onClick: () => { - updateMapConfig({ - markerDataSourceIds: selected - ? mapConfig.markerDataSourceIds.filter( - (id) => id !== dataSource.id - ) - : [...mapConfig.markerDataSourceIds, dataSource.id], - }); - }, - }; + const handleSelectMarkerDataSource = ( + dataSourceId: string, + isSelected: boolean + ) => { + updateMapConfig({ + markerDataSourceIds: isSelected + ? mapConfig.markerDataSourceIds.filter((id) => id !== dataSourceId) + : [...mapConfig.markerDataSourceIds, dataSourceId], }); }; @@ -115,61 +92,32 @@ export default function MarkersControl() { { type: "submenu" as const, label: "Add Single Marker", - icon: ( -
- ), + icon: , items: [ { type: "item" as const, label: "Search for a location", + icon: , onClick: () => handleManualSearch(), }, { type: "item" as const, label: "Drop a pin on the map", + icon: , onClick: () => handleDropPin(), }, ], }, { - type: "submenu" as const, - label: "Add Member Collection", - icon: , - items: [ - ...getMemberDataSourceDropdownItems(), - ...(getMemberDataSourceDropdownItems().length > 0 - ? [{ 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", - onClick: () => router.push("/data-sources/new"), - }, - ], + type: "item" as const, + label: "Markers from data sources", + icon: , + onClick: () => setIsDataSourceModalOpen(true), }, { type: "separator" as const }, { type: "item" as const, - icon: , + icon: , label: "Add Folder", onClick: () => createFolder(), }, @@ -201,6 +149,16 @@ export default function MarkersControl() {
)} + + ); } From 07b3b228b4c6a176c028e717c21997f59823b935 Mon Sep 17 00:00:00 2001 From: Arbyhisenaj <41119392+Arbyhisenaj@users.noreply.github.com> Date: Wed, 25 Feb 2026 14:57:02 +0000 Subject: [PATCH 03/21] greyed instead of hidden layers on hexmap --- .../components/controls/PrivateMapControls.tsx | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/src/app/map/[id]/components/controls/PrivateMapControls.tsx b/src/app/map/[id]/components/controls/PrivateMapControls.tsx index 7144b6702..9d0ef9b1b 100644 --- a/src/app/map/[id]/components/controls/PrivateMapControls.tsx +++ b/src/app/map/[id]/components/controls/PrivateMapControls.tsx @@ -64,12 +64,16 @@ export default function PrivateMapControls() { className="flex-1 overflow-y-auto / flex flex-col" style={{ width: `${CONTROL_PANEL_WIDTH}px` }} > - {viewConfig.mapType !== MapType.Hex && ( - <> - - - - )} +
+ + +
From 20f7eddd741e9fea5be8c632c95c9a7e5f40a01d Mon Sep 17 00:00:00 2001 From: Arbyhisenaj <41119392+Arbyhisenaj@users.noreply.github.com> Date: Wed, 25 Feb 2026 19:15:43 +0000 Subject: [PATCH 04/21] checkpoint --- .../components/DataSourceSelectButton.tsx | 2 +- src/app/map/[id]/components/TogglePanel.tsx | 17 +- .../inspector/BoundaryDataPanel.tsx | 64 +- .../inspector/InspectorConfigTab.tsx | 114 --- .../components/inspector/InspectorDataTab.tsx | 142 ++- .../inspector/InspectorFullPreview.tsx | 186 ++++ .../inspector/InspectorOnMapSection.tsx | 70 ++ .../components/inspector/InspectorPanel.tsx | 54 +- .../components/inspector/InspectorPreview.tsx | 62 ++ .../inspector/InspectorSettingsModal.tsx | 928 ++++++++++++++++++ .../components/inspector/PropertiesList.tsx | 88 +- .../inspector/SortableColumnRow.tsx | 101 ++ .../map/[id]/hooks/useDebouncedCallback.ts | 27 + src/app/map/[id]/hooks/useDebouncedValue.ts | 18 + src/server/models/MapView.ts | 29 +- 15 files changed, 1670 insertions(+), 232 deletions(-) delete mode 100644 src/app/map/[id]/components/inspector/InspectorConfigTab.tsx create mode 100644 src/app/map/[id]/components/inspector/InspectorFullPreview.tsx create mode 100644 src/app/map/[id]/components/inspector/InspectorOnMapSection.tsx create mode 100644 src/app/map/[id]/components/inspector/InspectorPreview.tsx create mode 100644 src/app/map/[id]/components/inspector/InspectorSettingsModal.tsx create mode 100644 src/app/map/[id]/components/inspector/SortableColumnRow.tsx create mode 100644 src/app/map/[id]/hooks/useDebouncedCallback.ts create mode 100644 src/app/map/[id]/hooks/useDebouncedValue.ts diff --git a/src/app/map/[id]/components/DataSourceSelectButton.tsx b/src/app/map/[id]/components/DataSourceSelectButton.tsx index ad2e1290a..e2ede8eb1 100644 --- a/src/app/map/[id]/components/DataSourceSelectButton.tsx +++ b/src/app/map/[id]/components/DataSourceSelectButton.tsx @@ -87,7 +87,7 @@ function DataSourceSelectButtonModalTrigger({ setIsModalOpen(true); }} className={cn( - "group-hover:bg-neutral-100 transition-colors cursor-pointer rounded-lg", + "w-full group-hover:bg-neutral-100 transition-colors cursor-pointer rounded-lg", className, )} > diff --git a/src/app/map/[id]/components/TogglePanel.tsx b/src/app/map/[id]/components/TogglePanel.tsx index c6ad37486..f83725652 100644 --- a/src/app/map/[id]/components/TogglePanel.tsx +++ b/src/app/map/[id]/components/TogglePanel.tsx @@ -16,7 +16,7 @@ interface TogglePanelProps { export default function TogglePanel({ label, icon: Icon, - defaultExpanded = false, + defaultExpanded = true, children, headerRight, rightIconButton: RightIconButton, @@ -25,23 +25,22 @@ export default function TogglePanel({ const [expanded, setExpanded] = useState(defaultExpanded); return ( -
-
+
+
{headerRight && ( diff --git a/src/app/map/[id]/components/inspector/BoundaryDataPanel.tsx b/src/app/map/[id]/components/inspector/BoundaryDataPanel.tsx index b3c452a9a..f3bf6f5ea 100644 --- a/src/app/map/[id]/components/inspector/BoundaryDataPanel.tsx +++ b/src/app/map/[id]/components/inspector/BoundaryDataPanel.tsx @@ -5,23 +5,30 @@ import { useInspector } from "@/app/map/[id]/hooks/useInspector"; import DataSourceIcon from "@/components/DataSourceIcon"; import { getDataSourceType } from "@/components/DataSourceItem"; import { AreaSetCode } from "@/server/models/AreaSet"; +import type { InspectorBoundaryConfig } from "@/server/models/MapView"; import { useTRPC } from "@/services/trpc/react"; import { DataRecordMatchType } from "@/types"; import { buildName } from "@/utils/dataRecord"; import { useDataSources } from "../../hooks/useDataSources"; -import PropertiesList from "./PropertiesList"; +import PropertiesList, { type PropertyEntry } from "./PropertiesList"; export function BoundaryDataPanel({ config, dataSourceId, areaCode, columns, + columnMetadata, + columnGroups, + layout, defaultExpanded, }: { config: { name: string; dataSourceId: string }; dataSourceId: string; areaCode: string; columns: string[]; + columnMetadata?: InspectorBoundaryConfig["columnMetadata"]; + columnGroups?: InspectorBoundaryConfig["columnGroups"]; + layout?: InspectorBoundaryConfig["layout"]; defaultExpanded: boolean; }) { const trpc = useTRPC(); @@ -61,6 +68,9 @@ export function BoundaryDataPanel({ ) : data?.records.length ? ( @@ -74,6 +84,9 @@ export function BoundaryDataPanel({ @@ -92,30 +105,57 @@ export function BoundaryDataPanel({ function BoundaryDataProperties({ json, columns, + columnMetadata, + columnGroups, + layout, match, }: { json: Record; columns: string[]; + columnMetadata?: InspectorBoundaryConfig["columnMetadata"]; + columnGroups?: InspectorBoundaryConfig["columnGroups"]; + layout?: InspectorBoundaryConfig["layout"]; match: DataRecordMatchType; }) { - const filteredProperties = useMemo(() => { - const filtered: Record = {}; - columns.forEach((columnName) => { - if (json[columnName] !== undefined) { - filtered[columnName] = json[columnName]; - } + const entries = useMemo((): PropertyEntry[] => { + const meta = columnMetadata ?? {}; + const groups = columnGroups ?? []; + const keyToGroup = new Map(); + groups.forEach((g) => { + g.columnNames.forEach((col) => keyToGroup.set(col, g.label)); + }); + const ordered: PropertyEntry[] = []; + groups.forEach((g) => { + g.columnNames.forEach((col) => { + if (json[col] === undefined) return; + ordered.push({ + key: col, + label: meta[col]?.displayName ?? col, + value: json[col], + groupLabel: g.label, + }); + }); + }); + columns.forEach((col) => { + if (keyToGroup.has(col)) return; + if (json[col] === undefined) return; + ordered.push({ + key: col, + label: meta[col]?.displayName ?? col, + value: json[col], + }); }); - return filtered; - }, [columns, json]); + return ordered; + }, [json, columns, columnMetadata, columnGroups]); return ( -
+
{match === DataRecordMatchType.Approximate && (

Approximate boundary match

)} - {Object.keys(filteredProperties).length > 0 ? ( - + {entries.length > 0 ? ( + ) : (

No data available

)} diff --git a/src/app/map/[id]/components/inspector/InspectorConfigTab.tsx b/src/app/map/[id]/components/inspector/InspectorConfigTab.tsx deleted file mode 100644 index 2534764f6..000000000 --- a/src/app/map/[id]/components/inspector/InspectorConfigTab.tsx +++ /dev/null @@ -1,114 +0,0 @@ -import { useCallback, useEffect, useRef } from "react"; -import { v4 as uuidv4 } from "uuid"; -import { - type InspectorBoundaryConfig, - InspectorBoundaryConfigType, -} from "@/server/models/MapView"; -import { useDataSources } from "../../hooks/useDataSources"; -import { useMapViews } from "../../hooks/useMapViews"; -import DataSourceSelectButton from "../DataSourceSelectButton"; -import TogglePanel from "../TogglePanel"; -import { BoundaryConfigItem } from "./BoundaryConfigItem"; - -export default function InspectorConfigTab() { - const { view, viewConfig, updateView } = useMapViews(); - const { getDataSourceById } = useDataSources(); - - const boundaryStatsConfig = view?.inspectorConfig?.boundaries || []; - const initializationAttemptedRef = useRef(false); - - const addDataSourceToConfig = useCallback( - (dataSourceId: string) => { - if (!view) { - return; - } - - const dataSource = getDataSourceById(dataSourceId); - const newBoundaryConfig: InspectorBoundaryConfig = { - id: uuidv4(), - dataSourceId, - name: dataSource?.name || "Boundary Data", - type: InspectorBoundaryConfigType.Simple, - columns: [], - }; - - const prevBoundaries = view.inspectorConfig?.boundaries || []; - - updateView({ - ...view, - inspectorConfig: { - ...view.inspectorConfig, - boundaries: [...prevBoundaries, newBoundaryConfig], - }, - }); - }, - [getDataSourceById, updateView, view], - ); - - // Initialize boundaries with areaDataSourceId if empty - useEffect(() => { - if (!view || initializationAttemptedRef.current) return; - - const hasBoundaries = boundaryStatsConfig.length > 0; - const hasAreaDataSource = viewConfig.areaDataSourceId; - - if (!hasBoundaries && hasAreaDataSource) { - initializationAttemptedRef.current = true; - addDataSourceToConfig(viewConfig.areaDataSourceId); - } - }, [ - view, - viewConfig.areaDataSourceId, - boundaryStatsConfig.length, - getDataSourceById, - updateView, - addDataSourceToConfig, - ]); - - return ( -
- -
- {boundaryStatsConfig.map((boundaryConfig, index) => ( - { - if (!view) return; - const updatedBoundaries = - view.inspectorConfig?.boundaries?.filter( - (_, i) => i !== index, - ); - updateView({ - ...view, - inspectorConfig: { - ...view.inspectorConfig, - boundaries: updatedBoundaries, - }, - }); - }} - onUpdate={(updatedConfig) => { - if (!view) return; - const updatedBoundaries = [...boundaryStatsConfig]; - updatedBoundaries[index] = updatedConfig; - updateView({ - ...view, - inspectorConfig: { - ...view.inspectorConfig, - boundaries: updatedBoundaries, - }, - }); - }} - /> - ))} - addDataSourceToConfig(dataSourceId)} - selectButtonText={"Add a data source"} - /> -
-
-
- ); -} diff --git a/src/app/map/[id]/components/inspector/InspectorDataTab.tsx b/src/app/map/[id]/components/inspector/InspectorDataTab.tsx index aa464c6c1..4c9d012b6 100644 --- a/src/app/map/[id]/components/inspector/InspectorDataTab.tsx +++ b/src/app/map/[id]/components/inspector/InspectorDataTab.tsx @@ -1,6 +1,7 @@ import { useQuery } from "@tanstack/react-query"; import { MapPinIcon, TableIcon } from "lucide-react"; -import { useMemo } from "react"; +import { useCallback, useEffect, useMemo, useRef } from "react"; +import { v4 as uuidv4 } from "uuid"; import { useDataSources } from "@/app/map/[id]/hooks/useDataSources"; import { useInspector } from "@/app/map/[id]/hooks/useInspector"; import { useMapRef } from "@/app/map/[id]/hooks/useMapCore"; @@ -8,10 +9,16 @@ import { useMapViews } from "@/app/map/[id]/hooks/useMapViews"; import { useTable } from "@/app/map/[id]/hooks/useTable"; import DataSourceIcon from "@/components/DataSourceIcon"; import { type DataSource } from "@/server/models/DataSource"; +import { + type InspectorBoundaryConfig, + InspectorBoundaryConfigType, +} from "@/server/models/MapView"; import { useTRPC } from "@/services/trpc/react"; import { Button } from "@/shadcn/ui/button"; import { LayerType } from "@/types"; +import DataSourceSelectButton from "../DataSourceSelectButton"; import { BoundaryDataPanel } from "./BoundaryDataPanel"; +import InspectorOnMapSection from "./InspectorOnMapSection"; import PropertiesList from "./PropertiesList"; import type { SelectedRecord } from "@/app/map/[id]/types/inspector"; @@ -33,9 +40,33 @@ export default function InspectorDataTab({ const mapRef = useMapRef(); const { setSelectedDataSourceId } = useTable(); const trpc = useTRPC(); - const { view } = useMapViews(); + const { view, viewConfig, updateView } = useMapViews(); const { getDataSourceById } = useDataSources(); const { selectedBoundary } = useInspector(); + const initializationAttemptedRef = useRef(false); + + const addDataSourceToConfig = useCallback( + (dataSourceId: string) => { + if (!view) return; + const ds = getDataSourceById(dataSourceId); + const newBoundaryConfig: InspectorBoundaryConfig = { + id: uuidv4(), + dataSourceId, + name: ds?.name || "Boundary Data", + type: InspectorBoundaryConfigType.Simple, + columns: [], + }; + const prev = view.inspectorConfig?.boundaries || []; + updateView({ + ...view, + inspectorConfig: { + ...view.inspectorConfig, + boundaries: [...prev, newBoundaryConfig], + }, + }); + }, + [getDataSourceById, updateView, view], + ); const { data: recordData, isFetching: recordLoading } = useQuery( trpc.dataRecord.byId.queryOptions( @@ -53,37 +84,27 @@ export default function InspectorDataTab({ () => view?.inspectorConfig?.boundaries || [], [view?.inspectorConfig?.boundaries], ); - const shouldUseInspectorConfig = - boundaryConfigs.length > 0 && - type === LayerType.Boundary && - boundaryConfigs.some((c) => c.columns.length); const boundaryData = useMemo(() => { - if ( - !shouldUseInspectorConfig || - !selectedBoundary?.areaCode || - !selectedBoundary?.areaSetCode - ) - return []; - - return boundaryConfigs.map((config) => { - const ds = getDataSourceById(config.dataSourceId); - - return { - config, - dataSource: ds, - dataSourceId: config.dataSourceId, - areaCode: selectedBoundary.areaCode, - areaSetCode: selectedBoundary.areaSetCode, - columns: config.columns, - }; - }); - }, [ - shouldUseInspectorConfig, - selectedBoundary, - boundaryConfigs, - getDataSourceById, - ]); + if (type !== LayerType.Boundary) return []; + return boundaryConfigs.map((config) => ({ + config, + dataSourceId: config.dataSourceId, + areaCode: selectedBoundary?.areaCode ?? "", + columns: config.columns, + })); + }, [type, selectedBoundary?.areaCode, boundaryConfigs]); + + // Initialise boundary inspector config from choropleth data source when empty + useEffect(() => { + if (!view || type !== LayerType.Boundary || initializationAttemptedRef.current) return; + const hasBoundaries = boundaryConfigs.length > 0; + const hasAreaDataSource = viewConfig.areaDataSourceId; + if (!hasBoundaries && hasAreaDataSource) { + initializationAttemptedRef.current = true; + addDataSourceToConfig(viewConfig.areaDataSourceId); + } + }, [view, type, viewConfig.areaDataSourceId, boundaryConfigs.length, addDataSourceToConfig]); const flyToMarker = () => { const map = mapRef?.current; @@ -95,26 +116,45 @@ export default function InspectorDataTab({ return (
- {shouldUseInspectorConfig ? ( - // Show data based on inspector config - boundaryData.length > 0 ? ( - boundaryData.map((item, index) => ( - - )) - ) : ( -
-

No boundary data configured

-
- ) - ) : ( - // Show default data source and properties + {type === LayerType.Boundary && ( + <> + +
+

+ Data in this area +

+ {boundaryConfigs.length === 0 ? ( +
+

+ No data sources added yet +

+ +
+ ) : ( +
+ {boundaryData.map((item, index) => ( + + ))} +
+ )} +
+ + )} + {type !== LayerType.Boundary && ( <> {dataSource && (
diff --git a/src/app/map/[id]/components/inspector/InspectorFullPreview.tsx b/src/app/map/[id]/components/inspector/InspectorFullPreview.tsx new file mode 100644 index 000000000..1ee1ed1fa --- /dev/null +++ b/src/app/map/[id]/components/inspector/InspectorFullPreview.tsx @@ -0,0 +1,186 @@ +"use client"; + +import { + DndContext, + KeyboardSensor, + PointerSensor, + closestCenter, + useSensor, + useSensors, +} from "@dnd-kit/core"; +import { restrictToVerticalAxis } from "@dnd-kit/modifiers"; +import { + SortableContext, + sortableKeyboardCoordinates, + useSortable, + verticalListSortingStrategy, +} from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; +import { GripVertical } from "lucide-react"; +import { useCallback, useMemo } from "react"; +import type { InspectorBoundaryConfig } from "@/server/models/MapView"; +import { useInspector } from "@/app/map/[id]/hooks/useInspector"; +import { useMapViews } from "@/app/map/[id]/hooks/useMapViews"; +import { cn } from "@/shadcn/utils"; +import { BoundaryDataPanel } from "./BoundaryDataPanel"; +import InspectorOnMapSection from "./InspectorOnMapSection"; +import type { DragEndEvent } from "@dnd-kit/core"; + +/** + * Renders a full preview of the inspector Data tab for boundaries: + * On the map section + Data in this area with all BoundaryDataPanels expanded. + * Panels can be reordered by dragging; order is synced to the list. + */ +export function InspectorFullPreview({ className }: { className?: string }) { + const { selectedBoundary } = useInspector(); + const { view, updateView } = useMapViews(); + const boundaryConfigs = view?.inspectorConfig?.boundaries ?? []; + + const boundaryData = useMemo( + () => + boundaryConfigs.map((config) => ({ + config, + dataSourceId: config.dataSourceId, + areaCode: selectedBoundary?.areaCode ?? "", + columns: config.columns, + })), + [boundaryConfigs, selectedBoundary?.areaCode], + ); + + const reorderBoundaries = useCallback( + (oldIndex: number, newIndex: number) => { + if (!view || oldIndex === newIndex) return; + const next = [...boundaryConfigs]; + const [removed] = next.splice(oldIndex, 1); + next.splice(newIndex, 0, removed); + updateView({ + ...view, + inspectorConfig: { + ...view.inspectorConfig, + boundaries: next, + }, + }); + }, + [view, boundaryConfigs, updateView], + ); + + const handleDragEnd = useCallback( + (event: DragEndEvent) => { + const { active, over } = event; + if (!over || active.id === over.id) return; + const ids = boundaryConfigs.map((c) => c.id); + const oldIndex = ids.indexOf(active.id as string); + const newIndex = ids.indexOf(over.id as string); + if (oldIndex === -1 || newIndex === -1) return; + reorderBoundaries(oldIndex, newIndex); + }, + [boundaryConfigs, reorderBoundaries], + ); + + const sensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 6 } }), + useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }), + ); + + return ( +
+
+

Preview

+

+ {selectedBoundary?.name ?? "Boundary"} +

+
+
+ +
+

+ Data in this area — drag to reorder +

+ {boundaryConfigs.length === 0 ? ( +
+

+ No data sources added yet +

+
+ ) : ( + + c.id)} + strategy={verticalListSortingStrategy} + > +
+ {boundaryData.map((item) => ( + + ))} +
+
+
+ )} +
+
+
+ ); +} + +function SortableBoundaryPanel({ + item, +}: { + item: { + config: InspectorBoundaryConfig; + dataSourceId: string; + areaCode: string; + columns: string[]; + }; +}) { + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id: item.config.id }); + const style = { + transform: CSS.Transform.toString(transform), + transition, + }; + return ( +
+ +
+ +
+
+ ); +} diff --git a/src/app/map/[id]/components/inspector/InspectorOnMapSection.tsx b/src/app/map/[id]/components/inspector/InspectorOnMapSection.tsx new file mode 100644 index 000000000..ee06c815d --- /dev/null +++ b/src/app/map/[id]/components/inspector/InspectorOnMapSection.tsx @@ -0,0 +1,70 @@ +import { CalculationType } from "@/server/models/MapView"; +import { useAreaStats } from "../../data"; +import { useChoroplethDataSource } from "../../hooks/useDataSources"; +import { useInspector } from "../../hooks/useInspector"; +import { useMapViews } from "../../hooks/useMapViews"; + +/** + * Shows the value(s) currently driving the map colour for the selected boundary. + * Only rendered when a choropleth data source is set and a boundary is selected. + */ +export default function InspectorOnMapSection() { + const { viewConfig } = useMapViews(); + const { selectedBoundary } = useInspector(); + const choroplethDataSource = useChoroplethDataSource(); + const areaStatsQuery = useAreaStats(); + const areaStats = areaStatsQuery.data; + + const hasChoropleth = Boolean(viewConfig.areaDataSourceId); + if (!hasChoropleth || !selectedBoundary?.areaCode || !areaStats) { + return null; + } + + const isSameAreaSet = areaStats.areaSetCode === selectedBoundary.areaSetCode; + const stat = isSameAreaSet + ? areaStats.stats.find( + (s: { areaCode: string }) => s.areaCode === selectedBoundary.areaCode, + ) + : null; + + const label = + areaStats.calculationType === CalculationType.Count + ? `${choroplethDataSource?.name ?? "Data"} count` + : viewConfig.areaDataColumn || "Value"; + + const hasSecondary = Boolean(viewConfig.areaDataSecondaryColumn); + + return ( +
+

+ On the map +

+ {stat ? ( +
+
+ {label} + + {typeof stat.primary === "number" + ? stat.primary.toLocaleString() + : String(stat.primary ?? "—")} + +
+ {hasSecondary && ( +
+ + {viewConfig.areaDataSecondaryColumn} + + + {typeof stat.secondary === "number" + ? stat.secondary.toLocaleString() + : String(stat.secondary ?? "—")} + +
+ )} +
+ ) : ( +

No value for this area

+ )} +
+ ); +} diff --git a/src/app/map/[id]/components/inspector/InspectorPanel.tsx b/src/app/map/[id]/components/inspector/InspectorPanel.tsx index bdb499a56..331b9ce8a 100644 --- a/src/app/map/[id]/components/inspector/InspectorPanel.tsx +++ b/src/app/map/[id]/components/inspector/InspectorPanel.tsx @@ -3,12 +3,13 @@ import { useMemo, useState } from "react"; import { useInspector } from "@/app/map/[id]/hooks/useInspector"; import { useHoverArea } from "@/app/map/[id]/hooks/useMapHover"; +import { Button } from "@/shadcn/ui/button"; import { cn } from "@/shadcn/utils"; import { LayerType } from "@/types"; -import InspectorConfigTab from "./InspectorConfigTab"; import InspectorDataTab from "./InspectorDataTab"; import InspectorMarkersTab from "./InspectorMarkersTab"; import InspectorNotesTab from "./InspectorNotesTab"; +import InspectorSettingsModal from "./InspectorSettingsModal"; import { UnderlineTabs, UnderlineTabsContent, @@ -22,6 +23,7 @@ export default function InspectorPanel({ boundariesPanelOpen?: boolean; } = {}) { const [activeTab, setActiveTab] = useState("data"); + const [settingsOpen, setSettingsOpen] = useState(false); const [hoverArea] = useHoverArea(); const boundaryHoverVisible = boundariesPanelOpen && !!hoverArea; @@ -44,9 +46,6 @@ export default function InspectorPanel({ if (activeTab === "markers" && isMarkers) { return "data"; } - if (activeTab === "config" && type !== LayerType.Boundary) { - return type === LayerType.Cluster ? "markers" : "data"; - } return activeTab; }, [activeTab, type]); @@ -70,8 +69,7 @@ export default function InspectorPanel({ id="inspector-panel" className={cn("absolute top-0 bottom-0 right-4 / flex flex-col gap-6")} style={{ - minWidth: safeActiveTab === "config" ? "400px" : "250px", - maxWidth: "450px", + width: "300px", maxHeight: "calc(100% - 80px)", paddingTop: boundaryHoverVisible ? "80px" : "20px", paddingBottom: "20px", @@ -84,18 +82,33 @@ export default function InspectorPanel({ "min-h-0", )} > -
-

+
+

{inspectorContent?.name as string}

- +
+ + +
+ {isDetailsView && (
@@ -140,11 +153,6 @@ export default function InspectorPanel({ Notes 0 - {type === LayerType.Boundary && ( - - - - )} {type !== LayerType.Cluster && ( @@ -166,12 +174,6 @@ export default function InspectorPanel({ - - {type === LayerType.Boundary && ( - - - - )}

diff --git a/src/app/map/[id]/components/inspector/InspectorPreview.tsx b/src/app/map/[id]/components/inspector/InspectorPreview.tsx new file mode 100644 index 000000000..03a8cd2ac --- /dev/null +++ b/src/app/map/[id]/components/inspector/InspectorPreview.tsx @@ -0,0 +1,62 @@ +"use client"; + +import DataSourceIcon from "@/components/DataSourceIcon"; +import { getDataSourceType } from "@/components/DataSourceItem"; +import type { InspectorBoundaryConfig } from "@/server/models/MapView"; +import TogglePanel from "@/app/map/[id]/components/TogglePanel"; +import { cn } from "@/shadcn/utils"; +import type { DataSourceWithImportInfo } from "@/components/DataSourceItem"; + +export function InspectorPreview({ + boundaryConfigs, + onMapDataSourceName, + getDataSourceById, + className, +}: { + boundaryConfigs: InspectorBoundaryConfig[]; + onMapDataSourceName: string | null; + getDataSourceById: (id: string) => DataSourceWithImportInfo | null; + className?: string; +}) { + return ( +
+ {onMapDataSourceName && ( +
+

+ On the map +

+

{onMapDataSourceName}

+
+ )} +

+ Data in this area +

+ {boundaryConfigs.length === 0 ? ( +

No data sources added

+ ) : ( + boundaryConfigs.map((config) => { + const ds = getDataSourceById(config.dataSourceId); + const type = ds ? getDataSourceType(ds) : null; + return ( + : undefined} + defaultExpanded={true} + > +
+ {config.columns.length === 0 ? ( +

No columns selected

+ ) : ( +

+ {config.columns.join(", ")} +

+ )} +
+
+ ); + }) + )} +
+ ); +} diff --git a/src/app/map/[id]/components/inspector/InspectorSettingsModal.tsx b/src/app/map/[id]/components/inspector/InspectorSettingsModal.tsx new file mode 100644 index 000000000..4f1a1bf4d --- /dev/null +++ b/src/app/map/[id]/components/inspector/InspectorSettingsModal.tsx @@ -0,0 +1,928 @@ +"use client"; + +import { + DndContext, + DragOverlay, + KeyboardSensor, + PointerSensor, + closestCenter, + useDraggable, + useDroppable, + useSensor, + useSensors, +} from "@dnd-kit/core"; +import { + SortableContext, + sortableKeyboardCoordinates, + verticalListSortingStrategy, +} from "@dnd-kit/sortable"; +import { + GripVertical, + LayoutGrid, + LayoutList, + MapPin, + PlusIcon, +} from "lucide-react"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { createPortal } from "react-dom"; +import { v4 as uuidv4 } from "uuid"; +import DataSourceIcon from "@/components/DataSourceIcon"; +import { getDataSourceType } from "@/components/DataSourceItem"; +import { + type InspectorBoundaryConfig, + InspectorBoundaryConfigType, + type InspectorColumnGroup, +} from "@/server/models/MapView"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/shadcn/ui/dialog"; +import { Input } from "@/shadcn/ui/input"; +import { Label } from "@/shadcn/ui/label"; +import { Switch } from "@/shadcn/ui/switch"; +import { cn } from "@/shadcn/utils"; +import { useDataSources } from "../../hooks/useDataSources"; +import { useDebouncedCallback } from "../../hooks/useDebouncedCallback"; +import { useDebouncedValue } from "../../hooks/useDebouncedValue"; +import { useMapViews } from "../../hooks/useMapViews"; +import { InspectorFullPreview } from "./InspectorFullPreview"; +import { SortableColumnRow } from "./SortableColumnRow"; +import type { DataSource } from "@/server/models/DataSource"; +import type { DragEndEvent } from "@dnd-kit/core"; + +const SELECTED_DROPPABLE_ID = "selected-columns"; +const AVAILABLE_DROPPABLE_ID = "available-columns"; + +type InspectorLayout = "single" | "twoColumn"; + +export default function InspectorSettingsModal({ + open, + onOpenChange, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; +}) { + const [selectedDataSourceId, setSelectedDataSourceId] = useState< + string | null + >(null); + const [searchQuery, setSearchQuery] = useState(""); + const debouncedSearchQuery = useDebouncedValue(searchQuery, 250); + const { data: dataSources, getDataSourceById } = useDataSources(); + const { view, viewConfig, updateView } = useMapViews(); + + const boundaryConfigs = view?.inspectorConfig?.boundaries ?? []; + const onMapId = viewConfig.areaDataSourceId || null; + + const filteredSources = useMemo(() => { + const list = dataSources ?? []; + if (!debouncedSearchQuery.trim()) return list; + const q = debouncedSearchQuery.toLowerCase(); + return list.filter( + (ds) => + ds.name.toLowerCase().includes(q) || + ds.columnDefs.some((col) => col.name.toLowerCase().includes(q)), + ); + }, [dataSources, debouncedSearchQuery]); + + const matchesSearch = useCallback( + (ds: DataSource) => { + if (!debouncedSearchQuery.trim()) return true; + const q = debouncedSearchQuery.toLowerCase(); + return ( + ds.name.toLowerCase().includes(q) || + ds.columnDefs.some((col) => col.name.toLowerCase().includes(q)) + ); + }, + [debouncedSearchQuery], + ); + + const { inspectorOrdered, otherSources } = useMemo(() => { + const inInspector = boundaryConfigs + .map((config) => ({ + config, + dataSource: getDataSourceById(config.dataSourceId), + })) + .filter( + ( + x, + ): x is { + config: InspectorBoundaryConfig; + dataSource: NonNullable>; + } => x.dataSource != null && matchesSearch(x.dataSource), + ); + const inIds = new Set(inInspector.map((x) => x.dataSource.id)); + const other = (filteredSources ?? []).filter((ds) => !inIds.has(ds.id)); + return { inspectorOrdered: inInspector, otherSources: other }; + }, [boundaryConfigs, getDataSourceById, filteredSources, matchesSearch]); + + const selectedConfig = useMemo( + () => + selectedDataSourceId + ? (boundaryConfigs.find( + (c) => c.dataSourceId === selectedDataSourceId, + ) ?? null) + : null, + [selectedDataSourceId, boundaryConfigs], + ); + const selectedDataSource = useMemo( + () => + selectedDataSourceId + ? ((dataSources ?? []).find((ds) => ds.id === selectedDataSourceId) ?? + null) + : null, + [selectedDataSourceId, dataSources], + ); + + const handleAddToInspector = useCallback(() => { + if (!view || !selectedDataSourceId) return; + const ds = (dataSources ?? []).find((d) => d.id === selectedDataSourceId); + const newConfig: InspectorBoundaryConfig = { + id: uuidv4(), + dataSourceId: selectedDataSourceId, + name: ds?.name ?? "Boundary Data", + type: InspectorBoundaryConfigType.Simple, + columns: [], + columnMetadata: undefined, + columnGroups: undefined, + layout: "single", + }; + const prev = view.inspectorConfig?.boundaries ?? []; + updateView({ + ...view, + inspectorConfig: { + ...view.inspectorConfig, + boundaries: [...prev, newConfig], + }, + }); + }, [view, selectedDataSourceId, dataSources, updateView]); + + return ( + + setSelectedDataSourceId(null)} + > + + Inspector settings +

+ Choose data sources to show in the inspector and configure columns, + order, and layout. +

+
+ +
+ {/* Left: data sources list (inspector ones first, order synced from preview) */} +
+
+ setSearchQuery(e.target.value)} + className="h-9" + /> +
+
+ {inspectorOrdered.map(({ dataSource: ds }) => { + const isOnMap = ds.id === onMapId; + const isSelected = ds.id === selectedDataSourceId; + return ( + + ); + })} + {otherSources.map((ds) => { + const isOnMap = ds.id === onMapId; + const isSelected = ds.id === selectedDataSourceId; + return ( + + ); + })} + {inspectorOrdered.length === 0 && otherSources.length === 0 && ( +

+ No data sources match. +

+ )} +
+
+ + {/* Middle: config panel (flexes to fill space between list and preview) */} +
+ {selectedDataSource && view ? ( + + ) : selectedDataSource ? ( +
+ No view loaded. +
+ ) : ( +
+ Select a data source to configure columns (drag them into the + list). +
+ )} +
+ + {/* Right: full inspector preview (same width as real inspector panel: 250–450px) */} +
+ +
+
+
+
+ ); +} + +function InspectorSourceConfigPanel({ + dataSource, + config, + onAddToInspector, + isInInspector, + view, + updateView, + boundaryConfigs, +}: { + dataSource: DataSource; + config: InspectorBoundaryConfig | null; + onAddToInspector: () => void; + isInInspector: boolean; + view: NonNullable["view"]>; + updateView: ReturnType["updateView"]; + boundaryConfigs: InspectorBoundaryConfig[]; +}) { + const [activeId, setActiveId] = useState(null); + + const sensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 6 } }), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }), + ); + + const columns = config?.columns ?? []; + const columnIds = useMemo(() => columns.map((c) => `col-${c}`), [columns]); + const allColumnNames = useMemo( + () => dataSource.columnDefs.map((c) => c.name), + [dataSource.columnDefs], + ); + const availableColumns = useMemo( + () => + allColumnNames + .filter((c) => !columns.includes(c)) + .slice() + .sort((a, b) => a.localeCompare(b, undefined, { sensitivity: "base" })), + [allColumnNames, columns], + ); + + const updateConfig = useCallback( + (updater: (prev: InspectorBoundaryConfig) => InspectorBoundaryConfig) => { + if (!view || !config) return; + const updated = updater(config); + const index = boundaryConfigs.findIndex((c) => c.id === config.id); + if (index < 0) return; + const next = [...boundaryConfigs]; + next[index] = updated; + updateView({ + ...view, + inspectorConfig: { ...view.inspectorConfig, boundaries: next }, + }); + }, + [view, config, boundaryConfigs, updateView], + ); + + const [displayName, setDisplayName] = useState(config?.name ?? ""); + useEffect(() => setDisplayName(config?.name ?? ""), [config?.name]); + const debouncedUpdateName = useDebouncedCallback( + (value: string) => updateConfig((prev) => ({ ...prev, name: value })), + 300, + ); + + const handleDragEnd = useCallback( + (event: DragEndEvent) => { + const { active, over } = event; + setActiveId(null); + if (!config) return; + const activeStr = String(active.id); + const overStr = over ? String(over.id) : null; + + if (activeStr.startsWith("available-")) { + // Only add to Columns to show when dropped on the CTS zone (droppable or a column row) + const droppedOnCts = + over != null && + (overStr === SELECTED_DROPPABLE_ID || overStr?.startsWith("col-")); + if (!droppedOnCts) return; + + const colName = activeStr.slice("available-".length); + if (!allColumnNames.includes(colName)) return; + const insertIndex = + overStr === SELECTED_DROPPABLE_ID + ? columns.length + : overStr?.startsWith("col-") + ? columns.indexOf(overStr.slice("col-".length)) + : -1; + if (insertIndex === -1) return; + const idx = Math.max(0, insertIndex); + const next = [...columns]; + next.splice(idx, 0, colName); + updateConfig((prev) => ({ ...prev, columns: next })); + return; + } + + if (activeStr.startsWith("col-") && overStr?.startsWith("col-")) { + const oldIndex = columnIds.indexOf(activeStr); + const newIndex = columnIds.indexOf(overStr); + if (oldIndex === -1 || newIndex === -1) return; + const next = [...config.columns]; + const [removed] = next.splice(oldIndex, 1); + next.splice(newIndex, 0, removed); + updateConfig((prev) => ({ ...prev, columns: next })); + } + }, + [allColumnNames, columnIds, columns, config, updateConfig], + ); + + if (!isInInspector) { + return ( +
+

+ {dataSource.name} is not shown in the inspector yet. +

+ +
+ ); + } + + if (!config) return null; + + const columnMetadata = config.columnMetadata ?? {}; + const layout = (config.layout ?? "single") as InspectorLayout; + + return ( +
+
+
+ + { + const v = e.target.value; + setDisplayName(v); + debouncedUpdateName(v); + }} + placeholder="e.g. Main data" + className="max-w-sm" + /> +
+ +
+ +

+ Drag columns from Available into Columns to show. Reorder with the + handle. +

+ setActiveId(active.id as string)} + onDragEnd={handleDragEnd} + > +
+
+

+ Available (alphabetical, drag to add) +

+ +
+
+

+ Columns to show +

+ +
+
+ {typeof document !== "undefined" && + createPortal( + ({ + ...transform, + x: transform.x, + y: transform.y, + }), + ]} + > + {activeId ? ( + + ) : null} + , + document.body, + )} +
+
+ +
+
+ +
+ + + updateConfig((prev) => ({ + ...prev, + layout: checked ? "twoColumn" : "single", + })) + } + /> + +
+
+

+ {layout === "single" + ? "Single column list (default)." + : "Two-column grid (Airtable-style)."} +

+
+ + +
+
+ ); +} + +function ColumnDragPreview({ + activeId, + columnMetadata, +}: { + activeId: string; + columnMetadata: Record; +}) { + const fromAvailable = activeId.startsWith("available-"); + const columnName = fromAvailable + ? activeId.slice("available-".length) + : activeId.startsWith("col-") + ? activeId.slice("col-".length) + : ""; + const displayName = columnMetadata[columnName]?.displayName; + + if (fromAvailable) { + return ( +
+ + {columnName} +
+ ); + } + return ( +
+
+ + + {columnName} + +
+ + {displayName || columnName || "—"} + +
+ ); +} + +function AvailableColumnsList({ + availableColumns, + activeId, +}: { + availableColumns: string[]; + activeId: string | null; +}) { + const { setNodeRef, isOver } = useDroppable({ id: AVAILABLE_DROPPABLE_ID }); + return ( +
+ {availableColumns.map((col) => ( + + ))} + {availableColumns.length === 0 && ( +

+ All columns added +

+ )} +
+ ); +} + +function DraggableAvailableColumn({ + columnName, + isDragging, +}: { + columnName: string; + isDragging: boolean; +}) { + const { attributes, listeners, setNodeRef, transform } = useDraggable({ + id: `available-${columnName}`, + }); + // When dragging, hide source so only DragOverlay is visible (no double preview) + const style = isDragging + ? undefined + : transform + ? { transform: `translate3d(${transform.x}px, ${transform.y}px, 0)` } + : undefined; + return ( +
+ + {columnName} +
+ ); +} + +function DroppableSelectedColumns({ + columns, + columnMetadata, + updateConfig, + activeId, + layout = "single", +}: { + columns: string[]; + columnMetadata: Record; + updateConfig: ( + updater: (prev: InspectorBoundaryConfig) => InspectorBoundaryConfig, + ) => void; + activeId: string | null; + layout?: "single" | "twoColumn"; +}) { + const { setNodeRef, isOver } = useDroppable({ id: SELECTED_DROPPABLE_ID }); + const columnIds = useMemo(() => columns.map((c) => `col-${c}`), [columns]); + const isTwoColumn = layout === "twoColumn"; + const isDraggingFromAvailable = + activeId != null && String(activeId).startsWith("available-"); + + return ( +
+ {columns.length === 0 ? ( +

+ {isOver && isDraggingFromAvailable + ? "Drop here to add column" + : "Drop columns here"} +

+ ) : ( + +
+ {columns.map((col) => ( + + updateConfig((prev) => ({ + ...prev, + columnMetadata: { + ...(prev.columnMetadata ?? {}), + [col]: { + ...(prev.columnMetadata?.[col] ?? {}), + displayName: value || undefined, + }, + }, + })) + } + onRemove={() => + updateConfig((prev) => { + const nextColumns = prev.columns.filter((c) => c !== col); + const nextMeta = Object.fromEntries( + Object.entries(prev.columnMetadata ?? {}).filter( + ([k]) => k !== col, + ), + ); + const nextGroups = (prev.columnGroups ?? []) + .map((g) => ({ + ...g, + columnNames: g.columnNames.filter((c) => c !== col), + })) + .filter((g) => g.columnNames.length > 0); + return { + ...prev, + columns: nextColumns, + columnMetadata: nextMeta, + columnGroups: nextGroups, + }; + }) + } + isDragging={activeId === `col-${col}`} + /> + ))} +
+
+ )} +
+ ); +} + +function GroupLabelRow({ + groupIndex, + label, + updateGroup, + removeGroup, + columns, + columnNamesInGroup, + toggleColumnInGroup, +}: { + groupIndex: number; + label: string; + updateGroup: ( + index: number, + updater: (g: InspectorColumnGroup) => InspectorColumnGroup, + ) => void; + removeGroup: (index: number) => void; + columns: string[]; + columnNamesInGroup: string[]; + toggleColumnInGroup: (groupIndex: number, columnName: string) => void; +}) { + const [localLabel, setLocalLabel] = useState(label); + useEffect(() => setLocalLabel(label), [label]); + const debouncedUpdateLabel = useDebouncedCallback( + (value: string) => + updateGroup(groupIndex, (prev) => ({ ...prev, label: value })), + 300, + ); + + return ( +
+
+ { + const v = e.target.value; + setLocalLabel(v); + debouncedUpdateLabel(v); + }} + placeholder="Group label" + className="h-8 flex-1" + /> + +
+
+ {columns.map((col) => { + const inGroup = columnNamesInGroup.includes(col); + return ( + + ); + })} +
+
+ ); +} + +function ColumnGroupsEditor({ + config, + updateConfig, +}: { + config: InspectorBoundaryConfig; + updateConfig: ( + updater: (prev: InspectorBoundaryConfig) => InspectorBoundaryConfig, + ) => void; +}) { + const groups = config.columnGroups ?? []; + const columns = config.columns; + + const addGroup = useCallback(() => { + const id = uuidv4(); + updateConfig((prev) => ({ + ...prev, + columnGroups: [ + ...(prev.columnGroups ?? []), + { id, label: "New group", columnNames: [] }, + ], + })); + }, [updateConfig]); + + const updateGroup = useCallback( + ( + index: number, + updater: (g: InspectorColumnGroup) => InspectorColumnGroup, + ) => { + updateConfig((prev) => { + const next = [...(prev.columnGroups ?? [])]; + if (!next[index]) return prev; + next[index] = updater(next[index]); + return { ...prev, columnGroups: next }; + }); + }, + [updateConfig], + ); + + const removeGroup = useCallback( + (index: number) => { + updateConfig((prev) => { + const next = (prev.columnGroups ?? []).filter((_, i) => i !== index); + return { ...prev, columnGroups: next }; + }); + }, + [updateConfig], + ); + + const toggleColumnInGroup = useCallback( + (groupIndex: number, columnName: string) => { + updateGroup(groupIndex, (g) => { + const has = g.columnNames.includes(columnName); + const columnNames = has + ? g.columnNames.filter((c) => c !== columnName) + : [...g.columnNames, columnName]; + return { ...g, columnNames }; + }); + }, + [updateGroup], + ); + + return ( +
+
+ + +
+

+ Group columns under headings in the inspector. +

+
+ {groups.map((g, i) => ( + + ))} +
+
+ ); +} diff --git a/src/app/map/[id]/components/inspector/PropertiesList.tsx b/src/app/map/[id]/components/inspector/PropertiesList.tsx index ef0148f07..14effb839 100644 --- a/src/app/map/[id]/components/inspector/PropertiesList.tsx +++ b/src/app/map/[id]/components/inspector/PropertiesList.tsx @@ -1,30 +1,82 @@ import { Fragment } from "react"; +import { cn } from "@/shadcn/utils"; + +export type PropertyEntry = { + key: string; + label: string; + value: unknown; + groupLabel?: string; +}; export default function PropertiesList({ properties, + entries: entriesProp, + layout = "single", }: { - properties: Record | null | undefined; + properties?: Record | null; + entries?: PropertyEntry[] | null; + layout?: "single" | "twoColumn"; }) { - if (!properties || !Object.keys(properties as object)?.length) { - return <>; - } + const entries: PropertyEntry[] = entriesProp + ? entriesProp.filter((e) => e.value !== undefined && e.value !== null && String(e.value) !== "") + : properties && Object.keys(properties).length + ? Object.entries(properties).map(([key, value]) => ({ + key, + label: key, + value, + })) + : []; - return ( -
- {Object.keys(properties as object).map((label) => { - const value = `${properties?.[label]}`; + if (!entries.length) return <>; + + const isTwoColumn = layout === "twoColumn"; + const renderEntry = (e: PropertyEntry) => ( +
+
+ {e.label} +
+
{String(e.value)}
+
+ ); - if (!value) return ; + const byGroup = entries.reduce<{ group?: string; items: PropertyEntry[] }[]>( + (acc, e) => { + const last = acc[acc.length - 1]; + if (e.groupLabel !== undefined) { + if (last?.group === e.groupLabel) last.items.push(e); + else acc.push({ group: e.groupLabel, items: [e] }); + } else { + if (last && last.group === undefined) last.items.push(e); + else acc.push({ items: [e] }); + } + return acc; + }, + [], + ); - return ( -
-
- {label} -
-
{value}
-
- ); - })} + return ( +
+ {byGroup.map((block, i) => ( + + {block.group && ( +
+ {block.group} +
+ )} + {block.items.map(renderEntry)} +
+ ))}
); } diff --git a/src/app/map/[id]/components/inspector/SortableColumnRow.tsx b/src/app/map/[id]/components/inspector/SortableColumnRow.tsx new file mode 100644 index 000000000..33672f08f --- /dev/null +++ b/src/app/map/[id]/components/inspector/SortableColumnRow.tsx @@ -0,0 +1,101 @@ +"use client"; + +import { useSortable } from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; +import { GripVertical, X } from "lucide-react"; +import { useEffect, useState } from "react"; +import { Input } from "@/shadcn/ui/input"; +import { cn } from "@/shadcn/utils"; + +import { useDebouncedCallback } from "../../hooks/useDebouncedCallback"; + +export function SortableColumnRow({ + id, + columnName, + displayName, + onDisplayNameChange, + onRemove, + isDragging, +}: { + id: string; + columnName: string; + displayName: string | undefined; + onDisplayNameChange: (value: string) => void; + onRemove?: () => void; + isDragging?: boolean; +}) { + const [localDisplayName, setLocalDisplayName] = useState(displayName ?? ""); + useEffect(() => setLocalDisplayName(displayName ?? ""), [displayName]); + const debouncedChange = useDebouncedCallback(onDisplayNameChange, 300); + + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging: dndDragging, + } = useSortable({ id }); + + const dragging = isDragging ?? dndDragging; + // When dragging, hide source so only DragOverlay is visible; keep slot in place + const style = dragging + ? { transition } + : { + transform: CSS.Transform.toString(transform), + transition, + }; + + return ( +
+
+ + + {columnName} + + {onRemove && ( + + )} +
+ { + const v = e.target.value; + setLocalDisplayName(v); + debouncedChange(v); + }} + onClick={(e) => e.stopPropagation()} + /> +
+ ); +} diff --git a/src/app/map/[id]/hooks/useDebouncedCallback.ts b/src/app/map/[id]/hooks/useDebouncedCallback.ts new file mode 100644 index 000000000..67548b57a --- /dev/null +++ b/src/app/map/[id]/hooks/useDebouncedCallback.ts @@ -0,0 +1,27 @@ +"use client"; + +import { useCallback, useRef } from "react"; + +/** + * Returns a callback that invokes the given function after `delay` ms of no further calls. + * The last arguments are used when the timer fires. + */ +export function useDebouncedCallback( + fn: (...args: A) => R, + delay: number, +): (...args: A) => void { + const timeoutRef = useRef | null>(null); + const fnRef = useRef(fn); + fnRef.current = fn; + + return useCallback( + (...args: A) => { + if (timeoutRef.current) clearTimeout(timeoutRef.current); + timeoutRef.current = setTimeout(() => { + timeoutRef.current = null; + fnRef.current(...args); + }, delay); + }, + [delay], + ); +} diff --git a/src/app/map/[id]/hooks/useDebouncedValue.ts b/src/app/map/[id]/hooks/useDebouncedValue.ts new file mode 100644 index 000000000..a1e95c26b --- /dev/null +++ b/src/app/map/[id]/hooks/useDebouncedValue.ts @@ -0,0 +1,18 @@ +"use client"; + +import { useEffect, useState } from "react"; + +/** + * Returns a value that updates after `delay` ms of the source value not changing. + * Useful for search/filter inputs so the input stays responsive while heavy work is debounced. + */ +export function useDebouncedValue(value: T, delay: number): T { + const [debouncedValue, setDebouncedValue] = useState(value); + + useEffect(() => { + const timer = setTimeout(() => setDebouncedValue(value), delay); + return () => clearTimeout(timer); + }, [value, delay]); + + return debouncedValue; +} diff --git a/src/server/models/MapView.ts b/src/server/models/MapView.ts index 24bfa1355..31fc2373b 100644 --- a/src/server/models/MapView.ts +++ b/src/server/models/MapView.ts @@ -162,12 +162,33 @@ export const inspectorBoundaryTypes = Object.values( InspectorBoundaryConfigType, ); +/** + * Display metadata for a single column (e.g. custom label) + */ +export const inspectorColumnMetaSchema = z.object({ + displayName: z.string().optional(), +}); +export type InspectorColumnMeta = z.infer; + +/** + * A group of columns shown under one heading in the inspector + */ +export const inspectorColumnGroupSchema = z.object({ + id: z.string(), + label: z.string(), + columnNames: z.array(z.string()), +}); +export type InspectorColumnGroup = z.infer; + /** * Configuration for a single boundary data source in the inspector * - dataSourceId: Reference to the data source * - name: User-friendly name for this inspector config * - type: The type of inspector display (currently only "simple") - * - columns: Array of column names to display from this data source + * - columns: Ordered array of column names to display + * - columnMetadata: Optional display names per column + * - columnGroups: Optional groups for visual grouping (columns appear under group label) + * - layout: "single" (one column) or "twoColumn" (Airtable-style grid) */ export const inspectorBoundaryConfigSchema = z.object({ id: z.string(), @@ -175,6 +196,12 @@ export const inspectorBoundaryConfigSchema = z.object({ name: z.string(), type: z.nativeEnum(InspectorBoundaryConfigType), columns: z.array(z.string()), + columnMetadata: z + .record(z.string(), inspectorColumnMetaSchema) + .optional() + .nullable(), + columnGroups: z.array(inspectorColumnGroupSchema).optional().nullable(), + layout: z.enum(["single", "twoColumn"]).optional().nullable(), }); export type InspectorBoundaryConfig = z.infer< From 71895f0afb04ef9d04ef5e02839153432243dc01 Mon Sep 17 00:00:00 2001 From: Arbyhisenaj <41119392+Arbyhisenaj@users.noreply.github.com> Date: Wed, 25 Feb 2026 21:33:59 +0000 Subject: [PATCH 05/21] checkpoint --- src/app/map/[id]/components/TogglePanel.tsx | 5 +- .../inspector/BoundaryDataPanel.tsx | 141 +- .../components/inspector/InspectorChart.tsx | 99 ++ .../inspector/InspectorFullPreview.tsx | 29 +- .../inspector/InspectorSettingsModal.tsx | 1286 +++++++++++------ .../components/inspector/PropertiesList.tsx | 92 +- .../inspector/SortableColumnRow.tsx | 257 +++- .../inspector/inspectorPanelOptions.tsx | 195 +++ src/components/ColorPalette.tsx | 3 +- src/server/models/MapView.ts | 64 +- 10 files changed, 1694 insertions(+), 477 deletions(-) create mode 100644 src/app/map/[id]/components/inspector/InspectorChart.tsx create mode 100644 src/app/map/[id]/components/inspector/inspectorPanelOptions.tsx diff --git a/src/app/map/[id]/components/TogglePanel.tsx b/src/app/map/[id]/components/TogglePanel.tsx index f83725652..24879c4ae 100644 --- a/src/app/map/[id]/components/TogglePanel.tsx +++ b/src/app/map/[id]/components/TogglePanel.tsx @@ -11,6 +11,8 @@ interface TogglePanelProps { headerRight?: React.ReactNode; rightIconButton?: LucideIcon; onRightIconButtonClick?: () => void; + /** Optional class for the outer wrapper (e.g. panel background colour) */ + wrapperClassName?: string; } export default function TogglePanel({ @@ -21,11 +23,12 @@ export default function TogglePanel({ headerRight, rightIconButton: RightIconButton, onRightIconButtonClick, + wrapperClassName, }: TogglePanelProps) { const [expanded, setExpanded] = useState(defaultExpanded); return ( -
+
-
+

@@ -158,6 +180,7 @@ function SortableBoundaryPanel({

- {inspectorOrdered.map(({ dataSource: ds }) => { - const isOnMap = ds.id === onMapId; - const isSelected = ds.id === selectedDataSourceId; - return ( - + +
+ ); + })} +
+ + )} + {otherSources.length > 0 && ( +
0 && "mt-2")} + > + {otherSources.map((ds) => { + const isOnMap = ds.id === onMapId; + const isSelected = ds.id === selectedDataSourceId; + return ( +
- - ); - })} - {otherSources.map((ds) => { - const isOnMap = ds.id === onMapId; - const isSelected = ds.id === selectedDataSourceId; - return ( - - ); - })} +
+ {isOnMap && ( + + + On map + + )} +
+ + ); + })} +
+ )} {inspectorOrdered.length === 0 && otherSources.length === 0 && (

No data sources match. @@ -302,7 +364,10 @@ export default function InspectorSettingsModal({ className="flex flex-col shrink-0 overflow-hidden p-4 bg-neutral-50" style={{ width: "380px", minWidth: "250px", maxWidth: "450px" }} > - +

@@ -337,18 +402,43 @@ function InspectorSourceConfigPanel({ ); const columns = config?.columns ?? []; - const columnIds = useMemo(() => columns.map((c) => `col-${c}`), [columns]); const allColumnNames = useMemo( () => dataSource.columnDefs.map((c) => c.name), [dataSource.columnDefs], ); - const availableColumns = useMemo( + const allColumnsSorted = useMemo( () => - allColumnNames - .filter((c) => !columns.includes(c)) - .slice() - .sort((a, b) => a.localeCompare(b, undefined, { sensitivity: "base" })), - [allColumnNames, columns], + [...allColumnNames].sort((a, b) => + a.localeCompare(b, undefined, { sensitivity: "base" }), + ), + [allColumnNames], + ); + /** Full list order: use saved columnOrder when valid; else selected at top then alphabetical. */ + const allColumnsInOrder = useMemo(() => { + const order = config?.columnOrder?.filter((c) => + allColumnNames.includes(c), + ); + if (order?.length === allColumnNames.length) return order; + const selected = columns.filter((c) => allColumnNames.includes(c)); + const rest = allColumnsSorted.filter((c) => !selected.includes(c)); + return [...selected, ...rest]; + }, [config?.columnOrder, allColumnNames, allColumnsSorted, columns]); + const availableColumns = useMemo( + () => allColumnsInOrder.filter((c) => !columns.includes(c)), + [allColumnsInOrder, columns], + ); + const availableIds = useMemo( + () => allColumnsInOrder.map((c) => `available-${c}`), + [allColumnsInOrder], + ); + /** Selected columns in same order as left list (so both columns match) */ + const selectedColumnsInOrder = useMemo( + () => allColumnsInOrder.filter((c) => columns.includes(c)), + [allColumnsInOrder, columns], + ); + const columnIds = useMemo( + () => selectedColumnsInOrder.map((c) => `col-${c}`), + [selectedColumnsInOrder], ); const updateConfig = useCallback( @@ -371,7 +461,118 @@ function InspectorSourceConfigPanel({ useEffect(() => setDisplayName(config?.name ?? ""), [config?.name]); const debouncedUpdateName = useDebouncedCallback( (value: string) => updateConfig((prev) => ({ ...prev, name: value })), - 300, + 600, + ); + + const handleAddColumn = useCallback( + (colName: string) => { + const inferred = inferFormat(colName); + updateConfig((prev) => { + const order = prev.columnOrder?.filter((c) => + allColumnNames.includes(c), + ); + const baseOrder = + order?.length === allColumnNames.length ? order : allColumnsSorted; + const newOrder = [ + colName, + ...baseOrder.filter((c) => c !== colName), + ]; + return { + ...prev, + columns: [colName, ...prev.columns], + columnOrder: newOrder, + columnMetadata: { + ...prev.columnMetadata, + [colName]: { + ...prev.columnMetadata?.[colName], + format: + prev.columnMetadata?.[colName]?.format ?? inferred ?? undefined, + }, + }, + }; + }); + }, + [updateConfig, allColumnNames, allColumnsSorted], + ); + + const handleRemoveColumn = useCallback( + (colName: string) => { + updateConfig((prev) => { + const nextColumns = prev.columns.filter((c) => c !== colName); + const nextMeta = Object.fromEntries( + Object.entries(prev.columnMetadata ?? {}).filter( + ([k]) => k !== colName, + ), + ); + const nextChartColumnNames = (prev.chart?.columnNames ?? []).filter( + (c) => c !== colName, + ); + const order = prev.columnOrder?.filter((c) => + allColumnNames.includes(c), + ); + const baseOrder = + order?.length === allColumnNames.length ? order : allColumnsSorted; + const newOrder = [ + ...baseOrder.filter((c) => c !== colName), + colName, + ]; + return { + ...prev, + columns: nextColumns, + columnOrder: newOrder, + columnMetadata: nextMeta, + chart: + prev.chart && nextChartColumnNames.length >= 0 + ? { + ...prev.chart, + columnNames: + nextChartColumnNames.length > 0 + ? nextChartColumnNames + : undefined, + } + : prev.chart, + }; + }); + }, + [updateConfig, allColumnNames, allColumnsSorted], + ); + + /** Remove from right list and move to bottom of left list (keeps order in sync) */ + const handleRemoveColumnFromRight = useCallback( + (colName: string) => { + updateConfig((prev) => { + const nextColumns = prev.columns.filter((c) => c !== colName); + const nextMeta = Object.fromEntries( + Object.entries(prev.columnMetadata ?? {}).filter( + ([k]) => k !== colName, + ), + ); + const nextChartColumnNames = (prev.chart?.columnNames ?? []).filter( + (c) => c !== colName, + ); + const newColumnOrder = [ + ...nextColumns, + ...allColumnsInOrder.filter((c) => !nextColumns.includes(c)), + ]; + return { + ...prev, + columns: nextColumns, + columnOrder: newColumnOrder, + columnMetadata: nextMeta, + chart: + prev.chart && nextChartColumnNames.length >= 0 + ? { + ...prev.chart, + columnNames: + nextChartColumnNames.length > 0 + ? nextChartColumnNames + : undefined, + } + : prev.chart, + }; + }); + }, + [updateConfig, allColumnsInOrder], ); const handleDragEnd = useCallback( @@ -382,40 +583,46 @@ function InspectorSourceConfigPanel({ const activeStr = String(active.id); const overStr = over ? String(over.id) : null; - if (activeStr.startsWith("available-")) { - // Only add to Columns to show when dropped on the CTS zone (droppable or a column row) - const droppedOnCts = - over != null && - (overStr === SELECTED_DROPPABLE_ID || overStr?.startsWith("col-")); - if (!droppedOnCts) return; - - const colName = activeStr.slice("available-".length); - if (!allColumnNames.includes(colName)) return; - const insertIndex = - overStr === SELECTED_DROPPABLE_ID - ? columns.length - : overStr?.startsWith("col-") - ? columns.indexOf(overStr.slice("col-".length)) - : -1; - if (insertIndex === -1) return; - const idx = Math.max(0, insertIndex); - const next = [...columns]; - next.splice(idx, 0, colName); - updateConfig((prev) => ({ ...prev, columns: next })); - return; - } - if (activeStr.startsWith("col-") && overStr?.startsWith("col-")) { const oldIndex = columnIds.indexOf(activeStr); const newIndex = columnIds.indexOf(overStr); if (oldIndex === -1 || newIndex === -1) return; - const next = [...config.columns]; + const next = [...selectedColumnsInOrder]; const [removed] = next.splice(oldIndex, 1); next.splice(newIndex, 0, removed); - updateConfig((prev) => ({ ...prev, columns: next })); + const newColumnOrder = [ + ...next, + ...allColumnsInOrder.filter((c) => !next.includes(c)), + ]; + updateConfig((prev) => ({ + ...prev, + columns: next, + columnOrder: newColumnOrder, + })); + return; + } + if ( + activeStr.startsWith("available-") && + overStr?.startsWith("available-") + ) { + const activeCol = activeStr.slice("available-".length); + const overCol = overStr.slice("available-".length); + const oldIdx = allColumnsInOrder.indexOf(activeCol); + const newIdx = allColumnsInOrder.indexOf(overCol); + if (oldIdx === -1 || newIdx === -1) return; + const nextOrder = [...allColumnsInOrder]; + const [removed] = nextOrder.splice(oldIdx, 1); + nextOrder.splice(newIdx, 0, removed); + updateConfig((prev) => ({ ...prev, columnOrder: nextOrder })); } }, - [allColumnNames, columnIds, columns, config, updateConfig], + [ + columnIds, + config, + updateConfig, + allColumnsInOrder, + selectedColumnsInOrder, + ], ); if (!isInInspector) { @@ -440,30 +647,302 @@ function InspectorSourceConfigPanel({ const columnMetadata = config.columnMetadata ?? {}; const layout = (config.layout ?? "single") as InspectorLayout; + const panelIcon = config.icon ?? undefined; + const panelColor = config.color ?? undefined; return (
-
- - { - const v = e.target.value; - setDisplayName(v); - debouncedUpdateName(v); - }} - placeholder="e.g. Main data" - className="max-w-sm" - /> +
+
+ + { + const v = e.target.value; + setDisplayName(v); + debouncedUpdateName(v); + }} + placeholder="e.g. Main data" + className="max-w-sm" + /> +
+
+ + +
+
+ + +
+
+ +
+ + + updateConfig((prev) => ({ + ...prev, + layout: checked ? "twoColumn" : "single", + })) + } + /> + +
+

+ {layout === "single" ? "Single column" : "Two-column grid"} +

+
+
+ +
+
+ + +
+
+
+ + updateConfig((prev) => ({ + ...prev, + chart: { + enabled: checked, + dataSource: prev.chart?.dataSource ?? "percentage", + columnNames: prev.chart?.columnNames, + hideZeroValues: prev.chart?.hideZeroValues, + hideChartColumnsFromList: + prev.chart?.hideChartColumnsFromList, + }, + })) + } + /> + Show chart at top +
+ + {(config.chart?.enabled ?? false) && ( +
+ + +
+ )} + {(config.chart?.enabled ?? false) && ( +
+ + updateConfig((prev) => { + const ch = prev.chart ?? { + enabled: true, + dataSource: "percentage" as const, + columnNames: undefined, + }; + return { + ...prev, + chart: { + ...ch, + hideZeroValues: checked, + hideChartColumnsFromList: checked + ? false + : ch.hideChartColumnsFromList, + }, + }; + }) + } + /> + Hide zero values from the chart +
+ )} + {(config.chart?.enabled ?? false) && ( +
+ + updateConfig((prev) => { + const ch = prev.chart ?? { + enabled: true, + dataSource: "percentage" as const, + columnNames: undefined, + }; + return { + ...prev, + chart: { + ...ch, + hideChartColumnsFromList: checked, + hideZeroValues: checked ? false : ch.hideZeroValues, + }, + }; + }) + } + /> + + Hide data used in chart from list below + +
+ )} +
+ {(config.chart?.enabled ?? false) && + (config.chart?.dataSource ?? "percentage") === "custom" && ( +

+ Tick “Include in chart” on each column in Columns to show below. +

+ )}
- -

- Drag columns from Available into Columns to show. Reorder with the - handle. -

+
+
+ +

+ Tick columns in Available to add. Reorder in Columns to show + with the handle. +

+
+
+ + +
+

- Available (alphabetical, drag to add) + All columns (selected at top, drag to reorder, tick to add)

-
@@ -485,11 +968,17 @@ function InspectorSourceConfigPanel({ Columns to show

@@ -505,43 +994,19 @@ function InspectorSourceConfigPanel({ }), ]} > - {activeId ? ( + {activeId && String(activeId).startsWith("col-") ? ( + ) : activeId && String(activeId).startsWith("available-") ? ( + ) : null} , document.body, )}
- -
-
- -
- - - updateConfig((prev) => ({ - ...prev, - layout: checked ? "twoColumn" : "single", - })) - } - /> - -
-
-

- {layout === "single" - ? "Single column list (default)." - : "Two-column grid (Airtable-style)."} -

-
- -
); @@ -554,22 +1019,11 @@ function ColumnDragPreview({ activeId: string; columnMetadata: Record; }) { - const fromAvailable = activeId.startsWith("available-"); - const columnName = fromAvailable - ? activeId.slice("available-".length) - : activeId.startsWith("col-") - ? activeId.slice("col-".length) - : ""; + const columnName = activeId.startsWith("col-") + ? activeId.slice("col-".length) + : ""; const displayName = columnMetadata[columnName]?.displayName; - if (fromAvailable) { - return ( -
- - {columnName} -
- ); - } return (
@@ -588,69 +1042,122 @@ function ColumnDragPreview({ ); } -function AvailableColumnsList({ - availableColumns, - activeId, -}: { - availableColumns: string[]; - activeId: string | null; -}) { - const { setNodeRef, isOver } = useDroppable({ id: AVAILABLE_DROPPABLE_ID }); +function AvailableDragPreview({ activeId }: { activeId: string }) { + const columnName = activeId.startsWith("available-") + ? activeId.slice("available-".length) + : ""; return ( -
- {availableColumns.map((col) => ( - - ))} - {availableColumns.length === 0 && ( -

- All columns added -

- )} +
+ + + {columnName} +
); } -function DraggableAvailableColumn({ +function SortableAvailableRow({ + id, columnName, + selected, + onToggle, isDragging, }: { + id: string; columnName: string; - isDragging: boolean; + selected: boolean; + onToggle: (checked: boolean) => void; + isDragging?: boolean; }) { - const { attributes, listeners, setNodeRef, transform } = useDraggable({ - id: `available-${columnName}`, - }); - // When dragging, hide source so only DragOverlay is visible (no double preview) - const style = isDragging - ? undefined - : transform - ? { transform: `translate3d(${transform.x}px, ${transform.y}px, 0)` } - : undefined; + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging: dndDragging, + } = useSortable({ id }); + const dragging = isDragging ?? dndDragging; + const style = dragging + ? { transition } + : { + transform: CSS.Transform.toString(transform), + transition, + }; return (
- - {columnName} + + onToggle(checked === true)} + aria-label={ + selected + ? `Remove ${columnName} from columns to show` + : `Add ${columnName} to columns to show` + } + /> + {columnName} +
+ ); +} + +function AvailableColumnsCheckboxList({ + allColumnsInOrder, + selectedColumns, + onAddColumn, + onRemoveColumn, + availableIds, + activeId, +}: { + allColumnsInOrder: string[]; + selectedColumns: string[]; + onAddColumn: (columnName: string) => void; + onRemoveColumn: (columnName: string) => void; + availableIds: string[]; + activeId: string | null; +}) { + return ( +
+ + {allColumnsInOrder.map((col) => ( + + checked ? onAddColumn(col) : onRemoveColumn(col) + } + isDragging={activeId === `available-${col}`} + /> + ))} + + {allColumnsInOrder.length === 0 && ( +

+ No columns in this data source +

+ )}
); } @@ -659,63 +1166,51 @@ function DroppableSelectedColumns({ columns, columnMetadata, updateConfig, + onRemoveColumn, activeId, - layout = "single", + chartDataSource, + chartColumnNames, }: { columns: string[]; - columnMetadata: Record; + columnMetadata?: InspectorBoundaryConfig["columnMetadata"]; updateConfig: ( updater: (prev: InspectorBoundaryConfig) => InspectorBoundaryConfig, ) => void; + onRemoveColumn?: (columnName: string) => void; activeId: string | null; - layout?: "single" | "twoColumn"; + chartDataSource?: InspectorChartDataSource | null; + chartColumnNames?: string[]; }) { + const meta = columnMetadata ?? {}; const { setNodeRef, isOver } = useDroppable({ id: SELECTED_DROPPABLE_ID }); const columnIds = useMemo(() => columns.map((c) => `col-${c}`), [columns]); - const isTwoColumn = layout === "twoColumn"; - const isDraggingFromAvailable = - activeId != null && String(activeId).startsWith("available-"); return (
{columns.length === 0 ? ( -

- {isOver && isDraggingFromAvailable - ? "Drop here to add column" - : "Drop columns here"} +

+ No columns — tick Available to add

) : ( -
+
{columns.map((col) => ( updateConfig((prev) => ({ ...prev, @@ -728,27 +1223,107 @@ function DroppableSelectedColumns({ }, })) } - onRemove={() => - updateConfig((prev) => { - const nextColumns = prev.columns.filter((c) => c !== col); - const nextMeta = Object.fromEntries( - Object.entries(prev.columnMetadata ?? {}).filter( - ([k]) => k !== col, - ), - ); - const nextGroups = (prev.columnGroups ?? []) - .map((g) => ({ - ...g, - columnNames: g.columnNames.filter((c) => c !== col), - })) - .filter((g) => g.columnNames.length > 0); - return { - ...prev, - columns: nextColumns, - columnMetadata: nextMeta, - columnGroups: nextGroups, - }; - }) + format={meta[col]?.format ?? "text"} + onFormatChange={(format) => + updateConfig((prev) => ({ + ...prev, + columnMetadata: { + ...(prev.columnMetadata ?? {}), + [col]: { + ...(prev.columnMetadata?.[col] ?? {}), + format, + }, + }, + })) + } + scaleMax={meta[col]?.scaleMax ?? 3} + onScaleMaxChange={(scaleMax) => + updateConfig((prev) => ({ + ...prev, + columnMetadata: { + ...(prev.columnMetadata ?? {}), + [col]: { + ...(prev.columnMetadata?.[col] ?? {}), + scaleMax, + }, + }, + })) + } + barColor={meta[col]?.barColor} + onBarColorChange={(value) => + updateConfig((prev) => ({ + ...prev, + columnMetadata: { + ...(prev.columnMetadata ?? {}), + [col]: { + ...(prev.columnMetadata?.[col] ?? {}), + barColor: value || undefined, + }, + }, + })) + } + includeInChart={ + chartDataSource === "custom" && + (chartColumnNames?.includes(col) ?? false) + } + onIncludeInChartChange={ + chartDataSource === "custom" + ? (include) => + updateConfig((prev) => { + const current = prev.chart?.columnNames ?? []; + const next = include + ? current.includes(col) + ? current + : [...current, col] + : current.filter((c) => c !== col); + const chart = prev.chart ?? { + enabled: true, + dataSource: "custom" as const, + columnNames: [], + }; + return { + ...prev, + chart: { + ...chart, + dataSource: "custom", + columnNames: next, + }, + }; + }) + : undefined + } + showChartCheckbox={chartDataSource === "custom"} + onRemove={ + onRemoveColumn + ? () => onRemoveColumn(col) + : () => + updateConfig((prev) => { + const nextColumns = prev.columns.filter( + (c) => c !== col, + ); + const nextMeta = Object.fromEntries( + Object.entries(prev.columnMetadata ?? {}).filter( + ([k]) => k !== col, + ), + ); + const nextChartColumnNames = ( + prev.chart?.columnNames ?? [] + ).filter((c) => c !== col); + return { + ...prev, + columns: nextColumns, + columnMetadata: nextMeta, + chart: prev.chart + ? { + ...prev.chart, + columnNames: + nextChartColumnNames.length > 0 + ? nextChartColumnNames + : undefined, + } + : prev.chart, + }; + }) } isDragging={activeId === `col-${col}`} /> @@ -759,170 +1334,3 @@ function DroppableSelectedColumns({
); } - -function GroupLabelRow({ - groupIndex, - label, - updateGroup, - removeGroup, - columns, - columnNamesInGroup, - toggleColumnInGroup, -}: { - groupIndex: number; - label: string; - updateGroup: ( - index: number, - updater: (g: InspectorColumnGroup) => InspectorColumnGroup, - ) => void; - removeGroup: (index: number) => void; - columns: string[]; - columnNamesInGroup: string[]; - toggleColumnInGroup: (groupIndex: number, columnName: string) => void; -}) { - const [localLabel, setLocalLabel] = useState(label); - useEffect(() => setLocalLabel(label), [label]); - const debouncedUpdateLabel = useDebouncedCallback( - (value: string) => - updateGroup(groupIndex, (prev) => ({ ...prev, label: value })), - 300, - ); - - return ( -
-
- { - const v = e.target.value; - setLocalLabel(v); - debouncedUpdateLabel(v); - }} - placeholder="Group label" - className="h-8 flex-1" - /> - -
-
- {columns.map((col) => { - const inGroup = columnNamesInGroup.includes(col); - return ( - - ); - })} -
-
- ); -} - -function ColumnGroupsEditor({ - config, - updateConfig, -}: { - config: InspectorBoundaryConfig; - updateConfig: ( - updater: (prev: InspectorBoundaryConfig) => InspectorBoundaryConfig, - ) => void; -}) { - const groups = config.columnGroups ?? []; - const columns = config.columns; - - const addGroup = useCallback(() => { - const id = uuidv4(); - updateConfig((prev) => ({ - ...prev, - columnGroups: [ - ...(prev.columnGroups ?? []), - { id, label: "New group", columnNames: [] }, - ], - })); - }, [updateConfig]); - - const updateGroup = useCallback( - ( - index: number, - updater: (g: InspectorColumnGroup) => InspectorColumnGroup, - ) => { - updateConfig((prev) => { - const next = [...(prev.columnGroups ?? [])]; - if (!next[index]) return prev; - next[index] = updater(next[index]); - return { ...prev, columnGroups: next }; - }); - }, - [updateConfig], - ); - - const removeGroup = useCallback( - (index: number) => { - updateConfig((prev) => { - const next = (prev.columnGroups ?? []).filter((_, i) => i !== index); - return { ...prev, columnGroups: next }; - }); - }, - [updateConfig], - ); - - const toggleColumnInGroup = useCallback( - (groupIndex: number, columnName: string) => { - updateGroup(groupIndex, (g) => { - const has = g.columnNames.includes(columnName); - const columnNames = has - ? g.columnNames.filter((c) => c !== columnName) - : [...g.columnNames, columnName]; - return { ...g, columnNames }; - }); - }, - [updateGroup], - ); - - return ( -
-
- - -
-

- Group columns under headings in the inspector. -

-
- {groups.map((g, i) => ( - - ))} -
-
- ); -} diff --git a/src/app/map/[id]/components/inspector/PropertiesList.tsx b/src/app/map/[id]/components/inspector/PropertiesList.tsx index 14effb839..b5f2b46e8 100644 --- a/src/app/map/[id]/components/inspector/PropertiesList.tsx +++ b/src/app/map/[id]/components/inspector/PropertiesList.tsx @@ -1,13 +1,96 @@ import { Fragment } from "react"; import { cn } from "@/shadcn/utils"; +export type ColumnFormat = "text" | "number" | "percentage" | "scale"; + export type PropertyEntry = { key: string; label: string; value: unknown; groupLabel?: string; + format?: ColumnFormat; + scaleMax?: number; + /** Bar colour (CSS color) for percentage/scale; same colour used in chart. */ + barColor?: string; }; +function formatNumber(n: number): string { + if (Number.isInteger(n)) return n.toLocaleString(); + const s = n.toFixed(2); + if (s.endsWith("00")) return n.toLocaleString(undefined, { maximumFractionDigits: 0 }); + return n.toLocaleString(undefined, { minimumFractionDigits: 0, maximumFractionDigits: 2 }); +} + +function parseNumeric(value: unknown): number | null { + if (typeof value === "number" && !Number.isNaN(value)) return value; + const n = Number(value); + return Number.isNaN(n) ? null : n; +} + +function barFill(barColor?: string): string { + return barColor?.trim() ? barColor : "hsl(var(--primary))"; +} + +function PropertyValue({ + value, + format = "text", + scaleMax = 3, + barColor, +}: { + value: unknown; + format?: ColumnFormat; + scaleMax?: number; + barColor?: string; +}) { + const num = parseNumeric(value); + const fill = barFill(barColor); + + if (format === "number" && num !== null) { + return {formatNumber(num)}; + } + + if (format === "percentage" && num !== null) { + const pct = num > 1 ? Math.min(100, Math.max(0, num)) : Math.min(100, Math.max(0, num * 100)); + return ( +
+
+
+
+ + {pct.toFixed(0)}% + +
+ ); + } + + if (format === "scale" && num !== null) { + const max = Math.max(2, Math.min(10, scaleMax)); + const filled = Math.min(max, Math.max(0, Math.round(num))); + return ( +
+ {Array.from({ length: max }, (_, i) => ( +
+ ))} +
+ ); + } + + return {String(value)}; +} + export default function PropertiesList({ properties, entries: entriesProp, @@ -35,7 +118,14 @@ export default function PropertiesList({
{e.label}
-
{String(e.value)}
+
+ +
); diff --git a/src/app/map/[id]/components/inspector/SortableColumnRow.tsx b/src/app/map/[id]/components/inspector/SortableColumnRow.tsx index 33672f08f..bec2c6670 100644 --- a/src/app/map/[id]/components/inspector/SortableColumnRow.tsx +++ b/src/app/map/[id]/components/inspector/SortableColumnRow.tsx @@ -3,17 +3,153 @@ import { useSortable } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; import { GripVertical, X } from "lucide-react"; -import { useEffect, useState } from "react"; +import { type MouseEvent, useEffect, useState } from "react"; +import { Checkbox } from "@/shadcn/ui/checkbox"; import { Input } from "@/shadcn/ui/input"; +import { Label } from "@/shadcn/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/shadcn/ui/select"; import { cn } from "@/shadcn/utils"; import { useDebouncedCallback } from "../../hooks/useDebouncedCallback"; +import { + DEFAULT_BAR_COLOR_VALUE, + INSPECTOR_BAR_COLOR_OPTIONS, + SMART_MATCH_BAR_COLOR_VALUE, + getSmartMatchInfo, +} from "./inspectorPanelOptions"; + +import type { InspectorColumnFormat } from "@/server/models/MapView"; + +const FORMAT_OPTIONS: { value: InspectorColumnFormat; label: string }[] = [ + { value: "text", label: "Text" }, + { value: "number", label: "Number" }, + { value: "percentage", label: "Percentage (bar)" }, + { value: "scale", label: "Scale (bars)" }, +]; + +/** Resolve select value: empty/undefined treated as Smart match for backward compat. */ +function barColorSelectValue(barColor: string | undefined): string { + if (barColor === DEFAULT_BAR_COLOR_VALUE) return DEFAULT_BAR_COLOR_VALUE; + if (!barColor || barColor === "" || barColor === SMART_MATCH_BAR_COLOR_VALUE) + return SMART_MATCH_BAR_COLOR_VALUE; + return barColor; +} + +function BarColorSelect({ + barColor, + onBarColorChange, + displayName, + columnName, + onClick, +}: { + barColor?: string; + onBarColorChange: (value: string) => void; + displayName: string | undefined; + columnName: string; + onClick: (e: MouseEvent) => void; +}) { + const value = barColorSelectValue(barColor); + const smartMatch = getSmartMatchInfo(displayName ?? columnName, columnName); + + const triggerLabel = + value === DEFAULT_BAR_COLOR_VALUE + ? "Default" + : value === SMART_MATCH_BAR_COLOR_VALUE + ? `Smart match (${smartMatch.matchLabel})` + : null; + + const triggerSwatchColor = + value === DEFAULT_BAR_COLOR_VALUE + ? "hsl(var(--primary))" + : value === SMART_MATCH_BAR_COLOR_VALUE + ? smartMatch.color + : null; + + return ( +
+ + +
+ ); +} export function SortableColumnRow({ id, columnName, displayName, onDisplayNameChange, + format = "text", + onFormatChange, + scaleMax = 3, + onScaleMaxChange, + includeInChart, + onIncludeInChartChange, + showChartCheckbox, + barColor, + onBarColorChange, onRemove, isDragging, }: { @@ -21,12 +157,28 @@ export function SortableColumnRow({ columnName: string; displayName: string | undefined; onDisplayNameChange: (value: string) => void; + format?: InspectorColumnFormat; + onFormatChange?: (format: InspectorColumnFormat) => void; + scaleMax?: number; + onScaleMaxChange?: (value: number) => void; + includeInChart?: boolean; + onIncludeInChartChange?: (include: boolean) => void; + showChartCheckbox?: boolean; + barColor?: string; + onBarColorChange?: (value: string) => void; onRemove?: () => void; isDragging?: boolean; }) { const [localDisplayName, setLocalDisplayName] = useState(displayName ?? ""); useEffect(() => setLocalDisplayName(displayName ?? ""), [displayName]); - const debouncedChange = useDebouncedCallback(onDisplayNameChange, 300); + const debouncedChange = useDebouncedCallback(onDisplayNameChange, 600); + + const [localScaleMax, setLocalScaleMax] = useState(String(scaleMax)); + useEffect(() => setLocalScaleMax(String(scaleMax)), [scaleMax]); + const debouncedScaleMax = useDebouncedCallback( + (v: number) => onScaleMaxChange?.(v), + 600, + ); const { attributes, @@ -38,7 +190,6 @@ export function SortableColumnRow({ } = useSortable({ id }); const dragging = isDragging ?? dndDragging; - // When dragging, hide source so only DragOverlay is visible; keep slot in place const style = dragging ? { transition } : { @@ -85,17 +236,95 @@ export function SortableColumnRow({ )}
- { - const v = e.target.value; - setLocalDisplayName(v); - debouncedChange(v); - }} - onClick={(e) => e.stopPropagation()} - /> +
+ + { + const v = e.target.value; + setLocalDisplayName(v); + debouncedChange(v); + }} + onClick={(e) => e.stopPropagation()} + /> +
+ {onFormatChange && ( +
+ + +
+ )} + {format === "scale" && onScaleMaxChange && ( +
+ + { + const v = e.target.value; + setLocalScaleMax(v); + const n = parseInt(v, 10); + if (!Number.isNaN(n) && n >= 2 && n <= 10) debouncedScaleMax(n); + }} + onClick={(e) => e.stopPropagation()} + /> +
+ )} + {(format === "percentage" || format === "scale") && onBarColorChange && ( + e.stopPropagation()} + /> + )} + {showChartCheckbox && onIncludeInChartChange && ( +
+ + onIncludeInChartChange(checked === true) + } + onClick={(e) => e.stopPropagation()} + /> + +
+ )}
); } diff --git a/src/app/map/[id]/components/inspector/inspectorPanelOptions.tsx b/src/app/map/[id]/components/inspector/inspectorPanelOptions.tsx new file mode 100644 index 000000000..fffbd344a --- /dev/null +++ b/src/app/map/[id]/components/inspector/inspectorPanelOptions.tsx @@ -0,0 +1,195 @@ +"use client"; + +import { + Car, + CircleDollarSign, + Database, + Heart, + Home, + Leaf, + MapPin, + Scale, + Table2, + Users, + UtensilsCrossed, + Vote, + Wifi, +} from "lucide-react"; +import React from "react"; +import { COLOR_PALETTE_DATA } from "@/components/ColorPalette"; + +/** Sentinel for default bar colour (primary only, no smart match). */ +export const DEFAULT_BAR_COLOR_VALUE = "__default__"; + +/** Sentinel for smart match (party/palette by column name). */ +export const SMART_MATCH_BAR_COLOR_VALUE = "__smart__"; + +/** Bar colour options (percentage/scale bars and chart). Default = primary, Smart match = party/palette. */ +export const INSPECTOR_BAR_COLOR_OPTIONS: { value: string; label: string; hex: string }[] = [ + { value: DEFAULT_BAR_COLOR_VALUE, label: "Default", hex: "hsl(var(--primary))" }, + { value: SMART_MATCH_BAR_COLOR_VALUE, label: "Smart match", hex: "transparent" }, + ...COLOR_PALETTE_DATA.map((c) => ({ value: c.hex, label: c.name, hex: c.hex })), +]; + +/** + * UK (and NI/IRL) political party colours. Patterns are matched as substrings (case-insensitive) + * against column name and display name. Order matters: more specific patterns first. + * partyName is used for "Smart match (Party name)" in the UI. + */ +const POLITICAL_PARTY_COLORS: { patterns: string[]; color: string; partyName: string }[] = [ + { patterns: ["conservative", "con ", " con", "con %", "con%"], color: "#0087DC", partyName: "Conservative" }, + { patterns: ["labour", "lab ", " lab", "lab %", "lab%"], color: "#E4003B", partyName: "Labour" }, + { patterns: ["liberal democrat", "lib dem", "ld ", " ld", "ld %", "ld%"], color: "#FAA61A", partyName: "Liberal Democrat" }, + { patterns: ["scottish national", "snp "], color: "#FDF38E", partyName: "Scottish National Party" }, + { patterns: ["green ", " green", "green %", "green%"], color: "#6AB023", partyName: "Green party" }, + { patterns: ["reform uk", "reform ", " reform", "reform %", "ruk ", " ruk", "ruk %", "ruk%"], color: "#00AEEF", partyName: "Reform UK" }, + { patterns: ["ukip", "uk indep"], color: "#70147A", partyName: "UKIP" }, + { patterns: ["plaid cymru", "plaid", "pc ", " pc", "pc %", "pc%"], color: "#008142", partyName: "Plaid Cymru" }, + { patterns: ["sinn féin", "sinn fein", "sf ", " sf", "sf %", "sf%"], color: "#326760", partyName: "Sinn Féin" }, + { patterns: ["democratic unionist", "dup "], color: "#D46A4C", partyName: "DUP" }, + { patterns: ["ulster unionist", "ulster union", "uup ", " uup", "uup %", "uup%"], color: "#80BD41", partyName: "UUP" }, + { patterns: ["social democratic", "sdlp"], color: "#2AA82C", partyName: "SDLP" }, + { patterns: ["alliance ", " alliance", "alliance %"], color: "#F6CB2F", partyName: "Alliance" }, + { patterns: ["traditional unionist", "tuv "], color: "#000080", partyName: "TUV" }, + { patterns: ["other win", "other ", " other", "other %", "other%"], color: "#6B7280", partyName: "Other" }, +]; + +/** Fallback palette when no party match; looped by index. */ +const BAR_COLOR_FALLBACK_PALETTE = [ + "#0087DC", + "#E4003B", + "#FAA61A", + "#6AB023", + "#00AEEF", + "#9B59B6", + "#1ABC9C", + "#E67E22", + "#3498DB", + "#2ECC71", +]; + +function normaliseForMatching(s: string): string { + return `${s.toLowerCase().replace(/%/g, " ").replace(/\s+/g, " ").trim()} `; +} + +/** Simple hash so the same label+column gets the same fallback colour in list and chart. */ +function hashForFallback(s: string): number { + let h = 0; + for (let i = 0; i < s.length; i++) h = (h << 5) - h + s.charCodeAt(i); + return Math.abs(h); +} + +const PRIMARY_HEX = "hsl(var(--primary))"; + +/** + * Returns the smart-matched colour and display label (e.g. "Green party", "Palette") for the dropdown. + */ +export function getSmartMatchInfo( + label: string, + columnName: string, +): { color: string; matchLabel: string } { + const combined = normaliseForMatching(label) + normaliseForMatching(columnName); + for (const { patterns, color, partyName } of POLITICAL_PARTY_COLORS) { + for (const p of patterns) { + const plain = p.trim().toLowerCase(); + if (plain.length >= 2 && combined.includes(plain)) { + return { color, matchLabel: partyName }; + } + } + } + const fallbackIndex = hashForFallback(combined + columnName) % BAR_COLOR_FALLBACK_PALETTE.length; + return { + color: BAR_COLOR_FALLBACK_PALETTE[fallbackIndex], + matchLabel: "Palette", + }; +} + +/** + * Resolve bar colour: __default__ = primary; __smart__ / empty / undefined = smart match; else explicit hex. + * Same label+column always gets the same colour so list and chart stay in sync. + */ +export function getBarColorForLabel( + label: string, + columnName: string, + _index: number, + explicitBarColor?: string | null, +): string { + const trimmed = explicitBarColor?.trim(); + if (trimmed === DEFAULT_BAR_COLOR_VALUE) return PRIMARY_HEX; + if (trimmed && trimmed !== SMART_MATCH_BAR_COLOR_VALUE) return trimmed; + + const { color } = getSmartMatchInfo(label, columnName); + return color; +} + +import type { LucideIcon } from "lucide-react"; + +/** General/sector icon options for inspector data source panels */ +export const INSPECTOR_ICON_OPTIONS: { value: string; label: string; Icon: LucideIcon }[] = [ + { value: "", label: "Default", Icon: Database }, + { value: "Users", label: "People / community", Icon: Users }, + { value: "UtensilsCrossed", label: "Food / access", Icon: UtensilsCrossed }, + { value: "Scale", label: "Deprivation / need", Icon: Scale }, + { value: "Vote", label: "Polling / democracy", Icon: Vote }, + { value: "Wifi", label: "Connectivity", Icon: Wifi }, + { value: "Heart", label: "Health", Icon: Heart }, + { value: "Home", label: "Housing", Icon: Home }, + { value: "Leaf", label: "Environment", Icon: Leaf }, + { value: "Car", label: "Transport", Icon: Car }, + { value: "CircleDollarSign", label: "Economy", Icon: CircleDollarSign }, + { value: "MapPin", label: "Place / location", Icon: MapPin }, + { value: "Table2", label: "Data / table", Icon: Table2 }, +]; + +const iconMap: Record = Object.fromEntries( + INSPECTOR_ICON_OPTIONS.filter((o) => o.value).map((o) => [o.value, o.Icon]), +); + +export function getInspectorIcon(iconName: string | null | undefined): LucideIcon | null { + if (!iconName) return null; + return iconMap[iconName] ?? null; +} + +/** Renders the chosen inspector panel icon (use this to avoid creating component during render) */ +export function InspectorPanelIcon({ + iconName, + className, +}: { + iconName: string | null | undefined; + className?: string; +}) { + const Icon = getInspectorIcon(iconName); + return Icon ? React.createElement(Icon, { className }) : null; +} + +/** Map layer-panel colour names to Tailwind bg classes (same order as ColorPalette) */ +const LAYER_COLOR_NAME_TO_BG: Record = { + Red: "bg-red-50", + Blue: "bg-blue-50", + Green: "bg-green-50", + Orange: "bg-orange-50", + Purple: "bg-violet-50", + Turquoise: "bg-teal-50", + Carrot: "bg-amber-50", + "Dark Blue Grey": "bg-slate-100", + "Dark Red": "bg-rose-50", + "Light Blue": "bg-sky-50", + Emerald: "bg-emerald-50", + "Dark Purple": "bg-purple-100", +}; + +/** Same colours as layer panel (ColorPalette), for inspector panel background */ +export const INSPECTOR_COLOR_OPTIONS: { value: string; label: string; className: string }[] = [ + { value: "", label: "Default", className: "bg-neutral-100" }, + ...COLOR_PALETTE_DATA.map((c) => ({ + value: c.name, + label: c.name, + className: LAYER_COLOR_NAME_TO_BG[c.name] ?? "bg-neutral-100", + })), +]; + +export function getInspectorColorClass(color: string | null | undefined): string { + if (!color) return "bg-neutral-100"; + const option = INSPECTOR_COLOR_OPTIONS.find((o) => o.value === color); + return option ? option.className : "bg-neutral-100"; +} diff --git a/src/components/ColorPalette.tsx b/src/components/ColorPalette.tsx index ca35dd509..52776ccf9 100644 --- a/src/components/ColorPalette.tsx +++ b/src/components/ColorPalette.tsx @@ -3,7 +3,8 @@ import { CheckIcon } from "lucide-react"; import { cn } from "@/shadcn/utils"; -const COLOR_PALETTE_DATA = [ +/** Same palette as layer panel colour picker; export for reuse (e.g. inspector) */ +export const COLOR_PALETTE_DATA = [ { hex: "#FF6B6B", name: "Red" }, { hex: "#678DE3", name: "Blue" }, { hex: "#4DAB37", name: "Green" }, diff --git a/src/server/models/MapView.ts b/src/server/models/MapView.ts index 31fc2373b..d34531ad4 100644 --- a/src/server/models/MapView.ts +++ b/src/server/models/MapView.ts @@ -163,10 +163,30 @@ export const inspectorBoundaryTypes = Object.values( ); /** - * Display metadata for a single column (e.g. custom label) + * How to display a column value in the inspector + * - text: plain string + * - number: formatted number + * - percentage: 0–100 (or 0–1) shown as progress bar + * - scale: integer 0..scaleMax-1 (or 1..scaleMax), shown as N thin filled/grey bars + */ +export const inspectorColumnFormatSchema = z.enum([ + "text", + "number", + "percentage", + "scale", +]); +export type InspectorColumnFormat = z.infer; + +/** + * Display metadata for a single column (label, format, scale size, bar colour) */ export const inspectorColumnMetaSchema = z.object({ displayName: z.string().optional(), + format: inspectorColumnFormatSchema.optional(), + /** For format "scale": max value (e.g. 3 for a 0–2 or 1–3 scale). Number of bars shown. */ + scaleMax: z.number().int().min(2).max(10).optional(), + /** Bar colour (CSS color) for percentage/scale bars and chart bar. Empty = primary. */ + barColor: z.string().optional(), }); export type InspectorColumnMeta = z.infer; @@ -180,6 +200,39 @@ export const inspectorColumnGroupSchema = z.object({ }); export type InspectorColumnGroup = z.infer; +/** + * Chart data source: which columns to include in the inspector chart + * - number: columns with Number format + * - percentage: columns with Percentage format + * - scale: columns with Scale format + * - custom: columns selected via chartColumnNames + */ +export const inspectorChartDataSourceSchema = z.enum([ + "number", + "percentage", + "scale", + "custom", +]); +export type InspectorChartDataSource = z.infer< + typeof inspectorChartDataSourceSchema +>; + +/** + * Chart config: show a chart at the top of this boundary panel + */ +export const inspectorChartConfigSchema = z.object({ + enabled: z.boolean(), + /** Which columns to use: by format or custom list */ + dataSource: inspectorChartDataSourceSchema, + /** When dataSource is "custom", column names to include in the chart */ + columnNames: z.array(z.string()).optional(), + /** When true, bars with value 0 are not shown in the chart */ + hideZeroValues: z.boolean().optional(), + /** When true, columns used in the chart are hidden from the list below to avoid duplication */ + hideChartColumnsFromList: z.boolean().optional(), +}); +export type InspectorChartConfig = z.infer; + /** * Configuration for a single boundary data source in the inspector * - dataSourceId: Reference to the data source @@ -189,6 +242,10 @@ export type InspectorColumnGroup = z.infer; * - columnMetadata: Optional display names per column * - columnGroups: Optional groups for visual grouping (columns appear under group label) * - layout: "single" (one column) or "twoColumn" (Airtable-style grid) + * - icon: optional Lucide icon name for custom panel icon + * - color: optional Tailwind color name for panel background (e.g. "blue" -> bg-blue-50) + * - chart: optional chart shown at top of panel (data source: number/percentage/scale/custom) + * - columnOrder: optional display order for all columns (used for Available list order; when set, reorderable) */ export const inspectorBoundaryConfigSchema = z.object({ id: z.string(), @@ -196,12 +253,17 @@ export const inspectorBoundaryConfigSchema = z.object({ name: z.string(), type: z.nativeEnum(InspectorBoundaryConfigType), columns: z.array(z.string()), + /** When set, order of "Available" list; full list of column names in desired order. */ + columnOrder: z.array(z.string()).optional().nullable(), columnMetadata: z .record(z.string(), inspectorColumnMetaSchema) .optional() .nullable(), columnGroups: z.array(inspectorColumnGroupSchema).optional().nullable(), layout: z.enum(["single", "twoColumn"]).optional().nullable(), + icon: z.string().optional().nullable(), + color: z.string().optional().nullable(), + chart: inspectorChartConfigSchema.optional().nullable(), }); export type InspectorBoundaryConfig = z.infer< From e2fa6fa8c9e0b5df7891b9a7ad8eacffecf63769 Mon Sep 17 00:00:00 2001 From: Arbyhisenaj <41119392+Arbyhisenaj@users.noreply.github.com> Date: Wed, 25 Feb 2026 23:24:19 +0000 Subject: [PATCH 06/21] refactor --- .../inspector/BoundaryDataPanel.tsx | 20 +- .../inspector/InspectorSettingsModal.tsx | 1336 ----------------- .../AvailableColumnsCheckboxList.tsx | 47 + .../InspectorSettingsModal/ChartSection.tsx | 169 +++ .../InspectorSettingsModal/ColumnsSection.tsx | 227 +++ .../DataSourcesList.tsx | 159 ++ .../InspectorSettingsModal/DragPreviews.tsx | 50 + .../DroppableSelectedColumns.tsx | 185 +++ .../InspectorSettingsModal.tsx | 199 +++ .../InspectorSourceConfigPanel.tsx | 353 +++++ .../SortableAvailableRow.tsx | 67 + .../InspectorSettingsModal/constants.ts | 16 + .../inspector/InspectorSettingsModal/index.ts | 1 + .../inspector/SortableColumnRow.tsx | 15 +- .../inspector/inspectorColumnOrder.ts | 71 + .../inspector/inspectorPanelOptions.tsx | 156 +- src/app/map/[id]/hooks/useMapViews.ts | 11 + 17 files changed, 1697 insertions(+), 1385 deletions(-) delete mode 100644 src/app/map/[id]/components/inspector/InspectorSettingsModal.tsx create mode 100644 src/app/map/[id]/components/inspector/InspectorSettingsModal/AvailableColumnsCheckboxList.tsx create mode 100644 src/app/map/[id]/components/inspector/InspectorSettingsModal/ChartSection.tsx create mode 100644 src/app/map/[id]/components/inspector/InspectorSettingsModal/ColumnsSection.tsx create mode 100644 src/app/map/[id]/components/inspector/InspectorSettingsModal/DataSourcesList.tsx create mode 100644 src/app/map/[id]/components/inspector/InspectorSettingsModal/DragPreviews.tsx create mode 100644 src/app/map/[id]/components/inspector/InspectorSettingsModal/DroppableSelectedColumns.tsx create mode 100644 src/app/map/[id]/components/inspector/InspectorSettingsModal/InspectorSettingsModal.tsx create mode 100644 src/app/map/[id]/components/inspector/InspectorSettingsModal/InspectorSourceConfigPanel.tsx create mode 100644 src/app/map/[id]/components/inspector/InspectorSettingsModal/SortableAvailableRow.tsx create mode 100644 src/app/map/[id]/components/inspector/InspectorSettingsModal/constants.ts create mode 100644 src/app/map/[id]/components/inspector/InspectorSettingsModal/index.ts create mode 100644 src/app/map/[id]/components/inspector/inspectorColumnOrder.ts diff --git a/src/app/map/[id]/components/inspector/BoundaryDataPanel.tsx b/src/app/map/[id]/components/inspector/BoundaryDataPanel.tsx index d0c50e8e3..5786e6cf7 100644 --- a/src/app/map/[id]/components/inspector/BoundaryDataPanel.tsx +++ b/src/app/map/[id]/components/inspector/BoundaryDataPanel.tsx @@ -10,6 +10,7 @@ import { DataRecordMatchType } from "@/types"; import { buildName } from "@/utils/dataRecord"; import { useDataSources } from "../../hooks/useDataSources"; import { InspectorChart } from "./InspectorChart"; +import { getChartColumnNames } from "./inspectorColumnOrder"; import { InspectorPanelIcon, getBarColorForLabel, @@ -20,23 +21,8 @@ import PropertiesList, { type PropertyEntry } from "./PropertiesList"; import type { InspectorBoundaryConfig, InspectorChartConfig, - InspectorChartDataSource, } from "@/server/models/MapView"; -function getChartColumns( - columns: string[], - columnMetadata: InspectorBoundaryConfig["columnMetadata"], - chartDataSource: InspectorChartDataSource, - chartColumnNames: string[] | undefined, -): string[] { - const meta = columnMetadata ?? {}; - if (chartDataSource === "custom" && chartColumnNames?.length) { - return chartColumnNames.filter((c) => columns.includes(c)); - } - const formatMatch = chartDataSource as "number" | "percentage" | "scale"; - return columns.filter((col) => meta[col]?.format === formatMatch); -} - export function BoundaryDataPanel({ config, dataSourceId, @@ -115,7 +101,7 @@ export function BoundaryDataPanel({ match={data.match} hideFromListColumnNames={ config.chart?.enabled && config.chart?.hideChartColumnsFromList - ? getChartColumns( + ? getChartColumnNames( columns, columnMetadata, config.chart.dataSource, @@ -165,7 +151,7 @@ function BoundaryChart({ columnMetadata?: InspectorBoundaryConfig["columnMetadata"]; chart: InspectorChartConfig; }) { - const chartColumns = getChartColumns( + const chartColumns = getChartColumnNames( columns, columnMetadata, chart.dataSource, diff --git a/src/app/map/[id]/components/inspector/InspectorSettingsModal.tsx b/src/app/map/[id]/components/inspector/InspectorSettingsModal.tsx deleted file mode 100644 index 893577708..000000000 --- a/src/app/map/[id]/components/inspector/InspectorSettingsModal.tsx +++ /dev/null @@ -1,1336 +0,0 @@ -"use client"; - -import { - DndContext, - DragOverlay, - KeyboardSensor, - PointerSensor, - closestCenter, - useDroppable, - useSensor, - useSensors, -} from "@dnd-kit/core"; -import { - SortableContext, - sortableKeyboardCoordinates, - useSortable, - verticalListSortingStrategy, -} from "@dnd-kit/sortable"; -import { CSS } from "@dnd-kit/utilities"; -import { - BarChart3, - GripVertical, - LayoutGrid, - LayoutList, - MapPin, - PlusIcon, - XCircle, -} from "lucide-react"; -import { useCallback, useEffect, useMemo, useState } from "react"; -import { createPortal } from "react-dom"; -import { v4 as uuidv4 } from "uuid"; -import DataSourceIcon from "@/components/DataSourceIcon"; -import { getDataSourceType } from "@/components/DataSourceItem"; -import { - type InspectorBoundaryConfig, - InspectorBoundaryConfigType, - type InspectorChartDataSource, - type InspectorColumnFormat, -} from "@/server/models/MapView"; -import { Checkbox } from "@/shadcn/ui/checkbox"; -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, -} from "@/shadcn/ui/dialog"; -import { Input } from "@/shadcn/ui/input"; -import { Label } from "@/shadcn/ui/label"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/shadcn/ui/select"; -import { Switch } from "@/shadcn/ui/switch"; -import { cn } from "@/shadcn/utils"; -import { useDataSources } from "../../hooks/useDataSources"; -import { useDebouncedCallback } from "../../hooks/useDebouncedCallback"; -import { useDebouncedValue } from "../../hooks/useDebouncedValue"; -import { useMapViews } from "../../hooks/useMapViews"; -import { InspectorFullPreview } from "./InspectorFullPreview"; -import { - INSPECTOR_COLOR_OPTIONS, - INSPECTOR_ICON_OPTIONS, -} from "./inspectorPanelOptions"; -import { SortableColumnRow } from "./SortableColumnRow"; -import type { DataSource } from "@/server/models/DataSource"; -import type { DragEndEvent } from "@dnd-kit/core"; - -const SELECTED_DROPPABLE_ID = "selected-columns"; -/** Sentinel for Select default option (Radix Select.Item cannot have value="") */ -const DEFAULT_SELECT_VALUE = "__default__"; - -type InspectorLayout = "single" | "twoColumn"; - -/** Infer column format from name: Percentage if name contains % or "percentage". */ -function inferFormat(columnName: string): InspectorColumnFormat | undefined { - const lower = columnName.toLowerCase(); - if (lower.includes("%") || lower.includes("percentage")) return "percentage"; - return undefined; -} - -export default function InspectorSettingsModal({ - open, - onOpenChange, -}: { - open: boolean; - onOpenChange: (open: boolean) => void; -}) { - const [selectedDataSourceId, setSelectedDataSourceId] = useState< - string | null - >(null); - const [searchQuery, setSearchQuery] = useState(""); - const debouncedSearchQuery = useDebouncedValue(searchQuery, 500); - const { data: dataSources, getDataSourceById } = useDataSources(); - const { view, viewConfig, updateView } = useMapViews(); - - const boundaryConfigs = view?.inspectorConfig?.boundaries ?? []; - const onMapId = viewConfig.areaDataSourceId || null; - - const filteredSources = useMemo(() => { - const list = dataSources ?? []; - if (!debouncedSearchQuery.trim()) return list; - const q = debouncedSearchQuery.toLowerCase(); - return list.filter( - (ds) => - ds.name.toLowerCase().includes(q) || - ds.columnDefs.some((col) => col.name.toLowerCase().includes(q)), - ); - }, [dataSources, debouncedSearchQuery]); - - const matchesSearch = useCallback( - (ds: DataSource) => { - if (!debouncedSearchQuery.trim()) return true; - const q = debouncedSearchQuery.toLowerCase(); - return ( - ds.name.toLowerCase().includes(q) || - ds.columnDefs.some((col) => col.name.toLowerCase().includes(q)) - ); - }, - [debouncedSearchQuery], - ); - - const { inspectorOrdered, otherSources } = useMemo(() => { - const inInspector = boundaryConfigs - .map((config) => ({ - config, - dataSource: getDataSourceById(config.dataSourceId), - })) - .filter( - ( - x, - ): x is { - config: InspectorBoundaryConfig; - dataSource: NonNullable>; - } => x.dataSource != null && matchesSearch(x.dataSource), - ); - const inIds = new Set(inInspector.map((x) => x.dataSource.id)); - const other = (filteredSources ?? []).filter((ds) => !inIds.has(ds.id)); - return { inspectorOrdered: inInspector, otherSources: other }; - }, [boundaryConfigs, getDataSourceById, filteredSources, matchesSearch]); - - const handleRemoveFromInspector = useCallback( - (configId: string) => { - if (!view) return; - const next = boundaryConfigs.filter((c) => c.id !== configId); - updateView({ - ...view, - inspectorConfig: { - ...view.inspectorConfig, - boundaries: next, - }, - }); - }, - [view, boundaryConfigs, updateView], - ); - - const selectedConfig = useMemo( - () => - selectedDataSourceId - ? (boundaryConfigs.find( - (c) => c.dataSourceId === selectedDataSourceId, - ) ?? null) - : null, - [selectedDataSourceId, boundaryConfigs], - ); - const selectedDataSource = useMemo( - () => - selectedDataSourceId - ? ((dataSources ?? []).find((ds) => ds.id === selectedDataSourceId) ?? - null) - : null, - [selectedDataSourceId, dataSources], - ); - - const handleAddToInspector = useCallback(() => { - if (!view || !selectedDataSourceId) return; - const ds = (dataSources ?? []).find((d) => d.id === selectedDataSourceId); - const newConfig: InspectorBoundaryConfig = { - id: uuidv4(), - dataSourceId: selectedDataSourceId, - name: ds?.name ?? "Boundary Data", - type: InspectorBoundaryConfigType.Simple, - columns: [], - columnMetadata: undefined, - columnGroups: undefined, - layout: "single", - }; - const prev = view.inspectorConfig?.boundaries ?? []; - updateView({ - ...view, - inspectorConfig: { - ...view.inspectorConfig, - boundaries: [...prev, newConfig], - }, - }); - }, [view, selectedDataSourceId, dataSources, updateView]); - - return ( - - setSelectedDataSourceId(null)} - > - - Inspector settings -

- Choose data sources to show in the inspector and configure columns, - order, and layout. -

-
- -
- {/* Left: data sources list (inspector ones first, order synced from preview) */} -
-
- setSearchQuery(e.target.value)} - className="h-9" - /> -
-
- {inspectorOrdered.length > 0 && ( -
-

- Showing inspector -

-
- {inspectorOrdered.map(({ config, dataSource: ds }) => { - const isOnMap = ds.id === onMapId; - const isSelected = ds.id === selectedDataSourceId; - return ( -
- - -
- ); - })} -
-
- )} - {otherSources.length > 0 && ( -
0 && "mt-2")} - > - {otherSources.map((ds) => { - const isOnMap = ds.id === onMapId; - const isSelected = ds.id === selectedDataSourceId; - return ( - - ); - })} -
- )} - {inspectorOrdered.length === 0 && otherSources.length === 0 && ( -

- No data sources match. -

- )} -
-
- - {/* Middle: config panel (flexes to fill space between list and preview) */} -
- {selectedDataSource && view ? ( - - ) : selectedDataSource ? ( -
- No view loaded. -
- ) : ( -
- Select a data source to configure columns (drag them into the - list). -
- )} -
- - {/* Right: full inspector preview (same width as real inspector panel: 250–450px) */} -
- -
-
-
-
- ); -} - -function InspectorSourceConfigPanel({ - dataSource, - config, - onAddToInspector, - isInInspector, - view, - updateView, - boundaryConfigs, -}: { - dataSource: DataSource; - config: InspectorBoundaryConfig | null; - onAddToInspector: () => void; - isInInspector: boolean; - view: NonNullable["view"]>; - updateView: ReturnType["updateView"]; - boundaryConfigs: InspectorBoundaryConfig[]; -}) { - const [activeId, setActiveId] = useState(null); - - const sensors = useSensors( - useSensor(PointerSensor, { activationConstraint: { distance: 6 } }), - useSensor(KeyboardSensor, { - coordinateGetter: sortableKeyboardCoordinates, - }), - ); - - const columns = config?.columns ?? []; - const allColumnNames = useMemo( - () => dataSource.columnDefs.map((c) => c.name), - [dataSource.columnDefs], - ); - const allColumnsSorted = useMemo( - () => - [...allColumnNames].sort((a, b) => - a.localeCompare(b, undefined, { sensitivity: "base" }), - ), - [allColumnNames], - ); - /** Full list order: use saved columnOrder when valid; else selected at top then alphabetical. */ - const allColumnsInOrder = useMemo(() => { - const order = config?.columnOrder?.filter((c) => - allColumnNames.includes(c), - ); - if (order?.length === allColumnNames.length) return order; - const selected = columns.filter((c) => allColumnNames.includes(c)); - const rest = allColumnsSorted.filter((c) => !selected.includes(c)); - return [...selected, ...rest]; - }, [config?.columnOrder, allColumnNames, allColumnsSorted, columns]); - const availableColumns = useMemo( - () => allColumnsInOrder.filter((c) => !columns.includes(c)), - [allColumnsInOrder, columns], - ); - const availableIds = useMemo( - () => allColumnsInOrder.map((c) => `available-${c}`), - [allColumnsInOrder], - ); - /** Selected columns in same order as left list (so both columns match) */ - const selectedColumnsInOrder = useMemo( - () => allColumnsInOrder.filter((c) => columns.includes(c)), - [allColumnsInOrder, columns], - ); - const columnIds = useMemo( - () => selectedColumnsInOrder.map((c) => `col-${c}`), - [selectedColumnsInOrder], - ); - - const updateConfig = useCallback( - (updater: (prev: InspectorBoundaryConfig) => InspectorBoundaryConfig) => { - if (!view || !config) return; - const updated = updater(config); - const index = boundaryConfigs.findIndex((c) => c.id === config.id); - if (index < 0) return; - const next = [...boundaryConfigs]; - next[index] = updated; - updateView({ - ...view, - inspectorConfig: { ...view.inspectorConfig, boundaries: next }, - }); - }, - [view, config, boundaryConfigs, updateView], - ); - - const [displayName, setDisplayName] = useState(config?.name ?? ""); - useEffect(() => setDisplayName(config?.name ?? ""), [config?.name]); - const debouncedUpdateName = useDebouncedCallback( - (value: string) => updateConfig((prev) => ({ ...prev, name: value })), - 600, - ); - - const handleAddColumn = useCallback( - (colName: string) => { - const inferred = inferFormat(colName); - updateConfig((prev) => { - const order = prev.columnOrder?.filter((c) => - allColumnNames.includes(c), - ); - const baseOrder = - order?.length === allColumnNames.length ? order : allColumnsSorted; - const newOrder = [ - colName, - ...baseOrder.filter((c) => c !== colName), - ]; - return { - ...prev, - columns: [colName, ...prev.columns], - columnOrder: newOrder, - columnMetadata: { - ...prev.columnMetadata, - [colName]: { - ...prev.columnMetadata?.[colName], - format: - prev.columnMetadata?.[colName]?.format ?? inferred ?? undefined, - }, - }, - }; - }); - }, - [updateConfig, allColumnNames, allColumnsSorted], - ); - - const handleRemoveColumn = useCallback( - (colName: string) => { - updateConfig((prev) => { - const nextColumns = prev.columns.filter((c) => c !== colName); - const nextMeta = Object.fromEntries( - Object.entries(prev.columnMetadata ?? {}).filter( - ([k]) => k !== colName, - ), - ); - const nextChartColumnNames = (prev.chart?.columnNames ?? []).filter( - (c) => c !== colName, - ); - const order = prev.columnOrder?.filter((c) => - allColumnNames.includes(c), - ); - const baseOrder = - order?.length === allColumnNames.length ? order : allColumnsSorted; - const newOrder = [ - ...baseOrder.filter((c) => c !== colName), - colName, - ]; - return { - ...prev, - columns: nextColumns, - columnOrder: newOrder, - columnMetadata: nextMeta, - chart: - prev.chart && nextChartColumnNames.length >= 0 - ? { - ...prev.chart, - columnNames: - nextChartColumnNames.length > 0 - ? nextChartColumnNames - : undefined, - } - : prev.chart, - }; - }); - }, - [updateConfig, allColumnNames, allColumnsSorted], - ); - - /** Remove from right list and move to bottom of left list (keeps order in sync) */ - const handleRemoveColumnFromRight = useCallback( - (colName: string) => { - updateConfig((prev) => { - const nextColumns = prev.columns.filter((c) => c !== colName); - const nextMeta = Object.fromEntries( - Object.entries(prev.columnMetadata ?? {}).filter( - ([k]) => k !== colName, - ), - ); - const nextChartColumnNames = (prev.chart?.columnNames ?? []).filter( - (c) => c !== colName, - ); - const newColumnOrder = [ - ...nextColumns, - ...allColumnsInOrder.filter((c) => !nextColumns.includes(c)), - ]; - return { - ...prev, - columns: nextColumns, - columnOrder: newColumnOrder, - columnMetadata: nextMeta, - chart: - prev.chart && nextChartColumnNames.length >= 0 - ? { - ...prev.chart, - columnNames: - nextChartColumnNames.length > 0 - ? nextChartColumnNames - : undefined, - } - : prev.chart, - }; - }); - }, - [updateConfig, allColumnsInOrder], - ); - - const handleDragEnd = useCallback( - (event: DragEndEvent) => { - const { active, over } = event; - setActiveId(null); - if (!config) return; - const activeStr = String(active.id); - const overStr = over ? String(over.id) : null; - - if (activeStr.startsWith("col-") && overStr?.startsWith("col-")) { - const oldIndex = columnIds.indexOf(activeStr); - const newIndex = columnIds.indexOf(overStr); - if (oldIndex === -1 || newIndex === -1) return; - const next = [...selectedColumnsInOrder]; - const [removed] = next.splice(oldIndex, 1); - next.splice(newIndex, 0, removed); - const newColumnOrder = [ - ...next, - ...allColumnsInOrder.filter((c) => !next.includes(c)), - ]; - updateConfig((prev) => ({ - ...prev, - columns: next, - columnOrder: newColumnOrder, - })); - return; - } - if ( - activeStr.startsWith("available-") && - overStr?.startsWith("available-") - ) { - const activeCol = activeStr.slice("available-".length); - const overCol = overStr.slice("available-".length); - const oldIdx = allColumnsInOrder.indexOf(activeCol); - const newIdx = allColumnsInOrder.indexOf(overCol); - if (oldIdx === -1 || newIdx === -1) return; - const nextOrder = [...allColumnsInOrder]; - const [removed] = nextOrder.splice(oldIdx, 1); - nextOrder.splice(newIdx, 0, removed); - updateConfig((prev) => ({ ...prev, columnOrder: nextOrder })); - } - }, - [ - columnIds, - config, - updateConfig, - allColumnsInOrder, - selectedColumnsInOrder, - ], - ); - - if (!isInInspector) { - return ( -
-

- {dataSource.name} is not shown in the inspector yet. -

- -
- ); - } - - if (!config) return null; - - const columnMetadata = config.columnMetadata ?? {}; - const layout = (config.layout ?? "single") as InspectorLayout; - const panelIcon = config.icon ?? undefined; - const panelColor = config.color ?? undefined; - - return ( -
-
-
-
- - { - const v = e.target.value; - setDisplayName(v); - debouncedUpdateName(v); - }} - placeholder="e.g. Main data" - className="max-w-sm" - /> -
-
- - -
-
- - -
-
- -
- - - updateConfig((prev) => ({ - ...prev, - layout: checked ? "twoColumn" : "single", - })) - } - /> - -
-

- {layout === "single" ? "Single column" : "Two-column grid"} -

-
-
- -
-
- - -
-
-
- - updateConfig((prev) => ({ - ...prev, - chart: { - enabled: checked, - dataSource: prev.chart?.dataSource ?? "percentage", - columnNames: prev.chart?.columnNames, - hideZeroValues: prev.chart?.hideZeroValues, - hideChartColumnsFromList: - prev.chart?.hideChartColumnsFromList, - }, - })) - } - /> - Show chart at top -
- - {(config.chart?.enabled ?? false) && ( -
- - -
- )} - {(config.chart?.enabled ?? false) && ( -
- - updateConfig((prev) => { - const ch = prev.chart ?? { - enabled: true, - dataSource: "percentage" as const, - columnNames: undefined, - }; - return { - ...prev, - chart: { - ...ch, - hideZeroValues: checked, - hideChartColumnsFromList: checked - ? false - : ch.hideChartColumnsFromList, - }, - }; - }) - } - /> - Hide zero values from the chart -
- )} - {(config.chart?.enabled ?? false) && ( -
- - updateConfig((prev) => { - const ch = prev.chart ?? { - enabled: true, - dataSource: "percentage" as const, - columnNames: undefined, - }; - return { - ...prev, - chart: { - ...ch, - hideChartColumnsFromList: checked, - hideZeroValues: checked ? false : ch.hideZeroValues, - }, - }; - }) - } - /> - - Hide data used in chart from list below - -
- )} -
- {(config.chart?.enabled ?? false) && - (config.chart?.dataSource ?? "percentage") === "custom" && ( -

- Tick “Include in chart” on each column in Columns to show below. -

- )} -
- -
-
-
- -

- Tick columns in Available to add. Reorder in Columns to show - with the handle. -

-
-
- - -
-
- setActiveId(active.id as string)} - onDragEnd={handleDragEnd} - > -
-
-

- All columns (selected at top, drag to reorder, tick to add) -

- -
-
-

- Columns to show -

- -
-
- {typeof document !== "undefined" && - createPortal( - ({ - ...transform, - x: transform.x, - y: transform.y, - }), - ]} - > - {activeId && String(activeId).startsWith("col-") ? ( - - ) : activeId && String(activeId).startsWith("available-") ? ( - - ) : null} - , - document.body, - )} -
-
-
-
- ); -} - -function ColumnDragPreview({ - activeId, - columnMetadata, -}: { - activeId: string; - columnMetadata: Record; -}) { - const columnName = activeId.startsWith("col-") - ? activeId.slice("col-".length) - : ""; - const displayName = columnMetadata[columnName]?.displayName; - - return ( -
-
- - - {columnName} - -
- - {displayName || columnName || "—"} - -
- ); -} - -function AvailableDragPreview({ activeId }: { activeId: string }) { - const columnName = activeId.startsWith("available-") - ? activeId.slice("available-".length) - : ""; - return ( -
- - - {columnName} - -
- ); -} - -function SortableAvailableRow({ - id, - columnName, - selected, - onToggle, - isDragging, -}: { - id: string; - columnName: string; - selected: boolean; - onToggle: (checked: boolean) => void; - isDragging?: boolean; -}) { - const { - attributes, - listeners, - setNodeRef, - transform, - transition, - isDragging: dndDragging, - } = useSortable({ id }); - const dragging = isDragging ?? dndDragging; - const style = dragging - ? { transition } - : { - transform: CSS.Transform.toString(transform), - transition, - }; - return ( -
- - onToggle(checked === true)} - aria-label={ - selected - ? `Remove ${columnName} from columns to show` - : `Add ${columnName} to columns to show` - } - /> - {columnName} -
- ); -} - -function AvailableColumnsCheckboxList({ - allColumnsInOrder, - selectedColumns, - onAddColumn, - onRemoveColumn, - availableIds, - activeId, -}: { - allColumnsInOrder: string[]; - selectedColumns: string[]; - onAddColumn: (columnName: string) => void; - onRemoveColumn: (columnName: string) => void; - availableIds: string[]; - activeId: string | null; -}) { - return ( -
- - {allColumnsInOrder.map((col) => ( - - checked ? onAddColumn(col) : onRemoveColumn(col) - } - isDragging={activeId === `available-${col}`} - /> - ))} - - {allColumnsInOrder.length === 0 && ( -

- No columns in this data source -

- )} -
- ); -} - -function DroppableSelectedColumns({ - columns, - columnMetadata, - updateConfig, - onRemoveColumn, - activeId, - chartDataSource, - chartColumnNames, -}: { - columns: string[]; - columnMetadata?: InspectorBoundaryConfig["columnMetadata"]; - updateConfig: ( - updater: (prev: InspectorBoundaryConfig) => InspectorBoundaryConfig, - ) => void; - onRemoveColumn?: (columnName: string) => void; - activeId: string | null; - chartDataSource?: InspectorChartDataSource | null; - chartColumnNames?: string[]; -}) { - const meta = columnMetadata ?? {}; - const { setNodeRef, isOver } = useDroppable({ id: SELECTED_DROPPABLE_ID }); - const columnIds = useMemo(() => columns.map((c) => `col-${c}`), [columns]); - - return ( -
- {columns.length === 0 ? ( -

- No columns — tick Available to add -

- ) : ( - -
- {columns.map((col) => ( - - updateConfig((prev) => ({ - ...prev, - columnMetadata: { - ...(prev.columnMetadata ?? {}), - [col]: { - ...(prev.columnMetadata?.[col] ?? {}), - displayName: value || undefined, - }, - }, - })) - } - format={meta[col]?.format ?? "text"} - onFormatChange={(format) => - updateConfig((prev) => ({ - ...prev, - columnMetadata: { - ...(prev.columnMetadata ?? {}), - [col]: { - ...(prev.columnMetadata?.[col] ?? {}), - format, - }, - }, - })) - } - scaleMax={meta[col]?.scaleMax ?? 3} - onScaleMaxChange={(scaleMax) => - updateConfig((prev) => ({ - ...prev, - columnMetadata: { - ...(prev.columnMetadata ?? {}), - [col]: { - ...(prev.columnMetadata?.[col] ?? {}), - scaleMax, - }, - }, - })) - } - barColor={meta[col]?.barColor} - onBarColorChange={(value) => - updateConfig((prev) => ({ - ...prev, - columnMetadata: { - ...(prev.columnMetadata ?? {}), - [col]: { - ...(prev.columnMetadata?.[col] ?? {}), - barColor: value || undefined, - }, - }, - })) - } - includeInChart={ - chartDataSource === "custom" && - (chartColumnNames?.includes(col) ?? false) - } - onIncludeInChartChange={ - chartDataSource === "custom" - ? (include) => - updateConfig((prev) => { - const current = prev.chart?.columnNames ?? []; - const next = include - ? current.includes(col) - ? current - : [...current, col] - : current.filter((c) => c !== col); - const chart = prev.chart ?? { - enabled: true, - dataSource: "custom" as const, - columnNames: [], - }; - return { - ...prev, - chart: { - ...chart, - dataSource: "custom", - columnNames: next, - }, - }; - }) - : undefined - } - showChartCheckbox={chartDataSource === "custom"} - onRemove={ - onRemoveColumn - ? () => onRemoveColumn(col) - : () => - updateConfig((prev) => { - const nextColumns = prev.columns.filter( - (c) => c !== col, - ); - const nextMeta = Object.fromEntries( - Object.entries(prev.columnMetadata ?? {}).filter( - ([k]) => k !== col, - ), - ); - const nextChartColumnNames = ( - prev.chart?.columnNames ?? [] - ).filter((c) => c !== col); - return { - ...prev, - columns: nextColumns, - columnMetadata: nextMeta, - chart: prev.chart - ? { - ...prev.chart, - columnNames: - nextChartColumnNames.length > 0 - ? nextChartColumnNames - : undefined, - } - : prev.chart, - }; - }) - } - isDragging={activeId === `col-${col}`} - /> - ))} -
-
- )} -
- ); -} diff --git a/src/app/map/[id]/components/inspector/InspectorSettingsModal/AvailableColumnsCheckboxList.tsx b/src/app/map/[id]/components/inspector/InspectorSettingsModal/AvailableColumnsCheckboxList.tsx new file mode 100644 index 000000000..c7ff84d7e --- /dev/null +++ b/src/app/map/[id]/components/inspector/InspectorSettingsModal/AvailableColumnsCheckboxList.tsx @@ -0,0 +1,47 @@ +"use client"; + +import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable"; +import { SortableAvailableRow } from "./SortableAvailableRow"; + +export function AvailableColumnsCheckboxList({ + allColumnsInOrder, + selectedColumns, + onAddColumn, + onRemoveColumn, + availableIds, + activeId, +}: { + allColumnsInOrder: string[]; + selectedColumns: string[]; + onAddColumn: (columnName: string) => void; + onRemoveColumn: (columnName: string) => void; + availableIds: string[]; + activeId: string | null; +}) { + return ( +
+ + {allColumnsInOrder.map((col) => ( + + checked ? onAddColumn(col) : onRemoveColumn(col) + } + isDragging={activeId === `available-${col}`} + /> + ))} + + {allColumnsInOrder.length === 0 && ( +

+ No columns in this data source +

+ )} +
+ ); +} diff --git a/src/app/map/[id]/components/inspector/InspectorSettingsModal/ChartSection.tsx b/src/app/map/[id]/components/inspector/InspectorSettingsModal/ChartSection.tsx new file mode 100644 index 000000000..8b1158e43 --- /dev/null +++ b/src/app/map/[id]/components/inspector/InspectorSettingsModal/ChartSection.tsx @@ -0,0 +1,169 @@ +"use client"; + +import { BarChart3 } from "lucide-react"; +import type { + InspectorBoundaryConfig, + InspectorChartDataSource, +} from "@/server/models/MapView"; +import { Label } from "@/shadcn/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/shadcn/ui/select"; +import { Switch } from "@/shadcn/ui/switch"; + +export function ChartSection({ + config, + updateConfig, +}: { + config: InspectorBoundaryConfig; + updateConfig: ( + updater: (prev: InspectorBoundaryConfig) => InspectorBoundaryConfig, + ) => void; +}) { + return ( +
+
+ + +
+
+
+ + updateConfig((prev) => ({ + ...prev, + chart: { + enabled: checked, + dataSource: prev.chart?.dataSource ?? "percentage", + columnNames: prev.chart?.columnNames, + hideZeroValues: prev.chart?.hideZeroValues, + hideChartColumnsFromList: + prev.chart?.hideChartColumnsFromList, + }, + })) + } + /> + Show chart at top +
+ + {(config.chart?.enabled ?? false) && ( +
+ + +
+ )} + {(config.chart?.enabled ?? false) && ( +
+ + updateConfig((prev) => { + const ch = prev.chart ?? { + enabled: true, + dataSource: "percentage" as const, + columnNames: undefined, + }; + return { + ...prev, + chart: { + ...ch, + hideZeroValues: checked, + hideChartColumnsFromList: checked + ? false + : ch.hideChartColumnsFromList, + }, + }; + }) + } + /> + Hide zero values from the chart +
+ )} + {(config.chart?.enabled ?? false) && ( +
+ + updateConfig((prev) => { + const ch = prev.chart ?? { + enabled: true, + dataSource: "percentage" as const, + columnNames: undefined, + }; + return { + ...prev, + chart: { + ...ch, + hideChartColumnsFromList: checked, + hideZeroValues: checked ? false : ch.hideZeroValues, + }, + }; + }) + } + /> + + Hide data used in chart from list below + +
+ )} +
+ {(config.chart?.enabled ?? false) && + (config.chart?.dataSource ?? "percentage") === "custom" && ( +

+ Tick "Include in chart" on each column in Columns to show + below. +

+ )} +
+ ); +} diff --git a/src/app/map/[id]/components/inspector/InspectorSettingsModal/ColumnsSection.tsx b/src/app/map/[id]/components/inspector/InspectorSettingsModal/ColumnsSection.tsx new file mode 100644 index 000000000..2b1d50edc --- /dev/null +++ b/src/app/map/[id]/components/inspector/InspectorSettingsModal/ColumnsSection.tsx @@ -0,0 +1,227 @@ +"use client"; + +import { + DndContext, + DragOverlay, + KeyboardSensor, + PointerSensor, + closestCenter, + useSensor, + useSensors, +} from "@dnd-kit/core"; +import { sortableKeyboardCoordinates } from "@dnd-kit/sortable"; +import { useCallback, useState } from "react"; +import { createPortal } from "react-dom"; +import type { InspectorBoundaryConfig } from "@/server/models/MapView"; +import { Label } from "@/shadcn/ui/label"; +import { inferFormat } from "./constants"; +import { AvailableColumnsCheckboxList } from "./AvailableColumnsCheckboxList"; +import { AvailableDragPreview, ColumnDragPreview } from "./DragPreviews"; +import { DroppableSelectedColumns } from "./DroppableSelectedColumns"; +import type { DragEndEvent } from "@dnd-kit/core"; + +export function ColumnsSection({ + config, + allColumnsInOrder, + selectedColumnsInOrder, + availableColumns, + availableIds, + columnIds, + columns, + columnMetadata, + updateConfig, + handleAddColumn, + handleRemoveColumn, + handleRemoveColumnFromRight, +}: { + config: InspectorBoundaryConfig; + allColumnsInOrder: string[]; + selectedColumnsInOrder: string[]; + availableColumns: string[]; + availableIds: string[]; + columnIds: string[]; + columns: string[]; + columnMetadata: Record; + updateConfig: ( + updater: (prev: InspectorBoundaryConfig) => InspectorBoundaryConfig, + ) => void; + handleAddColumn: (colName: string) => void; + handleRemoveColumn: (colName: string) => void; + handleRemoveColumnFromRight: (colName: string) => void; +}) { + const [activeId, setActiveId] = useState(null); + + const sensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 6 } }), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }), + ); + + const handleDragEnd = useCallback( + (event: DragEndEvent) => { + const { active, over } = event; + setActiveId(null); + const activeStr = String(active.id); + const overStr = over ? String(over.id) : null; + + if (activeStr.startsWith("col-") && overStr?.startsWith("col-")) { + const oldIndex = columnIds.indexOf(activeStr); + const newIndex = columnIds.indexOf(overStr); + if (oldIndex === -1 || newIndex === -1) return; + const next = [...selectedColumnsInOrder]; + const [removed] = next.splice(oldIndex, 1); + next.splice(newIndex, 0, removed); + const newColumnOrder = [ + ...next, + ...allColumnsInOrder.filter((c) => !next.includes(c)), + ]; + updateConfig((prev) => ({ + ...prev, + columns: next, + columnOrder: newColumnOrder, + })); + return; + } + if ( + activeStr.startsWith("available-") && + overStr?.startsWith("available-") + ) { + const activeCol = activeStr.slice("available-".length); + const overCol = overStr.slice("available-".length); + const oldIdx = allColumnsInOrder.indexOf(activeCol); + const newIdx = allColumnsInOrder.indexOf(overCol); + if (oldIdx === -1 || newIdx === -1) return; + const nextOrder = [...allColumnsInOrder]; + const [removed] = nextOrder.splice(oldIdx, 1); + nextOrder.splice(newIdx, 0, removed); + updateConfig((prev) => ({ ...prev, columnOrder: nextOrder })); + } + }, + [ + columnIds, + updateConfig, + allColumnsInOrder, + selectedColumnsInOrder, + ], + ); + + return ( +
+
+
+ +

+ Tick columns in Available to add. Reorder in Columns to show with + the handle. +

+
+
+ + +
+
+ setActiveId(active.id as string)} + onDragEnd={handleDragEnd} + > +
+
+

+ All columns (selected at top, drag to reorder, tick to add) +

+ +
+
+

+ Columns to show +

+ +
+
+ {typeof document !== "undefined" && + createPortal( + ({ + ...transform, + x: transform.x, + y: transform.y, + }), + ]} + > + {activeId && String(activeId).startsWith("col-") ? ( + + ) : activeId && String(activeId).startsWith("available-") ? ( + + ) : null} + , + document.body, + )} +
+
+ ); +} diff --git a/src/app/map/[id]/components/inspector/InspectorSettingsModal/DataSourcesList.tsx b/src/app/map/[id]/components/inspector/InspectorSettingsModal/DataSourcesList.tsx new file mode 100644 index 000000000..34c9ac0fa --- /dev/null +++ b/src/app/map/[id]/components/inspector/InspectorSettingsModal/DataSourcesList.tsx @@ -0,0 +1,159 @@ +"use client"; + +import DataSourceIcon from "@/components/DataSourceIcon"; +import { getDataSourceType } from "@/components/DataSourceItem"; +import { MapPin, XCircle } from "lucide-react"; +import { Input } from "@/shadcn/ui/input"; +import { cn } from "@/shadcn/utils"; +import type { InspectorBoundaryConfig } from "@/server/models/MapView"; +import type { DataSource } from "@/server/models/DataSource"; + +export function DataSourcesList({ + searchQuery, + onSearchChange, + inspectorOrdered, + otherSources, + onMapId, + selectedDataSourceId, + onSelectDataSource, + onRemoveFromInspector, +}: { + searchQuery: string; + onSearchChange: (value: string) => void; + inspectorOrdered: Array<{ + config: InspectorBoundaryConfig; + dataSource: DataSource; + }>; + otherSources: DataSource[]; + onMapId: string | null; + selectedDataSourceId: string | null; + onSelectDataSource: (id: string) => void; + onRemoveFromInspector: (configId: string) => void; +}) { + return ( +
+
+ onSearchChange(e.target.value)} + className="h-9" + /> +
+
+ {inspectorOrdered.length > 0 && ( +
+

+ Showing inspector +

+
+ {inspectorOrdered.map(({ config, dataSource: ds }) => { + const isOnMap = ds.id === onMapId; + const isSelected = ds.id === selectedDataSourceId; + return ( +
+ + +
+ ); + })} +
+
+ )} + {otherSources.length > 0 && ( +
0 && "mt-2")} + > + {otherSources.map((ds) => { + const isOnMap = ds.id === onMapId; + const isSelected = ds.id === selectedDataSourceId; + return ( + + ); + })} +
+ )} + {inspectorOrdered.length === 0 && otherSources.length === 0 && ( +

+ No data sources match. +

+ )} +
+
+ ); +} diff --git a/src/app/map/[id]/components/inspector/InspectorSettingsModal/DragPreviews.tsx b/src/app/map/[id]/components/inspector/InspectorSettingsModal/DragPreviews.tsx new file mode 100644 index 000000000..74893e0df --- /dev/null +++ b/src/app/map/[id]/components/inspector/InspectorSettingsModal/DragPreviews.tsx @@ -0,0 +1,50 @@ +"use client"; + +import { GripVertical } from "lucide-react"; + +export function ColumnDragPreview({ + activeId, + columnMetadata, +}: { + activeId: string; + columnMetadata: Record; +}) { + const columnName = activeId.startsWith("col-") + ? activeId.slice("col-".length) + : ""; + const displayName = columnMetadata[columnName]?.displayName; + + return ( +
+
+ + + {columnName} + +
+ + {displayName || columnName || "—"} + +
+ ); +} + +export function AvailableDragPreview({ activeId }: { activeId: string }) { + const columnName = activeId.startsWith("available-") + ? activeId.slice("available-".length) + : ""; + return ( +
+ + + {columnName} + +
+ ); +} diff --git a/src/app/map/[id]/components/inspector/InspectorSettingsModal/DroppableSelectedColumns.tsx b/src/app/map/[id]/components/inspector/InspectorSettingsModal/DroppableSelectedColumns.tsx new file mode 100644 index 000000000..50b1ed3da --- /dev/null +++ b/src/app/map/[id]/components/inspector/InspectorSettingsModal/DroppableSelectedColumns.tsx @@ -0,0 +1,185 @@ +"use client"; + +import { useDroppable } from "@dnd-kit/core"; +import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable"; +import { useMemo } from "react"; +import type { + InspectorBoundaryConfig, + InspectorChartDataSource, +} from "@/server/models/MapView"; +import { cn } from "@/shadcn/utils"; +import { SortableColumnRow } from "../SortableColumnRow"; +import { SELECTED_DROPPABLE_ID } from "./constants"; + +export function DroppableSelectedColumns({ + columns, + columnMetadata, + updateConfig, + onRemoveColumn, + activeId, + chartDataSource, + chartColumnNames, +}: { + columns: string[]; + columnMetadata?: InspectorBoundaryConfig["columnMetadata"]; + updateConfig: ( + updater: (prev: InspectorBoundaryConfig) => InspectorBoundaryConfig, + ) => void; + onRemoveColumn?: (columnName: string) => void; + activeId: string | null; + chartDataSource?: InspectorChartDataSource | null; + chartColumnNames?: string[]; +}) { + const meta = columnMetadata ?? {}; + const { setNodeRef, isOver } = useDroppable({ id: SELECTED_DROPPABLE_ID }); + const columnIds = useMemo(() => columns.map((c) => `col-${c}`), [columns]); + + return ( +
+ {columns.length === 0 ? ( +

+ No columns — tick Available to add +

+ ) : ( + +
+ {columns.map((col) => ( + + updateConfig((prev) => ({ + ...prev, + columnMetadata: { + ...(prev.columnMetadata ?? {}), + [col]: { + ...(prev.columnMetadata?.[col] ?? {}), + displayName: value || undefined, + }, + }, + })) + } + format={meta[col]?.format ?? "text"} + onFormatChange={(format) => + updateConfig((prev) => ({ + ...prev, + columnMetadata: { + ...(prev.columnMetadata ?? {}), + [col]: { + ...(prev.columnMetadata?.[col] ?? {}), + format, + }, + }, + })) + } + scaleMax={meta[col]?.scaleMax ?? 3} + onScaleMaxChange={(scaleMax) => + updateConfig((prev) => ({ + ...prev, + columnMetadata: { + ...(prev.columnMetadata ?? {}), + [col]: { + ...(prev.columnMetadata?.[col] ?? {}), + scaleMax, + }, + }, + })) + } + barColor={meta[col]?.barColor} + onBarColorChange={(value) => + updateConfig((prev) => ({ + ...prev, + columnMetadata: { + ...(prev.columnMetadata ?? {}), + [col]: { + ...(prev.columnMetadata?.[col] ?? {}), + barColor: value || undefined, + }, + }, + })) + } + includeInChart={ + chartDataSource === "custom" && + (chartColumnNames?.includes(col) ?? false) + } + onIncludeInChartChange={ + chartDataSource === "custom" + ? (include) => + updateConfig((prev) => { + const current = prev.chart?.columnNames ?? []; + const next = include + ? current.includes(col) + ? current + : [...current, col] + : current.filter((c) => c !== col); + const chart = prev.chart ?? { + enabled: true, + dataSource: "custom" as const, + columnNames: [], + }; + return { + ...prev, + chart: { + ...chart, + dataSource: "custom", + columnNames: next, + }, + }; + }) + : undefined + } + showChartCheckbox={chartDataSource === "custom"} + onRemove={ + onRemoveColumn + ? () => onRemoveColumn(col) + : () => + updateConfig((prev) => { + const nextColumns = prev.columns.filter( + (c) => c !== col, + ); + const nextMeta = Object.fromEntries( + Object.entries(prev.columnMetadata ?? {}).filter( + ([k]) => k !== col, + ), + ); + const nextChartColumnNames = ( + prev.chart?.columnNames ?? [] + ).filter((c) => c !== col); + return { + ...prev, + columns: nextColumns, + columnMetadata: nextMeta, + chart: prev.chart + ? { + ...prev.chart, + columnNames: + nextChartColumnNames.length > 0 + ? nextChartColumnNames + : undefined, + } + : prev.chart, + }; + }) + } + isDragging={activeId === `col-${col}`} + /> + ))} +
+
+ )} +
+ ); +} diff --git a/src/app/map/[id]/components/inspector/InspectorSettingsModal/InspectorSettingsModal.tsx b/src/app/map/[id]/components/inspector/InspectorSettingsModal/InspectorSettingsModal.tsx new file mode 100644 index 000000000..f8069887c --- /dev/null +++ b/src/app/map/[id]/components/inspector/InspectorSettingsModal/InspectorSettingsModal.tsx @@ -0,0 +1,199 @@ +"use client"; + +import { useCallback, useMemo, useState } from "react"; +import { v4 as uuidv4 } from "uuid"; +import type { InspectorBoundaryConfig } from "@/server/models/MapView"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/shadcn/ui/dialog"; +import { useDataSources } from "../../../hooks/useDataSources"; +import { useDebouncedValue } from "../../../hooks/useDebouncedValue"; +import { useMapViews } from "../../../hooks/useMapViews"; +import { InspectorBoundaryConfigType } from "@/server/models/MapView"; +import type { DataSource } from "@/server/models/DataSource"; +import { DataSourcesList } from "./DataSourcesList"; +import { InspectorFullPreview } from "../InspectorFullPreview"; +import { InspectorSourceConfigPanel } from "./InspectorSourceConfigPanel"; + +export default function InspectorSettingsModal({ + open, + onOpenChange, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; +}) { + const [selectedDataSourceId, setSelectedDataSourceId] = useState< + string | null + >(null); + const [searchQuery, setSearchQuery] = useState(""); + const debouncedSearchQuery = useDebouncedValue(searchQuery, 500); + const { data: dataSources, getDataSourceById } = useDataSources(); + const { view, viewConfig, getLatestView, updateView } = useMapViews(); + + const boundaryConfigs = view?.inspectorConfig?.boundaries ?? []; + const onMapId = viewConfig.areaDataSourceId || null; + + const filteredSources = useMemo(() => { + const list = dataSources ?? []; + if (!debouncedSearchQuery.trim()) return list; + const q = debouncedSearchQuery.toLowerCase(); + return list.filter( + (ds) => + ds.name.toLowerCase().includes(q) || + ds.columnDefs.some((col) => col.name.toLowerCase().includes(q)), + ); + }, [dataSources, debouncedSearchQuery]); + + const matchesSearch = useCallback( + (ds: DataSource) => { + if (!debouncedSearchQuery.trim()) return true; + const q = debouncedSearchQuery.toLowerCase(); + return ( + ds.name.toLowerCase().includes(q) || + ds.columnDefs.some((col) => col.name.toLowerCase().includes(q)) + ); + }, + [debouncedSearchQuery], + ); + + const { inspectorOrdered, otherSources } = useMemo(() => { + const inInspector = boundaryConfigs + .map((config) => ({ + config, + dataSource: getDataSourceById(config.dataSourceId), + })) + .filter( + ( + x, + ): x is { + config: InspectorBoundaryConfig; + dataSource: NonNullable>; + } => x.dataSource != null && matchesSearch(x.dataSource), + ); + const inIds = new Set(inInspector.map((x) => x.dataSource.id)); + const other = (filteredSources ?? []).filter((ds) => !inIds.has(ds.id)); + return { inspectorOrdered: inInspector, otherSources: other }; + }, [boundaryConfigs, getDataSourceById, filteredSources, matchesSearch]); + + const handleRemoveFromInspector = useCallback( + (configId: string) => { + if (!view) return; + const next = boundaryConfigs.filter((c) => c.id !== configId); + updateView({ + ...view, + inspectorConfig: { + ...view.inspectorConfig, + boundaries: next, + }, + }); + }, + [view, boundaryConfigs, updateView], + ); + + const selectedConfig = useMemo( + () => + selectedDataSourceId + ? (boundaryConfigs.find( + (c) => c.dataSourceId === selectedDataSourceId, + ) ?? null) + : null, + [selectedDataSourceId, boundaryConfigs], + ); + const selectedDataSource = useMemo( + () => + selectedDataSourceId + ? ((dataSources ?? []).find((ds) => ds.id === selectedDataSourceId) ?? + null) + : null, + [selectedDataSourceId, dataSources], + ); + + const handleAddToInspector = useCallback(() => { + if (!view || !selectedDataSourceId) return; + const ds = (dataSources ?? []).find((d) => d.id === selectedDataSourceId); + const newConfig: InspectorBoundaryConfig = { + id: uuidv4(), + dataSourceId: selectedDataSourceId, + name: ds?.name ?? "Boundary Data", + type: InspectorBoundaryConfigType.Simple, + columns: [], + columnMetadata: undefined, + columnGroups: undefined, + layout: "single", + }; + const prev = view.inspectorConfig?.boundaries ?? []; + updateView({ + ...view, + inspectorConfig: { + ...view.inspectorConfig, + boundaries: [...prev, newConfig], + }, + }); + }, [view, selectedDataSourceId, dataSources, updateView]); + + return ( + + setSelectedDataSourceId(null)} + > + + Inspector settings +

+ Choose data sources to show in the inspector and configure columns, + order, and layout. +

+
+ +
+ + +
+ {selectedDataSource && view ? ( + + ) : selectedDataSource ? ( +
+ No view loaded. +
+ ) : ( +
+ Select a data source to configure columns (drag them into the + list). +
+ )} +
+ +
+ +
+
+
+
+ ); +} diff --git a/src/app/map/[id]/components/inspector/InspectorSettingsModal/InspectorSourceConfigPanel.tsx b/src/app/map/[id]/components/inspector/InspectorSettingsModal/InspectorSourceConfigPanel.tsx new file mode 100644 index 000000000..8dab9ae83 --- /dev/null +++ b/src/app/map/[id]/components/inspector/InspectorSettingsModal/InspectorSourceConfigPanel.tsx @@ -0,0 +1,353 @@ +"use client"; + +import { LayoutGrid, LayoutList, PlusIcon } from "lucide-react"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import type { InspectorBoundaryConfig } from "@/server/models/MapView"; +import { Input } from "@/shadcn/ui/input"; +import { Label } from "@/shadcn/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/shadcn/ui/select"; +import { Switch } from "@/shadcn/ui/switch"; +import { cn } from "@/shadcn/utils"; +import { useDebouncedCallback } from "../../../hooks/useDebouncedCallback"; +import { + getAllColumnsSorted, + getColumnOrderState, +} from "../inspectorColumnOrder"; +import { + INSPECTOR_COLOR_OPTIONS, + INSPECTOR_ICON_OPTIONS, +} from "../inspectorPanelOptions"; +import type { DataSource } from "@/server/models/DataSource"; +import { ChartSection } from "./ChartSection"; +import { ColumnsSection } from "./ColumnsSection"; +import { DEFAULT_SELECT_VALUE } from "./constants"; +import type { InspectorLayout } from "./constants"; +import { inferFormat } from "./constants"; + +export function InspectorSourceConfigPanel({ + dataSource, + config, + onAddToInspector, + isInInspector, + getLatestView, + updateView, +}: { + dataSource: DataSource; + config: InspectorBoundaryConfig | null; + onAddToInspector: () => void; + isInInspector: boolean; + getLatestView: ReturnType< + typeof import("../../../hooks/useMapViews").useMapViews + >["getLatestView"]; + updateView: ReturnType< + typeof import("../../../hooks/useMapViews").useMapViews + >["updateView"]; +}) { + const columns = config?.columns ?? []; + const allColumnNames = useMemo( + () => dataSource.columnDefs.map((c) => c.name), + [dataSource.columnDefs], + ); + const allColumnsSorted = useMemo( + () => getAllColumnsSorted(allColumnNames), + [allColumnNames], + ); + const { + allColumnsInOrder, + selectedColumnsInOrder, + availableColumns, + availableIds, + columnIds, + } = useMemo( + () => getColumnOrderState(config, allColumnNames), + [config, allColumnNames], + ); + + const updateConfig = useCallback( + (updater: (prev: InspectorBoundaryConfig) => InspectorBoundaryConfig) => { + if (!config) return; + const latestView = getLatestView(); + if (!latestView?.inspectorConfig?.boundaries) return; + const boundaries = latestView.inspectorConfig.boundaries; + const index = boundaries.findIndex((c) => c.id === config.id); + if (index < 0) return; + const updated = updater(boundaries[index]); + const next = [...boundaries]; + next[index] = updated; + updateView({ + ...latestView, + inspectorConfig: { ...latestView.inspectorConfig, boundaries: next }, + }); + }, + [config, getLatestView, updateView], + ); + + const [displayName, setDisplayName] = useState(config?.name ?? ""); + useEffect(() => setDisplayName(config?.name ?? ""), [config?.name]); + const debouncedUpdateName = useDebouncedCallback( + (value: string) => updateConfig((prev) => ({ ...prev, name: value })), + 600, + ); + + const handleAddColumn = useCallback( + (colName: string) => { + const inferred = inferFormat(colName); + updateConfig((prev) => { + const order = prev.columnOrder?.filter((c) => + allColumnNames.includes(c), + ); + const baseOrder = + order?.length === allColumnNames.length ? order : allColumnsSorted; + const newOrder = [ + colName, + ...baseOrder.filter((c) => c !== colName), + ]; + return { + ...prev, + columns: [colName, ...prev.columns], + columnOrder: newOrder, + columnMetadata: { + ...prev.columnMetadata, + [colName]: { + ...prev.columnMetadata?.[colName], + format: + prev.columnMetadata?.[colName]?.format ?? inferred ?? undefined, + }, + }, + }; + }); + }, + [updateConfig, allColumnNames, allColumnsSorted], + ); + + const handleRemoveColumn = useCallback( + (colName: string) => { + updateConfig((prev) => { + const nextColumns = prev.columns.filter((c) => c !== colName); + const nextMeta = Object.fromEntries( + Object.entries(prev.columnMetadata ?? {}).filter( + ([k]) => k !== colName, + ), + ); + const nextChartColumnNames = (prev.chart?.columnNames ?? []).filter( + (c) => c !== colName, + ); + const order = prev.columnOrder?.filter((c) => + allColumnNames.includes(c), + ); + const baseOrder = + order?.length === allColumnNames.length ? order : allColumnsSorted; + const newOrder = [ + ...baseOrder.filter((c) => c !== colName), + colName, + ]; + return { + ...prev, + columns: nextColumns, + columnOrder: newOrder, + columnMetadata: nextMeta, + chart: + prev.chart && nextChartColumnNames.length >= 0 + ? { + ...prev.chart, + columnNames: + nextChartColumnNames.length > 0 + ? nextChartColumnNames + : undefined, + } + : prev.chart, + }; + }); + }, + [updateConfig, allColumnNames, allColumnsSorted], + ); + + const handleRemoveColumnFromRight = useCallback( + (colName: string) => { + updateConfig((prev) => { + const nextColumns = prev.columns.filter((c) => c !== colName); + const nextMeta = Object.fromEntries( + Object.entries(prev.columnMetadata ?? {}).filter( + ([k]) => k !== colName, + ), + ); + const nextChartColumnNames = (prev.chart?.columnNames ?? []).filter( + (c) => c !== colName, + ); + const newColumnOrder = [ + ...nextColumns, + ...allColumnsInOrder.filter((c) => !nextColumns.includes(c)), + ]; + return { + ...prev, + columns: nextColumns, + columnOrder: newColumnOrder, + columnMetadata: nextMeta, + chart: + prev.chart && nextChartColumnNames.length >= 0 + ? { + ...prev.chart, + columnNames: + nextChartColumnNames.length > 0 + ? nextChartColumnNames + : undefined, + } + : prev.chart, + }; + }); + }, + [updateConfig, allColumnsInOrder], + ); + + if (!isInInspector) { + return ( +
+

+ {dataSource.name} is not shown in the inspector yet. +

+ +
+ ); + } + + if (!config) return null; + + const columnMetadata = config.columnMetadata ?? {}; + const layout = (config.layout ?? "single") as InspectorLayout; + const panelIcon = config.icon ?? undefined; + const panelColor = config.color ?? undefined; + + return ( +
+
+
+
+ + { + const v = e.target.value; + setDisplayName(v); + debouncedUpdateName(v); + }} + placeholder="e.g. Main data" + className="max-w-sm" + /> +
+
+ + +
+
+ + +
+
+ +
+ + + updateConfig((prev) => ({ + ...prev, + layout: checked ? "twoColumn" : "single", + })) + } + /> + +
+

+ {layout === "single" ? "Single column" : "Two-column grid"} +

+
+
+ + + + +
+
+ ); +} diff --git a/src/app/map/[id]/components/inspector/InspectorSettingsModal/SortableAvailableRow.tsx b/src/app/map/[id]/components/inspector/InspectorSettingsModal/SortableAvailableRow.tsx new file mode 100644 index 000000000..87689b5c4 --- /dev/null +++ b/src/app/map/[id]/components/inspector/InspectorSettingsModal/SortableAvailableRow.tsx @@ -0,0 +1,67 @@ +"use client"; + +import { useSortable } from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; +import { GripVertical } from "lucide-react"; +import { Checkbox } from "@/shadcn/ui/checkbox"; +import { cn } from "@/shadcn/utils"; + +export function SortableAvailableRow({ + id, + columnName, + selected, + onToggle, + isDragging, +}: { + id: string; + columnName: string; + selected: boolean; + onToggle: (checked: boolean) => void; + isDragging?: boolean; +}) { + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging: dndDragging, + } = useSortable({ id }); + const dragging = isDragging ?? dndDragging; + const style = dragging + ? { transition } + : { + transform: CSS.Transform.toString(transform), + transition, + }; + return ( +
+ + onToggle(checked === true)} + aria-label={ + selected + ? `Remove ${columnName} from columns to show` + : `Add ${columnName} to columns to show` + } + /> + {columnName} +
+ ); +} diff --git a/src/app/map/[id]/components/inspector/InspectorSettingsModal/constants.ts b/src/app/map/[id]/components/inspector/InspectorSettingsModal/constants.ts new file mode 100644 index 000000000..0f5d1ae87 --- /dev/null +++ b/src/app/map/[id]/components/inspector/InspectorSettingsModal/constants.ts @@ -0,0 +1,16 @@ +import type { InspectorColumnFormat } from "@/server/models/MapView"; + +export const SELECTED_DROPPABLE_ID = "selected-columns"; +/** Sentinel for Select default option (Radix Select.Item cannot have value="") */ +export const DEFAULT_SELECT_VALUE = "__default__"; + +export type InspectorLayout = "single" | "twoColumn"; + +/** Infer column format from name: Percentage if name contains % or "percentage". */ +export function inferFormat( + columnName: string, +): InspectorColumnFormat | undefined { + const lower = columnName.toLowerCase(); + if (lower.includes("%") || lower.includes("percentage")) return "percentage"; + return undefined; +} diff --git a/src/app/map/[id]/components/inspector/InspectorSettingsModal/index.ts b/src/app/map/[id]/components/inspector/InspectorSettingsModal/index.ts new file mode 100644 index 000000000..9361f7cbd --- /dev/null +++ b/src/app/map/[id]/components/inspector/InspectorSettingsModal/index.ts @@ -0,0 +1 @@ +export { default } from "./InspectorSettingsModal"; diff --git a/src/app/map/[id]/components/inspector/SortableColumnRow.tsx b/src/app/map/[id]/components/inspector/SortableColumnRow.tsx index bec2c6670..df35a7c97 100644 --- a/src/app/map/[id]/components/inspector/SortableColumnRow.tsx +++ b/src/app/map/[id]/components/inspector/SortableColumnRow.tsx @@ -79,15 +79,14 @@ function BarColorSelect({
-
+
{ + const v = e.target.value; + setLocalLabel(v); + debouncedChange(v); + }} + onClick={(e) => e.stopPropagation()} + /> + {onRemove && ( + + )} +
+ ); +} diff --git a/src/app/map/[id]/components/inspector/InspectorSettingsModal/constants.ts b/src/app/map/[id]/components/inspector/InspectorSettingsModal/constants.ts index 0f5d1ae87..f1378d5c8 100644 --- a/src/app/map/[id]/components/inspector/InspectorSettingsModal/constants.ts +++ b/src/app/map/[id]/components/inspector/InspectorSettingsModal/constants.ts @@ -1,6 +1,7 @@ import type { InspectorColumnFormat } from "@/server/models/MapView"; export const SELECTED_DROPPABLE_ID = "selected-columns"; +export const AVAILABLE_DROPPABLE_ID = "available-columns"; /** Sentinel for Select default option (Radix Select.Item cannot have value="") */ export const DEFAULT_SELECT_VALUE = "__default__"; diff --git a/src/app/map/[id]/components/inspector/PropertiesList.tsx b/src/app/map/[id]/components/inspector/PropertiesList.tsx index b5f2b46e8..d73fa4649 100644 --- a/src/app/map/[id]/components/inspector/PropertiesList.tsx +++ b/src/app/map/[id]/components/inspector/PropertiesList.tsx @@ -6,19 +6,26 @@ export type ColumnFormat = "text" | "number" | "percentage" | "scale"; export type PropertyEntry = { key: string; label: string; - value: unknown; + /** Not used when isDivider is true. */ + value?: unknown; groupLabel?: string; format?: ColumnFormat; scaleMax?: number; /** Bar colour (CSS color) for percentage/scale; same colour used in chart. */ barColor?: string; + /** When true, renders as a label divider row (spans 2 cols when grid layout). */ + isDivider?: boolean; }; function formatNumber(n: number): string { if (Number.isInteger(n)) return n.toLocaleString(); const s = n.toFixed(2); - if (s.endsWith("00")) return n.toLocaleString(undefined, { maximumFractionDigits: 0 }); - return n.toLocaleString(undefined, { minimumFractionDigits: 0, maximumFractionDigits: 2 }); + if (s.endsWith("00")) + return n.toLocaleString(undefined, { maximumFractionDigits: 0 }); + return n.toLocaleString(undefined, { + minimumFractionDigits: 0, + maximumFractionDigits: 2, + }); } function parseNumeric(value: unknown): number | null { @@ -46,11 +53,16 @@ function PropertyValue({ const fill = barFill(barColor); if (format === "number" && num !== null) { - return {formatNumber(num)}; + return ( + {formatNumber(num)} + ); } if (format === "percentage" && num !== null) { - const pct = num > 1 ? Math.min(100, Math.max(0, num)) : Math.min(100, Math.max(0, num * 100)); + const pct = + num > 1 + ? Math.min(100, Math.max(0, num)) + : Math.min(100, Math.max(0, num * 100)); return (
| null; entries?: PropertyEntry[] | null; layout?: "single" | "twoColumn"; + /** Background class for divider labels (to cover vertical line). Inherits from panel color. */ + dividerBackgroundClassName?: string; }) { const entries: PropertyEntry[] = entriesProp - ? entriesProp.filter((e) => e.value !== undefined && e.value !== null && String(e.value) !== "") + ? entriesProp.filter( + (e) => + e.isDivider || + (e.value !== undefined && e.value !== null && String(e.value) !== ""), + ) : properties && Object.keys(properties).length ? Object.entries(properties).map(([key, value]) => ({ key, @@ -115,7 +134,7 @@ export default function PropertiesList({ const isTwoColumn = layout === "twoColumn"; const renderEntry = (e: PropertyEntry) => (
-
+
{e.label}
@@ -132,7 +151,9 @@ export default function PropertiesList({ const byGroup = entries.reduce<{ group?: string; items: PropertyEntry[] }[]>( (acc, e) => { const last = acc[acc.length - 1]; - if (e.groupLabel !== undefined) { + if (e.isDivider) { + acc.push({ group: e.label, items: [] }); + } else if (e.groupLabel !== undefined) { if (last?.group === e.groupLabel) last.items.push(e); else acc.push({ group: e.groupLabel, items: [e] }); } else { @@ -157,11 +178,16 @@ export default function PropertiesList({ {block.group && (
- {block.group} +
+ {block.group} +
)} {block.items.map(renderEntry)} diff --git a/src/app/map/[id]/components/inspector/inspectorColumnOrder.ts b/src/app/map/[id]/components/inspector/inspectorColumnOrder.ts index 7a39a21d1..ebcb24492 100644 --- a/src/app/map/[id]/components/inspector/inspectorColumnOrder.ts +++ b/src/app/map/[id]/components/inspector/inspectorColumnOrder.ts @@ -1,25 +1,38 @@ "use client"; -import type { InspectorBoundaryConfig, InspectorChartDataSource } from "@/server/models/MapView"; +import type { + InspectorBoundaryConfig, + InspectorChartDataSource, + InspectorColumnItem, +} from "@/server/models/MapView"; /** * Single source of truth for inspector column order. * - allColumnsInOrder: full list in display order (columnOrder when valid, else selected first then alphabetical) * - selectedColumnsInOrder: columns that are in config.columns, in the same order as allColumnsInOrder + * - selectedItemsInOrder: columns + label dividers in display order (from columnItems or derived from columns) */ - export function getAllColumnsSorted(allColumnNames: string[]): string[] { return [...allColumnNames].sort((a, b) => a.localeCompare(b, undefined, { sensitivity: "base" }), ); } +function isDivider( + item: InspectorColumnItem, +): item is { type: "divider"; id: string; label: string } { + return typeof item === "object" && item !== null && item.type === "divider"; +} + export function getColumnOrderState( config: InspectorBoundaryConfig | null, allColumnNames: string[], ): { allColumnsInOrder: string[]; selectedColumnsInOrder: string[]; + selectedItemsInOrder: InspectorColumnItem[]; + /** Full list for Available column: visible (columns + dividers) then non-visible, with border between */ + allItemsInOrder: InspectorColumnItem[]; availableColumns: string[]; availableIds: string[]; columnIds: string[]; @@ -40,19 +53,109 @@ export function getColumnOrderState( const selectedColumnsInOrder = allColumnsInOrder.filter((c) => columns.includes(c), ); + + const selectedItemsInOrder: InspectorColumnItem[] = + config?.columnItems?.length && + config.columnItems.some((i) => isDivider(i)) + ? config.columnItems.filter( + (i) => + typeof i === "string" + ? allColumnNames.includes(i) && columns.includes(i) + : true, + ) + : selectedColumnsInOrder; + const availableColumns = allColumnsInOrder.filter((c) => !columns.includes(c)); - const availableIds = allColumnsInOrder.map((c) => `available-${c}`); - const columnIds = selectedColumnsInOrder.map((c) => `col-${c}`); + + const allItemsInOrder: InspectorColumnItem[] = + config?.columnItems?.length && + config.columnItems.some((i) => isDivider(i)) + ? [ + ...config.columnItems.filter((i) => + typeof i === "string" + ? allColumnNames.includes(i) + : true, + ), + ...availableColumns, + ] + : [...selectedColumnsInOrder, ...availableColumns]; + + const availableIds = allItemsInOrder.map((i, idx) => + typeof i === "string" ? `available-${idx}::${i}` : `divider-${i.id}`, + ); + const columnIds = selectedItemsInOrder.map((i) => + typeof i === "string" ? `col-${i}` : `divider-${i.id}`, + ); return { allColumnsInOrder, selectedColumnsInOrder, + selectedItemsInOrder, + allItemsInOrder, availableColumns, availableIds, columnIds, }; } +/** + * Returns selected columns in their canonical display order. + * Uses columnItems when set (single source of truth); else columnOrder; else config.columns. + */ +export function getSelectedColumnsOrdered( + config: Pick< + InspectorBoundaryConfig, + "columns" | "columnOrder" | "columnItems" + >, +): string[] { + const { columns, columnOrder, columnItems } = config; + const columnsSet = new Set(columns ?? []); + + if (columnItems?.length) { + const fromItems = columnItems.filter( + (i): i is string => + typeof i === "string" && columnsSet.has(i), + ); + if (fromItems.length > 0) return fromItems; + } + + if (!columnOrder?.length) return columns ?? []; + const ordered = columnOrder.filter((c) => columnsSet.has(c)); + const orderedSet = new Set(ordered); + for (const c of columns ?? []) { + if (!orderedSet.has(c)) ordered.push(c); + } + return ordered; +} + +/** + * Returns selected items (columns + dividers) in display order. + * Uses columnItems when set; otherwise returns columns only. + */ +export function getSelectedItemsOrdered( + config: Pick< + InspectorBoundaryConfig, + "columns" | "columnOrder" | "columnItems" + >, + allColumnNames: string[], +): InspectorColumnItem[] { + const { columns, columnItems } = config; + const columnsSet = new Set(columns ?? []); + const validColumns = (allColumnNames: string[]) => + allColumnNames.filter((c) => columnsSet.has(c)); + + if (columnItems?.length && columnItems.some((i) => isDivider(i))) { + return columnItems.filter( + (i) => + typeof i === "string" + ? columnsSet.has(i) && allColumnNames.includes(i) + : true, + ); + } + const ordered = getSelectedColumnsOrdered(config); + return ordered.filter((c) => allColumnNames.includes(c)); +} + /** * Chart columns in the same order as config.columns (so chart order matches column list). */ diff --git a/src/app/map/[id]/hooks/useMapViews.ts b/src/app/map/[id]/hooks/useMapViews.ts index 6178a6085..869751637 100644 --- a/src/app/map/[id]/hooks/useMapViews.ts +++ b/src/app/map/[id]/hooks/useMapViews.ts @@ -162,31 +162,32 @@ export function useMapViews() { }, [mapId, viewId, queryClient, trpc.map.byId]); const updateView = useCallback( - (view: View) => { + (updatedView: View) => { if (!mapId) return; - const updatedViews = - views?.map((v) => (v.id === view.id ? view : v)) || []; const isPublicMap = publicMap?.id; - setDirtyViewIds((ids) => ids.concat([view.id])); - - // Synchronously update cache BEFORE calling mutation for instant UI feedback - queryClient.setQueryData(trpc.map.byId.queryKey({ mapId }), (old) => { - if (!old) return old; - return { - ...old, - views: updatedViews.map((v) => ({ - ...v, - mapId, - createdAt: - old.views.find((ov) => ov.id === v.id)?.createdAt || new Date(), - })), - }; - }); - - if (!isPublicMap) { - updateViewMutate({ mapId, views: updatedViews }); + setDirtyViewIds((ids) => ids.concat([updatedView.id])); + + // Derive updatedViews from the latest cache inside the callback to avoid + // stale-closure issues when multiple updates fire before a re-render. + const newData = queryClient.setQueryData( + trpc.map.byId.queryKey({ mapId }), + (old) => { + if (!old) return old; + return { + ...old, + views: old.views.map((v) => + v.id === updatedView.id + ? { ...updatedView, mapId, createdAt: v.createdAt } + : v, + ), + }; + }, + ); + + if (!isPublicMap && newData) { + updateViewMutate({ mapId, views: newData.views }); } }, [ @@ -195,7 +196,6 @@ export function useMapViews() { queryClient, trpc.map.byId, updateViewMutate, - views, publicMap, ], ); diff --git a/src/server/models/MapView.ts b/src/server/models/MapView.ts index d34531ad4..e3c6b52bb 100644 --- a/src/server/models/MapView.ts +++ b/src/server/models/MapView.ts @@ -200,6 +200,22 @@ export const inspectorColumnGroupSchema = z.object({ }); export type InspectorColumnGroup = z.infer; +/** + * Label divider: a visual separator that groups columns. Spans two cols when grid layout is on. + */ +export const inspectorLabelDividerSchema = z.object({ + type: z.literal("divider"), + id: z.string(), + label: z.string(), +}); +export type InspectorLabelDivider = z.infer; + +export const inspectorColumnItemSchema = z.union([ + z.string(), + inspectorLabelDividerSchema, +]); +export type InspectorColumnItem = z.infer; + /** * Chart data source: which columns to include in the inspector chart * - number: columns with Number format @@ -255,6 +271,8 @@ export const inspectorBoundaryConfigSchema = z.object({ columns: z.array(z.string()), /** When set, order of "Available" list; full list of column names in desired order. */ columnOrder: z.array(z.string()).optional().nullable(), + /** Ordered list of columns and label dividers. When set, used for display order; columns derived from it. */ + columnItems: z.array(inspectorColumnItemSchema).optional().nullable(), columnMetadata: z .record(z.string(), inspectorColumnMetaSchema) .optional() From e6216406b7b6b3cf031ad73ad92c71688f057fa5 Mon Sep 17 00:00:00 2001 From: Arbyhisenaj <41119392+Arbyhisenaj@users.noreply.github.com> Date: Thu, 26 Feb 2026 11:48:12 +0000 Subject: [PATCH 08/21] removed charts --- .../inspector/BoundaryDataPanel.tsx | 109 +++-------- .../components/inspector/InspectorChart.tsx | 99 ---------- .../InspectorSettingsModal/ChartSection.tsx | 169 ------------------ .../InspectorSettingsModal/ColumnsSection.tsx | 6 - .../DroppableSelectedColumns.tsx | 52 +----- .../InspectorSourceConfigPanel.tsx | 29 --- .../inspector/SortableColumnRow.tsx | 25 --- .../inspector/inspectorColumnOrder.ts | 17 -- src/server/models/MapView.ts | 37 +--- 9 files changed, 27 insertions(+), 516 deletions(-) delete mode 100644 src/app/map/[id]/components/inspector/InspectorChart.tsx delete mode 100644 src/app/map/[id]/components/inspector/InspectorSettingsModal/ChartSection.tsx diff --git a/src/app/map/[id]/components/inspector/BoundaryDataPanel.tsx b/src/app/map/[id]/components/inspector/BoundaryDataPanel.tsx index 13a536962..8e7a811ac 100644 --- a/src/app/map/[id]/components/inspector/BoundaryDataPanel.tsx +++ b/src/app/map/[id]/components/inspector/BoundaryDataPanel.tsx @@ -9,8 +9,6 @@ import { useTRPC } from "@/services/trpc/react"; import { DataRecordMatchType } from "@/types"; import { buildName } from "@/utils/dataRecord"; import { useDataSources } from "../../hooks/useDataSources"; -import { InspectorChart } from "./InspectorChart"; -import { getChartColumnNames } from "./inspectorColumnOrder"; import { InspectorPanelIcon, getBarColorForLabel, @@ -18,10 +16,7 @@ import { } from "./inspectorPanelOptions"; import PropertiesList, { type PropertyEntry } from "./PropertiesList"; -import type { - InspectorBoundaryConfig, - InspectorChartConfig, -} from "@/server/models/MapView"; +import type { InspectorBoundaryConfig } from "@/server/models/MapView"; export function BoundaryDataPanel({ config, @@ -35,7 +30,7 @@ export function BoundaryDataPanel({ }: { config: Pick< InspectorBoundaryConfig, - "name" | "dataSourceId" | "icon" | "color" | "chart" | "columnItems" + "name" | "dataSourceId" | "icon" | "color" | "columnItems" >; dataSourceId: string; areaCode: string; @@ -84,36 +79,16 @@ export function BoundaryDataPanel({

Loading...

) : data?.records.length === 1 ? ( - <> - {config.chart?.enabled && ( - - )} - - + ) : data?.records.length ? (
    {data.records.map((d, i) => ( @@ -130,7 +105,9 @@ export function BoundaryDataPanel({ columnItems={config.columnItems} layout={layout} match={data.match} - dividerBackgroundClassName={getInspectorColorClass(config.color)} + dividerBackgroundClassName={getInspectorColorClass( + config.color, + )} /> @@ -145,34 +122,6 @@ export function BoundaryDataPanel({ ); } -function BoundaryChart({ - json, - columns, - columnMetadata, - chart, -}: { - json: Record; - columns: string[]; - columnMetadata?: InspectorBoundaryConfig["columnMetadata"]; - chart: InspectorChartConfig; -}) { - const chartColumns = getChartColumnNames( - columns, - columnMetadata, - chart.dataSource, - chart.columnNames, - ); - if (chartColumns.length === 0) return null; - return ( - - ); -} - function isColumnItemDivider( item: unknown, ): item is { type: "divider"; id: string; label: string } { @@ -191,7 +140,6 @@ function BoundaryDataProperties({ columnItems, layout, match, - hideFromListColumnNames, dividerBackgroundClassName, }: { json: Record; @@ -201,18 +149,9 @@ function BoundaryDataProperties({ columnItems?: InspectorBoundaryConfig["columnItems"]; layout?: InspectorBoundaryConfig["layout"]; match: DataRecordMatchType; - /** When set, these columns are excluded from the list (e.g. already shown in chart) */ - hideFromListColumnNames?: string[]; /** Background class for divider labels. Matches panel color. */ dividerBackgroundClassName?: string; }) { - const hideSet = useMemo( - () => - hideFromListColumnNames?.length - ? new Set(hideFromListColumnNames) - : undefined, - [hideFromListColumnNames], - ); const entries = useMemo((): PropertyEntry[] => { const meta = columnMetadata ?? {}; const columnsSet = new Set(columns); @@ -229,12 +168,11 @@ function BoundaryDataProperties({ }); currentGroupLabel = item.label; } else if (typeof item === "string" && columnsSet.has(item)) { - if (hideSet?.has(item)) continue; if (json[item] === undefined) continue; const m = meta[item]; const label = m?.displayName ?? item; ordered.push({ - key: item, + key: `${item}-${ordered.length}`, label, value: json[item], groupLabel: currentGroupLabel, @@ -260,29 +198,32 @@ function BoundaryDataProperties({ const ordered: PropertyEntry[] = []; groups.forEach((g) => { g.columnNames.forEach((col) => { - if (hideSet?.has(col)) return; if (json[col] === undefined) return; const m = meta[col]; const label = m?.displayName ?? col; ordered.push({ - key: col, + key: `${col}-${ordered.length}`, label, value: json[col], groupLabel: g.label, format: m?.format, scaleMax: m?.scaleMax, - barColor: getBarColorForLabel(label, col, ordered.length, m?.barColor), + barColor: getBarColorForLabel( + label, + col, + ordered.length, + m?.barColor, + ), }); }); }); columns.forEach((col) => { - if (hideSet?.has(col)) return; if (keyToGroup.has(col)) return; if (json[col] === undefined) return; const m = meta[col]; const label = m?.displayName ?? col; ordered.push({ - key: col, + key: `${col}-${ordered.length}`, label, value: json[col], format: m?.format, @@ -291,7 +232,7 @@ function BoundaryDataProperties({ }); }); return ordered; - }, [json, columns, columnMetadata, columnGroups, columnItems, hideSet]); + }, [json, columns, columnMetadata, columnGroups, columnItems]); return (
    {match === DataRecordMatchType.Approximate && ( diff --git a/src/app/map/[id]/components/inspector/InspectorChart.tsx b/src/app/map/[id]/components/inspector/InspectorChart.tsx deleted file mode 100644 index 80da6e199..000000000 --- a/src/app/map/[id]/components/inspector/InspectorChart.tsx +++ /dev/null @@ -1,99 +0,0 @@ -"use client"; - -import { useMemo } from "react"; -import { Bar, BarChart, CartesianGrid, XAxis, YAxis } from "recharts"; -import { - ChartContainer, - ChartTooltip, - ChartTooltipContent, -} from "@/shadcn/ui/chart"; -import type { InspectorBoundaryConfig } from "@/server/models/MapView"; -import { getBarColorForLabel } from "./inspectorPanelOptions"; - -function parseNumeric(value: unknown): number | null { - if (typeof value === "number" && !Number.isNaN(value)) return value; - const n = Number(value); - return Number.isNaN(n) ? null : n; -} - -export function InspectorChart({ - json, - columnNames, - columnMetadata, - hideZeroValues, -}: { - json: Record; - columnNames: string[]; - columnMetadata?: InspectorBoundaryConfig["columnMetadata"]; - hideZeroValues?: boolean; -}) { - const meta = columnMetadata ?? {}; - const defaultFill = "hsl(var(--primary))"; - const chartData = useMemo(() => { - const rows = columnNames.map((col, i) => { - const num = parseNumeric(json[col]); - const label = meta[col]?.displayName ?? col; - const fill = - getBarColorForLabel(label, col, i, meta[col]?.barColor) || defaultFill; - return { - label, - value: num ?? 0, - fill, - }; - }); - return hideZeroValues ? rows.filter((d) => d.value !== 0) : rows; - }, [json, columnNames, meta, hideZeroValues]); - - if (chartData.length === 0) return null; - - const chartConfig = useMemo( - () => ({ - value: { - label: "Value", - color: defaultFill, - }, - }), - [], - ); - - /** Height scales with row count so all y-axis labels and bars stay visible */ - const rowHeight = 28; - const chartHeight = Math.max(120, chartData.length * rowHeight); - - return ( -
    - - - - - - } - /> - entry.fill ?? defaultFill} - radius={[0, 6, 6, 0]} - /> - - -
    - ); -} diff --git a/src/app/map/[id]/components/inspector/InspectorSettingsModal/ChartSection.tsx b/src/app/map/[id]/components/inspector/InspectorSettingsModal/ChartSection.tsx deleted file mode 100644 index 8b1158e43..000000000 --- a/src/app/map/[id]/components/inspector/InspectorSettingsModal/ChartSection.tsx +++ /dev/null @@ -1,169 +0,0 @@ -"use client"; - -import { BarChart3 } from "lucide-react"; -import type { - InspectorBoundaryConfig, - InspectorChartDataSource, -} from "@/server/models/MapView"; -import { Label } from "@/shadcn/ui/label"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/shadcn/ui/select"; -import { Switch } from "@/shadcn/ui/switch"; - -export function ChartSection({ - config, - updateConfig, -}: { - config: InspectorBoundaryConfig; - updateConfig: ( - updater: (prev: InspectorBoundaryConfig) => InspectorBoundaryConfig, - ) => void; -}) { - return ( -
    -
    - - -
    -
    -
    - - updateConfig((prev) => ({ - ...prev, - chart: { - enabled: checked, - dataSource: prev.chart?.dataSource ?? "percentage", - columnNames: prev.chart?.columnNames, - hideZeroValues: prev.chart?.hideZeroValues, - hideChartColumnsFromList: - prev.chart?.hideChartColumnsFromList, - }, - })) - } - /> - Show chart at top -
    - - {(config.chart?.enabled ?? false) && ( -
    - - -
    - )} - {(config.chart?.enabled ?? false) && ( -
    - - updateConfig((prev) => { - const ch = prev.chart ?? { - enabled: true, - dataSource: "percentage" as const, - columnNames: undefined, - }; - return { - ...prev, - chart: { - ...ch, - hideZeroValues: checked, - hideChartColumnsFromList: checked - ? false - : ch.hideChartColumnsFromList, - }, - }; - }) - } - /> - Hide zero values from the chart -
    - )} - {(config.chart?.enabled ?? false) && ( -
    - - updateConfig((prev) => { - const ch = prev.chart ?? { - enabled: true, - dataSource: "percentage" as const, - columnNames: undefined, - }; - return { - ...prev, - chart: { - ...ch, - hideChartColumnsFromList: checked, - hideZeroValues: checked ? false : ch.hideZeroValues, - }, - }; - }) - } - /> - - Hide data used in chart from list below - -
    - )} -
    - {(config.chart?.enabled ?? false) && - (config.chart?.dataSource ?? "percentage") === "custom" && ( -

    - Tick "Include in chart" on each column in Columns to show - below. -

    - )} -
    - ); -} diff --git a/src/app/map/[id]/components/inspector/InspectorSettingsModal/ColumnsSection.tsx b/src/app/map/[id]/components/inspector/InspectorSettingsModal/ColumnsSection.tsx index d23b917ef..ab1a9c298 100644 --- a/src/app/map/[id]/components/inspector/InspectorSettingsModal/ColumnsSection.tsx +++ b/src/app/map/[id]/components/inspector/InspectorSettingsModal/ColumnsSection.tsx @@ -288,12 +288,6 @@ export function ColumnsSection({ updateConfig={updateConfig} onRemoveColumn={handleRemoveColumnFromRight} activeId={activeId} - chartDataSource={ - config.chart?.enabled - ? (config.chart?.dataSource ?? null) - : null - } - chartColumnNames={config.chart?.columnNames} />
diff --git a/src/app/map/[id]/components/inspector/InspectorSettingsModal/DroppableSelectedColumns.tsx b/src/app/map/[id]/components/inspector/InspectorSettingsModal/DroppableSelectedColumns.tsx index a3a3296ee..f7022ab9b 100644 --- a/src/app/map/[id]/components/inspector/InspectorSettingsModal/DroppableSelectedColumns.tsx +++ b/src/app/map/[id]/components/inspector/InspectorSettingsModal/DroppableSelectedColumns.tsx @@ -3,10 +3,7 @@ import { useDroppable } from "@dnd-kit/core"; import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable"; import { useMemo } from "react"; -import type { - InspectorBoundaryConfig, - InspectorChartDataSource, -} from "@/server/models/MapView"; +import type { InspectorBoundaryConfig } from "@/server/models/MapView"; import { cn } from "@/shadcn/utils"; import { SortableColumnRow } from "../SortableColumnRow"; import { SELECTED_DROPPABLE_ID } from "./constants"; @@ -17,8 +14,6 @@ export function DroppableSelectedColumns({ updateConfig, onRemoveColumn, activeId, - chartDataSource, - chartColumnNames, }: { columns: string[]; columnMetadata?: InspectorBoundaryConfig["columnMetadata"]; @@ -27,8 +22,6 @@ export function DroppableSelectedColumns({ ) => void; onRemoveColumn?: (columnName: string) => void; activeId: string | null; - chartDataSource?: InspectorChartDataSource | null; - chartColumnNames?: string[]; }) { const meta = columnMetadata ?? {}; const { setNodeRef, isOver } = useDroppable({ id: SELECTED_DROPPABLE_ID }); @@ -113,37 +106,6 @@ export function DroppableSelectedColumns({ }, })) } - includeInChart={ - chartDataSource === "custom" && - (chartColumnNames?.includes(col) ?? false) - } - onIncludeInChartChange={ - chartDataSource === "custom" - ? (include) => - updateConfig((prev) => { - const current = prev.chart?.columnNames ?? []; - const next = include - ? current.includes(col) - ? current - : [...current, col] - : current.filter((c) => c !== col); - const chart = prev.chart ?? { - enabled: true, - dataSource: "custom" as const, - columnNames: [], - }; - return { - ...prev, - chart: { - ...chart, - dataSource: "custom", - columnNames: next, - }, - }; - }) - : undefined - } - showChartCheckbox={chartDataSource === "custom"} onRemove={ onRemoveColumn ? () => onRemoveColumn(col) @@ -157,21 +119,9 @@ export function DroppableSelectedColumns({ ([k]) => k !== col, ), ); - const nextChartColumnNames = ( - prev.chart?.columnNames ?? [] - ).filter((c) => c !== col); const base: Partial = { columns: nextColumns, columnMetadata: nextMeta, - chart: prev.chart - ? { - ...prev.chart, - columnNames: - nextChartColumnNames.length > 0 - ? nextChartColumnNames - : undefined, - } - : prev.chart, }; if (prev.columnItems?.length) { base.columnItems = prev.columnItems.filter( diff --git a/src/app/map/[id]/components/inspector/InspectorSettingsModal/InspectorSourceConfigPanel.tsx b/src/app/map/[id]/components/inspector/InspectorSettingsModal/InspectorSourceConfigPanel.tsx index ed3175884..17cfb3f98 100644 --- a/src/app/map/[id]/components/inspector/InspectorSettingsModal/InspectorSourceConfigPanel.tsx +++ b/src/app/map/[id]/components/inspector/InspectorSettingsModal/InspectorSourceConfigPanel.tsx @@ -24,7 +24,6 @@ import { INSPECTOR_ICON_OPTIONS, } from "../inspectorPanelOptions"; import type { DataSource } from "@/server/models/DataSource"; -import { ChartSection } from "./ChartSection"; import { ColumnsSection } from "./ColumnsSection"; import { DEFAULT_SELECT_VALUE } from "./constants"; import { inferFormat } from "./constants"; @@ -139,9 +138,6 @@ export function InspectorSourceConfigPanel({ ([k]) => k !== colName, ), ); - const nextChartColumnNames = (prev.chart?.columnNames ?? []).filter( - (c) => c !== colName, - ); const order = prev.columnOrder?.filter((c) => allColumnNames.includes(c), ); @@ -155,16 +151,6 @@ export function InspectorSourceConfigPanel({ columnOrder: newOrder, ...(nextItems !== undefined && { columnItems: nextItems }), columnMetadata: nextMeta, - chart: - prev.chart && nextChartColumnNames.length >= 0 - ? { - ...prev.chart, - columnNames: - nextChartColumnNames.length > 0 - ? nextChartColumnNames - : undefined, - } - : prev.chart, }; }); }, @@ -180,9 +166,6 @@ export function InspectorSourceConfigPanel({ ([k]) => k !== colName, ), ); - const nextChartColumnNames = (prev.chart?.columnNames ?? []).filter( - (c) => c !== colName, - ); const newColumnOrder = [ ...nextColumns, ...allColumnsInOrder.filter((c) => !nextColumns.includes(c)), @@ -194,16 +177,6 @@ export function InspectorSourceConfigPanel({ columnOrder: newColumnOrder, ...(nextItems !== undefined && { columnItems: nextItems }), columnMetadata: nextMeta, - chart: - prev.chart && nextChartColumnNames.length >= 0 - ? { - ...prev.chart, - columnNames: - nextChartColumnNames.length > 0 - ? nextChartColumnNames - : undefined, - } - : prev.chart, }; }); }, @@ -336,8 +309,6 @@ export function InspectorSourceConfigPanel({
- - void; scaleMax?: number; onScaleMaxChange?: (value: number) => void; - includeInChart?: boolean; - onIncludeInChartChange?: (include: boolean) => void; - showChartCheckbox?: boolean; barColor?: string; onBarColorChange?: (value: string) => void; onRemove?: () => void; @@ -308,24 +301,6 @@ export function SortableColumnRow({ onClick={(e: MouseEvent) => e.stopPropagation()} /> )} - {showChartCheckbox && onIncludeInChartChange && ( -
- - onIncludeInChartChange(checked === true) - } - onClick={(e) => e.stopPropagation()} - /> - -
- )}
); } diff --git a/src/app/map/[id]/components/inspector/inspectorColumnOrder.ts b/src/app/map/[id]/components/inspector/inspectorColumnOrder.ts index ebcb24492..e962d003d 100644 --- a/src/app/map/[id]/components/inspector/inspectorColumnOrder.ts +++ b/src/app/map/[id]/components/inspector/inspectorColumnOrder.ts @@ -2,7 +2,6 @@ import type { InspectorBoundaryConfig, - InspectorChartDataSource, InspectorColumnItem, } from "@/server/models/MapView"; @@ -156,19 +155,3 @@ export function getSelectedItemsOrdered( return ordered.filter((c) => allColumnNames.includes(c)); } -/** - * Chart columns in the same order as config.columns (so chart order matches column list). - */ -export function getChartColumnNames( - columns: string[], - columnMetadata: InspectorBoundaryConfig["columnMetadata"], - chartDataSource: InspectorChartDataSource, - chartColumnNames: string[] | undefined, -): string[] { - const meta = columnMetadata ?? {}; - if (chartDataSource === "custom" && chartColumnNames?.length) { - return columns.filter((c) => chartColumnNames.includes(c)); - } - const formatMatch = chartDataSource as "number" | "percentage" | "scale"; - return columns.filter((col) => meta[col]?.format === formatMatch); -} diff --git a/src/server/models/MapView.ts b/src/server/models/MapView.ts index e3c6b52bb..2bab824f6 100644 --- a/src/server/models/MapView.ts +++ b/src/server/models/MapView.ts @@ -185,7 +185,7 @@ export const inspectorColumnMetaSchema = z.object({ format: inspectorColumnFormatSchema.optional(), /** For format "scale": max value (e.g. 3 for a 0–2 or 1–3 scale). Number of bars shown. */ scaleMax: z.number().int().min(2).max(10).optional(), - /** Bar colour (CSS color) for percentage/scale bars and chart bar. Empty = primary. */ + /** Bar colour (CSS color) for percentage/scale bars. Empty = primary. */ barColor: z.string().optional(), }); export type InspectorColumnMeta = z.infer; @@ -216,39 +216,6 @@ export const inspectorColumnItemSchema = z.union([ ]); export type InspectorColumnItem = z.infer; -/** - * Chart data source: which columns to include in the inspector chart - * - number: columns with Number format - * - percentage: columns with Percentage format - * - scale: columns with Scale format - * - custom: columns selected via chartColumnNames - */ -export const inspectorChartDataSourceSchema = z.enum([ - "number", - "percentage", - "scale", - "custom", -]); -export type InspectorChartDataSource = z.infer< - typeof inspectorChartDataSourceSchema ->; - -/** - * Chart config: show a chart at the top of this boundary panel - */ -export const inspectorChartConfigSchema = z.object({ - enabled: z.boolean(), - /** Which columns to use: by format or custom list */ - dataSource: inspectorChartDataSourceSchema, - /** When dataSource is "custom", column names to include in the chart */ - columnNames: z.array(z.string()).optional(), - /** When true, bars with value 0 are not shown in the chart */ - hideZeroValues: z.boolean().optional(), - /** When true, columns used in the chart are hidden from the list below to avoid duplication */ - hideChartColumnsFromList: z.boolean().optional(), -}); -export type InspectorChartConfig = z.infer; - /** * Configuration for a single boundary data source in the inspector * - dataSourceId: Reference to the data source @@ -260,7 +227,6 @@ export type InspectorChartConfig = z.infer; * - layout: "single" (one column) or "twoColumn" (Airtable-style grid) * - icon: optional Lucide icon name for custom panel icon * - color: optional Tailwind color name for panel background (e.g. "blue" -> bg-blue-50) - * - chart: optional chart shown at top of panel (data source: number/percentage/scale/custom) * - columnOrder: optional display order for all columns (used for Available list order; when set, reorderable) */ export const inspectorBoundaryConfigSchema = z.object({ @@ -281,7 +247,6 @@ export const inspectorBoundaryConfigSchema = z.object({ layout: z.enum(["single", "twoColumn"]).optional().nullable(), icon: z.string().optional().nullable(), color: z.string().optional().nullable(), - chart: inspectorChartConfigSchema.optional().nullable(), }); export type InspectorBoundaryConfig = z.infer< From 6ba26d5cb6f598ccc26dadd19b0b9491fdcc942b Mon Sep 17 00:00:00 2001 From: Arbyhisenaj <41119392+Arbyhisenaj@users.noreply.github.com> Date: Thu, 26 Feb 2026 12:45:45 +0000 Subject: [PATCH 09/21] latest --- .../inspector/InspectorFullPreview.tsx | 4 +- .../inspector/InspectorOnMapSection.tsx | 4 +- .../components/inspector/InspectorPanel.tsx | 12 - .../DataSourcesList.tsx | 265 +++++++++++------- 4 files changed, 171 insertions(+), 114 deletions(-) diff --git a/src/app/map/[id]/components/inspector/InspectorFullPreview.tsx b/src/app/map/[id]/components/inspector/InspectorFullPreview.tsx index d30058ba2..05aa08057 100644 --- a/src/app/map/[id]/components/inspector/InspectorFullPreview.tsx +++ b/src/app/map/[id]/components/inspector/InspectorFullPreview.tsx @@ -61,10 +61,10 @@ export function InspectorFullPreview({ boundaryConfigs.map((config) => ({ config, dataSourceId: config.dataSourceId, - areaCode: selectedBoundary?.areaCode ?? "", + areaCode: selectedBoundary?.code ?? "", columns: getSelectedColumnsOrdered(config), })), - [boundaryConfigs, selectedBoundary?.areaCode], + [boundaryConfigs, selectedBoundary?.code], ); const reorderBoundaries = useCallback( diff --git a/src/app/map/[id]/components/inspector/InspectorOnMapSection.tsx b/src/app/map/[id]/components/inspector/InspectorOnMapSection.tsx index ee06c815d..b31991850 100644 --- a/src/app/map/[id]/components/inspector/InspectorOnMapSection.tsx +++ b/src/app/map/[id]/components/inspector/InspectorOnMapSection.tsx @@ -16,14 +16,14 @@ export default function InspectorOnMapSection() { const areaStats = areaStatsQuery.data; const hasChoropleth = Boolean(viewConfig.areaDataSourceId); - if (!hasChoropleth || !selectedBoundary?.areaCode || !areaStats) { + if (!hasChoropleth || !selectedBoundary?.code || !areaStats) { return null; } const isSameAreaSet = areaStats.areaSetCode === selectedBoundary.areaSetCode; const stat = isSameAreaSet ? areaStats.stats.find( - (s: { areaCode: string }) => s.areaCode === selectedBoundary.areaCode, + (s: { areaCode: string }) => s.areaCode === selectedBoundary.code, ) : null; diff --git a/src/app/map/[id]/components/inspector/InspectorPanel.tsx b/src/app/map/[id]/components/inspector/InspectorPanel.tsx index 17c175e22..6f6981c4a 100644 --- a/src/app/map/[id]/components/inspector/InspectorPanel.tsx +++ b/src/app/map/[id]/components/inspector/InspectorPanel.tsx @@ -14,7 +14,6 @@ import { useTRPC } from "@/services/trpc/react"; import { Button } from "@/shadcn/ui/button"; import { cn } from "@/shadcn/utils"; import { LayerType } from "@/types"; -import InspectorConfigTab from "./InspectorConfigTab"; import InspectorDataTab from "./InspectorDataTab"; import InspectorMarkersTab from "./InspectorMarkersTab"; import InspectorNotesTab from "./InspectorNotesTab"; @@ -212,11 +211,6 @@ export default function InspectorPanel({ Notes 0 - {hasConfig && ( - - - - )} {hasData && ( @@ -240,12 +234,6 @@ export default function InspectorPanel({ - - {hasConfig && ( - - - - )} {type === LayerType.Boundary && (
diff --git a/src/app/map/[id]/components/inspector/InspectorSettingsModal/DataSourcesList.tsx b/src/app/map/[id]/components/inspector/InspectorSettingsModal/DataSourcesList.tsx index 34c9ac0fa..c1776d7d6 100644 --- a/src/app/map/[id]/components/inspector/InspectorSettingsModal/DataSourcesList.tsx +++ b/src/app/map/[id]/components/inspector/InspectorSettingsModal/DataSourcesList.tsx @@ -1,5 +1,6 @@ "use client"; +import { useMemo } from "react"; import DataSourceIcon from "@/components/DataSourceIcon"; import { getDataSourceType } from "@/components/DataSourceItem"; import { MapPin, XCircle } from "lucide-react"; @@ -8,6 +9,13 @@ import { cn } from "@/shadcn/utils"; import type { InspectorBoundaryConfig } from "@/server/models/MapView"; import type { DataSource } from "@/server/models/DataSource"; +const GROUP_LABEL_USER = "User data"; +const GROUP_LABEL_PUBLIC = "Public data"; + +function isUserDataSource(ds: DataSource) { + return !ds.public; +} + export function DataSourcesList({ searchQuery, onSearchChange, @@ -30,6 +38,121 @@ export function DataSourcesList({ onSelectDataSource: (id: string) => void; onRemoveFromInspector: (configId: string) => void; }) { + const { inspectorUser, inspectorPublic, otherUser, otherPublic } = useMemo( + () => ({ + inspectorUser: inspectorOrdered.filter(({ dataSource: ds }) => + isUserDataSource(ds), + ), + inspectorPublic: inspectorOrdered.filter(({ dataSource: ds }) => ds.public), + otherUser: otherSources.filter(isUserDataSource), + otherPublic: otherSources.filter((ds) => ds.public), + }), + [inspectorOrdered, otherSources], + ); + + const renderInspectorItem = ({ + config, + dataSource: ds, + }: { + config: InspectorBoundaryConfig; + dataSource: DataSource; + }) => { + const isOnMap = ds.id === onMapId; + const isSelected = ds.id === selectedDataSourceId; + return ( +
+ + +
+ ); + }; + + const renderOtherItem = (ds: DataSource) => { + const isOnMap = ds.id === onMapId; + const isSelected = ds.id === selectedDataSourceId; + return ( + + ); + }; + + const hasInspector = inspectorUser.length > 0 || inspectorPublic.length > 0; + const hasOther = otherUser.length > 0 || otherPublic.length > 0; + return (
@@ -41,114 +164,60 @@ export function DataSourcesList({ />
- {inspectorOrdered.length > 0 && ( + {hasInspector && (

Showing inspector

-
- {inspectorOrdered.map(({ config, dataSource: ds }) => { - const isOnMap = ds.id === onMapId; - const isSelected = ds.id === selectedDataSourceId; - return ( -
- - +
+ {inspectorUser.length > 0 && ( +
+

+ {GROUP_LABEL_USER} +

+
+ {inspectorUser.map((item) => renderInspectorItem(item))} +
+
+ )} + {inspectorPublic.length > 0 && ( +
+

+ {GROUP_LABEL_PUBLIC} +

+
+ {inspectorPublic.map((item) => renderInspectorItem(item))}
- ); - })} +
+ )}
)} - {otherSources.length > 0 && ( -
0 && "mt-2")} - > - {otherSources.map((ds) => { - const isOnMap = ds.id === onMapId; - const isSelected = ds.id === selectedDataSourceId; - return ( - - ); - })} + {hasOther && ( +
+ {otherUser.length > 0 && ( +
+

+ {GROUP_LABEL_USER} +

+
+ {otherUser.map(renderOtherItem)} +
+
+ )} + {otherPublic.length > 0 && ( +
+

+ {GROUP_LABEL_PUBLIC} +

+
+ {otherPublic.map(renderOtherItem)} +
+
+ )}
)} - {inspectorOrdered.length === 0 && otherSources.length === 0 && ( + {!hasInspector && !hasOther && (

No data sources match.

From 8f525482c76c3fd85c07458de1ff4ed2a82c6167 Mon Sep 17 00:00:00 2001 From: Arbyhisenaj <41119392+Arbyhisenaj@users.noreply.github.com> Date: Thu, 26 Feb 2026 17:27:35 +0000 Subject: [PATCH 10/21] latest from work --- migrations/1770299199726_area_geom.ts | 3 +- ...00_data_source_default_inspector_config.ts | 17 + .../data-sources/[id]/DataSourceDashboard.tsx | 75 +-- .../DefaultInspectorConfigSection.tsx | 458 ++++++++++++++++++ .../components/DefaultInspectorPreview.tsx | 146 ++++++ .../VisualisationPanel/VisualisationPanel.tsx | 114 +---- .../inspector/BoundaryDataPanel.tsx | 72 +-- .../components/inspector/InspectorDataTab.tsx | 26 +- .../DroppableSelectedColumns.tsx | 10 +- .../InspectorSettingsModal.tsx | 20 +- .../InspectorSourceConfigPanel.tsx | 71 +-- .../inspector/inspectorColumnOrder.ts | 31 ++ src/server/models/DataSource.ts | 4 + src/server/models/MapView.ts | 14 + src/server/services/database/schema.ts | 3 +- src/server/trpc/routers/dataSource.ts | 1 + 16 files changed, 869 insertions(+), 196 deletions(-) create mode 100644 migrations/1772020000000_data_source_default_inspector_config.ts create mode 100644 src/app/(private)/data-sources/[id]/components/DefaultInspectorConfigSection.tsx create mode 100644 src/app/(private)/data-sources/[id]/components/DefaultInspectorPreview.tsx diff --git a/migrations/1770299199726_area_geom.ts b/migrations/1770299199726_area_geom.ts index f19f5ef24..8046de9bc 100644 --- a/migrations/1770299199726_area_geom.ts +++ b/migrations/1770299199726_area_geom.ts @@ -12,9 +12,10 @@ import { type Kysely, sql } from "kysely"; */ export async function up(db: Kysely): Promise { + // Use Geometry (not MultiPolygon) so rows with Point geography don't fail await sql` ALTER TABLE area - ADD COLUMN geom geometry(MultiPolygon, 4326) NOT NULL + ADD COLUMN geom geometry(Geometry, 4326) NOT NULL GENERATED ALWAYS AS (geography::geometry) STORED `.execute(db); diff --git a/migrations/1772020000000_data_source_default_inspector_config.ts b/migrations/1772020000000_data_source_default_inspector_config.ts new file mode 100644 index 000000000..edf448d00 --- /dev/null +++ b/migrations/1772020000000_data_source_default_inspector_config.ts @@ -0,0 +1,17 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { sql } from "kysely"; +import type { Kysely } from "kysely"; + +export async function up(db: Kysely): Promise { + await sql` + ALTER TABLE "data_source" + ADD COLUMN "default_inspector_config" jsonb DEFAULT NULL + `.execute(db); +} + +export async function down(db: Kysely): Promise { + await sql` + ALTER TABLE "data_source" + DROP COLUMN "default_inspector_config" + `.execute(db); +} diff --git a/src/app/(private)/data-sources/[id]/DataSourceDashboard.tsx b/src/app/(private)/data-sources/[id]/DataSourceDashboard.tsx index c63c0867c..69fc6433e 100644 --- a/src/app/(private)/data-sources/[id]/DataSourceDashboard.tsx +++ b/src/app/(private)/data-sources/[id]/DataSourceDashboard.tsx @@ -30,8 +30,10 @@ import { } from "@/shadcn/ui/breadcrumb"; import { Button } from "@/shadcn/ui/button"; import { Separator } from "@/shadcn/ui/separator"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/shadcn/ui/tabs"; import ColumnMetadataForm from "./components/ColumnMetadataForm"; import ConfigurationForm from "./components/ConfigurationForm"; +import { DefaultInspectorConfigSection } from "./components/DefaultInspectorConfigSection"; import type { RouterOutputs } from "@/services/trpc/react"; export function DataSourceDashboard({ @@ -222,39 +224,50 @@ export function DataSourceDashboard({ )} -
-
-

About this data source

- - -
+ + + Datasource settings + Inspector settings + + +
+
+

About this data source

+ + +
-
-

Configuration

- - -

Danger zone

-
- +
+

Configuration

+ + +

Danger zone

+
+ +
+
-
-
+
+ + + +
); } diff --git a/src/app/(private)/data-sources/[id]/components/DefaultInspectorConfigSection.tsx b/src/app/(private)/data-sources/[id]/components/DefaultInspectorConfigSection.tsx new file mode 100644 index 000000000..70fe5ec3d --- /dev/null +++ b/src/app/(private)/data-sources/[id]/components/DefaultInspectorConfigSection.tsx @@ -0,0 +1,458 @@ +"use client"; + +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { LayoutGrid, LayoutList } from "lucide-react"; +import { useCallback, useMemo, useState } from "react"; +import { toast } from "sonner"; +import type { + DefaultInspectorBoundaryConfig, + InspectorBoundaryConfig, +} from "@/server/models/MapView"; +import { InspectorBoundaryConfigType } from "@/server/models/MapView"; +import { useTRPC } from "@/services/trpc/react"; +import { Button } from "@/shadcn/ui/button"; +import { Input } from "@/shadcn/ui/input"; +import { Label } from "@/shadcn/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/shadcn/ui/select"; +import { cn } from "@/shadcn/utils"; +import type { DataSource } from "@/server/models/DataSource"; +import { ColumnsSection } from "@/app/map/[id]/components/inspector/InspectorSettingsModal/ColumnsSection"; +import { DEFAULT_SELECT_VALUE } from "@/app/map/[id]/components/inspector/InspectorSettingsModal/constants"; +import type { InspectorLayout } from "@/app/map/[id]/components/inspector/InspectorSettingsModal/constants"; +import { + getAllColumnsSorted, + getColumnOrderState, +} from "@/app/map/[id]/components/inspector/inspectorColumnOrder"; +import { + INSPECTOR_COLOR_OPTIONS, + INSPECTOR_ICON_OPTIONS, +} from "@/app/map/[id]/components/inspector/inspectorPanelOptions"; +import { inferFormat } from "@/app/map/[id]/components/inspector/InspectorSettingsModal/constants"; +import { DefaultInspectorPreview } from "./DefaultInspectorPreview"; + +const PLACEHOLDER_ID = "__default_inspector_edit__"; + +/** Dedupe array preserving order (first occurrence wins). */ +function dedupeColumns(cols: string[]): string[] { + const seen = new Set(); + return cols.filter((c) => { + if (seen.has(c)) return false; + seen.add(c); + return true; + }); +} + +/** Dedupe columnItems: keep dividers, dedupe column names (first occurrence wins). */ +function dedupeColumnItems( + items: InspectorBoundaryConfig["columnItems"], +): InspectorBoundaryConfig["columnItems"] { + if (!items?.length) return items; + const seen = new Set(); + return items.filter((i) => { + if (typeof i === "string") { + if (seen.has(i)) return false; + seen.add(i); + return true; + } + return true; + }); +} + +function toEditingConfig( + dataSourceId: string, + defaultConfig: DefaultInspectorBoundaryConfig | null | undefined, + dataSourceName: string, +): InspectorBoundaryConfig { + if (!defaultConfig) { + return { + id: PLACEHOLDER_ID, + dataSourceId, + name: dataSourceName, + type: InspectorBoundaryConfigType.Simple, + columns: [], + columnMetadata: undefined, + columnGroups: undefined, + layout: "single", + }; + } + const columns = dedupeColumns(defaultConfig.columns ?? []); + const columnItems = dedupeColumnItems(defaultConfig.columnItems); + return { + id: PLACEHOLDER_ID, + dataSourceId, + name: defaultConfig.name, + type: defaultConfig.type, + columns, + columnOrder: defaultConfig.columnOrder, + columnItems, + columnMetadata: defaultConfig.columnMetadata, + columnGroups: defaultConfig.columnGroups, + layout: defaultConfig.layout ?? "single", + icon: defaultConfig.icon, + color: defaultConfig.color, + }; +} + +function toDefaultConfig( + config: InspectorBoundaryConfig, +): DefaultInspectorBoundaryConfig { + const { id: _id, dataSourceId: _dsId, ...rest } = config; + return rest; +} + +export function DefaultInspectorConfigSection({ + dataSource, +}: { + dataSource: DataSource; +}) { + const trpc = useTRPC(); + const queryClient = useQueryClient(); + const { mutateAsync: saveDefaultConfig } = useMutation( + trpc.dataSource.updateConfig.mutationOptions({ + onSuccess: async () => { + await queryClient.invalidateQueries({ + queryKey: trpc.dataSource.byId.queryKey({ dataSourceId: dataSource.id }), + }); + toast.success("Default inspector settings saved."); + }, + onError: (err) => { + toast.error(err.message || "Failed to save default inspector settings."); + }, + }), + ); + + const onSave = useCallback( + async (config: DefaultInspectorBoundaryConfig) => { + await saveDefaultConfig({ + dataSourceId: dataSource.id, + defaultInspectorConfig: config, + }); + }, + [dataSource.id, saveDefaultConfig], + ); + + const [localConfig, setLocalConfig] = useState(() => + toEditingConfig( + dataSource.id, + dataSource.defaultInspectorConfig, + dataSource.name, + ), + ); + const [isDirty, setIsDirty] = useState(false); + const [isSaving, setIsSaving] = useState(false); + + const handleSave = useCallback(async () => { + setIsSaving(true); + try { + await onSave(toDefaultConfig(localConfig)); + setIsDirty(false); + } finally { + setIsSaving(false); + } + }, [localConfig, onSave]); + + const handleCancel = useCallback(() => { + setLocalConfig( + toEditingConfig( + dataSource.id, + dataSource.defaultInspectorConfig, + dataSource.name, + ), + ); + setIsDirty(false); + }, [dataSource.id, dataSource.defaultInspectorConfig, dataSource.name]); + + const allColumnNames = useMemo( + () => dataSource.columnDefs.map((c) => c.name), + [dataSource.columnDefs], + ); + const allColumnsSorted = useMemo( + () => getAllColumnsSorted(allColumnNames), + [allColumnNames], + ); + const { + allColumnsInOrder, + selectedColumnsInOrder, + selectedItemsInOrder, + allItemsInOrder, + availableColumns, + availableIds, + columnIds, + } = useMemo( + () => getColumnOrderState(localConfig, allColumnNames), + [localConfig, allColumnNames], + ); + + const updateConfig = useCallback( + (updater: (prev: InspectorBoundaryConfig) => InspectorBoundaryConfig) => { + setLocalConfig((prev) => updater(prev)); + setIsDirty(true); + }, + [], + ); + + const handleAddColumn = useCallback( + (colName: string) => { + if (!allColumnNames.includes(colName)) return; + const inferred = inferFormat(colName); + updateConfig((prev) => { + if (prev.columns.includes(colName)) return prev; + const order = prev.columnOrder?.filter((c) => + allColumnNames.includes(c), + ); + const baseOrder = + order?.length === allColumnNames.length ? order : allColumnsSorted; + const newOrder = [...baseOrder.filter((c) => c !== colName), colName]; + const nextColumns = [...prev.columns, colName]; + const nextItems = prev.columnItems + ? [...prev.columnItems, colName] + : undefined; + return { + ...prev, + columns: nextColumns, + columnOrder: newOrder, + ...(nextItems && { columnItems: nextItems }), + columnMetadata: { + ...prev.columnMetadata, + [colName]: { + ...prev.columnMetadata?.[colName], + format: + prev.columnMetadata?.[colName]?.format ?? inferred ?? undefined, + }, + }, + }; + }); + }, + [updateConfig, allColumnNames, allColumnsSorted], + ); + + const handleRemoveColumn = useCallback( + (colName: string) => { + updateConfig((prev) => { + const nextColumns = prev.columns.filter((c) => c !== colName); + const nextMeta = Object.fromEntries( + Object.entries(prev.columnMetadata ?? {}).filter( + ([k]) => k !== colName, + ), + ); + const order = prev.columnOrder?.filter((c) => + allColumnNames.includes(c), + ); + const baseOrder = + order?.length === allColumnNames.length ? order : allColumnsSorted; + const newOrder = [...baseOrder.filter((c) => c !== colName), colName]; + const nextItems = prev.columnItems?.filter((i) => i !== colName); + return { + ...prev, + columns: nextColumns, + columnOrder: newOrder, + ...(nextItems !== undefined && { columnItems: nextItems }), + columnMetadata: nextMeta, + }; + }); + }, + [updateConfig, allColumnNames, allColumnsSorted], + ); + + const handleRemoveColumnFromRight = useCallback( + (colName: string) => { + updateConfig((prev) => { + const nextColumns = prev.columns.filter((c) => c !== colName); + const nextMeta = Object.fromEntries( + Object.entries(prev.columnMetadata ?? {}).filter( + ([k]) => k !== colName, + ), + ); + const newColumnOrder = [ + ...nextColumns, + ...allColumnsInOrder.filter((c) => !nextColumns.includes(c)), + ]; + const nextItems = prev.columnItems?.filter((i) => i !== colName); + return { + ...prev, + columns: nextColumns, + columnOrder: newColumnOrder, + ...(nextItems !== undefined && { columnItems: nextItems }), + columnMetadata: nextMeta, + }; + }); + }, + [updateConfig, allColumnsInOrder], + ); + + const columnMetadata = localConfig.columnMetadata ?? {}; + const layout = (localConfig.layout ?? "single") as InspectorLayout; + const panelIcon = localConfig.icon ?? undefined; + const panelColor = localConfig.color ?? undefined; + const columns = localConfig.columns ?? []; + + return ( +
+
+
+

+ Default inspector settings +

+

+ These settings are saved automatically and used when this data source + is added to the inspector on a map (yours or others’ if shared). +

+
+
+
+ + + updateConfig((prev) => ({ ...prev, name: e.target.value })) + } + placeholder="e.g. Main data" + className="max-w-sm" + /> +
+
+ + +
+
+ + +
+
+ + +
+
+ + {isDirty && ( +
+ + +
+ )} +
+
+ +
+
+ ); +} diff --git a/src/app/(private)/data-sources/[id]/components/DefaultInspectorPreview.tsx b/src/app/(private)/data-sources/[id]/components/DefaultInspectorPreview.tsx new file mode 100644 index 000000000..b62e2fef6 --- /dev/null +++ b/src/app/(private)/data-sources/[id]/components/DefaultInspectorPreview.tsx @@ -0,0 +1,146 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import { useMemo } from "react"; +import DataSourceIcon from "@/components/DataSourceIcon"; +import { getDataSourceType } from "@/components/DataSourceItem"; +import type { InspectorBoundaryConfig } from "@/server/models/MapView"; +import type { DataSource } from "@/server/models/DataSource"; +import { useTRPC } from "@/services/trpc/react"; +import TogglePanel from "@/app/map/[id]/components/TogglePanel"; +import { + getInspectorColorClass, + InspectorPanelIcon, +} from "@/app/map/[id]/components/inspector/inspectorPanelOptions"; +import PropertiesList, { type PropertyEntry } from "@/app/map/[id]/components/inspector/PropertiesList"; +import { getBarColorForLabel } from "@/app/map/[id]/components/inspector/inspectorPanelOptions"; +import { getSelectedItemsOrdered } from "@/app/map/[id]/components/inspector/inspectorColumnOrder"; +import { cn } from "@/shadcn/utils"; + +function isDivider( + item: unknown, +): item is { type: "divider"; id: string; label: string } { + return ( + typeof item === "object" && + item !== null && + (item as { type?: string }).type === "divider" + ); +} + +/** + * Reduced inspector preview for the default inspector settings section. + * Shows a single panel for the given config using the first row from the data source when available. + */ +export function DefaultInspectorPreview({ + config, + dataSource, + className, +}: { + config: InspectorBoundaryConfig; + dataSource: DataSource; + className?: string; +}) { + const trpc = useTRPC(); + const { data: listData } = useQuery( + trpc.dataRecord.list.queryOptions({ + dataSourceId: dataSource.id, + page: 0, + }), + ); + const sampleRow = listData?.records?.[0]?.json as Record | undefined; + + const allColumnNames = useMemo( + () => dataSource.columnDefs.map((c) => c.name), + [dataSource.columnDefs], + ); + + const entries = useMemo((): PropertyEntry[] => { + const items = getSelectedItemsOrdered( + config, + allColumnNames, + ); + const meta = config.columnMetadata ?? {}; + const result: PropertyEntry[] = []; + let index = 0; + for (const item of items) { + if (isDivider(item)) { + result.push({ + key: `__divider_${item.id}`, + label: item.label, + isDivider: true, + }); + } else { + const m = meta[item]; + const raw = sampleRow?.[item]; + const value = + sampleRow && item in sampleRow && raw !== undefined && raw !== null + ? raw + : "—"; + result.push({ + key: `col-${index}-${String(item)}`, + label: m?.displayName ?? item, + value, + format: m?.format, + scaleMax: m?.scaleMax, + barColor: getBarColorForLabel( + m?.displayName ?? item, + item, + index, + m?.barColor, + ), + }); + index += 1; + } + } + return result; + }, [config, allColumnNames, sampleRow]); + + const dataSourceType = getDataSourceType(dataSource); + const panelIcon = config.icon ? ( + + ) : ( + + ); + + return ( +
+
+

+ Preview +

+

+ How this data source will appear in the inspector +

+
+
+ + {config.columns?.length === 0 ? ( +

+ No columns selected +

+ ) : entries.length === 0 ? ( +

+ Add columns above to see them here +

+ ) : ( + + )} +
+
+
+ ); +} diff --git a/src/app/map/[id]/components/controls/VisualisationPanel/VisualisationPanel.tsx b/src/app/map/[id]/components/controls/VisualisationPanel/VisualisationPanel.tsx index 1810c9400..6c642ed3c 100644 --- a/src/app/map/[id]/components/controls/VisualisationPanel/VisualisationPanel.tsx +++ b/src/app/map/[id]/components/controls/VisualisationPanel/VisualisationPanel.tsx @@ -462,6 +462,32 @@ export default function VisualisationPanel({ />
)} + + {canSelectSecondaryColumn && !viewConfig.areaDataSecondaryColumn && ( +
+ +
+ )}
{!viewConfig.areaDataSourceId && (
@@ -785,94 +811,6 @@ export default function VisualisationPanel({ />
- - {/* Bivariate visualization button at the bottom */} - {canSelectSecondaryColumn && !viewConfig.areaDataSecondaryColumn && ( -
-
{ - // Find the first available numeric column - const dataSource = dataSources?.find( - (ds) => ds.id === viewConfig.areaDataSourceId, - ); - const firstNumericColumn = dataSource?.columnDefs - .filter((col) => col.type === ColumnType.Number) - .find((col) => col.name !== viewConfig.areaDataColumn); - if (firstNumericColumn) { - updateViewConfig({ - areaDataSecondaryColumn: firstNumericColumn.name, - }); - } else { - updateViewConfig({ - areaDataSecondaryColumn: undefined, - }); - } - }} - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - // Find the first available numeric column - const dataSource = dataSources?.find( - (ds) => ds.id === viewConfig.areaDataSourceId, - ); - const firstNumericColumn = dataSource?.columnDefs - .filter((col) => col.type === ColumnType.Number) - .find((col) => col.name !== viewConfig.areaDataColumn); - if (firstNumericColumn) { - updateViewConfig({ - areaDataSecondaryColumn: firstNumericColumn.name, - }); - } else { - updateViewConfig({ - areaDataSecondaryColumn: undefined, - }); - } - } - }} - > -
-
- - Create bivariate visualization - - - Using a second column - -
-
-
- Column 1 -
-
-
- {[ - ["#e8e8e8", "#ace4e4", "#5ac8c8"], - ["#dfb0d6", "#a5add3", "#5698b9"], - ["#be64ac", "#8c62aa", "#3b4994"], - ] - .reverse() - .map((row, i) => - row.map((color, j) => ( -
- )), - )} -
-
- Column 2 → -
-
-
-
-
-
- )}
)} diff --git a/src/app/map/[id]/components/inspector/BoundaryDataPanel.tsx b/src/app/map/[id]/components/inspector/BoundaryDataPanel.tsx index 8e7a811ac..e58709ac8 100644 --- a/src/app/map/[id]/components/inspector/BoundaryDataPanel.tsx +++ b/src/app/map/[id]/components/inspector/BoundaryDataPanel.tsx @@ -1,4 +1,5 @@ import { useQuery } from "@tanstack/react-query"; +import { List } from "lucide-react"; import { useMemo } from "react"; import TogglePanel from "@/app/map/[id]/components/TogglePanel"; import { useInspector } from "@/app/map/[id]/hooks/useInspector"; @@ -67,52 +68,69 @@ export function BoundaryDataPanel({ ), ); + const recordCount = data?.records.length ?? 0; + const isList = recordCount > 1; + return ( + + {recordCount} records + + ) : undefined + } > {isLoading ? (

Loading...

- ) : data?.records.length === 1 ? ( + ) : recordCount === 1 ? ( - ) : data?.records.length ? ( -
    - {data.records.map((d, i) => ( -
  • - - - -
  • - ))} -
+ ) : isList ? ( +
+

+ {recordCount} records in this area +

+
    + {data!.records.map((d, i) => ( +
  • + + + +
  • + ))} +
+
) : (

No data available

diff --git a/src/app/map/[id]/components/inspector/InspectorDataTab.tsx b/src/app/map/[id]/components/inspector/InspectorDataTab.tsx index a210c4924..927c4080b 100644 --- a/src/app/map/[id]/components/inspector/InspectorDataTab.tsx +++ b/src/app/map/[id]/components/inspector/InspectorDataTab.tsx @@ -21,7 +21,11 @@ import { useDisplayAreaStat } from "../../hooks/useDisplayAreaStats"; import { useSelectedSecondaryArea } from "../../hooks/useSelectedSecondaryArea"; import DataSourceSelectButton from "../DataSourceSelectButton"; import { BoundaryDataPanel } from "./BoundaryDataPanel"; -import { getSelectedColumnsOrdered } from "./inspectorColumnOrder"; +import { + dedupeColumns, + dedupeColumnItems, + getSelectedColumnsOrdered, +} from "./inspectorColumnOrder"; import InspectorOnMapSection from "./InspectorOnMapSection"; import PropertiesList from "./PropertiesList"; import type { SelectedRecord } from "@/app/map/[id]/types/inspector"; @@ -56,12 +60,22 @@ export default function InspectorDataTab({ (dataSourceId: string) => { if (!view) return; const ds = getDataSourceById(dataSourceId); + const defaultConfig = ds?.defaultInspectorConfig; + const columns = dedupeColumns(defaultConfig?.columns ?? []); + const columnItems = dedupeColumnItems(defaultConfig?.columnItems); const newBoundaryConfig: InspectorBoundaryConfig = { id: uuidv4(), dataSourceId, - name: ds?.name || "Boundary Data", - type: InspectorBoundaryConfigType.Simple, - columns: [], + name: defaultConfig?.name ?? ds?.name ?? "Boundary Data", + type: defaultConfig?.type ?? InspectorBoundaryConfigType.Simple, + columns, + columnOrder: defaultConfig?.columnOrder, + columnItems, + columnMetadata: defaultConfig?.columnMetadata, + columnGroups: defaultConfig?.columnGroups, + layout: defaultConfig?.layout ?? "single", + icon: defaultConfig?.icon, + color: defaultConfig?.color, }; const prev = view.inspectorConfig?.boundaries || []; updateView({ @@ -175,7 +189,7 @@ export default function InspectorDataTab({
) : (
- {boundaryData.map((item, index) => ( + {boundaryData.map((item) => ( ))}
diff --git a/src/app/map/[id]/components/inspector/InspectorSettingsModal/DroppableSelectedColumns.tsx b/src/app/map/[id]/components/inspector/InspectorSettingsModal/DroppableSelectedColumns.tsx index f7022ab9b..88fbd61e2 100644 --- a/src/app/map/[id]/components/inspector/InspectorSettingsModal/DroppableSelectedColumns.tsx +++ b/src/app/map/[id]/components/inspector/InspectorSettingsModal/DroppableSelectedColumns.tsx @@ -26,7 +26,7 @@ export function DroppableSelectedColumns({ const meta = columnMetadata ?? {}; const { setNodeRef, isOver } = useDroppable({ id: SELECTED_DROPPABLE_ID }); const columnIds = useMemo( - () => columns.map((c) => `col-${c}`), + () => columns.map((c, i) => `col-${i}-${c}`), [columns], ); @@ -49,10 +49,10 @@ export function DroppableSelectedColumns({ ) : (
- {columns.map((col) => ( + {columns.map((col, i) => ( @@ -131,7 +131,7 @@ export function DroppableSelectedColumns({ return { ...prev, ...base }; }) } - isDragging={activeId === `col-${col}`} + isDragging={activeId === columnIds[i]} /> ))}
diff --git a/src/app/map/[id]/components/inspector/InspectorSettingsModal/InspectorSettingsModal.tsx b/src/app/map/[id]/components/inspector/InspectorSettingsModal/InspectorSettingsModal.tsx index f8069887c..0713f45de 100644 --- a/src/app/map/[id]/components/inspector/InspectorSettingsModal/InspectorSettingsModal.tsx +++ b/src/app/map/[id]/components/inspector/InspectorSettingsModal/InspectorSettingsModal.tsx @@ -14,6 +14,7 @@ import { useDebouncedValue } from "../../../hooks/useDebouncedValue"; import { useMapViews } from "../../../hooks/useMapViews"; import { InspectorBoundaryConfigType } from "@/server/models/MapView"; import type { DataSource } from "@/server/models/DataSource"; +import { dedupeColumns, dedupeColumnItems } from "../inspectorColumnOrder"; import { DataSourcesList } from "./DataSourcesList"; import { InspectorFullPreview } from "../InspectorFullPreview"; import { InspectorSourceConfigPanel } from "./InspectorSourceConfigPanel"; @@ -114,15 +115,22 @@ export default function InspectorSettingsModal({ const handleAddToInspector = useCallback(() => { if (!view || !selectedDataSourceId) return; const ds = (dataSources ?? []).find((d) => d.id === selectedDataSourceId); + const defaultConfig = ds?.defaultInspectorConfig; + const columns = dedupeColumns(defaultConfig?.columns ?? []); + const columnItems = dedupeColumnItems(defaultConfig?.columnItems); const newConfig: InspectorBoundaryConfig = { id: uuidv4(), dataSourceId: selectedDataSourceId, - name: ds?.name ?? "Boundary Data", - type: InspectorBoundaryConfigType.Simple, - columns: [], - columnMetadata: undefined, - columnGroups: undefined, - layout: "single", + name: defaultConfig?.name ?? ds?.name ?? "Boundary Data", + type: defaultConfig?.type ?? InspectorBoundaryConfigType.Simple, + columns, + columnOrder: defaultConfig?.columnOrder, + columnItems, + columnMetadata: defaultConfig?.columnMetadata, + columnGroups: defaultConfig?.columnGroups, + layout: defaultConfig?.layout ?? "single", + icon: defaultConfig?.icon, + color: defaultConfig?.color, }; const prev = view.inspectorConfig?.boundaries ?? []; updateView({ diff --git a/src/app/map/[id]/components/inspector/InspectorSettingsModal/InspectorSourceConfigPanel.tsx b/src/app/map/[id]/components/inspector/InspectorSettingsModal/InspectorSourceConfigPanel.tsx index 17cfb3f98..a2e52718b 100644 --- a/src/app/map/[id]/components/inspector/InspectorSettingsModal/InspectorSourceConfigPanel.tsx +++ b/src/app/map/[id]/components/inspector/InspectorSettingsModal/InspectorSourceConfigPanel.tsx @@ -12,7 +12,6 @@ import { SelectTrigger, SelectValue, } from "@/shadcn/ui/select"; -import { Switch } from "@/shadcn/ui/switch"; import { cn } from "@/shadcn/utils"; import { useDebouncedCallback } from "../../../hooks/useDebouncedCallback"; import { @@ -98,17 +97,19 @@ export function InspectorSourceConfigPanel({ const handleAddColumn = useCallback( (colName: string) => { + if (!allColumnNames.includes(colName)) return; const inferred = inferFormat(colName); updateConfig((prev) => { + if (prev.columns.includes(colName)) return prev; const order = prev.columnOrder?.filter((c) => allColumnNames.includes(c), ); const baseOrder = order?.length === allColumnNames.length ? order : allColumnsSorted; - const newOrder = [colName, ...baseOrder.filter((c) => c !== colName)]; - const nextColumns = [colName, ...prev.columns]; + const newOrder = [...baseOrder.filter((c) => c !== colName), colName]; + const nextColumns = [...prev.columns, colName]; const nextItems = prev.columnItems - ? [colName, ...prev.columnItems] + ? [...prev.columnItems, colName] : undefined; return { ...prev, @@ -211,8 +212,8 @@ export function InspectorSourceConfigPanel({ return (
-
-
+
+
-
+
-
+
-
+
-
- - - updateConfig((prev) => ({ - ...prev, - layout: checked ? "twoColumn" : "single", - })) - } - /> - -
-

- {layout === "single" ? "Single column" : "Two-column grid"} -

+
@@ -325,7 +334,7 @@ export function InspectorSourceConfigPanel({ handleRemoveColumn={handleRemoveColumn} handleRemoveColumnFromRight={handleRemoveColumnFromRight} /> -
-
+
+
); } diff --git a/src/app/map/[id]/components/inspector/inspectorColumnOrder.ts b/src/app/map/[id]/components/inspector/inspectorColumnOrder.ts index e962d003d..6392da798 100644 --- a/src/app/map/[id]/components/inspector/inspectorColumnOrder.ts +++ b/src/app/map/[id]/components/inspector/inspectorColumnOrder.ts @@ -5,6 +5,37 @@ import type { InspectorColumnItem, } from "@/server/models/MapView"; +/** + * Dedupe columns preserving order (first occurrence wins). + * Use when loading or applying config to avoid duplicate column entries. + */ +export function dedupeColumns(cols: string[]): string[] { + const seen = new Set(); + return cols.filter((c) => { + if (seen.has(c)) return false; + seen.add(c); + return true; + }); +} + +/** + * Dedupe columnItems: keep dividers, dedupe column names (first occurrence wins). + */ +export function dedupeColumnItems( + items: InspectorBoundaryConfig["columnItems"], +): InspectorBoundaryConfig["columnItems"] { + if (!items?.length) return items; + const seen = new Set(); + return items.filter((i) => { + if (typeof i === "string") { + if (seen.has(i)) return false; + seen.add(i); + return true; + } + return true; + }); +} + /** * Single source of truth for inspector column order. * - allColumnsInOrder: full list in display order (columnOrder when valid, else selected first then alphabetical) diff --git a/src/server/models/DataSource.ts b/src/server/models/DataSource.ts index 656548dcb..e856c5c4d 100644 --- a/src/server/models/DataSource.ts +++ b/src/server/models/DataSource.ts @@ -1,5 +1,6 @@ import z from "zod"; import { AreaSetCode } from "./AreaSet"; +import { defaultInspectorBoundaryConfigSchema } from "./MapView"; import type { Generated, Insertable, @@ -255,6 +256,9 @@ export const dataSourceSchema = z.object({ createdAt: z.date(), dateFormat: z.string(), naIsNull: z.boolean().optional(), + defaultInspectorConfig: defaultInspectorBoundaryConfigSchema + .nullable() + .optional(), }); export type DataSource = z.infer; diff --git a/src/server/models/MapView.ts b/src/server/models/MapView.ts index 9def87e19..0c70712b3 100644 --- a/src/server/models/MapView.ts +++ b/src/server/models/MapView.ts @@ -265,6 +265,20 @@ export type InspectorBoundaryConfig = z.infer< typeof inspectorBoundaryConfigSchema >; +/** + * Template for default inspector settings stored on a data source. + * When someone adds this data source to the inspector, these settings are applied. + * Omits id and dataSourceId (set when adding to a view). + */ +export const defaultInspectorBoundaryConfigSchema = + inspectorBoundaryConfigSchema.omit({ + id: true, + dataSourceId: true, + }); +export type DefaultInspectorBoundaryConfig = z.infer< + typeof defaultInspectorBoundaryConfigSchema +>; + /** * Complete inspector configuration for a map view * Organized by aspect (boundaries, markers, members, etc.) diff --git a/src/server/services/database/schema.ts b/src/server/services/database/schema.ts index 427b05764..b2ce6b0aa 100644 --- a/src/server/services/database/schema.ts +++ b/src/server/services/database/schema.ts @@ -39,7 +39,7 @@ export interface Area { name: string; // text, NOT NULL geography: unknown; // geography (PostGIS), NOT NULL areaSetId: number; // bigint, NOT NULL - geom: unknown; // geometry(MultiPolygon,4326), GENERATED ALWAYS AS ((geography)::geometry) STORED, NOT NULL + geom: unknown; // geometry(Geometry,4326), GENERATED ALWAYS AS ((geography)::geometry) STORED, NOT NULL // CONSTRAINTS: // - UNIQUE (code, areaSetId) @@ -138,6 +138,7 @@ export interface DataSource { dateFormat: string; // text, NOT NULL, DEFAULT 'yyyy-MM-dd' recordCount: number; // integer, NOT NULL, DEFAULT 0 createdAt: Date; // timestamp, DEFAULT CURRENT_TIMESTAMP, NOT NULL + defaultInspectorConfig: unknown | null; // jsonb, NULL – default inspector settings for public data sources // FOREIGN KEYS: // - organisationId -> organisation.id (CASCADE DELETE, CASCADE UPDATE) diff --git a/src/server/trpc/routers/dataSource.ts b/src/server/trpc/routers/dataSource.ts index 99fb59d37..18fb288bb 100644 --- a/src/server/trpc/routers/dataSource.ts +++ b/src/server/trpc/routers/dataSource.ts @@ -239,6 +239,7 @@ export const dataSourceRouter = router({ dateFormat: input.dateFormat, public: input.public, naIsNull: input.naIsNull, + defaultInspectorConfig: input.defaultInspectorConfig, } as DataSourceUpdate; logger.info( From 47656d4991ef5cf960c73a829966ce1262d7384a Mon Sep 17 00:00:00 2001 From: Arbyhisenaj <41119392+Arbyhisenaj@users.noreply.github.com> Date: Fri, 27 Feb 2026 12:58:32 +0000 Subject: [PATCH 11/21] save inspector settings as default --- ...rce_default_inspector_config_updated_at.ts | 18 + .../data-sources/[id]/DataSourceDashboard.tsx | 40 ++- .../DefaultInspectorConfigSection.tsx | 337 +++++++++--------- .../components/DefaultInspectorPreview.tsx | 8 +- src/app/(private)/data-sources/[id]/page.tsx | 2 +- src/app/map/[id]/components/MapWrapper.tsx | 5 +- .../inspector/BoundaryDataPanel.tsx | 7 +- .../components/inspector/InspectorDataTab.tsx | 19 +- .../components/inspector/InspectorPanel.tsx | 31 +- .../components/inspector/InspectorPreview.tsx | 2 +- .../DroppableSelectedColumns.tsx | 13 + .../InspectorSettingsModal.tsx | 22 +- .../InspectorSourceConfigPanel.tsx | 77 +++- .../inspector/SortableColumnRow.tsx | 28 ++ .../inspector/inspectorColumnOrder.ts | 142 +++++--- src/server/models/DataSource.ts | 1 + src/server/models/MapView.ts | 2 + src/server/services/database/schema.ts | 1 + src/server/trpc/routers/dataSource.ts | 11 +- 19 files changed, 472 insertions(+), 294 deletions(-) create mode 100644 migrations/1772030000000_data_source_default_inspector_config_updated_at.ts diff --git a/migrations/1772030000000_data_source_default_inspector_config_updated_at.ts b/migrations/1772030000000_data_source_default_inspector_config_updated_at.ts new file mode 100644 index 000000000..c62e0e153 --- /dev/null +++ b/migrations/1772030000000_data_source_default_inspector_config_updated_at.ts @@ -0,0 +1,18 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import type { Kysely } from "kysely"; + +export async function up(db: Kysely): Promise { + await db.schema + .alterTable("dataSource") + .addColumn("defaultInspectorConfigUpdatedAt", "timestamptz", (col) => + col.defaultTo(null), + ) + .execute(); +} + +export async function down(db: Kysely): Promise { + await db.schema + .alterTable("dataSource") + .dropColumn("defaultInspectorConfigUpdatedAt") + .execute(); +} diff --git a/src/app/(private)/data-sources/[id]/DataSourceDashboard.tsx b/src/app/(private)/data-sources/[id]/DataSourceDashboard.tsx index 69fc6433e..6e0bb183e 100644 --- a/src/app/(private)/data-sources/[id]/DataSourceDashboard.tsx +++ b/src/app/(private)/data-sources/[id]/DataSourceDashboard.tsx @@ -31,7 +31,6 @@ import { import { Button } from "@/shadcn/ui/button"; import { Separator } from "@/shadcn/ui/separator"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/shadcn/ui/tabs"; -import ColumnMetadataForm from "./components/ColumnMetadataForm"; import ConfigurationForm from "./components/ConfigurationForm"; import { DefaultInspectorConfigSection } from "./components/DefaultInspectorConfigSection"; import type { RouterOutputs } from "@/services/trpc/react"; @@ -41,6 +40,7 @@ export function DataSourceDashboard({ }: { dataSource: NonNullable; }) { + const [dataSourceTab, setDataSourceTab] = useState("datasource"); const [importing, setImporting] = useState(isImporting(dataSource)); const [importError, setImportError] = useState(""); const [lastImported, setLastImported] = useState( @@ -224,13 +224,32 @@ export function DataSourceDashboard({ )} - + - Datasource settings + + Datasource import settings + Inspector settings
+
+

Configuration

+ + +

Danger zone

+
+ +
+

About this data source

- -
- -
-

Configuration

- - -

Danger zone

-
- -
diff --git a/src/app/(private)/data-sources/[id]/components/DefaultInspectorConfigSection.tsx b/src/app/(private)/data-sources/[id]/components/DefaultInspectorConfigSection.tsx index 70fe5ec3d..01e1f67c9 100644 --- a/src/app/(private)/data-sources/[id]/components/DefaultInspectorConfigSection.tsx +++ b/src/app/(private)/data-sources/[id]/components/DefaultInspectorConfigSection.tsx @@ -28,6 +28,7 @@ import type { InspectorLayout } from "@/app/map/[id]/components/inspector/Inspec import { getAllColumnsSorted, getColumnOrderState, + normalizeInspectorBoundaryConfig, } from "@/app/map/[id]/components/inspector/inspectorColumnOrder"; import { INSPECTOR_COLOR_OPTIONS, @@ -38,32 +39,6 @@ import { DefaultInspectorPreview } from "./DefaultInspectorPreview"; const PLACEHOLDER_ID = "__default_inspector_edit__"; -/** Dedupe array preserving order (first occurrence wins). */ -function dedupeColumns(cols: string[]): string[] { - const seen = new Set(); - return cols.filter((c) => { - if (seen.has(c)) return false; - seen.add(c); - return true; - }); -} - -/** Dedupe columnItems: keep dividers, dedupe column names (first occurrence wins). */ -function dedupeColumnItems( - items: InspectorBoundaryConfig["columnItems"], -): InspectorBoundaryConfig["columnItems"] { - if (!items?.length) return items; - const seen = new Set(); - return items.filter((i) => { - if (typeof i === "string") { - if (seen.has(i)) return false; - seen.add(i); - return true; - } - return true; - }); -} - function toEditingConfig( dataSourceId: string, defaultConfig: DefaultInspectorBoundaryConfig | null | undefined, @@ -81,16 +56,14 @@ function toEditingConfig( layout: "single", }; } - const columns = dedupeColumns(defaultConfig.columns ?? []); - const columnItems = dedupeColumnItems(defaultConfig.columnItems); return { id: PLACEHOLDER_ID, dataSourceId, name: defaultConfig.name, type: defaultConfig.type, - columns, + columns: defaultConfig.columns ?? [], columnOrder: defaultConfig.columnOrder, - columnItems, + columnItems: defaultConfig.columnItems, columnMetadata: defaultConfig.columnMetadata, columnGroups: defaultConfig.columnGroups, layout: defaultConfig.layout ?? "single", @@ -117,12 +90,16 @@ export function DefaultInspectorConfigSection({ trpc.dataSource.updateConfig.mutationOptions({ onSuccess: async () => { await queryClient.invalidateQueries({ - queryKey: trpc.dataSource.byId.queryKey({ dataSourceId: dataSource.id }), + queryKey: trpc.dataSource.byId.queryKey({ + dataSourceId: dataSource.id, + }), }); toast.success("Default inspector settings saved."); }, onError: (err) => { - toast.error(err.message || "Failed to save default inspector settings."); + toast.error( + err.message || "Failed to save default inspector settings.", + ); }, }), ); @@ -137,13 +114,19 @@ export function DefaultInspectorConfigSection({ [dataSource.id, saveDefaultConfig], ); - const [localConfig, setLocalConfig] = useState(() => - toEditingConfig( + const allColumnNames = useMemo( + () => dataSource.columnDefs.map((c) => c.name), + [dataSource.columnDefs], + ); + const [localConfig, setLocalConfig] = useState(() => { + const raw = toEditingConfig( dataSource.id, dataSource.defaultInspectorConfig, dataSource.name, - ), - ); + ); + const allCols = dataSource.columnDefs.map((c) => c.name); + return normalizeInspectorBoundaryConfig(raw, allCols) ?? raw; + }); const [isDirty, setIsDirty] = useState(false); const [isSaving, setIsSaving] = useState(false); @@ -158,20 +141,22 @@ export function DefaultInspectorConfigSection({ }, [localConfig, onSave]); const handleCancel = useCallback(() => { + const raw = toEditingConfig( + dataSource.id, + dataSource.defaultInspectorConfig, + dataSource.name, + ); setLocalConfig( - toEditingConfig( - dataSource.id, - dataSource.defaultInspectorConfig, - dataSource.name, - ), + normalizeInspectorBoundaryConfig(raw, allColumnNames) ?? raw, ); setIsDirty(false); - }, [dataSource.id, dataSource.defaultInspectorConfig, dataSource.name]); + }, [ + dataSource.id, + dataSource.defaultInspectorConfig, + dataSource.name, + allColumnNames, + ]); - const allColumnNames = useMemo( - () => dataSource.columnDefs.map((c) => c.name), - [dataSource.columnDefs], - ); const allColumnsSorted = useMemo( () => getAllColumnsSorted(allColumnNames), [allColumnNames], @@ -191,10 +176,13 @@ export function DefaultInspectorConfigSection({ const updateConfig = useCallback( (updater: (prev: InspectorBoundaryConfig) => InspectorBoundaryConfig) => { - setLocalConfig((prev) => updater(prev)); + setLocalConfig((prev) => { + const next = updater(prev); + return normalizeInspectorBoundaryConfig(next, allColumnNames) ?? next; + }); setIsDirty(true); }, - [], + [allColumnNames], ); const handleAddColumn = useCallback( @@ -300,136 +288,16 @@ export function DefaultInspectorConfigSection({ Default inspector settings

- These settings are saved automatically and used when this data source - is added to the inspector on a map (yours or others’ if shared). + The inspector is panel that appears alongside the map to display + data from this data source. You can edit how this data appears by + changing the settings here. These settings are saved automatically + and used when this data source is added to the inspector on a map + (yours or others’ if shared).

-
-
- - - updateConfig((prev) => ({ ...prev, name: e.target.value })) - } - placeholder="e.g. Main data" - className="max-w-sm" - /> -
-
- - -
-
- - -
-
- - -
-
- {isDirty && ( -
-
)} +
+
+ + + updateConfig((prev) => ({ ...prev, name: e.target.value })) + } + placeholder="e.g. Main data" + className="max-w-sm" + /> +
+
+ + +
+
+ + +
+
+ + +
+
+
) : ( - + + + ); return ( diff --git a/src/app/(private)/data-sources/[id]/page.tsx b/src/app/(private)/data-sources/[id]/page.tsx index 410f87fc8..eb69911da 100644 --- a/src/app/(private)/data-sources/[id]/page.tsx +++ b/src/app/(private)/data-sources/[id]/page.tsx @@ -23,7 +23,7 @@ export default function DataSourcePage({ ), ); - if (isFetching) { + if (isFetching && !dataSource) { return (
diff --git a/src/app/map/[id]/components/MapWrapper.tsx b/src/app/map/[id]/components/MapWrapper.tsx index b72a330f6..cd58b77c2 100644 --- a/src/app/map/[id]/components/MapWrapper.tsx +++ b/src/app/map/[id]/components/MapWrapper.tsx @@ -28,8 +28,9 @@ export default function MapWrapper({ }) { const showControls = useShowControls(); const { viewConfig } = useMapViews(); - const { inspectorContent } = useInspector(); - const inspectorVisible = Boolean(inspectorContent); + useInspector(); + // Inspector panel is always visible (open by default); reserve space for it + const inspectorVisible = true; const { boundariesPanelOpen } = useChoropleth(); const compareGeographiesMode = useCompareGeographiesMode(); const { diff --git a/src/app/map/[id]/components/inspector/BoundaryDataPanel.tsx b/src/app/map/[id]/components/inspector/BoundaryDataPanel.tsx index e58709ac8..0ba1e7994 100644 --- a/src/app/map/[id]/components/inspector/BoundaryDataPanel.tsx +++ b/src/app/map/[id]/components/inspector/BoundaryDataPanel.tsx @@ -174,7 +174,9 @@ function BoundaryDataProperties({ const meta = columnMetadata ?? {}; const columnsSet = new Set(columns); - if (columnItems?.length) { + // Use columnItems order only when it contains dividers (matches settings panel). + // Otherwise use the columns prop order (already from getSelectedColumnsOrdered). + if (columnItems?.length && columnItems.some(isColumnItemDivider)) { const ordered: PropertyEntry[] = []; let currentGroupLabel: string | undefined; for (const item of columnItems) { @@ -202,6 +204,7 @@ function BoundaryDataProperties({ ordered.length, m?.barColor, ), + description: m?.description, }); } } @@ -232,6 +235,7 @@ function BoundaryDataProperties({ ordered.length, m?.barColor, ), + description: m?.description, }); }); }); @@ -247,6 +251,7 @@ function BoundaryDataProperties({ format: m?.format, scaleMax: m?.scaleMax, barColor: getBarColorForLabel(label, col, ordered.length, m?.barColor), + description: m?.description, }); }); return ordered; diff --git a/src/app/map/[id]/components/inspector/InspectorDataTab.tsx b/src/app/map/[id]/components/inspector/InspectorDataTab.tsx index 927c4080b..d6b4239bd 100644 --- a/src/app/map/[id]/components/inspector/InspectorDataTab.tsx +++ b/src/app/map/[id]/components/inspector/InspectorDataTab.tsx @@ -22,9 +22,8 @@ import { useSelectedSecondaryArea } from "../../hooks/useSelectedSecondaryArea"; import DataSourceSelectButton from "../DataSourceSelectButton"; import { BoundaryDataPanel } from "./BoundaryDataPanel"; import { - dedupeColumns, - dedupeColumnItems, getSelectedColumnsOrdered, + normalizeInspectorBoundaryConfig, } from "./inspectorColumnOrder"; import InspectorOnMapSection from "./InspectorOnMapSection"; import PropertiesList from "./PropertiesList"; @@ -60,23 +59,25 @@ export default function InspectorDataTab({ (dataSourceId: string) => { if (!view) return; const ds = getDataSourceById(dataSourceId); - const defaultConfig = ds?.defaultInspectorConfig; - const columns = dedupeColumns(defaultConfig?.columns ?? []); - const columnItems = dedupeColumnItems(defaultConfig?.columnItems); - const newBoundaryConfig: InspectorBoundaryConfig = { + if (!ds) return; + const defaultConfig = ds.defaultInspectorConfig; + const allCols = ds.columnDefs.map((c) => c.name); + const raw: InspectorBoundaryConfig = { id: uuidv4(), dataSourceId, - name: defaultConfig?.name ?? ds?.name ?? "Boundary Data", + name: defaultConfig?.name ?? ds.name ?? "Boundary Data", type: defaultConfig?.type ?? InspectorBoundaryConfigType.Simple, - columns, + columns: defaultConfig?.columns ?? [], columnOrder: defaultConfig?.columnOrder, - columnItems, + columnItems: defaultConfig?.columnItems, columnMetadata: defaultConfig?.columnMetadata, columnGroups: defaultConfig?.columnGroups, layout: defaultConfig?.layout ?? "single", icon: defaultConfig?.icon, color: defaultConfig?.color, }; + const newBoundaryConfig = + normalizeInspectorBoundaryConfig(raw, allCols) ?? raw; const prev = view.inspectorConfig?.boundaries || []; updateView({ ...view, diff --git a/src/app/map/[id]/components/inspector/InspectorPanel.tsx b/src/app/map/[id]/components/inspector/InspectorPanel.tsx index 6f6981c4a..b37e49f05 100644 --- a/src/app/map/[id]/components/inspector/InspectorPanel.tsx +++ b/src/app/map/[id]/components/inspector/InspectorPanel.tsx @@ -78,8 +78,35 @@ export default function InspectorPanel({ return activeTab; }, [activeTab, hasConfig, hasData, hasMarkers]); - if (!Boolean(inspectorContent)) { - return <>; + const isEmpty = !Boolean(inspectorContent); + + if (isEmpty) { + return ( +
+
+

Inspector

+

+ Select a marker, area or boundary (via the data visualisation + panel) to inspect its data +

+
+
+ ); } const isDetailsView = Boolean( diff --git a/src/app/map/[id]/components/inspector/InspectorPreview.tsx b/src/app/map/[id]/components/inspector/InspectorPreview.tsx index 03a8cd2ac..d44b3f109 100644 --- a/src/app/map/[id]/components/inspector/InspectorPreview.tsx +++ b/src/app/map/[id]/components/inspector/InspectorPreview.tsx @@ -41,7 +41,7 @@ export function InspectorPreview({ : undefined} + icon={type ? : undefined} defaultExpanded={true} >
diff --git a/src/app/map/[id]/components/inspector/InspectorSettingsModal/DroppableSelectedColumns.tsx b/src/app/map/[id]/components/inspector/InspectorSettingsModal/DroppableSelectedColumns.tsx index 88fbd61e2..5d3206806 100644 --- a/src/app/map/[id]/components/inspector/InspectorSettingsModal/DroppableSelectedColumns.tsx +++ b/src/app/map/[id]/components/inspector/InspectorSettingsModal/DroppableSelectedColumns.tsx @@ -67,6 +67,19 @@ export function DroppableSelectedColumns({ }, })) } + description={meta[col]?.description} + onDescriptionChange={(value) => + updateConfig((prev) => ({ + ...prev, + columnMetadata: { + ...(prev.columnMetadata ?? {}), + [col]: { + ...(prev.columnMetadata?.[col] ?? {}), + description: value || undefined, + }, + }, + })) + } format={meta[col]?.format ?? "text"} onFormatChange={(format) => updateConfig((prev) => ({ diff --git a/src/app/map/[id]/components/inspector/InspectorSettingsModal/InspectorSettingsModal.tsx b/src/app/map/[id]/components/inspector/InspectorSettingsModal/InspectorSettingsModal.tsx index 0713f45de..1b5f5bd81 100644 --- a/src/app/map/[id]/components/inspector/InspectorSettingsModal/InspectorSettingsModal.tsx +++ b/src/app/map/[id]/components/inspector/InspectorSettingsModal/InspectorSettingsModal.tsx @@ -14,7 +14,7 @@ import { useDebouncedValue } from "../../../hooks/useDebouncedValue"; import { useMapViews } from "../../../hooks/useMapViews"; import { InspectorBoundaryConfigType } from "@/server/models/MapView"; import type { DataSource } from "@/server/models/DataSource"; -import { dedupeColumns, dedupeColumnItems } from "../inspectorColumnOrder"; +import { normalizeInspectorBoundaryConfig } from "../inspectorColumnOrder"; import { DataSourcesList } from "./DataSourcesList"; import { InspectorFullPreview } from "../InspectorFullPreview"; import { InspectorSourceConfigPanel } from "./InspectorSourceConfigPanel"; @@ -107,7 +107,7 @@ export default function InspectorSettingsModal({ () => selectedDataSourceId ? ((dataSources ?? []).find((ds) => ds.id === selectedDataSourceId) ?? - null) + null) : null, [selectedDataSourceId, dataSources], ); @@ -115,23 +115,25 @@ export default function InspectorSettingsModal({ const handleAddToInspector = useCallback(() => { if (!view || !selectedDataSourceId) return; const ds = (dataSources ?? []).find((d) => d.id === selectedDataSourceId); - const defaultConfig = ds?.defaultInspectorConfig; - const columns = dedupeColumns(defaultConfig?.columns ?? []); - const columnItems = dedupeColumnItems(defaultConfig?.columnItems); - const newConfig: InspectorBoundaryConfig = { + if (!ds) return; + const defaultConfig = ds.defaultInspectorConfig; + const allCols = ds.columnDefs.map((c) => c.name); + const raw: InspectorBoundaryConfig = { id: uuidv4(), dataSourceId: selectedDataSourceId, - name: defaultConfig?.name ?? ds?.name ?? "Boundary Data", + name: defaultConfig?.name ?? ds.name ?? "Boundary Data", type: defaultConfig?.type ?? InspectorBoundaryConfigType.Simple, - columns, + columns: defaultConfig?.columns ?? [], columnOrder: defaultConfig?.columnOrder, - columnItems, + columnItems: defaultConfig?.columnItems, columnMetadata: defaultConfig?.columnMetadata, columnGroups: defaultConfig?.columnGroups, layout: defaultConfig?.layout ?? "single", icon: defaultConfig?.icon, color: defaultConfig?.color, }; + const newConfig = + normalizeInspectorBoundaryConfig(raw, allCols) ?? raw; const prev = view.inspectorConfig?.boundaries ?? []; updateView({ ...view, @@ -146,7 +148,7 @@ export default function InspectorSettingsModal({ setSelectedDataSourceId(null)} > diff --git a/src/app/map/[id]/components/inspector/InspectorSettingsModal/InspectorSourceConfigPanel.tsx b/src/app/map/[id]/components/inspector/InspectorSettingsModal/InspectorSourceConfigPanel.tsx index a2e52718b..523d1b5a3 100644 --- a/src/app/map/[id]/components/inspector/InspectorSettingsModal/InspectorSourceConfigPanel.tsx +++ b/src/app/map/[id]/components/inspector/InspectorSettingsModal/InspectorSourceConfigPanel.tsx @@ -1,8 +1,10 @@ "use client"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; import { LayoutGrid, LayoutList, PlusIcon } from "lucide-react"; import { useCallback, useEffect, useMemo, useState } from "react"; -import type { InspectorBoundaryConfig } from "@/server/models/MapView"; +import { toast } from "sonner"; +import { useTRPC } from "@/services/trpc/react"; import { Input } from "@/shadcn/ui/input"; import { Label } from "@/shadcn/ui/label"; import { @@ -17,16 +19,32 @@ import { useDebouncedCallback } from "../../../hooks/useDebouncedCallback"; import { getAllColumnsSorted, getColumnOrderState, + normalizeInspectorBoundaryConfig, } from "../inspectorColumnOrder"; import { INSPECTOR_COLOR_OPTIONS, INSPECTOR_ICON_OPTIONS, } from "../inspectorPanelOptions"; -import type { DataSource } from "@/server/models/DataSource"; import { ColumnsSection } from "./ColumnsSection"; -import { DEFAULT_SELECT_VALUE } from "./constants"; -import { inferFormat } from "./constants"; +import { DEFAULT_SELECT_VALUE, inferFormat } from "./constants"; import type { InspectorLayout } from "./constants"; +import type { useMapViews } from "../../../hooks/useMapViews"; +import type { DataSource } from "@/server/models/DataSource"; +import type { + DefaultInspectorBoundaryConfig, + InspectorBoundaryConfig, +} from "@/server/models/MapView"; + +export type ReadableDataSource = DataSource & { isOwner?: boolean }; + +function toDefaultConfig( + config: InspectorBoundaryConfig, +): DefaultInspectorBoundaryConfig { + const { id: _unusedId, dataSourceId: _unusedDsId, ...rest } = config; + void _unusedId; + void _unusedDsId; + return rest; +} export function InspectorSourceConfigPanel({ dataSource, @@ -36,17 +54,40 @@ export function InspectorSourceConfigPanel({ getLatestView, updateView, }: { - dataSource: DataSource; + dataSource: ReadableDataSource; config: InspectorBoundaryConfig | null; onAddToInspector: () => void; isInInspector: boolean; - getLatestView: ReturnType< - typeof import("../../../hooks/useMapViews").useMapViews - >["getLatestView"]; - updateView: ReturnType< - typeof import("../../../hooks/useMapViews").useMapViews - >["updateView"]; + getLatestView: ReturnType["getLatestView"]; + updateView: ReturnType["updateView"]; }) { + const trpc = useTRPC(); + const queryClient = useQueryClient(); + const { mutateAsync: saveAsDefault } = useMutation( + trpc.dataSource.updateConfig.mutationOptions({ + onSuccess: async () => { + await queryClient.invalidateQueries({ + queryKey: trpc.dataSource.listReadable.queryKey(), + }); + toast.success("Default inspector settings updated."); + }, + onError: (err) => { + toast.error( + err.message ?? "Failed to save as default inspector settings.", + ); + }, + }), + ); + const debouncedSaveAsDefault = useDebouncedCallback( + (cfg: InspectorBoundaryConfig) => { + if (!dataSource.isOwner) return; + saveAsDefault({ + dataSourceId: dataSource.id, + defaultInspectorConfig: toDefaultConfig(cfg), + }); + }, + 1500, + ); const columns = config?.columns ?? []; const allColumnNames = useMemo( () => dataSource.columnDefs.map((c) => c.name), @@ -78,14 +119,24 @@ export function InspectorSourceConfigPanel({ const index = boundaries.findIndex((c) => c.id === config.id); if (index < 0) return; const updated = updater(boundaries[index]); + const normalized = + normalizeInspectorBoundaryConfig(updated, allColumnNames) ?? updated; const next = [...boundaries]; - next[index] = updated; + next[index] = normalized; updateView({ ...latestView, inspectorConfig: { ...latestView.inspectorConfig, boundaries: next }, }); + if (dataSource.isOwner) debouncedSaveAsDefault(normalized); }, - [config, getLatestView, updateView], + [ + config, + dataSource.isOwner, + debouncedSaveAsDefault, + getLatestView, + updateView, + allColumnNames, + ], ); const [displayName, setDisplayName] = useState(config?.name ?? ""); diff --git a/src/app/map/[id]/components/inspector/SortableColumnRow.tsx b/src/app/map/[id]/components/inspector/SortableColumnRow.tsx index 6fbee00fe..f79377045 100644 --- a/src/app/map/[id]/components/inspector/SortableColumnRow.tsx +++ b/src/app/map/[id]/components/inspector/SortableColumnRow.tsx @@ -141,6 +141,8 @@ export function SortableColumnRow({ columnName, displayName, onDisplayNameChange, + description, + onDescriptionChange, format = "text", onFormatChange, scaleMax = 3, @@ -154,6 +156,8 @@ export function SortableColumnRow({ columnName: string; displayName: string | undefined; onDisplayNameChange: (value: string) => void; + description?: string; + onDescriptionChange?: (value: string) => void; format?: InspectorColumnFormat; onFormatChange?: (format: InspectorColumnFormat) => void; scaleMax?: number; @@ -166,6 +170,12 @@ export function SortableColumnRow({ const [localDisplayName, setLocalDisplayName] = useState(displayName ?? ""); useEffect(() => setLocalDisplayName(displayName ?? ""), [displayName]); const debouncedChange = useDebouncedCallback(onDisplayNameChange, 600); + const [localDescription, setLocalDescription] = useState(description ?? ""); + useEffect(() => setLocalDescription(description ?? ""), [description]); + const debouncedDescriptionChange = useDebouncedCallback( + (v: string) => onDescriptionChange?.(v), + 600, + ); const [localScaleMax, setLocalScaleMax] = useState(String(scaleMax)); useEffect(() => setLocalScaleMax(String(scaleMax)), [scaleMax]); @@ -246,6 +256,24 @@ export function SortableColumnRow({ onClick={(e) => e.stopPropagation()} />
+ {onDescriptionChange && ( +
+ + { + const v = e.target.value; + setLocalDescription(v); + debouncedDescriptionChange(v); + }} + onClick={(e) => e.stopPropagation()} + /> +
+ )} {onFormatChange && (
{isDirty && (
@@ -418,9 +429,7 @@ export function DefaultInspectorConfigSection({ allColumnsInOrder={allColumnsInOrder} selectedColumnsInOrder={selectedColumnsInOrder} selectedItemsInOrder={selectedItemsInOrder} - allItemsInOrder={allItemsInOrder} availableColumns={availableColumns} - availableIds={availableIds} columnIds={columnIds} columns={columns} columnMetadata={columnMetadata} diff --git a/src/app/(private)/data-sources/[id]/components/DefaultInspectorPreview.tsx b/src/app/(private)/data-sources/[id]/components/DefaultInspectorPreview.tsx index 6c296caf7..ce81573d3 100644 --- a/src/app/(private)/data-sources/[id]/components/DefaultInspectorPreview.tsx +++ b/src/app/(private)/data-sources/[id]/components/DefaultInspectorPreview.tsx @@ -1,6 +1,6 @@ "use client"; -import { useQuery } from "@tanstack/react-query"; +import { useQueries, useQuery } from "@tanstack/react-query"; import { useMemo } from "react"; import DataSourceIcon from "@/components/DataSourceIcon"; import type { InspectorBoundaryConfig } from "@/server/models/MapView"; @@ -53,6 +53,56 @@ export function DefaultInspectorPreview({ [dataSource.columnDefs], ); + const comparisonColumns = useMemo( + () => + (config.columns ?? []) + .filter( + (col) => + config.columnMetadata?.[col]?.format === "numberWithComparison" && + config.columnMetadata?.[col]?.comparisonStat, + ) + .map((col) => ({ + col, + stat: + config.columnMetadata?.[col]?.comparisonStat ?? "average", + })), + [config.columns, config.columnMetadata], + ); + + const baselineQueries = useQueries({ + queries: comparisonColumns.map(({ col, stat }) => + trpc.dataRecord.columnStat.queryOptions({ + dataSourceId: dataSource.id, + columnName: col, + stat, + }), + ), + }); + + const comparisonBaselines = useMemo((): Record => { + const out: Record = {}; + comparisonColumns.forEach(({ col }, i) => { + out[col] = baselineQueries[i]?.data ?? null; + }); + return out; + }, [comparisonColumns, baselineQueries]); + + const comparisonBaselineLoading = useMemo((): Record => { + const out: Record = {}; + comparisonColumns.forEach(({ col }, i) => { + const q = baselineQueries[i]; + out[col] = q?.isLoading === true || q?.isFetching === true; + }); + return out; + }, [comparisonColumns, baselineQueries]); + + const COMPARISON_STAT_LABEL: Record = { + average: "Average", + median: "Median", + min: "Min", + max: "Max", + }; + const entries = useMemo((): PropertyEntry[] => { const items = getSelectedItemsOrdered( config, @@ -88,12 +138,25 @@ export function DefaultInspectorPreview({ m?.barColor, ), description: m?.description, + ...(m?.format === "numberWithComparison" && { + comparisonBaseline: comparisonBaselines[item] ?? null, + comparisonStat: m.comparisonStat + ? COMPARISON_STAT_LABEL[m.comparisonStat] ?? m.comparisonStat + : undefined, + comparisonBaselineLoading: comparisonBaselineLoading[item] === true, + }), }); index += 1; } } return result; - }, [config, allColumnNames, sampleRow]); + }, [ + config, + allColumnNames, + sampleRow, + comparisonBaselines, + comparisonBaselineLoading, + ]); const dataSourceType = dataSource.config?.type ?? "unknown"; const panelIcon = config.icon ? ( diff --git a/src/app/map/[id]/components/inspector/BoundaryDataPanel.tsx b/src/app/map/[id]/components/inspector/BoundaryDataPanel.tsx index 0ba1e7994..737ecdcf1 100644 --- a/src/app/map/[id]/components/inspector/BoundaryDataPanel.tsx +++ b/src/app/map/[id]/components/inspector/BoundaryDataPanel.tsx @@ -1,5 +1,5 @@ -import { useQuery } from "@tanstack/react-query"; -import { List } from "lucide-react"; +import { useQueries, useQuery } from "@tanstack/react-query"; +import { List, Settings as SettingsIcon } from "lucide-react"; import { useMemo } from "react"; import TogglePanel from "@/app/map/[id]/components/TogglePanel"; import { useInspector } from "@/app/map/[id]/hooks/useInspector"; @@ -28,6 +28,7 @@ export function BoundaryDataPanel({ columnGroups, layout, defaultExpanded, + onOpenInspectorSettings, }: { config: Pick< InspectorBoundaryConfig, @@ -40,6 +41,7 @@ export function BoundaryDataPanel({ columnGroups?: InspectorBoundaryConfig["columnGroups"]; layout?: InspectorBoundaryConfig["layout"]; defaultExpanded?: boolean; + onOpenInspectorSettings?: (dataSourceId: string) => void; }) { const expanded = defaultExpanded ?? true; const trpc = useTRPC(); @@ -68,6 +70,49 @@ export function BoundaryDataPanel({ ), ); + const meta = useMemo(() => columnMetadata ?? {}, [columnMetadata]); + const comparisonColumns = useMemo( + () => + columns + .filter( + (col) => + meta[col]?.format === "numberWithComparison" && + meta[col]?.comparisonStat, + ) + .map((col) => ({ + col, + stat: meta[col]?.comparisonStat ?? "average", + })), + [columns, meta], + ); + + const baselineQueries = useQueries({ + queries: comparisonColumns.map(({ col, stat }) => + trpc.dataRecord.columnStat.queryOptions({ + dataSourceId, + columnName: col, + stat, + }), + ), + }); + + const comparisonBaselines = useMemo((): Record => { + const out: Record = {}; + comparisonColumns.forEach(({ col }, i) => { + out[col] = baselineQueries[i]?.data ?? null; + }); + return out; + }, [comparisonColumns, baselineQueries]); + + const comparisonBaselineLoading = useMemo((): Record => { + const out: Record = {}; + comparisonColumns.forEach(({ col }, i) => { + const q = baselineQueries[i]; + out[col] = q?.isLoading === true || q?.isFetching === true; + }); + return out; + }, [comparisonColumns, baselineQueries]); + const recordCount = data?.records.length ?? 0; const isList = recordCount > 1; @@ -78,36 +123,53 @@ export function BoundaryDataPanel({ defaultExpanded={expanded} wrapperClassName={getInspectorColorClass(config.color)} headerRight={ - isList ? ( - - - {recordCount} records - - ) : undefined +
+ {isList && ( + + + {recordCount} records + + )} + {onOpenInspectorSettings && ( + + )} +
} > {isLoading ? (

Loading...

- ) : recordCount === 1 ? ( + ) : recordCount === 1 && data?.records[0] ? ( - ) : isList ? ( + ) : isList && data?.records ? (

{recordCount} records in this area

    - {data!.records.map((d, i) => ( + {data.records.map((d, i) => (
  • @@ -150,6 +214,13 @@ function isColumnItemDivider( ); } +const COMPARISON_STAT_LABEL: Record = { + average: "Average", + median: "Median", + min: "Min", + max: "Max", +}; + function BoundaryDataProperties({ json, columns, @@ -159,6 +230,8 @@ function BoundaryDataProperties({ layout, match, dividerBackgroundClassName, + comparisonBaselines = {}, + comparisonBaselineLoading = {}, }: { json: Record; columns: string[]; @@ -169,15 +242,57 @@ function BoundaryDataProperties({ match: DataRecordMatchType; /** Background class for divider labels. Matches panel color. */ dividerBackgroundClassName?: string; + /** Baseline values for numberWithComparison columns (column name -> baseline). */ + comparisonBaselines?: Record; + /** True while baseline is loading for numberWithComparison columns. */ + comparisonBaselineLoading?: Record; }) { const entries = useMemo((): PropertyEntry[] => { const meta = columnMetadata ?? {}; const columnsSet = new Set(columns); + const baselines = comparisonBaselines ?? {}; + const loading = comparisonBaselineLoading ?? {}; + + const addEntry = ( + col: string, + opts: { + groupLabel?: string; + }, + ): void => { + const m = meta[col]; + const label = m?.displayName ?? col; + const base = { + label, + value: json[col], + groupLabel: opts.groupLabel, + format: m?.format, + scaleMax: m?.scaleMax, + barColor: getBarColorForLabel( + label, + col, + ordered.length, + m?.barColor, + ), + description: m?.description, + ...(m?.format === "numberWithComparison" && { + comparisonBaseline: baselines[col] ?? null, + comparisonStat: m.comparisonStat + ? COMPARISON_STAT_LABEL[m.comparisonStat] ?? m.comparisonStat + : undefined, + comparisonBaselineLoading: loading[col] === true, + }), + }; + ordered.push({ + key: `${col}-${ordered.length}`, + ...base, + }); + }; + + const ordered: PropertyEntry[] = []; // Use columnItems order only when it contains dividers (matches settings panel). // Otherwise use the columns prop order (already from getSelectedColumnsOrdered). if (columnItems?.length && columnItems.some(isColumnItemDivider)) { - const ordered: PropertyEntry[] = []; let currentGroupLabel: string | undefined; for (const item of columnItems) { if (isColumnItemDivider(item)) { @@ -205,6 +320,13 @@ function BoundaryDataProperties({ m?.barColor, ), description: m?.description, + ...(m?.format === "numberWithComparison" && { + comparisonBaseline: baselines[item] ?? null, + comparisonStat: m.comparisonStat + ? COMPARISON_STAT_LABEL[m.comparisonStat] ?? m.comparisonStat + : undefined, + comparisonBaselineLoading: loading[item] === true, + }), }); } } @@ -216,46 +338,27 @@ function BoundaryDataProperties({ groups.forEach((g) => { g.columnNames.forEach((col) => keyToGroup.set(col, g.label)); }); - const ordered: PropertyEntry[] = []; groups.forEach((g) => { g.columnNames.forEach((col) => { if (json[col] === undefined) return; - const m = meta[col]; - const label = m?.displayName ?? col; - ordered.push({ - key: `${col}-${ordered.length}`, - label, - value: json[col], - groupLabel: g.label, - format: m?.format, - scaleMax: m?.scaleMax, - barColor: getBarColorForLabel( - label, - col, - ordered.length, - m?.barColor, - ), - description: m?.description, - }); + addEntry(col, { groupLabel: g.label }); }); }); columns.forEach((col) => { if (keyToGroup.has(col)) return; if (json[col] === undefined) return; - const m = meta[col]; - const label = m?.displayName ?? col; - ordered.push({ - key: `${col}-${ordered.length}`, - label, - value: json[col], - format: m?.format, - scaleMax: m?.scaleMax, - barColor: getBarColorForLabel(label, col, ordered.length, m?.barColor), - description: m?.description, - }); + addEntry(col, {}); }); return ordered; - }, [json, columns, columnMetadata, columnGroups, columnItems]); + }, [ + json, + columns, + columnMetadata, + columnGroups, + columnItems, + comparisonBaselines, + comparisonBaselineLoading, + ]); return (
    {match === DataRecordMatchType.Approximate && ( diff --git a/src/app/map/[id]/components/inspector/InspectorDataTab.tsx b/src/app/map/[id]/components/inspector/InspectorDataTab.tsx index d6b4239bd..e61b92bcb 100644 --- a/src/app/map/[id]/components/inspector/InspectorDataTab.tsx +++ b/src/app/map/[id]/components/inspector/InspectorDataTab.tsx @@ -8,7 +8,6 @@ import { useMapRef } from "@/app/map/[id]/hooks/useMapCore"; import { useMapViews } from "@/app/map/[id]/hooks/useMapViews"; import { useTable } from "@/app/map/[id]/hooks/useTable"; import DataSourceIcon from "@/components/DataSourceIcon"; -import { AreaSetCodeLabels } from "@/labels"; import { type DataSource } from "@/server/models/DataSource"; import { type InspectorBoundaryConfig, @@ -35,6 +34,10 @@ interface InspectorDataTabProps { isDetailsView: boolean; focusedRecord: SelectedRecord | null; type: LayerType | undefined; + /** When set, "Add a data source" opens the inspector settings modal instead of the data source picker */ + onOpenInspectorSettings?: () => void; + /** Open inspector settings with a specific data source pre-selected (used by per-datasource cogs). */ + onOpenInspectorSettingsForDataSource?: (dataSourceId: string) => void; } export default function InspectorDataTab({ @@ -43,6 +46,8 @@ export default function InspectorDataTab({ isDetailsView, focusedRecord, type, + onOpenInspectorSettings, + onOpenInspectorSettingsForDataSource, }: InspectorDataTabProps) { const mapRef = useMapRef(); const { setSelectedDataSourceId } = useTable(); @@ -51,9 +56,8 @@ export default function InspectorDataTab({ const { getDataSourceById } = useDataSources(); const { selectedBoundary } = useInspector(); const initializationAttemptedRef = useRef(false); - const { areaToDisplay, primaryLabel, secondaryLabel, columnMetadata } = - useDisplayAreaStat(selectedBoundary); - const [selectedSecondaryArea] = useSelectedSecondaryArea(); + useDisplayAreaStat(selectedBoundary); + useSelectedSecondaryArea(); const addDataSourceToConfig = useCallback( (dataSourceId: string) => { @@ -119,42 +123,27 @@ export default function InspectorDataTab({ })); }, [isBoundary, selectedBoundary?.code, boundaryConfigs]); - const boundaryProperties = useMemo(() => { - if (!areaToDisplay) { - return properties; - } - const propertiesWithData = { ...properties }; - if (primaryLabel) { - propertiesWithData[primaryLabel] = areaToDisplay.primaryDisplayValue; - } - if (secondaryLabel) { - propertiesWithData[secondaryLabel] = areaToDisplay.secondaryDisplayValue; - } - if (selectedSecondaryArea) { - propertiesWithData[ - AreaSetCodeLabels[selectedSecondaryArea.areaSetCode] || - "Secondary boundary" - ] = selectedSecondaryArea.name; - } - return propertiesWithData; - }, [ - areaToDisplay, - properties, - primaryLabel, - secondaryLabel, - selectedSecondaryArea, - ]); - // Initialise boundary inspector config from choropleth data source when empty useEffect(() => { - if (!view || type !== LayerType.Boundary || initializationAttemptedRef.current) return; + if ( + !view || + type !== LayerType.Boundary || + initializationAttemptedRef.current + ) + return; const hasBoundaries = boundaryConfigs.length > 0; const hasAreaDataSource = viewConfig.areaDataSourceId; if (!hasBoundaries && hasAreaDataSource) { initializationAttemptedRef.current = true; addDataSourceToConfig(viewConfig.areaDataSourceId); } - }, [view, type, viewConfig.areaDataSourceId, boundaryConfigs.length, addDataSourceToConfig]); + }, [ + view, + type, + viewConfig.areaDataSourceId, + boundaryConfigs.length, + addDataSourceToConfig, + ]); const flyToMarker = () => { const map = mapRef?.current; @@ -169,26 +158,16 @@ export default function InspectorDataTab({ {isBoundary ? ( <> -

    Data in this area

    - {boundaryConfigs.length === 0 ? ( -
    -

    - No data sources added yet -

    - -
    - ) : ( + {boundaryConfigs.length === 0 && ( +

    + No data sources added yet +

    + )} + {boundaryConfigs.length > 0 && (
    {boundaryData.map((item) => ( ))}
    )} +
    + {onOpenInspectorSettings ? ( + + ) : ( + + )} +
    ) : ( diff --git a/src/app/map/[id]/components/inspector/InspectorOnMapSection.tsx b/src/app/map/[id]/components/inspector/InspectorOnMapSection.tsx index b31991850..c035cefb7 100644 --- a/src/app/map/[id]/components/inspector/InspectorOnMapSection.tsx +++ b/src/app/map/[id]/components/inspector/InspectorOnMapSection.tsx @@ -1,12 +1,15 @@ +import { LayoutDashboardIcon } from "lucide-react"; import { CalculationType } from "@/server/models/MapView"; +import { getBoundaryDatasetName } from "./helpers"; import { useAreaStats } from "../../data"; import { useChoroplethDataSource } from "../../hooks/useDataSources"; import { useInspector } from "../../hooks/useInspector"; import { useMapViews } from "../../hooks/useMapViews"; /** - * Shows the value(s) currently driving the map colour for the selected boundary. - * Only rendered when a choropleth data source is set and a boundary is selected. + * Single "Data used for visualisation" block: compact boundary metadata + * (code, set) and choropleth value(s) when available. Only rendered when + * a boundary is selected (parent only mounts this in boundary view). */ export default function InspectorOnMapSection() { const { viewConfig } = useMapViews(); @@ -15,56 +18,77 @@ export default function InspectorOnMapSection() { const areaStatsQuery = useAreaStats(); const areaStats = areaStatsQuery.data; - const hasChoropleth = Boolean(viewConfig.areaDataSourceId); - if (!hasChoropleth || !selectedBoundary?.code || !areaStats) { + if (!selectedBoundary?.code) { return null; } - const isSameAreaSet = areaStats.areaSetCode === selectedBoundary.areaSetCode; - const stat = isSameAreaSet - ? areaStats.stats.find( - (s: { areaCode: string }) => s.areaCode === selectedBoundary.code, - ) - : null; + const hasChoropleth = Boolean(viewConfig.areaDataSourceId); + const isSameAreaSet = areaStats?.areaSetCode === selectedBoundary.areaSetCode; + const stat = + hasChoropleth && areaStats && isSameAreaSet + ? areaStats.stats.find( + (s: { areaCode: string }) => s.areaCode === selectedBoundary.code, + ) + : null; const label = - areaStats.calculationType === CalculationType.Count + areaStats?.calculationType === CalculationType.Count ? `${choroplethDataSource?.name ?? "Data"} count` : viewConfig.areaDataColumn || "Value"; - const hasSecondary = Boolean(viewConfig.areaDataSecondaryColumn); + const boundarySetName = getBoundaryDatasetName( + selectedBoundary?.sourceLayerId, + ); return (
    -

    - On the map -

    - {stat ? ( -
    -
    - {label} - - {typeof stat.primary === "number" - ? stat.primary.toLocaleString() - : String(stat.primary ?? "—")} - -
    - {hasSecondary && ( -
    - - {viewConfig.areaDataSecondaryColumn} - - - {typeof stat.secondary === "number" - ? stat.secondary.toLocaleString() - : String(stat.secondary ?? "—")} - +
    + +

    + Data used for visualisation +

    +
    +
    +

    + {selectedBoundary.code} + {boundarySetName ? ( + <> + · + {boundarySetName} + + ) : null} +

    + {hasChoropleth && areaStats ? ( + stat ? ( +
    +
    + {label} + + {typeof stat.primary === "number" + ? stat.primary.toLocaleString() + : String(stat.primary ?? "—")} + +
    + {hasSecondary && ( +
    + + {viewConfig.areaDataSecondaryColumn} + + + {typeof stat.secondary === "number" + ? stat.secondary.toLocaleString() + : String(stat.secondary ?? "—")} + +
    + )}
    - )} -
    - ) : ( -

    No value for this area

    - )} + ) : ( +

    + No value for this area +

    + ) + ) : null} +
    ); } diff --git a/src/app/map/[id]/components/inspector/InspectorPanel.tsx b/src/app/map/[id]/components/inspector/InspectorPanel.tsx index b37e49f05..556a81e4e 100644 --- a/src/app/map/[id]/components/inspector/InspectorPanel.tsx +++ b/src/app/map/[id]/components/inspector/InspectorPanel.tsx @@ -32,6 +32,9 @@ export default function InspectorPanel({ } = {}) { const [activeTab, setActiveTab] = useState("data"); const [settingsOpen, setSettingsOpen] = useState(false); + const [settingsInitialDataSourceId, setSettingsInitialDataSourceId] = useState< + string | null + >(null); const [hoverArea] = useHoverArea(); const boundaryHoverVisible = boundariesPanelOpen && !!hoverArea; @@ -116,6 +119,11 @@ export default function InspectorPanel({ const markerCount = selectedRecords?.length || 0; + const openInspectorSettingsForDataSource = (dataSourceId: string) => { + setSettingsInitialDataSourceId(dataSourceId); + setSettingsOpen(true); + }; + const onCloseDetailsView = () => { setFocusedRecord(null); }; @@ -197,6 +205,7 @@ export default function InspectorPanel({ {isDetailsView && ( @@ -248,6 +257,10 @@ export default function InspectorPanel({ isDetailsView={isDetailsView} focusedRecord={focusedRecord} type={type} + onOpenInspectorSettings={() => setSettingsOpen(true)} + onOpenInspectorSettingsForDataSource={ + openInspectorSettingsForDataSource + } /> )} diff --git a/src/app/map/[id]/components/inspector/InspectorSettingsModal/AvailableColumnRow.tsx b/src/app/map/[id]/components/inspector/InspectorSettingsModal/AvailableColumnRow.tsx new file mode 100644 index 000000000..b4499f346 --- /dev/null +++ b/src/app/map/[id]/components/inspector/InspectorSettingsModal/AvailableColumnRow.tsx @@ -0,0 +1,28 @@ +"use client"; + +import { Checkbox } from "@/shadcn/ui/checkbox"; +import { cn } from "@/shadcn/utils"; + +/** Simple row for the Available list: checkbox to add, no drag. */ +export function AvailableColumnRow({ + columnName, + onAdd, +}: { + columnName: string; + onAdd: () => void; +}) { + return ( +
    + checked === true && onAdd()} + aria-label={`Add ${columnName} to columns to show`} + /> + {columnName} +
    + ); +} diff --git a/src/app/map/[id]/components/inspector/InspectorSettingsModal/AvailableColumnsCheckboxList.tsx b/src/app/map/[id]/components/inspector/InspectorSettingsModal/AvailableColumnsCheckboxList.tsx index c7ff84d7e..ea2ed86f8 100644 --- a/src/app/map/[id]/components/inspector/InspectorSettingsModal/AvailableColumnsCheckboxList.tsx +++ b/src/app/map/[id]/components/inspector/InspectorSettingsModal/AvailableColumnsCheckboxList.tsx @@ -1,6 +1,9 @@ "use client"; -import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable"; +import { + SortableContext, + verticalListSortingStrategy, +} from "@dnd-kit/sortable"; import { SortableAvailableRow } from "./SortableAvailableRow"; export function AvailableColumnsCheckboxList({ @@ -19,7 +22,7 @@ export function AvailableColumnsCheckboxList({ activeId: string | null; }) { return ( -
    +
    void; onRemoveColumn: (columnName: string) => void; onAddDivider: () => void; onDividerLabelChange: (id: string, label: string) => void; onRemoveDivider: (id: string) => void; - availableIds: string[]; activeId: string | null; + /** Which parts of the panel to show: both (default), only selected list, or only available list */ + mode?: "both" | "selected" | "available"; }) { - const { setNodeRef, isOver } = useDroppable({ id: AVAILABLE_DROPPABLE_ID }); - - const { visibleWithIndex, nonVisibleWithIndex } = useMemo(() => { - const visible: { item: InspectorColumnItem; index: number }[] = []; - const nonVisible: { item: string; index: number }[] = []; - allItemsInOrder.forEach((item, index) => { - if (isDivider(item)) { - visible.push({ item, index }); - } else if (selectedColumns.includes(item)) { - visible.push({ item, index }); - } else { - nonVisible.push({ item, index }); - } - }); - return { visibleWithIndex: visible, nonVisibleWithIndex: nonVisible }; - }, [allItemsInOrder, selectedColumns]); + const { setNodeRef, isOver } = useDroppable({ + id: SELECTED_LEFT_DROPPABLE_ID, + }); return ( -
    + {/* Selected (top): sortable list so you can always see and reorder selected columns + dividers */} + {mode !== "available" && ( + <> +
    + +
    +
    +

    + Selected +

    +
    + + {selectedItemsInOrder.length === 0 ? ( +

    + Tick columns below to add +

    + ) : ( + selectedItemsInOrder.map((item, i) => + typeof item === "string" ? ( + onRemoveColumn(item)} + isDragging={activeId === selectedSectionIds[i]} + /> + ) : ( + + onDividerLabelChange(item.id, value) + } + onRemove={() => onRemoveDivider(item.id)} + isDragging={activeId === selectedSectionIds[i]} + /> + ), + ) + )} +
    +
    +
    + )} - > -
    - -
    -
    - - {visibleWithIndex.map(({ item, index }) => - typeof item === "string" ? ( - onRemoveColumn(item)} - isDragging={activeId === availableIds[index]} - /> + + {/* Available (bottom): simple list, no sorting */} + {mode !== "selected" && ( +
    +
    + {availableColumns.length === 0 ? ( +

    + All columns selected +

    ) : ( - onDividerLabelChange(item.id, value)} - onRemove={() => onRemoveDivider(item.id)} - isDragging={activeId === `divider-${item.id}`} - /> - ), - )} - {nonVisibleWithIndex.length > 0 && ( - <> -
    - {nonVisibleWithIndex.map(({ item: col, index }) => ( - ( + onAddColumn(col)} - isDragging={activeId === availableIds[index]} + onAdd={() => onAddColumn(col)} /> - ))} - - )} - -
    - {allItemsInOrder.length === 0 && ( -

    - No columns in this data source -

    + )) + )} +
    +
    )}
    ); diff --git a/src/app/map/[id]/components/inspector/InspectorSettingsModal/ColumnsSection.tsx b/src/app/map/[id]/components/inspector/InspectorSettingsModal/ColumnsSection.tsx index ab1a9c298..db14ad58e 100644 --- a/src/app/map/[id]/components/inspector/InspectorSettingsModal/ColumnsSection.tsx +++ b/src/app/map/[id]/components/inspector/InspectorSettingsModal/ColumnsSection.tsx @@ -10,7 +10,7 @@ import { useSensors, } from "@dnd-kit/core"; import { sortableKeyboardCoordinates } from "@dnd-kit/sortable"; -import { useCallback, useState } from "react"; +import { useCallback, useMemo, useState } from "react"; import { createPortal } from "react-dom"; import type { InspectorBoundaryConfig, @@ -28,8 +28,8 @@ import { DroppableSelectedColumns } from "./DroppableSelectedColumns"; import type { DragEndEvent } from "@dnd-kit/core"; import { v4 as uuidv4 } from "uuid"; import { - AVAILABLE_DROPPABLE_ID, SELECTED_DROPPABLE_ID, + SELECTED_LEFT_DROPPABLE_ID, } from "./constants"; function isDivider( @@ -43,9 +43,7 @@ export function ColumnsSection({ allColumnsInOrder, selectedColumnsInOrder, selectedItemsInOrder, - allItemsInOrder, availableColumns, - availableIds, columnIds, columns, columnMetadata, @@ -58,9 +56,7 @@ export function ColumnsSection({ allColumnsInOrder: string[]; selectedColumnsInOrder: string[]; selectedItemsInOrder: InspectorColumnItem[]; - allItemsInOrder: InspectorColumnItem[]; availableColumns: string[]; - availableIds: string[]; columnIds: string[]; columns: string[]; columnMetadata: Record; @@ -80,8 +76,18 @@ export function ColumnsSection({ }), ); - const isAvailableItem = (s: string) => - s.startsWith("available-") || s.startsWith("divider-"); + const selectedSectionIds = useMemo( + () => + selectedItemsInOrder.map((item, i) => + typeof item === "string" + ? `left-selected-${i}-${item}` + : `divider-${item.id}`, + ), + [selectedItemsInOrder], + ); + + const isLeftSelectedItem = (s: string) => + s.startsWith("left-selected-") || s.startsWith("divider-"); const handleDragEnd = useCallback( (event: DragEndEvent) => { @@ -90,33 +96,33 @@ export function ColumnsSection({ const activeStr = String(active.id); const overStr = over ? String(over.id) : null; - if (isAvailableItem(activeStr) && overStr) { - const oldIndex = availableIds.indexOf(activeStr); + if (isLeftSelectedItem(activeStr) && overStr) { + const oldIndex = selectedSectionIds.indexOf(activeStr); if (oldIndex === -1) return; const newIndex = - overStr === AVAILABLE_DROPPABLE_ID - ? availableIds.length - : availableIds.indexOf(overStr); - if (!isAvailableItem(overStr) && overStr !== AVAILABLE_DROPPABLE_ID) + overStr === SELECTED_LEFT_DROPPABLE_ID + ? selectedSectionIds.length + : selectedSectionIds.indexOf(overStr); + if ( + !isLeftSelectedItem(overStr) && + overStr !== SELECTED_LEFT_DROPPABLE_ID + ) return; - if (newIndex === -1 && overStr !== AVAILABLE_DROPPABLE_ID) return; - const next = [...allItemsInOrder]; + if (newIndex === -1 && overStr !== SELECTED_LEFT_DROPPABLE_ID) return; + const next = [...selectedItemsInOrder]; const [removed] = next.splice(oldIndex, 1); next.splice(newIndex, 0, removed); - const nextColumnOrder = next - .filter((i): i is string => typeof i === "string") - .filter((c) => allColumnsInOrder.includes(c)); - const nextColumnItems = next.filter( - (i) => - (typeof i === "string" && allColumnsInOrder.includes(i)) || - isDivider(i), - ); + const nextColumnOrder = [ + ...next.filter((i): i is string => typeof i === "string"), + ...availableColumns, + ]; + const nextColumnItems = next.some((i) => isDivider(i)) ? next : undefined; updateConfig((prev) => ({ ...prev, columnOrder: nextColumnOrder, - columnItems: nextColumnItems.some((i) => isDivider(i)) - ? nextColumnItems - : prev.columnItems, + ...(nextColumnItems !== undefined && { + columnItems: nextColumnItems, + }), })); return; } @@ -158,18 +164,19 @@ export function ColumnsSection({ }, [ columnIds, - availableIds, + selectedSectionIds, + selectedItemsInOrder, + availableColumns, updateConfig, allColumnsInOrder, - allItemsInOrder, selectedColumnsInOrder, columns, ], ); return ( -
    -
    +
    +

    @@ -234,14 +241,16 @@ export function ColumnsSection({ onDragStart={({ active }) => setActiveId(active.id as string)} onDragEnd={handleDragEnd} > -

    -
    -

    - Available (tick to add, drag to reorder, add dividers) +

    + {/* Available columns */} +
    +

    + Available columns

    @@ -250,7 +259,7 @@ export function ColumnsSection({ const newDivider = { type: "divider" as const, id: uuidv4(), - label: "New section", + label: "", }; return { ...prev, @@ -274,13 +283,61 @@ export function ColumnsSection({ ), })) } - availableIds={availableIds} activeId={activeId} + mode="available" />
    -
    -

    - Columns to show + + {/* Selected columns */} +

    +

    + Selected columns +

    + + updateConfig((prev) => { + const items = prev.columnItems ?? prev.columns.map((c) => c); + const newDivider = { + type: "divider" as const, + id: uuidv4(), + label: "", + }; + return { + ...prev, + columnItems: [...items, newDivider], + }; + }) + } + onDividerLabelChange={(id, label) => + updateConfig((prev) => ({ + ...prev, + columnItems: (prev.columnItems ?? []).map((i) => + isDivider(i) && i.id === id ? { ...i, label } : i, + ), + })) + } + onRemoveDivider={(id) => + updateConfig((prev) => ({ + ...prev, + columnItems: (prev.columnItems ?? []).filter( + (i) => !(isDivider(i) && i.id === id), + ), + })) + } + activeId={activeId} + mode="selected" + /> +
    + + {/* Selected column settings */} +
    +

    + Selected column settings

    { - const item = allItemsInOrder.find( + const item = selectedItemsInOrder.find( (i) => isDivider(i) && `divider-${i.id}` === String(activeId), ); - return (item && isDivider(item) ? item.label : "Label divider") ?? "Label divider"; + return (item && isDivider(item) ? item.label : "Section label") ?? "Section label"; })() } /> - ) : activeId && String(activeId).startsWith("available-") ? ( + ) : activeId && + (String(activeId).startsWith("available-") || + String(activeId).startsWith("left-selected-")) ? ( ) : null} , diff --git a/src/app/map/[id]/components/inspector/InspectorSettingsModal/DragPreviews.tsx b/src/app/map/[id]/components/inspector/InspectorSettingsModal/DragPreviews.tsx index 4c83c67bf..fbaf092e2 100644 --- a/src/app/map/[id]/components/inspector/InspectorSettingsModal/DragPreviews.tsx +++ b/src/app/map/[id]/components/inspector/InspectorSettingsModal/DragPreviews.tsx @@ -43,19 +43,23 @@ export function DividerDragPreview({
    - - {label || "Label divider"} + + {label || "Section label"}
    ); } export function AvailableDragPreview({ activeId }: { activeId: string }) { - const columnName = activeId.startsWith("available-") - ? (activeId.includes("::") - ? activeId.slice(activeId.indexOf("::") + 2) - : activeId.slice("available-".length)) - : ""; + let columnName = ""; + if (activeId.startsWith("available-")) { + columnName = activeId.includes("::") + ? activeId.slice(activeId.indexOf("::") + 2) + : activeId.slice("available-".length); + } else if (activeId.startsWith("left-selected-")) { + const match = activeId.match(/^left-selected-\d+-/); + columnName = match ? activeId.slice(match[0].length) : ""; + } return (
    diff --git a/src/app/map/[id]/components/inspector/InspectorSettingsModal/DroppableSelectedColumns.tsx b/src/app/map/[id]/components/inspector/InspectorSettingsModal/DroppableSelectedColumns.tsx index 5d3206806..379e9d5c4 100644 --- a/src/app/map/[id]/components/inspector/InspectorSettingsModal/DroppableSelectedColumns.tsx +++ b/src/app/map/[id]/components/inspector/InspectorSettingsModal/DroppableSelectedColumns.tsx @@ -1,7 +1,10 @@ "use client"; import { useDroppable } from "@dnd-kit/core"; -import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable"; +import { + SortableContext, + verticalListSortingStrategy, +} from "@dnd-kit/sortable"; import { useMemo } from "react"; import type { InspectorBoundaryConfig } from "@/server/models/MapView"; import { cn } from "@/shadcn/utils"; @@ -36,10 +39,10 @@ export function DroppableSelectedColumns({
    {isEmpty ? ( @@ -47,105 +50,121 @@ export function DroppableSelectedColumns({ No columns — tick Available to add

    ) : ( - -
    + +
    {columns.map((col, i) => ( - - updateConfig((prev) => ({ - ...prev, - columnMetadata: { - ...(prev.columnMetadata ?? {}), - [col]: { - ...(prev.columnMetadata?.[col] ?? {}), - displayName: value || undefined, - }, + + updateConfig((prev) => ({ + ...prev, + columnMetadata: { + ...(prev.columnMetadata ?? {}), + [col]: { + ...(prev.columnMetadata?.[col] ?? {}), + displayName: value || undefined, }, - })) - } - description={meta[col]?.description} - onDescriptionChange={(value) => - updateConfig((prev) => ({ - ...prev, - columnMetadata: { - ...(prev.columnMetadata ?? {}), - [col]: { - ...(prev.columnMetadata?.[col] ?? {}), - description: value || undefined, - }, + }, + })) + } + description={meta[col]?.description} + onDescriptionChange={(value) => + updateConfig((prev) => ({ + ...prev, + columnMetadata: { + ...(prev.columnMetadata ?? {}), + [col]: { + ...(prev.columnMetadata?.[col] ?? {}), + description: value || undefined, }, - })) - } - format={meta[col]?.format ?? "text"} - onFormatChange={(format) => - updateConfig((prev) => ({ - ...prev, - columnMetadata: { - ...(prev.columnMetadata ?? {}), - [col]: { - ...(prev.columnMetadata?.[col] ?? {}), - format, - }, + }, + })) + } + format={meta[col]?.format ?? "text"} + onFormatChange={(format) => + updateConfig((prev) => ({ + ...prev, + columnMetadata: { + ...(prev.columnMetadata ?? {}), + [col]: { + ...(prev.columnMetadata?.[col] ?? {}), + format, }, - })) - } - scaleMax={meta[col]?.scaleMax ?? 3} - onScaleMaxChange={(scaleMax) => - updateConfig((prev) => ({ - ...prev, - columnMetadata: { - ...(prev.columnMetadata ?? {}), - [col]: { - ...(prev.columnMetadata?.[col] ?? {}), - scaleMax, - }, + }, + })) + } + comparisonStat={meta[col]?.comparisonStat} + onComparisonStatChange={(comparisonStat) => + updateConfig((prev) => ({ + ...prev, + columnMetadata: { + ...(prev.columnMetadata ?? {}), + [col]: { + ...(prev.columnMetadata?.[col] ?? {}), + comparisonStat, }, - })) - } - barColor={meta[col]?.barColor} - onBarColorChange={(value) => - updateConfig((prev) => ({ - ...prev, - columnMetadata: { - ...(prev.columnMetadata ?? {}), - [col]: { - ...(prev.columnMetadata?.[col] ?? {}), - barColor: value || undefined, - }, + }, + })) + } + scaleMax={meta[col]?.scaleMax ?? 3} + onScaleMaxChange={(scaleMax) => + updateConfig((prev) => ({ + ...prev, + columnMetadata: { + ...(prev.columnMetadata ?? {}), + [col]: { + ...(prev.columnMetadata?.[col] ?? {}), + scaleMax, }, - })) - } - onRemove={ - onRemoveColumn - ? () => onRemoveColumn(col) - : () => - updateConfig((prev) => { - const nextColumns = prev.columns.filter( - (c) => c !== col, - ); - const nextMeta = Object.fromEntries( - Object.entries(prev.columnMetadata ?? {}).filter( - ([k]) => k !== col, - ), + }, + })) + } + barColor={meta[col]?.barColor} + onBarColorChange={(value) => + updateConfig((prev) => ({ + ...prev, + columnMetadata: { + ...(prev.columnMetadata ?? {}), + [col]: { + ...(prev.columnMetadata?.[col] ?? {}), + barColor: value || undefined, + }, + }, + })) + } + onRemove={ + onRemoveColumn + ? () => onRemoveColumn(col) + : () => + updateConfig((prev) => { + const nextColumns = prev.columns.filter( + (c) => c !== col, + ); + const nextMeta = Object.fromEntries( + Object.entries(prev.columnMetadata ?? {}).filter( + ([k]) => k !== col, + ), + ); + const base: Partial = { + columns: nextColumns, + columnMetadata: nextMeta, + }; + if (prev.columnItems?.length) { + base.columnItems = prev.columnItems.filter( + (i) => i !== col, ); - const base: Partial = { - columns: nextColumns, - columnMetadata: nextMeta, - }; - if (prev.columnItems?.length) { - base.columnItems = prev.columnItems.filter( - (i) => i !== col, - ); - } - return { ...prev, ...base }; - }) - } - isDragging={activeId === columnIds[i]} - /> + } + return { ...prev, ...base }; + }) + } + isDragging={activeId === columnIds[i]} + /> ))}
    diff --git a/src/app/map/[id]/components/inspector/InspectorSettingsModal/InspectorSettingsModal.tsx b/src/app/map/[id]/components/inspector/InspectorSettingsModal/InspectorSettingsModal.tsx index 1b5f5bd81..c820755d4 100644 --- a/src/app/map/[id]/components/inspector/InspectorSettingsModal/InspectorSettingsModal.tsx +++ b/src/app/map/[id]/components/inspector/InspectorSettingsModal/InspectorSettingsModal.tsx @@ -22,13 +22,15 @@ import { InspectorSourceConfigPanel } from "./InspectorSourceConfigPanel"; export default function InspectorSettingsModal({ open, onOpenChange, + initialDataSourceId, }: { open: boolean; onOpenChange: (open: boolean) => void; + /** When provided, pre-select this data source in the left list (used from real inspector cogs). */ + initialDataSourceId?: string | null; }) { - const [selectedDataSourceId, setSelectedDataSourceId] = useState< - string | null - >(null); + const [selectedDataSourceId, setSelectedDataSourceId] = + useState(null); const [searchQuery, setSearchQuery] = useState(""); const debouncedSearchQuery = useDebouncedValue(searchQuery, 500); const { data: dataSources, getDataSourceById } = useDataSources(); @@ -79,6 +81,11 @@ export default function InspectorSettingsModal({ return { inspectorOrdered: inInspector, otherSources: other }; }, [boundaryConfigs, getDataSourceById, filteredSources, matchesSearch]); + // When opened with an initial data source id, focus that source. + if (open && initialDataSourceId && selectedDataSourceId !== initialDataSourceId) { + setSelectedDataSourceId(initialDataSourceId); + } + const handleRemoveFromInspector = useCallback( (configId: string) => { if (!view) return; @@ -132,8 +139,7 @@ export default function InspectorSettingsModal({ icon: defaultConfig?.icon, color: defaultConfig?.color, }; - const newConfig = - normalizeInspectorBoundaryConfig(raw, allCols) ?? raw; + const newConfig = normalizeInspectorBoundaryConfig(raw, allCols) ?? raw; const prev = view.inspectorConfig?.boundaries ?? []; updateView({ ...view, @@ -147,11 +153,11 @@ export default function InspectorSettingsModal({ return ( setSelectedDataSourceId(null)} > - + Inspector settings

    Choose data sources to show in the inspector and configure columns, diff --git a/src/app/map/[id]/components/inspector/InspectorSettingsModal/InspectorSourceConfigPanel.tsx b/src/app/map/[id]/components/inspector/InspectorSettingsModal/InspectorSourceConfigPanel.tsx index 523d1b5a3..5e8356517 100644 --- a/src/app/map/[id]/components/inspector/InspectorSettingsModal/InspectorSourceConfigPanel.tsx +++ b/src/app/map/[id]/components/inspector/InspectorSettingsModal/InspectorSourceConfigPanel.tsx @@ -69,7 +69,6 @@ export function InspectorSourceConfigPanel({ await queryClient.invalidateQueries({ queryKey: trpc.dataSource.listReadable.queryKey(), }); - toast.success("Default inspector settings updated."); }, onError: (err) => { toast.error( @@ -88,7 +87,15 @@ export function InspectorSourceConfigPanel({ }, 1500, ); - const columns = config?.columns ?? []; + + // Local config so the UI updates immediately; we persist to the cache in the background. + const [localConfig, setLocalConfig] = + useState(config); + useEffect(() => { + setLocalConfig(config); + // eslint-disable-next-line react-hooks/exhaustive-deps -- sync only when switching data source + }, [config?.id]); + const allColumnNames = useMemo( () => dataSource.columnDefs.map((c) => c.name), [dataSource.columnDefs], @@ -101,36 +108,42 @@ export function InspectorSourceConfigPanel({ allColumnsInOrder, selectedColumnsInOrder, selectedItemsInOrder, - allItemsInOrder, availableColumns, - availableIds, columnIds, } = useMemo( - () => getColumnOrderState(config, allColumnNames), - [config, allColumnNames], + () => getColumnOrderState(localConfig ?? config, allColumnNames), + [localConfig, config, allColumnNames], ); const updateConfig = useCallback( (updater: (prev: InspectorBoundaryConfig) => InspectorBoundaryConfig) => { if (!config) return; - const latestView = getLatestView(); - if (!latestView?.inspectorConfig?.boundaries) return; - const boundaries = latestView.inspectorConfig.boundaries; - const index = boundaries.findIndex((c) => c.id === config.id); - if (index < 0) return; - const updated = updater(boundaries[index]); + const prevConfig = localConfig ?? config; + const updated = updater(prevConfig); const normalized = normalizeInspectorBoundaryConfig(updated, allColumnNames) ?? updated; - const next = [...boundaries]; - next[index] = normalized; - updateView({ - ...latestView, - inspectorConfig: { ...latestView.inspectorConfig, boundaries: next }, - }); + setLocalConfig(normalized); + const latestView = getLatestView(); + if (latestView?.inspectorConfig?.boundaries) { + const boundaries = latestView.inspectorConfig.boundaries; + const index = boundaries.findIndex((c) => c.id === config.id); + if (index >= 0) { + const next = [...boundaries]; + next[index] = normalized; + updateView({ + ...latestView, + inspectorConfig: { + ...latestView.inspectorConfig, + boundaries: next, + }, + }); + } + } if (dataSource.isOwner) debouncedSaveAsDefault(normalized); }, [ config, + localConfig, dataSource.isOwner, debouncedSaveAsDefault, getLatestView, @@ -255,15 +268,17 @@ export function InspectorSourceConfigPanel({ if (!config) return null; - const columnMetadata = config.columnMetadata ?? {}; - const layout = (config.layout ?? "single") as InspectorLayout; - const panelIcon = config.icon ?? undefined; - const panelColor = config.color ?? undefined; + const effectiveConfig = localConfig ?? config; + const columnMetadata = effectiveConfig.columnMetadata ?? {}; + const layout = (effectiveConfig.layout ?? "single") as InspectorLayout; + const panelIcon = effectiveConfig.icon ?? undefined; + const panelColor = effectiveConfig.color ?? undefined; + const columns = effectiveConfig.columns ?? []; return (

    -
    -
    +
    +
    - + +
    + + {"defaultInspectorConfigUpdatedAt" in dataSource && + dataSource.defaultInspectorConfigUpdatedAt && ( +

    + Default inspector settings last updated{" "} + {new Date( + dataSource.defaultInspectorConfigUpdatedAt, + ).toLocaleString(undefined, { + dateStyle: "medium", + timeStyle: "short", + })} +

    + )}
    ); diff --git a/src/app/map/[id]/components/inspector/InspectorSettingsModal/SortableDividerRow.tsx b/src/app/map/[id]/components/inspector/InspectorSettingsModal/SortableDividerRow.tsx index 2fe85bb20..91ca39cf9 100644 --- a/src/app/map/[id]/components/inspector/InspectorSettingsModal/SortableDividerRow.tsx +++ b/src/app/map/[id]/components/inspector/InspectorSettingsModal/SortableDividerRow.tsx @@ -2,7 +2,7 @@ import { useSortable } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; -import { GripVertical, Minus, X } from "lucide-react"; +import { GripVertical, X } from "lucide-react"; import { useEffect, useState } from "react"; import { Input } from "@/shadcn/ui/input"; import { cn } from "@/shadcn/utils"; @@ -47,13 +47,13 @@ export function SortableDividerRow({ ref={setNodeRef} style={style} className={cn( - "flex items-center gap-2 rounded border border-dashed border-neutral-300 bg-neutral-200 py-1.5 px-2 group", + "flex items-center gap-2 border-t border-neutral-400 pt-2 mt-2 first:mt-0 first:border-t-0 first:pt-0 group", dragging && "opacity-0 pointer-events-none", )} > { const v = e.target.value; @@ -78,7 +78,7 @@ export function SortableDividerRow({ e.stopPropagation(); onRemove(); }} - className="p-1 rounded text-muted-foreground hover:text-destructive hover:bg-neutral-100" + className="p-1 rounded text-muted-foreground hover:text-destructive hover:bg-neutral-100 shrink-0" aria-label="Remove divider" > diff --git a/src/app/map/[id]/components/inspector/InspectorSettingsModal/constants.ts b/src/app/map/[id]/components/inspector/InspectorSettingsModal/constants.ts index f1378d5c8..2a329217e 100644 --- a/src/app/map/[id]/components/inspector/InspectorSettingsModal/constants.ts +++ b/src/app/map/[id]/components/inspector/InspectorSettingsModal/constants.ts @@ -2,6 +2,8 @@ import type { InspectorColumnFormat } from "@/server/models/MapView"; export const SELECTED_DROPPABLE_ID = "selected-columns"; export const AVAILABLE_DROPPABLE_ID = "available-columns"; +/** Droppable id for the left column's "Selected" section (reorder selected items). */ +export const SELECTED_LEFT_DROPPABLE_ID = "selected-left-section"; /** Sentinel for Select default option (Radix Select.Item cannot have value="") */ export const DEFAULT_SELECT_VALUE = "__default__"; diff --git a/src/app/map/[id]/components/inspector/PropertiesList.tsx b/src/app/map/[id]/components/inspector/PropertiesList.tsx index 3fb4ce939..1725ba583 100644 --- a/src/app/map/[id]/components/inspector/PropertiesList.tsx +++ b/src/app/map/[id]/components/inspector/PropertiesList.tsx @@ -1,10 +1,15 @@ -import { Info } from "lucide-react"; +import { Info, Loader2 } from "lucide-react"; import { Fragment } from "react"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/shadcn/ui/tooltip"; import { cn } from "@/shadcn/utils"; import type { ColumnMetadata } from "@/server/models/DataSource"; -export type ColumnFormat = "text" | "number" | "percentage" | "scale"; +export type ColumnFormat = + | "text" + | "number" + | "numberWithComparison" + | "percentage" + | "scale"; export interface PropertyEntry { key: string; @@ -20,6 +25,12 @@ export interface PropertyEntry { isDivider?: boolean; /** Optional description for tooltip (e.g. from column metadata). */ description?: string; + /** For format "numberWithComparison": baseline (e.g. average) to compute variance % against. */ + comparisonBaseline?: number | null; + /** For format "numberWithComparison": label of the stat (e.g. "Average") for tooltip. */ + comparisonStat?: string; + /** For format "numberWithComparison": true while baseline is still being fetched. */ + comparisonBaselineLoading?: boolean; } function formatNumber(n: number): string { @@ -43,16 +54,27 @@ function barFill(barColor?: string): string { return barColor?.trim() ? barColor : "hsl(var(--primary))"; } +function variancePercent(value: number, baseline: number): number | null { + if (baseline === 0) return null; + return ((value - baseline) / baseline) * 100; +} + function PropertyValue({ value, format = "text", scaleMax = 3, barColor, + comparisonBaseline, + comparisonStat, + comparisonBaselineLoading, }: { value: unknown; format?: ColumnFormat; scaleMax?: number; barColor?: string; + comparisonBaseline?: number | null; + comparisonStat?: string; + comparisonBaselineLoading?: boolean; }) { const num = parseNumeric(value); const fill = barFill(barColor); @@ -63,6 +85,78 @@ function PropertyValue({ ); } + if ( + (format === "numberWithComparison" || (comparisonStat && num !== null)) && + num !== null + ) { + const baseline = + comparisonBaseline !== undefined && comparisonBaseline !== null + ? comparisonBaseline + : null; + const pct = + baseline !== null ? variancePercent(num, baseline) : null; + const pctLabel = + pct !== null + ? pct >= 0 + ? `+${pct.toFixed(1)}%` + : `${pct.toFixed(1)}%` + : null; + const statAbbrev = comparisonStat + ? comparisonStat === "Average" + ? "AVG" + : comparisonStat === "Median" + ? "MED" + : comparisonStat === "Min" + ? "MIN" + : comparisonStat === "Max" + ? "MAX" + : comparisonStat.toUpperCase().slice(0, 3) + : ""; + const suffix = statAbbrev ? ` ${statAbbrev}` : ""; + const title = + comparisonStat && baseline !== null + ? `vs ${comparisonStat}: ${formatNumber(baseline)}` + : comparisonStat + ? `vs ${comparisonStat} (loading…)` + : undefined; + + if (comparisonBaselineLoading) { + return ( + + {formatNumber(num)} + + + {suffix ? suffix.trim() : "…"} + + + ); + } + + return ( + + {formatNumber(num)} + 0 && "text-green-700", + pct !== null && pct < 0 && "text-red-700", + )} + > + {pctLabel !== null ? `${pctLabel}${suffix}` : `—${suffix}`} + + + ); + } + if (format === "percentage" && num !== null) { const pct = num > 1 @@ -165,6 +259,9 @@ export default function PropertiesList({ format={e.format} scaleMax={e.scaleMax} barColor={e.barColor} + comparisonBaseline={e.comparisonBaseline} + comparisonStat={e.comparisonStat} + comparisonBaselineLoading={e.comparisonBaselineLoading} />
    diff --git a/src/app/map/[id]/components/inspector/SortableColumnRow.tsx b/src/app/map/[id]/components/inspector/SortableColumnRow.tsx index f79377045..86fdb976a 100644 --- a/src/app/map/[id]/components/inspector/SortableColumnRow.tsx +++ b/src/app/map/[id]/components/inspector/SortableColumnRow.tsx @@ -23,15 +23,29 @@ import { getSmartMatchInfo, } from "./inspectorPanelOptions"; -import type { InspectorColumnFormat } from "@/server/models/MapView"; +import type { + InspectorColumnFormat, + InspectorComparisonStat, +} from "@/server/models/MapView"; const FORMAT_OPTIONS: { value: InspectorColumnFormat; label: string }[] = [ { value: "text", label: "Text" }, { value: "number", label: "Number" }, + { value: "numberWithComparison", label: "Number with comparison" }, { value: "percentage", label: "Percentage (bar)" }, { value: "scale", label: "Scale (bars)" }, ]; +const COMPARISON_STAT_OPTIONS: { + value: InspectorComparisonStat; + label: string; +}[] = [ + { value: "average", label: "Average" }, + { value: "median", label: "Median" }, + { value: "min", label: "Min" }, + { value: "max", label: "Max" }, +]; + /** Resolve select value: empty/undefined treated as Smart match for backward compat. */ function barColorSelectValue(barColor: string | undefined): string { if (barColor === DEFAULT_BAR_COLOR_VALUE) return DEFAULT_BAR_COLOR_VALUE; @@ -145,6 +159,8 @@ export function SortableColumnRow({ onDescriptionChange, format = "text", onFormatChange, + comparisonStat, + onComparisonStatChange, scaleMax = 3, onScaleMaxChange, barColor, @@ -160,6 +176,8 @@ export function SortableColumnRow({ onDescriptionChange?: (value: string) => void; format?: InspectorColumnFormat; onFormatChange?: (format: InspectorColumnFormat) => void; + comparisonStat?: InspectorComparisonStat; + onComparisonStatChange?: (value: InspectorComparisonStat) => void; scaleMax?: number; onScaleMaxChange?: (value: number) => void; barColor?: string; @@ -206,7 +224,7 @@ export function SortableColumnRow({ ref={setNodeRef} style={style} className={cn( - "flex flex-col gap-2 rounded border border-transparent bg-neutral-50/80 py-1.5 px-2 group", + "flex flex-col gap-2 rounded bg-white py-1.5 px-2 group", dragging && "opacity-0 pointer-events-none", )} > @@ -221,7 +239,7 @@ export function SortableColumnRow({ {columnName} @@ -320,6 +338,33 @@ export function SortableColumnRow({ />
    )} + {format === "numberWithComparison" && onComparisonStatChange && ( +
    + + +
    + )} {(format === "percentage" || format === "scale") && onBarColorChange && ( ; +/** Statistic used as baseline for "number with comparison" format. */ +export const inspectorComparisonStatSchema = z.enum([ + "average", + "median", + "min", + "max", +]); +export type InspectorComparisonStat = z.infer< + typeof inspectorComparisonStatSchema +>; + /** * Display metadata for a single column (label, format, scale size, bar colour) */ @@ -201,6 +214,8 @@ export const inspectorColumnMetaSchema = z.object({ scaleMax: z.number().int().min(2).max(10).optional(), /** Bar colour (CSS color) for percentage/scale bars. Empty = primary. */ barColor: z.string().optional(), + /** For format "numberWithComparison": which statistic to compare against. */ + comparisonStat: inspectorComparisonStatSchema.optional(), }); export type InspectorColumnMeta = z.infer; diff --git a/src/server/repositories/DataRecord.ts b/src/server/repositories/DataRecord.ts index 4f2ad1f81..ab5c983f1 100644 --- a/src/server/repositories/DataRecord.ts +++ b/src/server/repositories/DataRecord.ts @@ -382,3 +382,64 @@ export const deleteByDataSourceId = async (dataSourceId: string) => .deleteFrom("dataRecord") .where("dataSourceId", "=", dataSourceId) .execute(); + +export type ColumnStatType = "average" | "median" | "min" | "max"; + +/** + * Returns the requested numeric stat for a JSON column across all records of a data source. + * Only considers rows where (json->>column)::float IS NOT NULL. + */ +export async function findColumnStat( + dataSourceId: string, + columnName: string, + stat: ColumnStatType, +): Promise { + const escaped = columnName.replace(/'/g, "''"); + const numExpr = sql`(NULLIF(trim(json->>${sql.raw(`'${escaped}'`)}), ''))::float`; + + function toNum(val: unknown): number | null { + const n = Number(val); + return Number.isNaN(n) ? null : n; + } + + if (stat === "median") { + const row = await sql<{ value: unknown }>` + SELECT PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY (NULLIF(trim(json->>${sql.raw(`'${escaped}'`)}), ''))::float) AS value + FROM data_record + WHERE data_source_id = ${dataSourceId} + AND (NULLIF(trim(json->>${sql.raw(`'${escaped}'`)}), ''))::float IS NOT NULL + `.execute(db); + return toNum(row.rows[0]?.value); + } + + const whereNotNull = sql`${numExpr} IS NOT NULL`; + + if (stat === "average") { + const row = await db + .selectFrom("dataRecord") + .where("dataSourceId", "=", dataSourceId) + .where(whereNotNull) + .select(sql`AVG(${numExpr})`.as("value")) + .executeTakeFirst(); + return toNum(row?.value); + } + if (stat === "min") { + const row = await db + .selectFrom("dataRecord") + .where("dataSourceId", "=", dataSourceId) + .where(whereNotNull) + .select(sql`MIN(${numExpr})`.as("value")) + .executeTakeFirst(); + return toNum(row?.value); + } + if (stat === "max") { + const row = await db + .selectFrom("dataRecord") + .where("dataSourceId", "=", dataSourceId) + .where(whereNotNull) + .select(sql`MAX(${numExpr})`.as("value")) + .executeTakeFirst(); + return toNum(row?.value); + } + return null; +} diff --git a/src/server/trpc/routers/dataRecord.ts b/src/server/trpc/routers/dataRecord.ts index 2c946919e..d7e5d225e 100644 --- a/src/server/trpc/routers/dataRecord.ts +++ b/src/server/trpc/routers/dataRecord.ts @@ -9,6 +9,7 @@ import { } from "@/server/repositories/Area"; import { countDataRecordsForDataSource, + findColumnStat, findDataRecordById, findDataRecordsByDataSource, findDataRecordsByDataSourceAndAreaCode, @@ -156,4 +157,16 @@ export const dataRecordRouter = router({ return { records, count }; }, ), + columnStat: dataSourceReadProcedure + .input( + z.object({ + columnName: z.string(), + stat: z.enum(["average", "median", "min", "max"]), + }), + ) + .query( + async ({ + input: { dataSourceId, columnName, stat }, + }) => findColumnStat(dataSourceId, columnName, stat), + ), }); From d5514c4bfeb65d7ba20f09bd472322545eefdb19 Mon Sep 17 00:00:00 2001 From: Arbyhisenaj <41119392+Arbyhisenaj@users.noreply.github.com> Date: Fri, 27 Feb 2026 16:08:16 +0000 Subject: [PATCH 16/21] merge/lint fixes --- .../components/DefaultInspectorPreview.tsx | 27 +++++++++---------- .../components/inspector/InspectorPanel.tsx | 7 +++-- .../components/inspector/InspectorPreview.tsx | 20 ++++---------- 3 files changed, 21 insertions(+), 33 deletions(-) diff --git a/src/app/(private)/data-sources/[id]/components/DefaultInspectorPreview.tsx b/src/app/(private)/data-sources/[id]/components/DefaultInspectorPreview.tsx index f78192fe9..ce81573d3 100644 --- a/src/app/(private)/data-sources/[id]/components/DefaultInspectorPreview.tsx +++ b/src/app/(private)/data-sources/[id]/components/DefaultInspectorPreview.tsx @@ -2,21 +2,19 @@ import { useQueries, useQuery } from "@tanstack/react-query"; import { useMemo } from "react"; -import { getSelectedItemsOrdered } from "@/app/map/[id]/components/inspector/inspectorColumnOrder"; +import DataSourceIcon from "@/components/DataSourceIcon"; +import type { InspectorBoundaryConfig } from "@/server/models/MapView"; +import type { DataSource } from "@/server/models/DataSource"; +import { useTRPC } from "@/services/trpc/react"; +import TogglePanel from "@/app/map/[id]/components/TogglePanel"; import { - InspectorPanelIcon, getInspectorColorClass, + InspectorPanelIcon, } from "@/app/map/[id]/components/inspector/inspectorPanelOptions"; +import PropertiesList, { type PropertyEntry } from "@/app/map/[id]/components/inspector/PropertiesList"; import { getBarColorForLabel } from "@/app/map/[id]/components/inspector/inspectorPanelOptions"; -import PropertiesList, { - type PropertyEntry, -} from "@/app/map/[id]/components/inspector/PropertiesList"; -import TogglePanel from "@/app/map/[id]/components/TogglePanel"; -import DataSourceIcon from "@/components/DataSourceIcon"; -import { useTRPC } from "@/services/trpc/react"; +import { getSelectedItemsOrdered } from "@/app/map/[id]/components/inspector/inspectorColumnOrder"; import { cn } from "@/shadcn/utils"; -import type { DataSource } from "@/server/models/DataSource"; -import type { InspectorBoundaryConfig } from "@/server/models/MapView"; function isDivider( item: unknown, @@ -48,9 +46,7 @@ export function DefaultInspectorPreview({ page: 0, }), ); - const sampleRow = listData?.records?.[0]?.json as - | Record - | undefined; + const sampleRow = listData?.records?.[0]?.json as Record | undefined; const allColumnNames = useMemo( () => dataSource.columnDefs.map((c) => c.name), @@ -108,7 +104,10 @@ export function DefaultInspectorPreview({ }; const entries = useMemo((): PropertyEntry[] => { - const items = getSelectedItemsOrdered(config, allColumnNames); + const items = getSelectedItemsOrdered( + config, + allColumnNames, + ); const meta = config.columnMetadata ?? {}; const result: PropertyEntry[] = []; let index = 0; diff --git a/src/app/map/[id]/components/inspector/InspectorPanel.tsx b/src/app/map/[id]/components/inspector/InspectorPanel.tsx index d44afd976..02f076216 100644 --- a/src/app/map/[id]/components/inspector/InspectorPanel.tsx +++ b/src/app/map/[id]/components/inspector/InspectorPanel.tsx @@ -32,9 +32,8 @@ export default function InspectorPanel({ } = {}) { const [activeTab, setActiveTab] = useState("data"); const [settingsOpen, setSettingsOpen] = useState(false); - const [settingsInitialDataSourceId, setSettingsInitialDataSourceId] = useState< - string | null - >(null); + const [settingsInitialDataSourceId, setSettingsInitialDataSourceId] = + useState(null); const [hoverArea] = useHoverArea(); const boundaryHoverVisible = boundariesPanelOpen && !!hoverArea; @@ -177,7 +176,7 @@ export default function InspectorPanel({

    {type === LayerType.Boundary && areaToDisplay?.backgroundColor && ( )} diff --git a/src/app/map/[id]/components/inspector/InspectorPreview.tsx b/src/app/map/[id]/components/inspector/InspectorPreview.tsx index 5c7f9a533..d44b3f109 100644 --- a/src/app/map/[id]/components/inspector/InspectorPreview.tsx +++ b/src/app/map/[id]/components/inspector/InspectorPreview.tsx @@ -1,11 +1,11 @@ "use client"; -import TogglePanel from "@/app/map/[id]/components/TogglePanel"; import DataSourceIcon from "@/components/DataSourceIcon"; import { getDataSourceType } from "@/components/DataSourceItem"; +import type { InspectorBoundaryConfig } from "@/server/models/MapView"; +import TogglePanel from "@/app/map/[id]/components/TogglePanel"; import { cn } from "@/shadcn/utils"; import type { DataSourceWithImportInfo } from "@/components/DataSourceItem"; -import type { InspectorBoundaryConfig } from "@/server/models/MapView"; export function InspectorPreview({ boundaryConfigs, @@ -32,9 +32,7 @@ export function InspectorPreview({ Data in this area

    {boundaryConfigs.length === 0 ? ( -

    - No data sources added -

    +

    No data sources added

    ) : ( boundaryConfigs.map((config) => { const ds = getDataSourceById(config.dataSourceId); @@ -43,20 +41,12 @@ export function InspectorPreview({ - -
    - ) : undefined - } + icon={type ? : undefined} defaultExpanded={true} >
    {config.columns.length === 0 ? ( -

    - No columns selected -

    +

    No columns selected

    ) : (

    {config.columns.join(", ")} From b629070e1531c977a8d322d17a414f51ad92ca70 Mon Sep 17 00:00:00 2001 From: Arbyhisenaj <41119392+Arbyhisenaj@users.noreply.github.com> Date: Fri, 27 Feb 2026 20:37:49 +0000 Subject: [PATCH 17/21] updated sidebar --- .../components/controls/DataSourceItem.tsx | 9 ++- .../[id]/components/controls/LayerIcon.tsx | 14 ++-- .../MarkersControl/SortableFolderItem.tsx | 23 ++----- .../MarkersControl/SortableMarkerItem.tsx | 41 +----------- .../SortableList/SortableFolderItem.tsx | 67 ++++++++----------- .../TurfsControl/SortableTurfItem.tsx | 66 ++++++------------ .../controls/TurfsControl/TurfItem.tsx | 10 ++- tsconfig.json | 14 +++- 8 files changed, 87 insertions(+), 157 deletions(-) diff --git a/src/app/map/[id]/components/controls/DataSourceItem.tsx b/src/app/map/[id]/components/controls/DataSourceItem.tsx index 472b28d77..3df3ef4b2 100644 --- a/src/app/map/[id]/components/controls/DataSourceItem.tsx +++ b/src/app/map/[id]/components/controls/DataSourceItem.tsx @@ -226,16 +226,15 @@ export default function DataSourceItem({ > -

    +
    + + +
    @@ -243,24 +250,6 @@ export default function SortableFolderItem({ )} - - -
    -
    - Color -
    - - - - - - setShowDeleteDialog(true)} diff --git a/src/app/map/[id]/components/controls/TurfsControl/SortableTurfItem.tsx b/src/app/map/[id]/components/controls/TurfsControl/SortableTurfItem.tsx index 4b2017e99..a6087401a 100644 --- a/src/app/map/[id]/components/controls/TurfsControl/SortableTurfItem.tsx +++ b/src/app/map/[id]/components/controls/TurfsControl/SortableTurfItem.tsx @@ -7,7 +7,6 @@ import { EyeIcon, EyeOffIcon, PencilIcon, TrashIcon } from "lucide-react"; import { useEffect, useState } from "react"; import { toast } from "sonner"; import DeleteConfirmationDialog from "@/components/DeleteConfirmationDialog"; -import { Button } from "@/shadcn/ui/button"; import { ContextMenu, ContextMenuContent, @@ -15,7 +14,6 @@ import { ContextMenuSeparator, ContextMenuTrigger, } from "@/shadcn/ui/context-menu"; -import { Input } from "@/shadcn/ui/input"; import { LayerType } from "@/types"; import { useMapConfig } from "../../../hooks/useMapConfig"; import { useShowControls } from "../../../hooks/useMapControls"; @@ -25,6 +23,7 @@ import { useTurfState } from "../../../hooks/useTurfState"; import { CONTROL_PANEL_WIDTH, mapColors } from "../../../styles"; import ControlEditForm from "../ControlEditForm"; import ControlWrapper from "../ControlWrapper"; +import LayerIcon from "../LayerIcon"; import type { Turf } from "@/server/models/Turf"; export default function SortableTurfItem({ @@ -138,7 +137,6 @@ export default function SortableTurfItem({ layerType={LayerType.Turf} isVisible={isVisible} onVisibilityToggle={() => setTurfVisibility(turf.id, !isVisible)} - color={currentColor} > {isEditing ? ( - + + +
    @@ -182,43 +195,6 @@ export default function SortableTurfItem({ )} -
    -

    - Area colour -

    -
    -
    - handleColorChange(e.target.value)} - className="absolute inset-0 w-full h-full opacity-0 cursor-pointer" - title="Choose area colour" - /> -
    - handleColorChange(e.target.value)} - className="h-6 w-24 text-xs" - placeholder={mapColors.areas.color} - /> -
    - {turf.color && ( - - )} -
    - setShowDeleteDialog(true)} diff --git a/src/app/map/[id]/components/controls/TurfsControl/TurfItem.tsx b/src/app/map/[id]/components/controls/TurfsControl/TurfItem.tsx index 7f9de873d..ab13eff51 100644 --- a/src/app/map/[id]/components/controls/TurfsControl/TurfItem.tsx +++ b/src/app/map/[id]/components/controls/TurfsControl/TurfItem.tsx @@ -41,7 +41,11 @@ export default function TurfItem({ turf }: { turf: Turf }) { const [isEditing, setEditing] = useState(false); const [editText, setEditText] = useState(turf.label); const [showDeleteDialog, setShowDeleteDialog] = useState(false); - const [layerColor, setLayerColor] = useState(mapColors.areas.color); + + const currentColor = turf.color ?? mapColors.areas.color; + const handleColorChange = (color: string) => { + updateTurf({ ...turf, color }); + }; const handleFlyTo = (turf: Turf) => { const map = mapRef?.current; @@ -115,8 +119,8 @@ export default function TurfItem({ turf }: { turf: Turf }) { - {getIconRenderer( - iconType, - color - )({ - color: color, - className: "w-4 h-4 absolute", - })} + ))}
    diff --git a/src/app/map/[id]/components/controls/MarkersControl/DataSourceSelectionModal.tsx b/src/app/map/[id]/components/controls/MarkersControl/DataSourceSelectionModal.tsx index ecdc6b62b..317bc6110 100644 --- a/src/app/map/[id]/components/controls/MarkersControl/DataSourceSelectionModal.tsx +++ b/src/app/map/[id]/components/controls/MarkersControl/DataSourceSelectionModal.tsx @@ -1,10 +1,9 @@ "use client"; -import { Boxes, Check, Database, PlusIcon, Users } from "lucide-react"; -import { useMemo } from "react"; +import { Boxes, Check, Database, PlusIcon } from "lucide-react"; import { useRouter } from "next/navigation"; +import { useMemo } from "react"; import { MarkerCollectionIcon } from "@/app/map/[id]/components/Icons"; -import { mapColors } from "@/app/map/[id]/styles"; import { Link } from "@/components/Link"; import { DataSourceRecordType } from "@/server/models/DataSource"; import { Button } from "@/shadcn/ui/button"; @@ -108,7 +107,9 @@ export default function DataSourceSelectionModal({ Member data sources

    -
    Single select
    +
    + Single select +
    @@ -162,7 +163,9 @@ export default function DataSourceSelectionModal({ Markers from data sources -
    Multi select
    +
    + Multi select +
    {groupedOtherDataSources.length === 0 ? ( @@ -185,7 +188,10 @@ export default function DataSourceSelectionModal({
    ); } - diff --git a/src/app/map/[id]/components/controls/MarkersControl/SortableFolderItem.tsx b/src/app/map/[id]/components/controls/MarkersControl/SortableFolderItem.tsx deleted file mode 100644 index 22e600fd2..000000000 --- a/src/app/map/[id]/components/controls/MarkersControl/SortableFolderItem.tsx +++ /dev/null @@ -1,309 +0,0 @@ -import { useDroppable } from "@dnd-kit/core"; -import { - SortableContext, - useSortable, - verticalListSortingStrategy, -} from "@dnd-kit/sortable"; -import { CSS } from "@dnd-kit/utilities"; -import { - ChevronDown, - ChevronRight, - CornerDownRightIcon, - EyeIcon, - EyeOffIcon, - Folder as FolderClosed, - FolderOpen, - PencilIcon, - TrashIcon, -} from "lucide-react"; -import { useMemo, useState } from "react"; - -import { sortByPositionAndId } from "@/app/map/[id]/utils/position"; -import { cn } from "@/shadcn/utils"; -import { LayerType } from "@/types"; -import { mapColors } from "../../../styles"; -import { useFolderMutations } from "../../../hooks/useFolders"; -import { usePlacedMarkerState } from "../../../hooks/usePlacedMarkers"; -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 { toast } from "sonner"; -import ControlEditForm from "../ControlEditForm"; -import ControlWrapper from "../ControlWrapper"; -import LayerIcon from "../LayerIcon"; -import SortableMarkerItem from "./SortableMarkerItem"; -import type { Folder } from "@/server/models/Folder"; -import type { PlacedMarker } from "@/server/models/PlacedMarker"; - -export default function SortableFolderItem({ - folder, - markers, - activeId, - setKeyboardCapture, - folderColor, - onFolderColorChange, -}: { - folder: Folder; - markers: PlacedMarker[]; - activeId: string | null; - isPulsing: boolean; - setKeyboardCapture: (captured: boolean) => void; - folderColor?: string; - onFolderColorChange?: (color: string) => void; -}) { - const { setNodeRef: setHeaderNodeRef, isOver: isHeaderOver } = useDroppable({ - id: `folder-${folder.id}`, - }); - - const { setNodeRef: setFooterNodeRef } = useDroppable({ - id: `folder-footer-${folder.id}`, - }); - - const { - attributes, - listeners, - setNodeRef, - transform, - transition, - isDragging, - } = useSortable({ id: `folder-drag-${folder.id}` }); - - // Check if this folder is the one being dragged - const isCurrentlyDragging = - isDragging || activeId === `folder-drag-${folder.id}`; - const isDraggingMarker = activeId?.startsWith("marker-"); - - const style = { - transform: CSS.Transform.toString(transform), - transition, - opacity: isCurrentlyDragging ? 0.3 : 1, - }; - - const { getPlacedMarkerVisibility, setPlacedMarkerVisibility } = - usePlacedMarkerState(); - - const { updateFolder, deleteFolder } = useFolderMutations(); - - const [isExpanded, setExpanded] = useState(false); - const [isEditing, setEditing] = useState(false); - const [editText, setEditText] = useState(folder.name); - const [showDeleteDialog, setShowDeleteDialog] = useState(false); - - const currentColor = folderColor ?? mapColors.markers.color; - const handleColorChange = (color: string) => { - onFolderColorChange?.(color); - }; - - const sortedMarkers = useMemo(() => { - return sortByPositionAndId(markers); - }, [markers]); - - const onClickFolder = () => { - if (isCurrentlyDragging || isEditing) { - return; - } - - setExpanded(!isExpanded); - }; - - const handleDelete = () => { - deleteFolder(folder.id); - setShowDeleteDialog(false); - toast.success("Folder deleted successfully"); - }; - - const onEdit = () => { - setEditText(folder.name); - setEditing(true); - setKeyboardCapture(true); - }; - - const onSubmit = () => { - if (editText.trim() && editText !== folder.name) { - updateFolder({ ...folder, name: editText.trim() }); - toast.success("Folder renamed successfully"); - } - setEditing(false); - setKeyboardCapture(false); - }; - - const visibleMarkers = useMemo( - () => - sortedMarkers.filter((marker) => getPlacedMarkerVisibility(marker.id)), - [sortedMarkers, getPlacedMarkerVisibility] - ); - const isFolderVisible = sortedMarkers?.length - ? Boolean(visibleMarkers?.length) - : true; - - const onVisibilityToggle = () => { - sortedMarkers.forEach((marker) => - setPlacedMarkerVisibility(marker.id, !isFolderVisible) - ); - }; - - return ( -
    - onVisibilityToggle()} - > - {isEditing ? ( - - ) : ( - - -
    - - -
    -
    - - - - Rename - - onVisibilityToggle()}> - {isFolderVisible ? ( - <> - - Hide - - ) : ( - <> - - Show - - )} - - - setShowDeleteDialog(true)} - > - - Delete - - -
    - )} -
    - - {isExpanded && ( - <> - {sortedMarkers.length > 0 ? ( -
      - `marker-${marker.id}`)} - strategy={verticalListSortingStrategy} - > - {sortedMarkers.map((marker, index) => ( -
    • - - -
    • - ))} -
      -
    - ) : ( -
    - No markers in this folder -
    - )} - {/* Invisible footer drop zone */} -
    - - )} - - - - - Are you absolutely sure? - - This action cannot be undone. This will permanently delete the - folder "{folder.name}" and all markers inside it will be lost. - - - - Cancel - - Delete - - - - -
    - ); -} diff --git a/src/app/map/[id]/components/controls/MarkersControl/SortableMarkerItem.tsx b/src/app/map/[id]/components/controls/MarkersControl/SortableMarkerItem.tsx index 2b1af365c..fffbebb8e 100644 --- a/src/app/map/[id]/components/controls/MarkersControl/SortableMarkerItem.tsx +++ b/src/app/map/[id]/components/controls/MarkersControl/SortableMarkerItem.tsx @@ -30,12 +30,10 @@ export default function SortableMarkerItem({ marker, activeId, setKeyboardCapture, - folderColor, }: { marker: PlacedMarker; activeId: string | null; setKeyboardCapture: (captured: boolean) => void; - folderColor?: string; }) { const { attributes, @@ -167,7 +165,9 @@ export default function SortableMarkerItem({ } }} > -
    {marker.label}
    +
    + {marker.label} +
    Individual marker
    diff --git a/src/app/map/[id]/components/controls/SortableList/SortableFolderItem.tsx b/src/app/map/[id]/components/controls/SortableList/SortableFolderItem.tsx index e852d1e76..f28163f21 100644 --- a/src/app/map/[id]/components/controls/SortableList/SortableFolderItem.tsx +++ b/src/app/map/[id]/components/controls/SortableList/SortableFolderItem.tsx @@ -20,9 +20,6 @@ import { ContextMenuContent, ContextMenuItem, ContextMenuSeparator, - ContextMenuSub, - ContextMenuSubContent, - ContextMenuSubTrigger, ContextMenuTrigger, } from "@/shadcn/ui/context-menu"; import { cn } from "@/shadcn/utils"; @@ -225,8 +222,7 @@ export default function SortableFolderItem({ {folder.name}
    - {sortedItems.length}{" "} - {isTurfFolder ? "areas" : "locations"} + {sortedItems.length} {isTurfFolder ? "areas" : "locations"}
    diff --git a/src/app/map/[id]/components/controls/TurfsControl/TurfItem.tsx b/src/app/map/[id]/components/controls/TurfsControl/TurfItem.tsx deleted file mode 100644 index ab13eff51..000000000 --- a/src/app/map/[id]/components/controls/TurfsControl/TurfItem.tsx +++ /dev/null @@ -1,191 +0,0 @@ -"use client"; - -import * as turfLib from "@turf/turf"; -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"; -import { useTurfMutations } from "../../../hooks/useTurfMutations"; -import { useTurfState } from "../../../hooks/useTurfState"; -import { CONTROL_PANEL_WIDTH, mapColors } from "../../../styles"; -import ControlEditForm from "../ControlEditForm"; -import ControlWrapper from "../ControlWrapper"; -import LayerIcon from "../LayerIcon"; -import type { Turf } from "@/server/models/Turf"; - -export default function TurfItem({ turf }: { turf: Turf }) { - const mapRef = useMapRef(); - const showControls = useShowControls(); - const { getTurfVisibility, setTurfVisibility } = useTurfState(); - const { updateTurf, deleteTurf } = useTurfMutations(); - - const [isEditing, setEditing] = useState(false); - const [editText, setEditText] = useState(turf.label); - const [showDeleteDialog, setShowDeleteDialog] = useState(false); - - const currentColor = turf.color ?? mapColors.areas.color; - const handleColorChange = (color: string) => { - updateTurf({ ...turf, color }); - }; - - const handleFlyTo = (turf: Turf) => { - const map = mapRef?.current; - if (!map) return; - - // the bounding box of the polygon - const bbox = turfLib.bbox(turf.polygon); - const padding = 20; - - map.fitBounds( - [ - [bbox[0], bbox[1]], // southwest corner - [bbox[2], bbox[3]], // northeast corner - ], - { - padding: { - left: showControls ? CONTROL_PANEL_WIDTH + padding : padding, - top: padding, - right: padding, - bottom: padding, - }, - duration: 1000, - }, - ); - }; - - 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 = () => { - 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 ? ( - - ) : ( - - -
    - - -
    -
    - - - - 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/app/map/[id]/components/inspector/BoundaryDataPanel.tsx b/src/app/map/[id]/components/inspector/BoundaryDataPanel.tsx index 80a8987ed..20f3d3be3 100644 --- a/src/app/map/[id]/components/inspector/BoundaryDataPanel.tsx +++ b/src/app/map/[id]/components/inspector/BoundaryDataPanel.tsx @@ -267,17 +267,12 @@ function BoundaryDataProperties({ groupLabel: opts.groupLabel, format: m?.format, scaleMax: m?.scaleMax, - barColor: getBarColorForLabel( - label, - col, - ordered.length, - m?.barColor, - ), + barColor: getBarColorForLabel(label, col, ordered.length, m?.barColor), description: m?.description, ...(m?.format === "numberWithComparison" && { comparisonBaseline: baselines[col] ?? null, comparisonStat: m.comparisonStat - ? COMPARISON_STAT_LABEL[m.comparisonStat] ?? m.comparisonStat + ? (COMPARISON_STAT_LABEL[m.comparisonStat] ?? m.comparisonStat) : undefined, comparisonBaselineLoading: loading[col] === true, }), @@ -323,7 +318,7 @@ function BoundaryDataProperties({ ...(m?.format === "numberWithComparison" && { comparisonBaseline: baselines[item] ?? null, comparisonStat: m.comparisonStat - ? COMPARISON_STAT_LABEL[m.comparisonStat] ?? m.comparisonStat + ? (COMPARISON_STAT_LABEL[m.comparisonStat] ?? m.comparisonStat) : undefined, comparisonBaselineLoading: loading[item] === true, }), diff --git a/src/app/map/[id]/components/inspector/InspectorDataTab.tsx b/src/app/map/[id]/components/inspector/InspectorDataTab.tsx index e61b92bcb..8a7ed7d4b 100644 --- a/src/app/map/[id]/components/inspector/InspectorDataTab.tsx +++ b/src/app/map/[id]/components/inspector/InspectorDataTab.tsx @@ -180,7 +180,9 @@ export default function InspectorDataTab({ columnGroups={item.config.columnGroups} layout={item.config.layout} defaultExpanded={true} - onOpenInspectorSettings={onOpenInspectorSettingsForDataSource} + onOpenInspectorSettings={ + onOpenInspectorSettingsForDataSource + } /> ))}
    diff --git a/src/app/map/[id]/components/inspector/InspectorOnMapSection.tsx b/src/app/map/[id]/components/inspector/InspectorOnMapSection.tsx index c035cefb7..029b55d90 100644 --- a/src/app/map/[id]/components/inspector/InspectorOnMapSection.tsx +++ b/src/app/map/[id]/components/inspector/InspectorOnMapSection.tsx @@ -1,10 +1,10 @@ import { LayoutDashboardIcon } from "lucide-react"; import { CalculationType } from "@/server/models/MapView"; -import { getBoundaryDatasetName } from "./helpers"; import { useAreaStats } from "../../data"; import { useChoroplethDataSource } from "../../hooks/useDataSources"; import { useInspector } from "../../hooks/useInspector"; import { useMapViews } from "../../hooks/useMapViews"; +import { getBoundaryDatasetName } from "./helpers"; /** * Single "Data used for visualisation" block: compact boundary metadata diff --git a/src/app/map/[id]/components/inspector/InspectorPreview.tsx b/src/app/map/[id]/components/inspector/InspectorPreview.tsx index d44b3f109..5c7f9a533 100644 --- a/src/app/map/[id]/components/inspector/InspectorPreview.tsx +++ b/src/app/map/[id]/components/inspector/InspectorPreview.tsx @@ -1,11 +1,11 @@ "use client"; +import TogglePanel from "@/app/map/[id]/components/TogglePanel"; import DataSourceIcon from "@/components/DataSourceIcon"; import { getDataSourceType } from "@/components/DataSourceItem"; -import type { InspectorBoundaryConfig } from "@/server/models/MapView"; -import TogglePanel from "@/app/map/[id]/components/TogglePanel"; import { cn } from "@/shadcn/utils"; import type { DataSourceWithImportInfo } from "@/components/DataSourceItem"; +import type { InspectorBoundaryConfig } from "@/server/models/MapView"; export function InspectorPreview({ boundaryConfigs, @@ -32,7 +32,9 @@ export function InspectorPreview({ Data in this area

    {boundaryConfigs.length === 0 ? ( -

    No data sources added

    +

    + No data sources added +

    ) : ( boundaryConfigs.map((config) => { const ds = getDataSourceById(config.dataSourceId); @@ -41,12 +43,20 @@ export function InspectorPreview({ : undefined} + icon={ + type ? ( + + + + ) : undefined + } defaultExpanded={true} >
    {config.columns.length === 0 ? ( -

    No columns selected

    +

    + No columns selected +

    ) : (

    {config.columns.join(", ")} diff --git a/src/app/map/[id]/components/inspector/InspectorSettingsModal/ColumnsSection.tsx b/src/app/map/[id]/components/inspector/InspectorSettingsModal/ColumnsSection.tsx index eaeaf87d6..a3b9853b5 100644 --- a/src/app/map/[id]/components/inspector/InspectorSettingsModal/ColumnsSection.tsx +++ b/src/app/map/[id]/components/inspector/InspectorSettingsModal/ColumnsSection.tsx @@ -115,7 +115,9 @@ export function ColumnsSection({ ...next.filter((i): i is string => typeof i === "string"), ...availableColumns, ]; - const nextColumnItems = next.some((i) => isDivider(i)) ? next : undefined; + const nextColumnItems = next.some((i) => isDivider(i)) + ? next + : undefined; updateConfig((prev) => ({ ...prev, columnOrder: nextColumnOrder, @@ -366,15 +368,17 @@ export function ColumnsSection({ /> ) : activeId && String(activeId).startsWith("divider-") ? ( { - const item = selectedItemsInOrder.find( - (i) => - isDivider(i) && `divider-${i.id}` === String(activeId), - ); - return (item && isDivider(item) ? item.label : "Section label") ?? "Section label"; - })() - } + label={(() => { + const item = selectedItemsInOrder.find( + (i) => + isDivider(i) && `divider-${i.id}` === String(activeId), + ); + return ( + (item && isDivider(item) + ? item.label + : "Section label") ?? "Section label" + ); + })()} /> ) : activeId && (String(activeId).startsWith("available-") || diff --git a/src/app/map/[id]/components/inspector/InspectorSettingsModal/InspectorSettingsModal.tsx b/src/app/map/[id]/components/inspector/InspectorSettingsModal/InspectorSettingsModal.tsx index 049384bcd..3a34e74e3 100644 --- a/src/app/map/[id]/components/inspector/InspectorSettingsModal/InspectorSettingsModal.tsx +++ b/src/app/map/[id]/components/inspector/InspectorSettingsModal/InspectorSettingsModal.tsx @@ -29,8 +29,9 @@ export default function InspectorSettingsModal({ /** When provided, pre-select this data source in the left list (used from real inspector cogs). */ initialDataSourceId?: string | null; }) { - const [selectedDataSourceId, setSelectedDataSourceId] = - useState(null); + const [selectedDataSourceId, setSelectedDataSourceId] = useState< + string | null + >(null); const [searchQuery, setSearchQuery] = useState(""); const debouncedSearchQuery = useDebouncedValue(searchQuery, 500); const { data: dataSources, getDataSourceById } = useDataSources(); @@ -85,7 +86,11 @@ export default function InspectorSettingsModal({ }, [boundaryConfigs, getDataSourceById, filteredSources, matchesSearch]); // When opened with an initial data source id, focus that source. - if (open && initialDataSourceId && selectedDataSourceId !== initialDataSourceId) { + if ( + open && + initialDataSourceId && + selectedDataSourceId !== initialDataSourceId + ) { setSelectedDataSourceId(initialDataSourceId); } diff --git a/src/app/map/[id]/components/inspector/InspectorSettingsModal/InspectorSourceConfigPanel.tsx b/src/app/map/[id]/components/inspector/InspectorSettingsModal/InspectorSourceConfigPanel.tsx index 384585e86..270fc1986 100644 --- a/src/app/map/[id]/components/inspector/InspectorSettingsModal/InspectorSourceConfigPanel.tsx +++ b/src/app/map/[id]/components/inspector/InspectorSettingsModal/InspectorSourceConfigPanel.tsx @@ -386,19 +386,19 @@ export function InspectorSourceConfigPanel({

    + config={effectiveConfig} + allColumnsInOrder={allColumnsInOrder} + selectedColumnsInOrder={selectedColumnsInOrder} + selectedItemsInOrder={selectedItemsInOrder} + availableColumns={availableColumns} + columnIds={columnIds} + columns={columns} + columnMetadata={columnMetadata} + updateConfig={updateConfig} + handleAddColumn={handleAddColumn} + handleRemoveColumn={handleRemoveColumn} + handleRemoveColumnFromRight={handleRemoveColumnFromRight} + />
    {"defaultInspectorConfigUpdatedAt" in dataSource && diff --git a/src/app/map/[id]/components/inspector/PropertiesList.tsx b/src/app/map/[id]/components/inspector/PropertiesList.tsx index 1725ba583..cfb6c5f4c 100644 --- a/src/app/map/[id]/components/inspector/PropertiesList.tsx +++ b/src/app/map/[id]/components/inspector/PropertiesList.tsx @@ -93,8 +93,7 @@ function PropertyValue({ comparisonBaseline !== undefined && comparisonBaseline !== null ? comparisonBaseline : null; - const pct = - baseline !== null ? variancePercent(num, baseline) : null; + const pct = baseline !== null ? variancePercent(num, baseline) : null; const pctLabel = pct !== null ? pct >= 0 @@ -128,10 +127,7 @@ function PropertyValue({ > {formatNumber(num)} - + {suffix ? suffix.trim() : "…"} diff --git a/src/server/trpc/routers/dataRecord.ts b/src/server/trpc/routers/dataRecord.ts index d7e5d225e..50e137174 100644 --- a/src/server/trpc/routers/dataRecord.ts +++ b/src/server/trpc/routers/dataRecord.ts @@ -164,9 +164,7 @@ export const dataRecordRouter = router({ stat: z.enum(["average", "median", "min", "max"]), }), ) - .query( - async ({ - input: { dataSourceId, columnName, stat }, - }) => findColumnStat(dataSourceId, columnName, stat), + .query(async ({ input: { dataSourceId, columnName, stat } }) => + findColumnStat(dataSourceId, columnName, stat), ), }); diff --git a/tsconfig.json b/tsconfig.json index 1a24779ef..40424caad 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,11 +1,7 @@ { "compilerOptions": { "target": "ES2017", - "lib": [ - "dom", - "dom.iterable", - "esnext" - ], + "lib": ["dom", "dom.iterable", "esnext"], "allowJs": true, "skipLibCheck": true, "strict": true, @@ -23,9 +19,7 @@ } ], "paths": { - "@/*": [ - "./src/*" - ] + "@/*": ["./src/*"] } }, "include": [ @@ -36,7 +30,5 @@ ".next/types/**/*.ts", ".next/dev/types/**/*.ts" ], - "exclude": [ - "node_modules" - ] + "exclude": ["node_modules"] } From a283744e2bf89d8389f408f5e5e2ab560cb3f89a Mon Sep 17 00:00:00 2001 From: Arbyhisenaj <41119392+Arbyhisenaj@users.noreply.github.com> Date: Wed, 4 Mar 2026 18:23:24 +0000 Subject: [PATCH 19/21] work laptop checkpoint --- src/app/map/[id]/atoms/inspectorAtoms.ts | 7 + src/app/map/[id]/components/MapWrapper.tsx | 27 ++ .../controls/DataControl/DataControl.tsx | 141 +++++++ .../components/controls/LegendMapWidget.tsx | 61 +++ .../controls/PrivateMapControls.tsx | 4 +- .../inspector/InspectorFullPreview.tsx | 160 +++++++- .../components/inspector/InspectorPanel.tsx | 33 +- .../GlobalColumnRow.tsx | 369 ++++++++++++++++++ .../GlobalColumnSettingsPanel.tsx | 270 +++++++++++++ .../InspectorSettingsModal.tsx | 323 +++++++++++++-- 10 files changed, 1332 insertions(+), 63 deletions(-) create mode 100644 src/app/map/[id]/components/controls/DataControl/DataControl.tsx create mode 100644 src/app/map/[id]/components/controls/LegendMapWidget.tsx create mode 100644 src/app/map/[id]/components/inspector/InspectorSettingsModal/GlobalColumnRow.tsx create mode 100644 src/app/map/[id]/components/inspector/InspectorSettingsModal/GlobalColumnSettingsPanel.tsx diff --git a/src/app/map/[id]/atoms/inspectorAtoms.ts b/src/app/map/[id]/atoms/inspectorAtoms.ts index 6f847339c..27c4e5573 100644 --- a/src/app/map/[id]/atoms/inspectorAtoms.ts +++ b/src/app/map/[id]/atoms/inspectorAtoms.ts @@ -11,3 +11,10 @@ export const focusedRecordAtom = atom(null); export const selectedTurfAtom = atom(null); export const selectedBoundaryAtom = atom(null); export const inspectorContentAtom = atom(null); + +/** When true, InspectorSettingsModal should be open. Used by layers panel Data section. */ +export const inspectorSettingsModalOpenAtom = atom(false); +/** When set, InspectorSettingsModal opens with this data source pre-selected. */ +export const inspectorSettingsInitialDataSourceIdAtom = atom( + null, +); diff --git a/src/app/map/[id]/components/MapWrapper.tsx b/src/app/map/[id]/components/MapWrapper.tsx index cd58b77c2..3156626e6 100644 --- a/src/app/map/[id]/components/MapWrapper.tsx +++ b/src/app/map/[id]/components/MapWrapper.tsx @@ -1,6 +1,11 @@ +import { useAtomValue, useSetAtom } from "jotai"; import { XIcon } from "lucide-react"; import { useEffect, useState } from "react"; import { MapType } from "@/server/models/MapView"; +import { + inspectorSettingsModalOpenAtom, + inspectorSettingsInitialDataSourceIdAtom, +} from "../atoms/inspectorAtoms"; import { useChoropleth } from "../hooks/useChoropleth"; import { useInspector } from "../hooks/useInspector"; import { @@ -12,6 +17,8 @@ import { useMapViews } from "../hooks/useMapViews"; import { CONTROL_PANEL_WIDTH, mapColors } from "../styles"; import BoundaryHoverInfo from "./BoundaryHoverInfo"; import InspectorPanel from "./inspector/InspectorPanel"; +import InspectorSettingsModal from "./inspector/InspectorSettingsModal"; +import LegendMapWidget from "./controls/LegendMapWidget"; import MapMarkerAndAreaControls from "./MapMarkerAndAreaControls"; import MapStyleSelector from "./MapStyleSelector"; import ZoomControl from "./ZoomControl"; @@ -29,6 +36,18 @@ export default function MapWrapper({ const showControls = useShowControls(); const { viewConfig } = useMapViews(); useInspector(); + const settingsOpen = useAtomValue(inspectorSettingsModalOpenAtom); + const settingsInitialDataSourceId = useAtomValue( + inspectorSettingsInitialDataSourceIdAtom, + ); + const setSettingsOpen = useSetAtom(inspectorSettingsModalOpenAtom); + const setSettingsInitialDataSourceId = useSetAtom( + inspectorSettingsInitialDataSourceIdAtom, + ); + const handleInspectorSettingsOpenChange = (open: boolean) => { + setSettingsOpen(open); + if (!open) setSettingsInitialDataSourceId(null); + }; // Inspector panel is always visible (open by default); reserve space for it const inspectorVisible = true; const { boundariesPanelOpen } = useChoropleth(); @@ -90,6 +109,8 @@ export default function MapWrapper({
    {children} + + + + {!hideDrawControls && ( <> diff --git a/src/app/map/[id]/components/controls/DataControl/DataControl.tsx b/src/app/map/[id]/components/controls/DataControl/DataControl.tsx new file mode 100644 index 000000000..2b32142de --- /dev/null +++ b/src/app/map/[id]/components/controls/DataControl/DataControl.tsx @@ -0,0 +1,141 @@ +"use client"; + +import { ChevronDown, Database, Plus } from "lucide-react"; +import { useSetAtom } from "jotai"; +import { useState } from "react"; +import { useDataSources } from "@/app/map/[id]/hooks/useDataSources"; +import { useMapViews } from "@/app/map/[id]/hooks/useMapViews"; +import { useMapConfig } from "@/app/map/[id]/hooks/useMapConfig"; +import { + inspectorSettingsModalOpenAtom, + inspectorSettingsInitialDataSourceIdAtom, +} from "@/app/map/[id]/atoms/inspectorAtoms"; +import { dataSourceRecordTypeLabels } from "@/components/DataSourceRecordTypeIcon"; +import { DataSourceTypeLabels } from "@/labels"; +import DataSourceRecordTypeIcon from "@/components/DataSourceRecordTypeIcon"; +import LayerControlWrapper from "../LayerControlWrapper"; +import type { + DataSourceRecordType, + DataSourceType, +} from "@/server/models/DataSource"; + +function useMapDataSources() { + const { viewConfig } = useMapViews(); + const { mapConfig } = useMapConfig(); + const { getDataSourceById } = useDataSources(); + + const ids = new Set(); + if (viewConfig.areaDataSourceId) ids.add(viewConfig.areaDataSourceId); + mapConfig.markerDataSourceIds.forEach((id) => ids.add(id)); + if (mapConfig.membersDataSourceId) ids.add(mapConfig.membersDataSourceId); + + return Array.from(ids) + .map((id) => getDataSourceById(id)) + .filter((ds): ds is NonNullable => ds != null); +} + +function DataSourceRow({ + dataSource, + onClick, +}: { + dataSource: { + id: string; + name: string; + config: { type: DataSourceType }; + recordCount?: number; + recordType?: DataSourceRecordType; + }; + onClick: () => void; +}) { + const subtitle = [ + DataSourceTypeLabels[dataSource.config.type], + dataSource.recordCount != null ? String(dataSource.recordCount) : null, + dataSource.recordType && dataSourceRecordTypeLabels[dataSource.recordType] + ? dataSourceRecordTypeLabels[dataSource.recordType] + : null, + ] + .filter(Boolean) + .join(" · "); + + return ( + + ); +} + +export default function DataControl() { + const [expanded, setExpanded] = useState(true); + const setModalOpen = useSetAtom(inspectorSettingsModalOpenAtom); + const setInitialDataSourceId = useSetAtom( + inspectorSettingsInitialDataSourceIdAtom, + ); + const mapDataSources = useMapDataSources(); + + const openInspectorSettings = (dataSourceId: string | null) => { + setInitialDataSourceId(dataSourceId); + setModalOpen(true); + }; + + return ( + +
    +
    + +
    + +
    + {expanded && ( +
    + {mapDataSources.length === 0 ? ( +

    + No data sources on this map. Add markers or a data visualisation. +

    + ) : ( + mapDataSources.map((ds) => ( + openInspectorSettings(ds.id)} + /> + )) + )} +
    + )} +
    + ); +} diff --git a/src/app/map/[id]/components/controls/LegendMapWidget.tsx b/src/app/map/[id]/components/controls/LegendMapWidget.tsx new file mode 100644 index 000000000..477f23495 --- /dev/null +++ b/src/app/map/[id]/components/controls/LegendMapWidget.tsx @@ -0,0 +1,61 @@ +"use client"; + +import { useChoropleth } from "@/app/map/[id]/hooks/useChoropleth"; +import { useMapViews } from "@/app/map/[id]/hooks/useMapViews"; +import LegendControl from "@/app/map/[id]/components/controls/BoundariesControl/LegendControl"; +import { useBoundariesControl } from "@/app/map/[id]/components/controls/BoundariesControl/useBoundariesControl"; +import { cn } from "@/shadcn/utils"; + +/** + * Compact legend widget positioned top-left over the map. + * Shows the choropleth legend; when clicked opens the visualisation settings panel. + */ +export default function LegendMapWidget() { + const { setBoundariesPanelOpen } = useChoropleth(); + const { hasDataSource } = useBoundariesControl(); + const { viewConfig } = useMapViews(); + + if (!hasDataSource) { + return ( +
    + +
    + ); + } + + return ( +
    +
    setBoundariesPanelOpen(true)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + setBoundariesPanelOpen(true); + } + }} + className="cursor-pointer hover:bg-neutral-50/50 transition-colors" + > + +
    +
    + ); +} diff --git a/src/app/map/[id]/components/controls/PrivateMapControls.tsx b/src/app/map/[id]/components/controls/PrivateMapControls.tsx index 9d0ef9b1b..516e5a6e3 100644 --- a/src/app/map/[id]/components/controls/PrivateMapControls.tsx +++ b/src/app/map/[id]/components/controls/PrivateMapControls.tsx @@ -7,7 +7,7 @@ import { useShowControlsAtom } from "../../hooks/useMapControls"; import { useMapViews } from "../../hooks/useMapViews"; import { CONTROL_PANEL_WIDTH } from "../../styles"; -import BoundariesControl from "./BoundariesControl/BoundariesControl"; +import DataControl from "./DataControl/DataControl"; import MarkersControl from "./MarkersControl/MarkersControl"; import TurfsControl from "./TurfsControl/TurfsControl"; @@ -74,7 +74,7 @@ export default function PrivateMapControls() {
    - +
    diff --git a/src/app/map/[id]/components/inspector/InspectorFullPreview.tsx b/src/app/map/[id]/components/inspector/InspectorFullPreview.tsx index 9b5d3209f..2e46092c8 100644 --- a/src/app/map/[id]/components/inspector/InspectorFullPreview.tsx +++ b/src/app/map/[id]/components/inspector/InspectorFullPreview.tsx @@ -30,15 +30,20 @@ import type { DragEndEvent } from "@dnd-kit/core"; /** * Renders a full preview of the inspector Data tab for boundaries: * On the map section + Data in this area with all BoundaryDataPanels expanded. - * Panels can be reordered by dragging; order is synced to the list. - * When selectedDataSourceId is set, the preview scrolls so that panel is visible. + * Panels can be reordered by dragging; when selectedDataSourceId is set, that + * panel's columns can be reordered via a sortable list. */ export function InspectorFullPreview({ className, selectedDataSourceId, + onReorderColumns, }: { className?: string; selectedDataSourceId?: string | null; + onReorderColumns?: ( + dataSourceId: string, + orderedColumnNames: string[], + ) => void; }) { const scrollContainerRef = useRef(null); const { selectedBoundary } = useInspector(); @@ -59,6 +64,23 @@ export function InspectorFullPreview({ el?.scrollIntoView({ block: "nearest", behavior: "smooth" }); }, [selectedDataSourceId]); + const selectedBoundaryConfig = useMemo( + () => + selectedDataSourceId + ? boundaryConfigs.find( + (c) => c.dataSourceId === selectedDataSourceId, + ) ?? null + : null, + [boundaryConfigs, selectedDataSourceId], + ); + const selectedColumns = useMemo( + () => + selectedBoundaryConfig + ? getSelectedColumnsOrdered(selectedBoundaryConfig) + : [], + [selectedBoundaryConfig], + ); + const boundaryData = useMemo( () => boundaryConfigs.map((config) => ({ @@ -90,7 +112,7 @@ export function InspectorFullPreview({ [getLatestView, updateView], ); - const handleDragEnd = useCallback( + const handlePanelDragEnd = useCallback( (event: DragEndEvent) => { const { active, over } = event; if (!over || active.id === over.id) return; @@ -103,6 +125,22 @@ export function InspectorFullPreview({ [boundaryConfigs, reorderBoundaries], ); + const handleColumnDragEnd = useCallback( + (event: DragEndEvent) => { + const { active, over } = event; + if (!over || active.id === over.id || !selectedBoundaryConfig || !onReorderColumns) return; + const columnIds = selectedColumns.map((_, i) => `col-${i}`); + const oldIndex = columnIds.indexOf(active.id as string); + const newIndex = columnIds.indexOf(over.id as string); + if (oldIndex === -1 || newIndex === -1) return; + const next = [...selectedColumns]; + const [removed] = next.splice(oldIndex, 1); + next.splice(newIndex, 0, removed); + onReorderColumns(selectedBoundaryConfig.dataSourceId, next); + }, + [selectedBoundaryConfig, selectedColumns, onReorderColumns], + ); + const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 6 } }), useSensor(KeyboardSensor, { @@ -130,7 +168,7 @@ export function InspectorFullPreview({

    - Data in this area — drag to reorder + Data in this area

    {boundaryConfigs.length === 0 ? (
    @@ -139,23 +177,61 @@ export function InspectorFullPreview({

    ) : ( - - c.id)} - strategy={verticalListSortingStrategy} + <> + {selectedBoundaryConfig && + selectedColumns.length > 0 && + onReorderColumns && ( +
    +

    + Column order for {selectedBoundaryConfig.name} — drag to reorder +

    + + `col-${i}`)} + strategy={verticalListSortingStrategy} + > +
    + {selectedColumns.map((col, i) => ( + + ))} +
    +
    +
    +
    + )} + -
    - {boundaryData.map((item) => ( - - ))} -
    -
    -
    + c.id)} + strategy={verticalListSortingStrategy} + > +
    +

    + Panels — drag to reorder +

    + {boundaryData.map((item) => ( + + ))} +
    +
    + + )}
    @@ -163,6 +239,50 @@ export function InspectorFullPreview({ ); } +function SortableColumnChip({ + id, + label, +}: { + id: string; + label: string; +}) { + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id }); + const style = { + transform: CSS.Transform.toString(transform), + transition, + }; + return ( +
    + + + {label} + +
    + ); +} + function SortableBoundaryPanel({ item, }: { diff --git a/src/app/map/[id]/components/inspector/InspectorPanel.tsx b/src/app/map/[id]/components/inspector/InspectorPanel.tsx index 02f076216..b834d18ff 100644 --- a/src/app/map/[id]/components/inspector/InspectorPanel.tsx +++ b/src/app/map/[id]/components/inspector/InspectorPanel.tsx @@ -1,10 +1,15 @@ import { useQuery } from "@tanstack/react-query"; import * as turf from "@turf/turf"; import { ArrowLeftIcon, SettingsIcon, XIcon } from "lucide-react"; +import { useSetAtom, useAtomValue } from "jotai"; import { useMemo, useState } from "react"; import { toast } from "sonner"; import { v4 as uuidv4 } from "uuid"; +import { + inspectorSettingsModalOpenAtom, + inspectorSettingsInitialDataSourceIdAtom, +} from "@/app/map/[id]/atoms/inspectorAtoms"; import { useDisplayAreaStat } from "@/app/map/[id]/hooks/useDisplayAreaStats"; import { useInspector } from "@/app/map/[id]/hooks/useInspector"; import { useHoverArea } from "@/app/map/[id]/hooks/useMapHover"; @@ -17,7 +22,6 @@ import { LayerType } from "@/types"; import InspectorDataTab from "./InspectorDataTab"; import InspectorMarkersTab from "./InspectorMarkersTab"; import InspectorNotesTab from "./InspectorNotesTab"; -import InspectorSettingsModal from "./InspectorSettingsModal"; import { UnderlineTabs, UnderlineTabsContent, @@ -31,9 +35,14 @@ export default function InspectorPanel({ boundariesPanelOpen?: boolean; } = {}) { const [activeTab, setActiveTab] = useState("data"); - const [settingsOpen, setSettingsOpen] = useState(false); - const [settingsInitialDataSourceId, setSettingsInitialDataSourceId] = - useState(null); + const settingsOpen = useAtomValue(inspectorSettingsModalOpenAtom); + const settingsInitialDataSourceId = useAtomValue( + inspectorSettingsInitialDataSourceIdAtom, + ); + const setSettingsOpen = useSetAtom(inspectorSettingsModalOpenAtom); + const setSettingsInitialDataSourceId = useSetAtom( + inspectorSettingsInitialDataSourceIdAtom, + ); const [hoverArea] = useHoverArea(); const boundaryHoverVisible = boundariesPanelOpen && !!hoverArea; @@ -103,8 +112,8 @@ export default function InspectorPanel({ >

    Inspector

    - Select a marker, area or boundary (via the data visualisation panel) - to inspect its data + Select a marker, area or boundary to inspect its data, or open a + data source from the Visualisation Data layer to configure the inspector.

    @@ -187,8 +196,11 @@ export default function InspectorPanel({ variant="ghost" size="icon" className="h-8 w-8" - onClick={() => setSettingsOpen(true)} - aria-label="Inspector settings" + onClick={() => { + setSettingsInitialDataSourceId(dataSource?.id ?? null); + setSettingsOpen(true); + }} + aria-label="Visualisation data settings" > @@ -201,11 +213,6 @@ export default function InspectorPanel({
    - {isDetailsView && (
    diff --git a/src/app/map/[id]/components/inspector/InspectorSettingsModal/GlobalColumnRow.tsx b/src/app/map/[id]/components/inspector/InspectorSettingsModal/GlobalColumnRow.tsx new file mode 100644 index 000000000..681244e0c --- /dev/null +++ b/src/app/map/[id]/components/inspector/InspectorSettingsModal/GlobalColumnRow.tsx @@ -0,0 +1,369 @@ +"use client"; + +import { type MouseEvent, useEffect, useState } from "react"; +import { Checkbox } from "@/shadcn/ui/checkbox"; +import { Input } from "@/shadcn/ui/input"; +import { Label } from "@/shadcn/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/shadcn/ui/select"; +import { cn } from "@/shadcn/utils"; +import { useDebouncedCallback } from "../../../hooks/useDebouncedCallback"; +import { + DEFAULT_BAR_COLOR_VALUE, + INSPECTOR_BAR_COLOR_OPTIONS, + SMART_MATCH_BAR_COLOR_VALUE, + getSmartMatchInfo, +} from "../inspectorPanelOptions"; +import type { + InspectorColumnFormat, + InspectorComparisonStat, +} from "@/server/models/MapView"; + +const FORMAT_OPTIONS: { value: InspectorColumnFormat; label: string }[] = [ + { value: "text", label: "Text" }, + { value: "number", label: "Number" }, + { value: "numberWithComparison", label: "Number with comparison" }, + { value: "percentage", label: "Percentage (bar)" }, + { value: "scale", label: "Scale (bars)" }, +]; + +const COMPARISON_STAT_OPTIONS: { + value: InspectorComparisonStat; + label: string; +}[] = [ + { value: "average", label: "Average" }, + { value: "median", label: "Median" }, + { value: "min", label: "Min" }, + { value: "max", label: "Max" }, +]; + +function barColorSelectValue(barColor: string | undefined): string { + if (barColor === DEFAULT_BAR_COLOR_VALUE) return DEFAULT_BAR_COLOR_VALUE; + if (!barColor || barColor === "" || barColor === SMART_MATCH_BAR_COLOR_VALUE) + return SMART_MATCH_BAR_COLOR_VALUE; + return barColor; +} + +function BarColorSelect({ + barColor, + onBarColorChange, + displayName, + columnName, + onClick, +}: { + barColor?: string; + onBarColorChange: (value: string) => void; + displayName: string | undefined; + columnName: string; + onClick: (e: MouseEvent) => void; +}) { + const value = barColorSelectValue(barColor); + const smartMatch = getSmartMatchInfo(displayName ?? columnName, columnName); + const triggerLabel = + value === DEFAULT_BAR_COLOR_VALUE + ? "Default" + : value === SMART_MATCH_BAR_COLOR_VALUE + ? `Smart match (${smartMatch.matchLabel})` + : null; + const triggerSwatchColor = + value === DEFAULT_BAR_COLOR_VALUE + ? "hsl(var(--primary))" + : value === SMART_MATCH_BAR_COLOR_VALUE + ? smartMatch.color + : null; + + return ( +
    + + +
    + ); +} + +export function GlobalColumnRow({ + columnName, + displayName, + onDisplayNameChange, + description, + onDescriptionChange, + format = "text", + onFormatChange, + comparisonStat, + onComparisonStatChange, + scaleMax = 3, + onScaleMaxChange, + barColor, + onBarColorChange, + isExpanded: initialExpanded = false, + showInInspector = true, + onShowInInspectorChange, +}: { + columnName: string; + displayName: string | undefined; + onDisplayNameChange: (value: string) => void; + description?: string; + onDescriptionChange?: (value: string) => void; + format?: InspectorColumnFormat; + onFormatChange?: (format: InspectorColumnFormat) => void; + comparisonStat?: InspectorComparisonStat; + onComparisonStatChange?: (value: InspectorComparisonStat) => void; + scaleMax?: number; + onScaleMaxChange?: (value: number) => void; + barColor?: string; + onBarColorChange?: (value: string) => void; + isExpanded?: boolean; + showInInspector?: boolean; + onShowInInspectorChange?: (show: boolean) => void; +}) { + const [localDisplayName, setLocalDisplayName] = useState(displayName ?? ""); + const [localDescription, setLocalDescription] = useState(description ?? ""); + const [localScaleMax, setLocalScaleMax] = useState(String(scaleMax)); + const [expanded, setExpanded] = useState(initialExpanded); + + useEffect(() => setLocalDisplayName(displayName ?? ""), [displayName]); + useEffect(() => setLocalDescription(description ?? ""), [description]); + useEffect(() => setLocalScaleMax(String(scaleMax)), [scaleMax]); + + const debouncedDisplayName = useDebouncedCallback(onDisplayNameChange, 600); + const debouncedDescription = useDebouncedCallback( + (v: string) => onDescriptionChange?.(v), + 600, + ); + const debouncedScaleMax = useDebouncedCallback( + (v: number) => onScaleMaxChange?.(v), + 600, + ); + + const hasCustomSettings = + (displayName ?? "") !== "" || + (description ?? "") !== "" || + format !== "text" || + (format === "scale" && scaleMax !== 3) || + (format === "numberWithComparison" && comparisonStat !== undefined) || + ((format === "percentage" || format === "scale") && (barColor ?? "") !== ""); + + return ( +
    + + {expanded && ( +
    +
    + + { + const v = e.target.value; + setLocalDisplayName(v); + debouncedDisplayName(v); + }} + /> +
    + {onDescriptionChange && ( +
    + + { + const v = e.target.value; + setLocalDescription(v); + debouncedDescription(v); + }} + /> +
    + )} + {onFormatChange && ( +
    + + +
    + )} + {format === "scale" && onScaleMaxChange && ( +
    + + { + const v = e.target.value; + setLocalScaleMax(v); + const n = parseInt(v, 10); + if (!Number.isNaN(n) && n >= 2 && n <= 10) + debouncedScaleMax(n); + }} + /> +
    + )} + {format === "numberWithComparison" && onComparisonStatChange && ( +
    + + +
    + )} + {(format === "percentage" || format === "scale") && + onBarColorChange && ( + e.stopPropagation()} + /> + )} +
    + )} +
    + ); +} diff --git a/src/app/map/[id]/components/inspector/InspectorSettingsModal/GlobalColumnSettingsPanel.tsx b/src/app/map/[id]/components/inspector/InspectorSettingsModal/GlobalColumnSettingsPanel.tsx new file mode 100644 index 000000000..fa03e84ea --- /dev/null +++ b/src/app/map/[id]/components/inspector/InspectorSettingsModal/GlobalColumnSettingsPanel.tsx @@ -0,0 +1,270 @@ +"use client"; + +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { toast } from "sonner"; +import { useTRPC } from "@/services/trpc/react"; +import { Input } from "@/shadcn/ui/input"; +import { GlobalColumnRow } from "./GlobalColumnRow"; +import { inferFormat } from "./constants"; +import { normalizeInspectorBoundaryConfig } from "../inspectorColumnOrder"; +import { InspectorBoundaryConfigType } from "@/server/models/MapView"; +import type { DataSource } from "@/server/models/DataSource"; +import type { + DefaultInspectorBoundaryConfig, + InspectorBoundaryConfig, + InspectorColumnMeta, +} from "@/server/models/MapView"; +import type { useMapViews } from "../../../hooks/useMapViews"; + +export function GlobalColumnSettingsPanel({ + dataSource, + boundaryConfig, + getLatestView, + updateView, +}: { + dataSource: DataSource; + boundaryConfig?: InspectorBoundaryConfig | null; + getLatestView?: ReturnType["getLatestView"]; + updateView?: ReturnType["updateView"]; +}) { + const trpc = useTRPC(); + const queryClient = useQueryClient(); + const { mutateAsync: saveConfig } = useMutation( + trpc.dataSource.updateConfig.mutationOptions({ + onSuccess: async () => { + await queryClient.invalidateQueries({ + queryKey: trpc.dataSource.listReadable.queryKey(), + }); + }, + onError: (err) => { + toast.error( + err.message ?? "Failed to save default column settings.", + ); + }, + }), + ); + + const defaultConfig = dataSource.defaultInspectorConfig ?? null; + const allColumnNames = useMemo( + () => dataSource.columnDefs.map((c) => c.name), + [dataSource.columnDefs], + ); + const columnMetadata = useMemo( + () => defaultConfig?.columnMetadata ?? {}, + [defaultConfig?.columnMetadata], + ); + + const [localMetadata, setLocalMetadata] = + useState>(columnMetadata); + useEffect(() => { + setLocalMetadata(columnMetadata); + }, [dataSource.id, columnMetadata]); + + const updateMetadata = useCallback( + (colName: string, updater: (prev: InspectorColumnMeta) => InspectorColumnMeta) => { + const updatedMeta = updater(localMetadata[colName] ?? {}); + setLocalMetadata((prev) => ({ + ...prev, + [colName]: updatedMeta, + })); + const nextMetadata = { + ...localMetadata, + [colName]: updatedMeta, + }; + const nextDefault: DefaultInspectorBoundaryConfig = { + ...defaultConfig, + name: defaultConfig?.name ?? dataSource.name ?? "Boundary Data", + type: defaultConfig?.type ?? InspectorBoundaryConfigType.Simple, + columns: defaultConfig?.columns ?? [], + columnMetadata: nextMetadata, + }; + saveConfig({ + dataSourceId: dataSource.id, + defaultInspectorConfig: nextDefault, + }).catch(() => {}); + }, + [dataSource.id, dataSource.name, defaultConfig, localMetadata, saveConfig], + ); + + const columnsInInspectorSet = useMemo(() => { + const list = boundaryConfig?.columns ?? defaultConfig?.columns ?? []; + if (list.length === 0) return new Set(allColumnNames); + return new Set(list); + }, [ + boundaryConfig?.columns, + defaultConfig?.columns, + allColumnNames, + ]); + + const setColumnShowInInspector = useCallback( + (colName: string, show: boolean) => { + if (boundaryConfig && getLatestView && updateView) { + const latestView = getLatestView(); + if (!latestView?.inspectorConfig?.boundaries) return; + const boundaries = latestView.inspectorConfig.boundaries; + const index = boundaries.findIndex( + (c) => c.dataSourceId === dataSource.id, + ); + if (index === -1) return; + const config = boundaries[index]; + const current = config.columns ?? []; + const nextColumns = show + ? current.includes(colName) + ? current + : [...current, colName] + : current.filter((c) => c !== colName); + const normalized = normalizeInspectorBoundaryConfig( + { ...config, columns: nextColumns, columnOrder: nextColumns }, + allColumnNames, + ); + if (!normalized) return; + const next = [...boundaries]; + next[index] = { ...normalized, columnItems: normalized.columns }; + updateView({ + ...latestView, + inspectorConfig: { + ...latestView.inspectorConfig, + boundaries: next, + }, + }); + } else { + const current = defaultConfig?.columns ?? []; + const nextColumns = show + ? current.includes(colName) + ? current + : [...current, colName] + : current.filter((c) => c !== colName); + const nextDefault: DefaultInspectorBoundaryConfig = { + ...defaultConfig, + name: defaultConfig?.name ?? dataSource.name ?? "Boundary Data", + type: defaultConfig?.type ?? InspectorBoundaryConfigType.Simple, + columns: nextColumns, + columnMetadata: defaultConfig?.columnMetadata ?? {}, + }; + saveConfig({ + dataSourceId: dataSource.id, + defaultInspectorConfig: nextDefault, + }).catch(() => {}); + } + }, + [ + boundaryConfig, + dataSource.id, + dataSource.name, + defaultConfig, + getLatestView, + updateView, + allColumnNames, + saveConfig, + ], + ); + + const [search, setSearch] = useState(""); + const filteredColumns = useMemo(() => { + if (!search.trim()) return allColumnNames; + const q = search.toLowerCase(); + return allColumnNames.filter((name) => + name.toLowerCase().includes(q) || + (localMetadata[name]?.displayName ?? "").toLowerCase().includes(q), + ); + }, [allColumnNames, search, localMetadata]); + + const isOwner = "isOwner" in dataSource && dataSource.isOwner === true; + if (!isOwner) { + return ( +
    + You don’t have permission to edit default column settings for this data + source. +
    + ); + } + + return ( +
    +
    +
    +

    + Column settings +

    +

    + Set display names, formats, and choose which columns show in the + inspector. Show in inspector is on by default. +

    +
    + setSearch(e.target.value)} + className="h-9 max-w-sm" + /> +
    +
    +
    + {filteredColumns.map((colName) => { + const meta = localMetadata[colName] ?? {}; + const inferredFormat = inferFormat(colName); + const format = meta.format ?? inferredFormat ?? "text"; + return ( + + setColumnShowInInspector(colName, show) + } + displayName={meta.displayName} + onDisplayNameChange={(value) => + updateMetadata(colName, (prev) => ({ + ...prev, + displayName: value || undefined, + })) + } + description={meta.description} + onDescriptionChange={(value) => + updateMetadata(colName, (prev) => ({ + ...prev, + description: value || undefined, + })) + } + format={format} + onFormatChange={(value) => + updateMetadata(colName, (prev) => ({ ...prev, format: value })) + } + comparisonStat={meta.comparisonStat} + onComparisonStatChange={(value) => + updateMetadata(colName, (prev) => ({ + ...prev, + comparisonStat: value, + })) + } + scaleMax={meta.scaleMax ?? 3} + onScaleMaxChange={(value) => + updateMetadata(colName, (prev) => ({ + ...prev, + scaleMax: value, + })) + } + barColor={meta.barColor} + onBarColorChange={(value) => + updateMetadata(colName, (prev) => ({ + ...prev, + barColor: value || undefined, + })) + } + /> + ); + })} +
    + {filteredColumns.length === 0 && ( +

    + {allColumnNames.length === 0 + ? "This data source has no columns." + : "No columns match your search."} +

    + )} +
    +
    + ); +} diff --git a/src/app/map/[id]/components/inspector/InspectorSettingsModal/InspectorSettingsModal.tsx b/src/app/map/[id]/components/inspector/InspectorSettingsModal/InspectorSettingsModal.tsx index 3a34e74e3..8132e0d00 100644 --- a/src/app/map/[id]/components/inspector/InspectorSettingsModal/InspectorSettingsModal.tsx +++ b/src/app/map/[id]/components/inspector/InspectorSettingsModal/InspectorSettingsModal.tsx @@ -1,21 +1,40 @@ "use client"; -import { useCallback, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { v4 as uuidv4 } from "uuid"; +import { LayoutGrid, LayoutList } from "lucide-react"; import { InspectorBoundaryConfigType } from "@/server/models/MapView"; +import { Checkbox } from "@/shadcn/ui/checkbox"; import { Dialog, DialogContent, DialogHeader, DialogTitle, } from "@/shadcn/ui/dialog"; +import { Input } from "@/shadcn/ui/input"; +import { Label } from "@/shadcn/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/shadcn/ui/select"; +import { cn } from "@/shadcn/utils"; +import { useDebouncedCallback } from "../../../hooks/useDebouncedCallback"; import { useDataSources } from "../../../hooks/useDataSources"; import { useDebouncedValue } from "../../../hooks/useDebouncedValue"; import { useMapViews } from "../../../hooks/useMapViews"; import { normalizeInspectorBoundaryConfig } from "../inspectorColumnOrder"; +import { + INSPECTOR_COLOR_OPTIONS, + INSPECTOR_ICON_OPTIONS, +} from "../inspectorPanelOptions"; import { InspectorFullPreview } from "../InspectorFullPreview"; import { DataSourcesList } from "./DataSourcesList"; -import { InspectorSourceConfigPanel } from "./InspectorSourceConfigPanel"; +import { DEFAULT_SELECT_VALUE } from "./constants"; +import type { InspectorLayout } from "./constants"; +import { GlobalColumnSettingsPanel } from "./GlobalColumnSettingsPanel"; import type { DataSource } from "@/server/models/DataSource"; import type { InspectorBoundaryConfig } from "@/server/models/MapView"; @@ -86,13 +105,11 @@ export default function InspectorSettingsModal({ }, [boundaryConfigs, getDataSourceById, filteredSources, matchesSearch]); // When opened with an initial data source id, focus that source. - if ( - open && - initialDataSourceId && - selectedDataSourceId !== initialDataSourceId - ) { - setSelectedDataSourceId(initialDataSourceId); - } + useEffect(() => { + if (open && initialDataSourceId != null) { + setSelectedDataSourceId(initialDataSourceId); + } + }, [open, initialDataSourceId]); const handleRemoveFromInspector = useCallback( (configId: string) => { @@ -133,13 +150,15 @@ export default function InspectorSettingsModal({ if (!ds) return; const defaultConfig = ds.defaultInspectorConfig; const allCols = ds.columnDefs.map((c) => c.name); + const defaultColumns = + defaultConfig?.columns?.length ? defaultConfig.columns : allCols; const raw: InspectorBoundaryConfig = { id: uuidv4(), dataSourceId: selectedDataSourceId, name: defaultConfig?.name ?? ds.name ?? "Boundary Data", type: defaultConfig?.type ?? InspectorBoundaryConfigType.Simple, - columns: defaultConfig?.columns ?? [], - columnOrder: defaultConfig?.columnOrder, + columns: defaultColumns, + columnOrder: defaultConfig?.columnOrder ?? defaultColumns, columnItems: defaultConfig?.columnItems, columnMetadata: defaultConfig?.columnMetadata, columnGroups: defaultConfig?.columnGroups, @@ -158,6 +177,107 @@ export default function InspectorSettingsModal({ }); }, [view, selectedDataSourceId, dataSources, updateView]); + // Appear in inspector on by default: when user selects a data source not yet in the inspector, add it. + useEffect(() => { + if ( + open && + view && + selectedDataSourceId && + !selectedConfig && + (dataSources ?? []).some((d) => d.id === selectedDataSourceId) + ) { + handleAddToInspector(); + } + }, [ + open, + view, + selectedDataSourceId, + selectedConfig, + dataSources, + handleAddToInspector, + ]); + + const isInInspector = !!selectedConfig; + const onAppearInInspectorChange = useCallback( + (checked: boolean) => { + if (!selectedDataSourceId || !view) return; + if (checked) { + handleAddToInspector(); + } else { + const config = boundaryConfigs.find( + (c) => c.dataSourceId === selectedDataSourceId, + ); + if (config) handleRemoveFromInspector(config.id); + } + }, + [ + selectedDataSourceId, + view, + boundaryConfigs, + handleAddToInspector, + handleRemoveFromInspector, + ], + ); + + const updateBoundaryConfig = useCallback( + (updater: (prev: InspectorBoundaryConfig) => InspectorBoundaryConfig) => { + if (!selectedConfig || !view) return; + const latestView = getLatestView(); + if (!latestView?.inspectorConfig?.boundaries) return; + const boundaries = latestView.inspectorConfig.boundaries; + const index = boundaries.findIndex((c) => c.id === selectedConfig.id); + if (index === -1) return; + const next = [...boundaries]; + next[index] = updater(boundaries[index]); + updateView({ + ...latestView, + inspectorConfig: { + ...latestView.inspectorConfig, + boundaries: next, + }, + }); + }, + [selectedConfig, view, getLatestView, updateView], + ); + + const [panelDisplayName, setPanelDisplayName] = useState( + selectedConfig?.name ?? "", + ); + useEffect(() => { + setPanelDisplayName(selectedConfig?.name ?? selectedDataSource?.name ?? ""); + }, [selectedConfig?.name, selectedDataSource?.name]); + const debouncedUpdatePanelName = useDebouncedCallback( + (value: string) => + updateBoundaryConfig((prev) => ({ ...prev, name: value })), + 600, + ); + + const handleReorderColumns = useCallback( + (dataSourceId: string, orderedColumnNames: string[]) => { + const latestView = getLatestView(); + if (!latestView?.inspectorConfig?.boundaries) return; + const boundaries = latestView.inspectorConfig.boundaries; + const index = boundaries.findIndex((c) => c.dataSourceId === dataSourceId); + if (index === -1) return; + const config = boundaries[index]; + const next = [...boundaries]; + next[index] = { + ...config, + columns: orderedColumnNames, + columnOrder: orderedColumnNames, + columnItems: orderedColumnNames, + }; + updateView({ + ...latestView, + inspectorConfig: { + ...latestView.inspectorConfig, + boundaries: next, + }, + }); + }, + [getLatestView, updateView], + ); + return ( setSelectedDataSourceId(null)} > - Inspector settings + Visualisation data settings

    - Choose data sources to show in the inspector and configure columns, - order, and layout. + Choose data sources to show in the inspector, edit column settings, + and reorder columns in the preview.

    @@ -186,23 +306,169 @@ export default function InspectorSettingsModal({ />
    - {selectedDataSource && view ? ( - - ) : selectedDataSource ? ( -
    - No view loaded. + {selectedDataSource ? ( +
    +
    +

    + {selectedDataSource.name} +

    + {view && ( +
    + + onAppearInInspectorChange(checked === true) + } + /> + +
    + )} + {isInInspector && selectedConfig && ( +
    +
    + + { + const v = e.target.value; + setPanelDisplayName(v); + debouncedUpdatePanelName(v); + }} + placeholder="e.g. Main data" + className="h-9 max-w-full" + /> +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + )} +
    +
    + +
    ) : (
    - Select a data source to configure columns (drag them into the - list). + Select a data source to edit column settings and choose whether it + appears in the inspector.
    )}
    @@ -214,6 +480,7 @@ export default function InspectorSettingsModal({
    From ffa1dce73e12f5163b736a47760586cb825b4d27 Mon Sep 17 00:00:00 2001 From: Arbyhisenaj <41119392+Arbyhisenaj@users.noreply.github.com> Date: Tue, 10 Mar 2026 11:32:20 +0000 Subject: [PATCH 20/21] dang - forgot to push --- src/app/map/[id]/components/TogglePanel.tsx | 18 +- .../inspector/BoundaryDataPanel.tsx | 7 +- .../inspector/InspectorFullPreview.tsx | 129 +---- .../ColumnOrderList.tsx | 136 +++++ .../DataSourcesList.tsx | 382 ++++++++------ .../GeneralColumnOptionsPanel.tsx | 244 +++++++++ .../GlobalColumnRow.tsx | 111 ++-- .../InspectorSettingsModal.tsx | 400 ++++---------- .../InspectorSettingsTabContent.tsx | 492 ++++++++++++++++++ tsconfig.json | 16 +- 10 files changed, 1309 insertions(+), 626 deletions(-) create mode 100644 src/app/map/[id]/components/inspector/InspectorSettingsModal/ColumnOrderList.tsx create mode 100644 src/app/map/[id]/components/inspector/InspectorSettingsModal/GeneralColumnOptionsPanel.tsx create mode 100644 src/app/map/[id]/components/inspector/InspectorSettingsModal/InspectorSettingsTabContent.tsx diff --git a/src/app/map/[id]/components/TogglePanel.tsx b/src/app/map/[id]/components/TogglePanel.tsx index 2fd6088b8..d935e2b65 100644 --- a/src/app/map/[id]/components/TogglePanel.tsx +++ b/src/app/map/[id]/components/TogglePanel.tsx @@ -1,5 +1,5 @@ import { ChevronDown } from "lucide-react"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import { cn } from "@/shadcn/utils"; import type { LucideIcon } from "lucide-react"; @@ -7,11 +7,12 @@ interface TogglePanelProps { label: string; icon?: React.ReactNode; defaultExpanded?: boolean; + /** When provided, controls the expanded state externally (overrides internal toggle). */ + expanded?: boolean; children?: React.ReactNode; headerRight?: React.ReactNode; rightIconButton?: LucideIcon; onRightIconButtonClick?: () => void; - /** Optional class for the outer wrapper (e.g. panel background colour) */ wrapperClassName?: string; } @@ -19,19 +20,28 @@ export default function TogglePanel({ label, icon: Icon, defaultExpanded = true, + expanded: controlledExpanded, children, headerRight, rightIconButton: RightIconButton, onRightIconButtonClick, wrapperClassName, }: TogglePanelProps) { - const [expanded, setExpanded] = useState(defaultExpanded); + const [internalExpanded, setInternalExpanded] = useState(controlledExpanded ?? defaultExpanded); + + useEffect(() => { + if (controlledExpanded !== undefined) { + setInternalExpanded(controlledExpanded); + } + }, [controlledExpanded]); + + const expanded = internalExpanded; return (
    ) : ( <> - {selectedBoundaryConfig && - selectedColumns.length > 0 && - onReorderColumns && ( -
    -

    - Column order for {selectedBoundaryConfig.name} — drag to reorder -

    - - `col-${i}`)} - strategy={verticalListSortingStrategy} - > -
    - {selectedColumns.map((col, i) => ( - - ))} -
    -
    -
    -
    - )} {boundaryData.map((item) => ( - + ))}
    @@ -239,52 +176,9 @@ export function InspectorFullPreview({ ); } -function SortableColumnChip({ - id, - label, -}: { - id: string; - label: string; -}) { - const { - attributes, - listeners, - setNodeRef, - transform, - transition, - isDragging, - } = useSortable({ id }); - const style = { - transform: CSS.Transform.toString(transform), - transition, - }; - return ( -
    - - - {label} - -
    - ); -} - function SortableBoundaryPanel({ item, + selectedDataSourceId, }: { item: { config: InspectorBoundaryConfig; @@ -292,6 +186,7 @@ function SortableBoundaryPanel({ areaCode: string; columns: string[]; }; + selectedDataSourceId?: string | null; }) { const { attributes, @@ -305,6 +200,9 @@ function SortableBoundaryPanel({ transform: CSS.Transform.toString(transform), transition, }; + const isSelected = + selectedDataSourceId != null && + item.config.dataSourceId === selectedDataSourceId; return (
    diff --git a/src/app/map/[id]/components/inspector/InspectorSettingsModal/ColumnOrderList.tsx b/src/app/map/[id]/components/inspector/InspectorSettingsModal/ColumnOrderList.tsx new file mode 100644 index 000000000..e97b1223c --- /dev/null +++ b/src/app/map/[id]/components/inspector/InspectorSettingsModal/ColumnOrderList.tsx @@ -0,0 +1,136 @@ +"use client"; + +import { + DndContext, + KeyboardSensor, + PointerSensor, + closestCenter, + useSensor, + useSensors, +} from "@dnd-kit/core"; +import { restrictToVerticalAxis } from "@dnd-kit/modifiers"; +import { + SortableContext, + sortableKeyboardCoordinates, + useSortable, + verticalListSortingStrategy, +} from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; +import { GripVertical } from "lucide-react"; +import { useCallback } from "react"; +import { cn } from "@/shadcn/utils"; +import type { DragEndEvent } from "@dnd-kit/core"; + +export function ColumnOrderList({ + selectedColumns, + getLabel, + dataSourceId, + onReorderColumns, +}: { + selectedColumns: string[]; + getLabel: (colName: string) => string; + dataSourceId: string; + onReorderColumns: ( + dataSourceId: string, + orderedColumnNames: string[], + ) => void; +}) { + const handleColumnDragEnd = useCallback( + (event: DragEndEvent) => { + const { active, over } = event; + if (!over || active.id === over.id) return; + const columnIds = selectedColumns.map((_, i) => `col-${i}`); + const oldIndex = columnIds.indexOf(active.id as string); + const newIndex = columnIds.indexOf(over.id as string); + if (oldIndex === -1 || newIndex === -1) return; + const next = [...selectedColumns]; + const [removed] = next.splice(oldIndex, 1); + next.splice(newIndex, 0, removed); + onReorderColumns(dataSourceId, next); + }, + [selectedColumns, dataSourceId, onReorderColumns], + ); + + const sensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 6 } }), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }), + ); + + if (selectedColumns.length === 0) { + return ( +

    + Tick columns in the list to add them, then reorder here. +

    + ); + } + + return ( + + `col-${i}`)} + strategy={verticalListSortingStrategy} + > +
    + {selectedColumns.map((col, i) => ( + + ))} +
    +
    +
    + ); +} + +function SortableColumnChip({ + id, + label, +}: { + id: string; + label: string; +}) { + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id }); + const style = { + transform: CSS.Transform.toString(transform), + transition, + }; + return ( +
    + + + {label} + +
    + ); +} diff --git a/src/app/map/[id]/components/inspector/InspectorSettingsModal/DataSourcesList.tsx b/src/app/map/[id]/components/inspector/InspectorSettingsModal/DataSourcesList.tsx index c1133e4d5..97a1fbc9e 100644 --- a/src/app/map/[id]/components/inspector/InspectorSettingsModal/DataSourcesList.tsx +++ b/src/app/map/[id]/components/inspector/InspectorSettingsModal/DataSourcesList.tsx @@ -1,6 +1,6 @@ "use client"; -import { MapPin, XCircle } from "lucide-react"; +import { Library, MapPin, XCircle } from "lucide-react"; import { useMemo } from "react"; import DataSourceIcon from "@/components/DataSourceIcon"; import { getDataSourceType } from "@/components/DataSourceItem"; @@ -9,148 +9,204 @@ import { cn } from "@/shadcn/utils"; import type { DataSource } from "@/server/models/DataSource"; import type { InspectorBoundaryConfig } from "@/server/models/MapView"; -const GROUP_LABEL_USER = "User data"; -const GROUP_LABEL_PUBLIC = "Public data"; +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- -function isUserDataSource(ds: DataSource) { - return !ds.public; +interface InspectorEntry { + config: InspectorBoundaryConfig; + dataSource: DataSource; } -export function DataSourcesList({ - searchQuery, - onSearchChange, - inspectorOrdered, - otherSources, - onMapId, - selectedDataSourceId, - onSelectDataSource, - onRemoveFromInspector, -}: { +interface DataSourcesListProps { searchQuery: string; onSearchChange: (value: string) => void; - inspectorOrdered: { - config: InspectorBoundaryConfig; - dataSource: DataSource; - }[]; + inspectorOrdered: InspectorEntry[]; otherSources: DataSource[]; onMapId: string | null; selectedDataSourceId: string | null; onSelectDataSource: (id: string) => void; onRemoveFromInspector: (configId: string) => void; -}) { - const { inspectorUser, inspectorPublic, otherUser, otherPublic } = useMemo( - () => ({ - inspectorUser: inspectorOrdered.filter(({ dataSource: ds }) => - isUserDataSource(ds), - ), - inspectorPublic: inspectorOrdered.filter( - ({ dataSource: ds }) => ds.public, - ), - otherUser: otherSources.filter(isUserDataSource), - otherPublic: otherSources.filter((ds) => ds.public), - }), - [inspectorOrdered, otherSources], +} + +// --------------------------------------------------------------------------- +// Shared pieces +// --------------------------------------------------------------------------- + +function OnMapBadge() { + return ( + + + On map + ); +} - const renderInspectorItem = ({ - config, - dataSource: ds, - }: { - config: InspectorBoundaryConfig; - dataSource: DataSource; - }) => { - const isOnMap = ds.id === onMapId; - const isSelected = ds.id === selectedDataSourceId; - return ( -
    + [0], )} - > - - -
    - ); - }; + /> + + ); +} - const renderOtherItem = (ds: DataSource) => { - const isOnMap = ds.id === onMapId; - const isSelected = ds.id === selectedDataSourceId; - return ( +// --------------------------------------------------------------------------- +// Item renderers +// --------------------------------------------------------------------------- + +function InspectorItem({ + entry, + isOnMap, + isSelected, + onSelect, + onRemove, +}: { + entry: InspectorEntry; + isOnMap: boolean; + isSelected: boolean; + onSelect: () => void; + onRemove: () => void; +}) { + const { dataSource: ds } = entry; + return ( +
    - ); - }; + +
    + ); +} - const hasInspector = inspectorUser.length > 0 || inspectorPublic.length > 0; +function LibraryItem({ + ds, + isOnMap, + isSelected, + onSelect, +}: { + ds: DataSource; + isOnMap: boolean; + isSelected: boolean; + onSelect: () => void; +}) { + return ( + + ); +} + +function LibraryGroup({ + label, + items, + onMapId, + selectedDataSourceId, + onSelectDataSource, + className, +}: { + label: string; + items: DataSource[]; + onMapId: string | null; + selectedDataSourceId: string | null; + onSelectDataSource: (id: string) => void; + className?: string; +}) { + if (items.length === 0) return null; + return ( +
    +

    + {label} +

    +
    + {items.map((ds) => ( + onSelectDataSource(ds.id)} + /> + ))} +
    +
    + ); +} + +// --------------------------------------------------------------------------- +// Main component +// --------------------------------------------------------------------------- + +export function DataSourcesList({ + searchQuery, + onSearchChange, + inspectorOrdered, + otherSources, + onMapId, + selectedDataSourceId, + onSelectDataSource, + onRemoveFromInspector, +}: DataSourcesListProps) { + const { otherUser, otherPublic } = useMemo( + () => ({ + otherUser: otherSources.filter((ds) => !ds.public), + otherPublic: otherSources.filter((ds) => ds.public), + }), + [otherSources], + ); + + const hasInspector = inspectorOrdered.length > 0; const hasOther = otherUser.length > 0 || otherPublic.length > 0; return ( @@ -163,60 +219,56 @@ export function DataSourcesList({ className="h-9" />
    +
    + {/* On this map */} {hasInspector && (

    - Showing inspector + On this map

    -
    - {inspectorUser.length > 0 && ( -
    -

    - {GROUP_LABEL_USER} -

    -
    - {inspectorUser.map((item) => renderInspectorItem(item))} -
    -
    - )} - {inspectorPublic.length > 0 && ( -
    -

    - {GROUP_LABEL_PUBLIC} -

    -
    - {inspectorPublic.map((item) => renderInspectorItem(item))} -
    -
    - )} +
    + {inspectorOrdered.map((entry) => ( + onSelectDataSource(entry.dataSource.id)} + onRemove={() => onRemoveFromInspector(entry.config.id)} + /> + ))}
    )} + + {/* Library */} {hasOther && ( -
    - {otherUser.length > 0 && ( -
    -

    - {GROUP_LABEL_USER} -

    -
    - {otherUser.map(renderOtherItem)} -
    -
    - )} - {otherPublic.length > 0 && ( -
    -

    - {GROUP_LABEL_PUBLIC} -

    -
    - {otherPublic.map(renderOtherItem)} -
    -
    - )} -
    +
    +

    + + Library +

    + + 0 && "mt-3")} + /> +
    )} + + {/* Empty state */} {!hasInspector && !hasOther && (

    No data sources match. diff --git a/src/app/map/[id]/components/inspector/InspectorSettingsModal/GeneralColumnOptionsPanel.tsx b/src/app/map/[id]/components/inspector/InspectorSettingsModal/GeneralColumnOptionsPanel.tsx new file mode 100644 index 000000000..d79e9464c --- /dev/null +++ b/src/app/map/[id]/components/inspector/InspectorSettingsModal/GeneralColumnOptionsPanel.tsx @@ -0,0 +1,244 @@ +"use client"; + +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { toast } from "sonner"; +import { useTRPC } from "@/services/trpc/react"; +import { Input } from "@/shadcn/ui/input"; +import { cn } from "@/shadcn/utils"; +import { GlobalColumnRow } from "./GlobalColumnRow"; +import { inferFormat } from "./constants"; +import { InspectorBoundaryConfigType } from "@/server/models/MapView"; +import type { DataSource } from "@/server/models/DataSource"; +import type { + DefaultInspectorBoundaryConfig, + InspectorColumnMeta, +} from "@/server/models/MapView"; + + +/** + * General column options that apply everywhere: label (display name), + * description, format. No inspector-specific visibility. + */ +export function GeneralColumnOptionsPanel({ + dataSource, +}: { + dataSource: DataSource; +}) { + const trpc = useTRPC(); + const queryClient = useQueryClient(); + const { mutateAsync: saveConfig } = useMutation( + trpc.dataSource.updateConfig.mutationOptions({ + onSuccess: async () => { + await queryClient.invalidateQueries({ + queryKey: trpc.dataSource.listReadable.queryKey(), + }); + }, + onError: (err) => { + toast.error( + err.message ?? "Failed to save column settings.", + ); + }, + }), + ); + + const defaultConfig = dataSource.defaultInspectorConfig ?? null; + const allColumnNames = useMemo( + () => dataSource.columnDefs.map((c) => c.name), + [dataSource.columnDefs], + ); + const columnMetadata = useMemo( + () => defaultConfig?.columnMetadata ?? {}, + [defaultConfig?.columnMetadata], + ); + + const [localMetadata, setLocalMetadata] = + useState>(columnMetadata); + useEffect(() => { + setLocalMetadata(columnMetadata); + }, [dataSource.id, columnMetadata]); + + const updateMetadata = useCallback( + (colName: string, updater: (prev: InspectorColumnMeta) => InspectorColumnMeta) => { + const updatedMeta = updater(localMetadata[colName] ?? {}); + setLocalMetadata((prev) => ({ + ...prev, + [colName]: updatedMeta, + })); + const nextMetadata = { + ...localMetadata, + [colName]: updatedMeta, + }; + const nextDefault: DefaultInspectorBoundaryConfig = { + ...defaultConfig, + name: defaultConfig?.name ?? dataSource.name ?? "Boundary Data", + type: defaultConfig?.type ?? InspectorBoundaryConfigType.Simple, + columns: defaultConfig?.columns ?? [], + columnMetadata: nextMetadata, + }; + saveConfig({ + dataSourceId: dataSource.id, + defaultInspectorConfig: nextDefault, + }).catch(() => {}); + }, + [dataSource.id, dataSource.name, defaultConfig, localMetadata, saveConfig], + ); + + const [search, setSearch] = useState(""); + const filteredColumns = useMemo(() => { + if (!search.trim()) return allColumnNames; + const q = search.toLowerCase(); + return allColumnNames.filter((name) => + name.toLowerCase().includes(q) || + (localMetadata[name]?.displayName ?? "").toLowerCase().includes(q), + ); + }, [allColumnNames, search, localMetadata]); + + const isOwner = "isOwner" in dataSource && dataSource.isOwner === true; + if (!isOwner) { + return ( +

    + You don’t have permission to edit column settings for this data source. +
    + ); + } + + const mainScrollRef = useRef(null); + const columnRefs = useRef>({}); + const scrollToColumn = useCallback((colName: string) => { + const container = mainScrollRef.current; + const row = columnRefs.current[colName]; + if (!container || !row) return; + const containerRect = container.getBoundingClientRect(); + const rowRect = row.getBoundingClientRect(); + const scrollTop = + container.scrollTop + (rowRect.top - containerRect.top) - 16; + container.scrollTo({ top: Math.max(0, scrollTop), behavior: "smooth" }); + }, []); + + return ( +
    + +
    +
    +

    + General column options +

    +

    + Label, description, and format apply everywhere this data source is + used. +

    +
    +
    +
    + {filteredColumns.map((colName) => { + const meta = localMetadata[colName] ?? {}; + const inferredFormat = inferFormat(colName); + const format = meta.format ?? inferredFormat ?? "text"; + return ( +
    { + if (el) columnRefs.current[colName] = el; + else delete columnRefs.current[colName]; + }} + className="border-t border-neutral-200 pt-4 first:border-t-0 first:pt-0" + > + + updateMetadata(colName, (prev) => ({ + ...prev, + displayName: value || undefined, + })) + } + description={meta.description} + onDescriptionChange={(value) => + updateMetadata(colName, (prev) => ({ + ...prev, + description: value || undefined, + })) + } + format={format} + onFormatChange={(value) => + updateMetadata(colName, (prev) => ({ ...prev, format: value })) + } + comparisonStat={meta.comparisonStat} + onComparisonStatChange={(value) => + updateMetadata(colName, (prev) => ({ + ...prev, + comparisonStat: value, + })) + } + scaleMax={meta.scaleMax ?? 3} + onScaleMaxChange={(value) => + updateMetadata(colName, (prev) => ({ + ...prev, + scaleMax: value, + })) + } + barColor={meta.barColor} + onBarColorChange={(value) => + updateMetadata(colName, (prev) => ({ + ...prev, + barColor: value || undefined, + })) + } + /> +
    + ); + })} +
    + {filteredColumns.length === 0 && ( +

    + {allColumnNames.length === 0 + ? "This data source has no columns." + : "No columns match your search."} +

    + )} +
    +
    +
    + ); +} diff --git a/src/app/map/[id]/components/inspector/InspectorSettingsModal/GlobalColumnRow.tsx b/src/app/map/[id]/components/inspector/InspectorSettingsModal/GlobalColumnRow.tsx index 681244e0c..c729591b8 100644 --- a/src/app/map/[id]/components/inspector/InspectorSettingsModal/GlobalColumnRow.tsx +++ b/src/app/map/[id]/components/inspector/InspectorSettingsModal/GlobalColumnRow.tsx @@ -158,6 +158,7 @@ export function GlobalColumnRow({ barColor, onBarColorChange, isExpanded: initialExpanded = false, + alwaysExpanded = false, showInInspector = true, onShowInInspectorChange, }: { @@ -175,6 +176,8 @@ export function GlobalColumnRow({ barColor?: string; onBarColorChange?: (value: string) => void; isExpanded?: boolean; + /** When true, show form fields always (no accordion). */ + alwaysExpanded?: boolean; showInInspector?: boolean; onShowInInspectorChange?: (show: boolean) => void; }) { @@ -206,54 +209,78 @@ export function GlobalColumnRow({ ((format === "percentage" || format === "scale") && (barColor ?? "") !== ""); return ( -
    - - {expanded && ( -
    -
    + {alwaysExpanded && ( +

    + {columnName} +

    + )} +
    diff --git a/src/app/map/[id]/components/inspector/InspectorSettingsModal/InspectorSettingsModal.tsx b/src/app/map/[id]/components/inspector/InspectorSettingsModal/InspectorSettingsModal.tsx index 8132e0d00..c31bd124b 100644 --- a/src/app/map/[id]/components/inspector/InspectorSettingsModal/InspectorSettingsModal.tsx +++ b/src/app/map/[id]/components/inspector/InspectorSettingsModal/InspectorSettingsModal.tsx @@ -2,42 +2,29 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { v4 as uuidv4 } from "uuid"; -import { LayoutGrid, LayoutList } from "lucide-react"; import { InspectorBoundaryConfigType } from "@/server/models/MapView"; -import { Checkbox } from "@/shadcn/ui/checkbox"; import { Dialog, DialogContent, DialogHeader, DialogTitle, } from "@/shadcn/ui/dialog"; -import { Input } from "@/shadcn/ui/input"; -import { Label } from "@/shadcn/ui/label"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/shadcn/ui/select"; -import { cn } from "@/shadcn/utils"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/shadcn/ui/tabs"; import { useDebouncedCallback } from "../../../hooks/useDebouncedCallback"; import { useDataSources } from "../../../hooks/useDataSources"; import { useDebouncedValue } from "../../../hooks/useDebouncedValue"; import { useMapViews } from "../../../hooks/useMapViews"; import { normalizeInspectorBoundaryConfig } from "../inspectorColumnOrder"; -import { - INSPECTOR_COLOR_OPTIONS, - INSPECTOR_ICON_OPTIONS, -} from "../inspectorPanelOptions"; -import { InspectorFullPreview } from "../InspectorFullPreview"; import { DataSourcesList } from "./DataSourcesList"; -import { DEFAULT_SELECT_VALUE } from "./constants"; -import type { InspectorLayout } from "./constants"; -import { GlobalColumnSettingsPanel } from "./GlobalColumnSettingsPanel"; +import { GeneralColumnOptionsPanel } from "./GeneralColumnOptionsPanel"; +import { InspectorSettingsTabContent } from "./InspectorSettingsTabContent"; import type { DataSource } from "@/server/models/DataSource"; import type { InspectorBoundaryConfig } from "@/server/models/MapView"; +// --------------------------------------------------------------------------- +// Main component +// --------------------------------------------------------------------------- + export default function InspectorSettingsModal({ open, onOpenChange, @@ -45,12 +32,9 @@ export default function InspectorSettingsModal({ }: { open: boolean; onOpenChange: (open: boolean) => void; - /** When provided, pre-select this data source in the left list (used from real inspector cogs). */ initialDataSourceId?: string | null; }) { - const [selectedDataSourceId, setSelectedDataSourceId] = useState< - string | null - >(null); + const [selectedDataSourceId, setSelectedDataSourceId] = useState(null); const [searchQuery, setSearchQuery] = useState(""); const debouncedSearchQuery = useDebouncedValue(searchQuery, 500); const { data: dataSources, getDataSourceById } = useDataSources(); @@ -62,16 +46,7 @@ export default function InspectorSettingsModal({ ); const onMapId = viewConfig.areaDataSourceId || null; - const filteredSources = useMemo(() => { - const list = dataSources ?? []; - if (!debouncedSearchQuery.trim()) return list; - const q = debouncedSearchQuery.toLowerCase(); - return list.filter( - (ds) => - ds.name.toLowerCase().includes(q) || - ds.columnDefs.some((col) => col.name.toLowerCase().includes(q)), - ); - }, [dataSources, debouncedSearchQuery]); + // ---- Search / filtering ------------------------------------------------ const matchesSearch = useCallback( (ds: DataSource) => { @@ -85,6 +60,12 @@ export default function InspectorSettingsModal({ [debouncedSearchQuery], ); + const filteredSources = useMemo(() => { + const list = dataSources ?? []; + if (!debouncedSearchQuery.trim()) return list; + return list.filter(matchesSearch); + }, [dataSources, debouncedSearchQuery, matchesSearch]); + const { inspectorOrdered, otherSources } = useMemo(() => { const inInspector = boundaryConfigs .map((config) => ({ @@ -92,112 +73,89 @@ export default function InspectorSettingsModal({ dataSource: getDataSourceById(config.dataSourceId), })) .filter( - ( - x, - ): x is { + (x): x is { config: InspectorBoundaryConfig; dataSource: NonNullable>; } => x.dataSource != null && matchesSearch(x.dataSource), ); const inIds = new Set(inInspector.map((x) => x.dataSource.id)); - const other = (filteredSources ?? []).filter((ds) => !inIds.has(ds.id)); + const other = filteredSources.filter((ds) => !inIds.has(ds.id)); return { inspectorOrdered: inInspector, otherSources: other }; }, [boundaryConfigs, getDataSourceById, filteredSources, matchesSearch]); - // When opened with an initial data source id, focus that source. + // ---- Initial selection ------------------------------------------------- + useEffect(() => { if (open && initialDataSourceId != null) { setSelectedDataSourceId(initialDataSourceId); } }, [open, initialDataSourceId]); - const handleRemoveFromInspector = useCallback( - (configId: string) => { - if (!view) return; - const next = boundaryConfigs.filter((c) => c.id !== configId); - updateView({ - ...view, - inspectorConfig: { - ...view.inspectorConfig, - boundaries: next, - }, - }); - }, - [view, boundaryConfigs, updateView], - ); + // ---- Derived selection state ------------------------------------------- const selectedConfig = useMemo( () => selectedDataSourceId - ? (boundaryConfigs.find( - (c) => c.dataSourceId === selectedDataSourceId, - ) ?? null) + ? (boundaryConfigs.find((c) => c.dataSourceId === selectedDataSourceId) ?? null) : null, [selectedDataSourceId, boundaryConfigs], ); + const selectedDataSource = useMemo( () => selectedDataSourceId - ? ((dataSources ?? []).find((ds) => ds.id === selectedDataSourceId) ?? - null) + ? ((dataSources ?? []).find((ds) => ds.id === selectedDataSourceId) ?? null) : null, [selectedDataSourceId, dataSources], ); + const isInInspector = !!selectedConfig; + + // ---- Mutations --------------------------------------------------------- + const handleAddToInspector = useCallback(() => { if (!view || !selectedDataSourceId) return; const ds = (dataSources ?? []).find((d) => d.id === selectedDataSourceId); if (!ds) return; - const defaultConfig = ds.defaultInspectorConfig; + const cfg = ds.defaultInspectorConfig; const allCols = ds.columnDefs.map((c) => c.name); - const defaultColumns = - defaultConfig?.columns?.length ? defaultConfig.columns : allCols; + const defaultColumns = cfg?.columns?.length ? cfg.columns : allCols; const raw: InspectorBoundaryConfig = { id: uuidv4(), dataSourceId: selectedDataSourceId, - name: defaultConfig?.name ?? ds.name ?? "Boundary Data", - type: defaultConfig?.type ?? InspectorBoundaryConfigType.Simple, + name: cfg?.name ?? ds.name ?? "Boundary Data", + type: cfg?.type ?? InspectorBoundaryConfigType.Simple, columns: defaultColumns, - columnOrder: defaultConfig?.columnOrder ?? defaultColumns, - columnItems: defaultConfig?.columnItems, - columnMetadata: defaultConfig?.columnMetadata, - columnGroups: defaultConfig?.columnGroups, - layout: defaultConfig?.layout ?? "single", - icon: defaultConfig?.icon, - color: defaultConfig?.color, + columnOrder: cfg?.columnOrder ?? defaultColumns, + columnItems: cfg?.columnItems, + columnMetadata: cfg?.columnMetadata, + columnGroups: cfg?.columnGroups, + layout: cfg?.layout ?? "single", + icon: cfg?.icon, + color: cfg?.color, }; const newConfig = normalizeInspectorBoundaryConfig(raw, allCols) ?? raw; const prev = view.inspectorConfig?.boundaries ?? []; updateView({ ...view, - inspectorConfig: { - ...view.inspectorConfig, - boundaries: [...prev, newConfig], - }, + inspectorConfig: { ...view.inspectorConfig, boundaries: [...prev, newConfig] }, }); }, [view, selectedDataSourceId, dataSources, updateView]); - // Appear in inspector on by default: when user selects a data source not yet in the inspector, add it. - useEffect(() => { - if ( - open && - view && - selectedDataSourceId && - !selectedConfig && - (dataSources ?? []).some((d) => d.id === selectedDataSourceId) - ) { - handleAddToInspector(); - } - }, [ - open, - view, - selectedDataSourceId, - selectedConfig, - dataSources, - handleAddToInspector, - ]); + const handleRemoveFromInspector = useCallback( + (configId: string) => { + if (!view) return; + updateView({ + ...view, + inspectorConfig: { + ...view.inspectorConfig, + boundaries: boundaryConfigs.filter((c) => c.id !== configId), + }, + }); + }, + [view, boundaryConfigs, updateView], + ); - const isInInspector = !!selectedConfig; const onAppearInInspectorChange = useCallback( (checked: boolean) => { if (!selectedDataSourceId || !view) return; @@ -210,13 +168,7 @@ export default function InspectorSettingsModal({ if (config) handleRemoveFromInspector(config.id); } }, - [ - selectedDataSourceId, - view, - boundaryConfigs, - handleAddToInspector, - handleRemoveFromInspector, - ], + [selectedDataSourceId, view, boundaryConfigs, handleAddToInspector, handleRemoveFromInspector], ); const updateBoundaryConfig = useCallback( @@ -231,27 +183,12 @@ export default function InspectorSettingsModal({ next[index] = updater(boundaries[index]); updateView({ ...latestView, - inspectorConfig: { - ...latestView.inspectorConfig, - boundaries: next, - }, + inspectorConfig: { ...latestView.inspectorConfig, boundaries: next }, }); }, [selectedConfig, view, getLatestView, updateView], ); - const [panelDisplayName, setPanelDisplayName] = useState( - selectedConfig?.name ?? "", - ); - useEffect(() => { - setPanelDisplayName(selectedConfig?.name ?? selectedDataSource?.name ?? ""); - }, [selectedConfig?.name, selectedDataSource?.name]); - const debouncedUpdatePanelName = useDebouncedCallback( - (value: string) => - updateBoundaryConfig((prev) => ({ ...prev, name: value })), - 600, - ); - const handleReorderColumns = useCallback( (dataSourceId: string, orderedColumnNames: string[]) => { const latestView = getLatestView(); @@ -259,38 +196,45 @@ export default function InspectorSettingsModal({ const boundaries = latestView.inspectorConfig.boundaries; const index = boundaries.findIndex((c) => c.dataSourceId === dataSourceId); if (index === -1) return; - const config = boundaries[index]; const next = [...boundaries]; next[index] = { - ...config, + ...boundaries[index], columns: orderedColumnNames, columnOrder: orderedColumnNames, columnItems: orderedColumnNames, }; updateView({ ...latestView, - inspectorConfig: { - ...latestView.inspectorConfig, - boundaries: next, - }, + inspectorConfig: { ...latestView.inspectorConfig, boundaries: next }, }); }, [getLatestView, updateView], ); + // ---- Panel display name (debounced) ------------------------------------ + + const [panelDisplayName, setPanelDisplayName] = useState(selectedConfig?.name ?? ""); + + useEffect(() => { + setPanelDisplayName(selectedConfig?.name ?? selectedDataSource?.name ?? ""); + }, [selectedConfig?.name, selectedDataSource?.name]); + + const debouncedUpdatePanelName = useDebouncedCallback( + (value: string) => updateBoundaryConfig((prev) => ({ ...prev, name: value })), + 600, + ); + + // ---- Render ------------------------------------------------------------ + return ( setSelectedDataSourceId(null)} > Visualisation data settings -

    - Choose data sources to show in the inspector, edit column settings, - and reorder columns in the preview. -

    @@ -305,184 +249,52 @@ export default function InspectorSettingsModal({ onRemoveFromInspector={handleRemoveFromInspector} /> -
    +
    {selectedDataSource ? ( -
    -
    -

    - {selectedDataSource.name} -

    - {view && ( -
    - - onAppearInInspectorChange(checked === true) - } - /> - -
    - )} - {isInInspector && selectedConfig && ( -
    -
    - - { - const v = e.target.value; - setPanelDisplayName(v); - debouncedUpdatePanelName(v); - }} - placeholder="e.g. Main data" - className="h-9 max-w-full" - /> -
    -
    - - -
    -
    - - -
    -
    - - -
    -
    - )} + +
    + + General + Inspector settings +
    -
    - + + + + + -
    -
    + + ) : (
    - Select a data source to edit column settings and choose whether it - appears in the inspector. + Select a data source to edit general column options and + inspector settings.
    )}
    - -
    - -
    diff --git a/src/app/map/[id]/components/inspector/InspectorSettingsModal/InspectorSettingsTabContent.tsx b/src/app/map/[id]/components/inspector/InspectorSettingsModal/InspectorSettingsTabContent.tsx new file mode 100644 index 000000000..e9456f323 --- /dev/null +++ b/src/app/map/[id]/components/inspector/InspectorSettingsModal/InspectorSettingsTabContent.tsx @@ -0,0 +1,492 @@ +"use client"; + +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { LayoutGrid, LayoutList } from "lucide-react"; +import { useCallback, useMemo } from "react"; +import { toast } from "sonner"; +import { InspectorBoundaryConfigType } from "@/server/models/MapView"; +import { useTRPC } from "@/services/trpc/react"; +import { Checkbox } from "@/shadcn/ui/checkbox"; +import { Input } from "@/shadcn/ui/input"; +import { Label } from "@/shadcn/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/shadcn/ui/select"; +import { cn } from "@/shadcn/utils"; +import { + INSPECTOR_COLOR_OPTIONS, + INSPECTOR_ICON_OPTIONS, +} from "../inspectorPanelOptions"; +import { + getSelectedColumnsOrdered, + normalizeInspectorBoundaryConfig, +} from "../inspectorColumnOrder"; +import { InspectorFullPreview } from "../InspectorFullPreview"; +import { ColumnOrderList } from "./ColumnOrderList"; +import { DEFAULT_SELECT_VALUE } from "./constants"; +import type { InspectorLayout } from "./constants"; +import type { DataSource } from "@/server/models/DataSource"; +import type { + DefaultInspectorBoundaryConfig, + InspectorBoundaryConfig, +} from "@/server/models/MapView"; +import type { useMapViews } from "../../../hooks/useMapViews"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +interface InspectorSettingsTabContentProps { + dataSource: DataSource; + dataSourceName: string; + boundaryConfig: InspectorBoundaryConfig | null; + isInInspector: boolean; + onAppearInInspectorChange: (checked: boolean) => void; + onAddToMap: () => void; + updateBoundaryConfig: ( + updater: (prev: InspectorBoundaryConfig) => InspectorBoundaryConfig, + ) => void; + panelDisplayName: string; + setPanelDisplayName: (v: string) => void; + debouncedUpdatePanelName: (v: string) => void; + getLatestView: ReturnType["getLatestView"]; + updateView: ReturnType["updateView"]; + selectedDataSourceId: string | null; + onReorderColumns: ( + dataSourceId: string, + orderedColumnNames: string[], + ) => void; +} + +// --------------------------------------------------------------------------- +// Sub-components +// --------------------------------------------------------------------------- + +function NotOnMapPrompt({ + name, + onAddToMap, +}: { + name: string; + onAddToMap: () => void; +}) { + return ( +
    +

    {name}

    +
    +

    + This data source is not on the map. Add it to show its data in the + inspector. +

    + +
    +
    + ); +} + +function PanelOptionsGrid({ + boundaryConfig, + panelDisplayName, + setPanelDisplayName, + debouncedUpdatePanelName, + updateBoundaryConfig, +}: { + boundaryConfig: InspectorBoundaryConfig; + panelDisplayName: string; + setPanelDisplayName: (v: string) => void; + debouncedUpdatePanelName: (v: string) => void; + updateBoundaryConfig: InspectorSettingsTabContentProps["updateBoundaryConfig"]; +}) { + return ( +
    +
    + + { + const v = e.target.value; + setPanelDisplayName(v); + debouncedUpdatePanelName(v); + }} + placeholder="e.g. Main data" + className="h-9 max-w-full" + /> +
    + +
    + + +
    + +
    + + +
    + +
    + + +
    +
    + ); +} + +// --------------------------------------------------------------------------- +// Main component +// --------------------------------------------------------------------------- + +export function InspectorSettingsTabContent({ + dataSource, + dataSourceName, + boundaryConfig, + isInInspector, + onAppearInInspectorChange, + onAddToMap, + updateBoundaryConfig, + panelDisplayName, + setPanelDisplayName, + debouncedUpdatePanelName, + getLatestView, + updateView, + selectedDataSourceId, + onReorderColumns, +}: InspectorSettingsTabContentProps) { + const trpc = useTRPC(); + const queryClient = useQueryClient(); + + const { mutateAsync: saveConfig } = useMutation( + trpc.dataSource.updateConfig.mutationOptions({ + onSuccess: async () => { + await queryClient.invalidateQueries({ + queryKey: trpc.dataSource.listReadable.queryKey(), + }); + }, + onError: (err) => { + toast.error(err.message ?? "Failed to save."); + }, + }), + ); + + const defaultConfig = dataSource.defaultInspectorConfig ?? null; + + const allColumnNames = useMemo( + () => dataSource.columnDefs.map((c) => c.name), + [dataSource.columnDefs], + ); + + const columnMetadata = useMemo( + () => defaultConfig?.columnMetadata ?? {}, + [defaultConfig?.columnMetadata], + ); + + const columnsInInspectorSet = useMemo(() => { + const list = boundaryConfig?.columns ?? defaultConfig?.columns ?? []; + return list.length === 0 + ? new Set(allColumnNames) + : new Set(list); + }, [boundaryConfig?.columns, defaultConfig?.columns, allColumnNames]); + + const selectedColumnsOrdered = useMemo( + () => (boundaryConfig ? getSelectedColumnsOrdered(boundaryConfig) : []), + [boundaryConfig], + ); + + const columnsListOrder = useMemo(() => { + const rest = allColumnNames.filter((n) => !selectedColumnsOrdered.includes(n)); + return [...selectedColumnsOrdered, ...rest]; + }, [selectedColumnsOrdered, allColumnNames]); + + const setColumnShowInInspector = useCallback( + (colName: string, show: boolean) => { + if (boundaryConfig && getLatestView && updateView) { + const latestView = getLatestView(); + if (!latestView?.inspectorConfig?.boundaries) return; + const boundaries = latestView.inspectorConfig.boundaries; + const index = boundaries.findIndex( + (c) => c.dataSourceId === dataSource.id, + ); + if (index === -1) return; + const config = boundaries[index]; + const current = config.columns ?? []; + const nextColumns = show + ? current.includes(colName) ? current : [...current, colName] + : current.filter((c) => c !== colName); + const normalized = normalizeInspectorBoundaryConfig( + { ...config, columns: nextColumns, columnOrder: nextColumns }, + allColumnNames, + ); + if (!normalized) return; + const next = [...boundaries]; + next[index] = { ...normalized, columnItems: normalized.columns }; + updateView({ + ...latestView, + inspectorConfig: { + ...latestView.inspectorConfig, + boundaries: next, + }, + }); + } else { + const current = defaultConfig?.columns ?? []; + const nextColumns = show + ? current.includes(colName) ? current : [...current, colName] + : current.filter((c) => c !== colName); + const nextDefault: DefaultInspectorBoundaryConfig = { + ...defaultConfig, + name: defaultConfig?.name ?? dataSource.name ?? "Boundary Data", + type: defaultConfig?.type ?? InspectorBoundaryConfigType.Simple, + columns: nextColumns, + columnMetadata: defaultConfig?.columnMetadata ?? {}, + }; + saveConfig({ + dataSourceId: dataSource.id, + defaultInspectorConfig: nextDefault, + }).catch(() => {}); + } + }, + [ + boundaryConfig, + dataSource.id, + dataSource.name, + defaultConfig, + getLatestView, + updateView, + allColumnNames, + saveConfig, + ], + ); + + // ------------------------------------------------------------------- + // Not on map → show prompt + // ------------------------------------------------------------------- + if (!isInInspector) { + return ( +
    +
    + +
    + +
    + ); + } + + // ------------------------------------------------------------------- + // On map → full settings + // ------------------------------------------------------------------- + return ( +
    + {/* Settings column */} +
    + {/* Header: name + "On this map" toggle + panel options */} +
    +
    +

    + {dataSourceName} +

    + +
    + {boundaryConfig && ( + + )} +
    + + {/* Columns: visibility + order (independently scrollable) */} +
    + {/* Visibility */} +
    +
    +

    + Columns in inspector +

    +

    + Choose which columns appear when this data source is shown in + the inspector. +

    +
    +
    +
    + {columnsListOrder.map((colName) => ( + + ))} +
    +
    +
    + + {/* Order */} +
    +
    +

    + Column order +

    +

    + Drag to reorder how columns appear in the inspector. +

    +
    +
    + columnMetadata[col]?.displayName ?? col} + dataSourceId={dataSource.id} + onReorderColumns={onReorderColumns} + /> +
    +
    +
    +
    + + {/* Preview sidebar */} + +
    + ); +} + +// --------------------------------------------------------------------------- +// Preview sidebar (shared between both states) +// --------------------------------------------------------------------------- + +function PreviewSidebar({ + selectedDataSourceId, + onReorderColumns, +}: { + selectedDataSourceId: string | null; + onReorderColumns: InspectorSettingsTabContentProps["onReorderColumns"]; +}) { + return ( +
    +

    + Preview +

    + +
    + ); +} diff --git a/tsconfig.json b/tsconfig.json index 40424caad..6dfb703d6 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,7 +1,11 @@ { "compilerOptions": { "target": "ES2017", - "lib": ["dom", "dom.iterable", "esnext"], + "lib": [ + "dom", + "dom.iterable", + "esnext" + ], "allowJs": true, "skipLibCheck": true, "strict": true, @@ -11,7 +15,7 @@ "moduleResolution": "bundler", "resolveJsonModule": true, "isolatedModules": true, - "jsx": "react-jsx", + "jsx": "preserve", "incremental": true, "plugins": [ { @@ -19,7 +23,9 @@ } ], "paths": { - "@/*": ["./src/*"] + "@/*": [ + "./src/*" + ] } }, "include": [ @@ -30,5 +36,7 @@ ".next/types/**/*.ts", ".next/dev/types/**/*.ts" ], - "exclude": ["node_modules"] + "exclude": [ + "node_modules" + ] } From 7755f56b3625d54ff689a8c6ae4dc55f8dfa4ac9 Mon Sep 17 00:00:00 2001 From: Arbyhisenaj <41119392+Arbyhisenaj@users.noreply.github.com> Date: Tue, 10 Mar 2026 16:59:04 +0000 Subject: [PATCH 21/21] optimisation --- src/app/map/[id]/atoms/inspectorAtoms.ts | 4 + src/app/map/[id]/components/MapWrapper.tsx | 3 + src/app/map/[id]/components/PrivateMap.tsx | 4 - .../controls/DataControl/DataControl.tsx | 58 +--- .../components/controls/DataSourceItem.tsx | 20 +- .../components/controls/LegendMapWidget.tsx | 44 ++- .../controls/PrivateMapControls.tsx | 46 ++- .../VisualisationPanel/VisualisationPanel.tsx | 42 ++- .../inspector/BoundaryDataPanel.tsx | 44 ++- .../components/inspector/InspectorDataTab.tsx | 22 +- .../inspector/InspectorFullPreview.tsx | 129 +++++-- .../components/inspector/InspectorPanel.tsx | 11 +- .../DataSourcesList.tsx | 299 +++++++++------- .../GeneralColumnOptionsPanel.tsx | 91 ++++- .../GlobalColumnRow.tsx | 4 +- .../InspectorSettingsModal.tsx | 321 +++++++++++++----- .../InspectorSettingsTabContent.tsx | 299 ++++------------ .../inspector/inspectorPanelOptions.tsx | 13 + .../map/[id]/components/table/MapTable.tsx | 27 +- 19 files changed, 900 insertions(+), 581 deletions(-) diff --git a/src/app/map/[id]/atoms/inspectorAtoms.ts b/src/app/map/[id]/atoms/inspectorAtoms.ts index 27c4e5573..9a4f02db0 100644 --- a/src/app/map/[id]/atoms/inspectorAtoms.ts +++ b/src/app/map/[id]/atoms/inspectorAtoms.ts @@ -18,3 +18,7 @@ export const inspectorSettingsModalOpenAtom = atom(false); export const inspectorSettingsInitialDataSourceIdAtom = atom( null, ); +/** Which tab to show when opening: "general" (layer/column options) or "inspector". Set by opener (layers vs inspector panel). */ +export const inspectorSettingsInitialTabAtom = atom<"general" | "inspector">( + "general", +); diff --git a/src/app/map/[id]/components/MapWrapper.tsx b/src/app/map/[id]/components/MapWrapper.tsx index 3156626e6..2244f37e7 100644 --- a/src/app/map/[id]/components/MapWrapper.tsx +++ b/src/app/map/[id]/components/MapWrapper.tsx @@ -5,6 +5,7 @@ import { MapType } from "@/server/models/MapView"; import { inspectorSettingsModalOpenAtom, inspectorSettingsInitialDataSourceIdAtom, + inspectorSettingsInitialTabAtom, } from "../atoms/inspectorAtoms"; import { useChoropleth } from "../hooks/useChoropleth"; import { useInspector } from "../hooks/useInspector"; @@ -40,6 +41,7 @@ export default function MapWrapper({ const settingsInitialDataSourceId = useAtomValue( inspectorSettingsInitialDataSourceIdAtom, ); + const settingsInitialTab = useAtomValue(inspectorSettingsInitialTabAtom); const setSettingsOpen = useSetAtom(inspectorSettingsModalOpenAtom); const setSettingsInitialDataSourceId = useSetAtom( inspectorSettingsInitialDataSourceIdAtom, @@ -142,6 +144,7 @@ export default function MapWrapper({ open={settingsOpen} onOpenChange={handleInspectorSettingsOpenChange} initialDataSourceId={settingsInitialDataSourceId} + initialTab={settingsInitialTab} /> {!hideDrawControls && ( diff --git a/src/app/map/[id]/components/PrivateMap.tsx b/src/app/map/[id]/components/PrivateMap.tsx index 385f7c031..5fdb20145 100644 --- a/src/app/map/[id]/components/PrivateMap.tsx +++ b/src/app/map/[id]/components/PrivateMap.tsx @@ -18,7 +18,6 @@ import { useMapId, useMapRef } from "../hooks/useMapCore"; import { useMapQuery } from "../hooks/useMapQuery"; import { CONTROL_PANEL_WIDTH } from "../styles"; import PrivateMapControls from "./controls/PrivateMapControls"; -import VisualisationPanel from "./controls/VisualisationPanel/VisualisationPanel"; import Loading from "./Loading"; import Map from "./Map"; import PrivateMapNavbar from "./PrivateMapNavbar"; @@ -72,9 +71,6 @@ export default function PrivateMap() {
    -
    diff --git a/src/app/map/[id]/components/controls/DataControl/DataControl.tsx b/src/app/map/[id]/components/controls/DataControl/DataControl.tsx index 2b32142de..452402d67 100644 --- a/src/app/map/[id]/components/controls/DataControl/DataControl.tsx +++ b/src/app/map/[id]/components/controls/DataControl/DataControl.tsx @@ -5,54 +5,35 @@ import { useSetAtom } from "jotai"; import { useState } from "react"; import { useDataSources } from "@/app/map/[id]/hooks/useDataSources"; import { useMapViews } from "@/app/map/[id]/hooks/useMapViews"; -import { useMapConfig } from "@/app/map/[id]/hooks/useMapConfig"; import { inspectorSettingsModalOpenAtom, inspectorSettingsInitialDataSourceIdAtom, + inspectorSettingsInitialTabAtom, } from "@/app/map/[id]/atoms/inspectorAtoms"; -import { dataSourceRecordTypeLabels } from "@/components/DataSourceRecordTypeIcon"; +import { DataSourceInspectorIcon } from "@/app/map/[id]/components/inspector/inspectorPanelOptions"; import { DataSourceTypeLabels } from "@/labels"; -import DataSourceRecordTypeIcon from "@/components/DataSourceRecordTypeIcon"; import LayerControlWrapper from "../LayerControlWrapper"; -import type { - DataSourceRecordType, - DataSourceType, -} from "@/server/models/DataSource"; +import type { DataSource } from "@/server/models/DataSource"; -function useMapDataSources() { +function useMapDataSources(): DataSource[] { const { viewConfig } = useMapViews(); - const { mapConfig } = useMapConfig(); const { getDataSourceById } = useDataSources(); - const ids = new Set(); - if (viewConfig.areaDataSourceId) ids.add(viewConfig.areaDataSourceId); - mapConfig.markerDataSourceIds.forEach((id) => ids.add(id)); - if (mapConfig.membersDataSourceId) ids.add(mapConfig.membersDataSourceId); - - return Array.from(ids) - .map((id) => getDataSourceById(id)) - .filter((ds): ds is NonNullable => ds != null); + if (!viewConfig.areaDataSourceId) return []; + const ds = getDataSourceById(viewConfig.areaDataSourceId); + return ds ? [ds] : []; } function DataSourceRow({ dataSource, onClick, }: { - dataSource: { - id: string; - name: string; - config: { type: DataSourceType }; - recordCount?: number; - recordType?: DataSourceRecordType; - }; + dataSource: DataSource; onClick: () => void; }) { const subtitle = [ DataSourceTypeLabels[dataSource.config.type], dataSource.recordCount != null ? String(dataSource.recordCount) : null, - dataSource.recordType && dataSourceRecordTypeLabels[dataSource.recordType] - ? dataSourceRecordTypeLabels[dataSource.recordType] - : null, ] .filter(Boolean) .join(" · "); @@ -63,15 +44,10 @@ function DataSourceRow({ onClick={onClick} className="flex items-center gap-2 w-full text-left px-4 py-2.5 rounded-md border border-neutral-200 bg-white hover:bg-neutral-50 transition-colors" > - {dataSource.recordType != null ? ( - - ) : ( - - )} +
    {dataSource.name}
    {subtitle}
    @@ -87,9 +63,11 @@ export default function DataControl() { const setInitialDataSourceId = useSetAtom( inspectorSettingsInitialDataSourceIdAtom, ); + const setInitialTab = useSetAtom(inspectorSettingsInitialTabAtom); const mapDataSources = useMapDataSources(); - const openInspectorSettings = (dataSourceId: string | null) => { + const openDataSettings = (dataSourceId: string | null, tab: "general" | "inspector" = "general") => { + setInitialTab(tab); setInitialDataSourceId(dataSourceId); setModalOpen(true); }; @@ -112,9 +90,9 @@ export default function DataControl() {
    @@ -130,7 +108,7 @@ export default function DataControl() { openInspectorSettings(ds.id)} + onClick={() => openDataSettings(ds.id)} /> )) )} diff --git a/src/app/map/[id]/components/controls/DataSourceItem.tsx b/src/app/map/[id]/components/controls/DataSourceItem.tsx index 35c94835e..b6488def9 100644 --- a/src/app/map/[id]/components/controls/DataSourceItem.tsx +++ b/src/app/map/[id]/components/controls/DataSourceItem.tsx @@ -1,7 +1,8 @@ "use client"; import { useMutation, useQueryClient } from "@tanstack/react-query"; -import { EyeIcon, EyeOffIcon, PencilIcon, TrashIcon } from "lucide-react"; +import { EyeIcon, EyeOffIcon, PencilIcon, Settings as SettingsIcon, TrashIcon } from "lucide-react"; +import { useSetAtom } from "jotai"; import { useEffect, useRef, useState } from "react"; import { toast } from "sonner"; import ColorPalette from "@/components/ColorPalette"; @@ -31,6 +32,10 @@ import { ContextMenuSubTrigger, ContextMenuTrigger, } from "@/shadcn/ui/context-menu"; +import { + inspectorSettingsInitialDataSourceIdAtom, + inspectorSettingsModalOpenAtom, +} from "@/app/map/[id]/atoms/inspectorAtoms"; import { LayerType } from "@/types"; import { useLayers } from "../../hooks/useLayers"; import { useMapConfig } from "../../hooks/useMapConfig"; @@ -58,6 +63,10 @@ export default function DataSourceItem({ handleDataSourceSelect: (id: string) => void; layerType: LayerType; }) { + const setSettingsOpen = useSetAtom(inspectorSettingsModalOpenAtom); + const setSettingsInitialDataSourceId = useSetAtom( + inspectorSettingsInitialDataSourceIdAtom, + ); const { setDataSourceVisibility, getDataSourceVisibility } = useLayers(); const { mapConfig, updateMapConfig } = useMapConfig(); const trpc = useTRPC(); @@ -166,6 +175,11 @@ export default function DataSourceItem({ setShowRemoveDialog(false); }; + const openInspectorSettings = () => { + setSettingsInitialDataSourceId(dataSource.id); + setSettingsOpen(true); + }; + return ( <> )} + + + Inspector settings + diff --git a/src/app/map/[id]/components/controls/LegendMapWidget.tsx b/src/app/map/[id]/components/controls/LegendMapWidget.tsx index 477f23495..563d7f246 100644 --- a/src/app/map/[id]/components/controls/LegendMapWidget.tsx +++ b/src/app/map/[id]/components/controls/LegendMapWidget.tsx @@ -1,27 +1,40 @@ "use client"; import { useChoropleth } from "@/app/map/[id]/hooks/useChoropleth"; +import { useShowControls } from "@/app/map/[id]/hooks/useMapControls"; import { useMapViews } from "@/app/map/[id]/hooks/useMapViews"; +import { CONTROL_PANEL_WIDTH } from "@/app/map/[id]/styles"; import LegendControl from "@/app/map/[id]/components/controls/BoundariesControl/LegendControl"; import { useBoundariesControl } from "@/app/map/[id]/components/controls/BoundariesControl/useBoundariesControl"; +import { ChoroplethSettingsForm } from "@/app/map/[id]/components/controls/VisualisationPanel/VisualisationPanel"; import { cn } from "@/shadcn/utils"; +const LEGEND_OFFSET = 12; + /** - * Compact legend widget positioned top-left over the map. - * Shows the choropleth legend; when clicked opens the visualisation settings panel. + * Legend widget positioned top-left on the map. + * Moves right when the layers panel is open. Clicking the legend expands to show choropleth settings. */ export default function LegendMapWidget() { - const { setBoundariesPanelOpen } = useChoropleth(); + const showControls = useShowControls(); + const { boundariesPanelOpen, setBoundariesPanelOpen } = useChoropleth(); const { hasDataSource } = useBoundariesControl(); const { viewConfig } = useMapViews(); + const toggleExpanded = () => setBoundariesPanelOpen(!boundariesPanelOpen); + + const leftStyle = { + left: showControls ? CONTROL_PANEL_WIDTH + LEGEND_OFFSET : LEGEND_OFFSET, + }; + if (!hasDataSource) { return (
    + {boundariesPanelOpen && ( +
    + setBoundariesPanelOpen(false)} /> +
    + )}
    ); } @@ -37,25 +55,31 @@ export default function LegendMapWidget() { return (
    setBoundariesPanelOpen(true)} + onClick={toggleExpanded} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); - setBoundariesPanelOpen(true); + toggleExpanded(); } }} - className="cursor-pointer hover:bg-neutral-50/50 transition-colors" + className="cursor-pointer hover:bg-neutral-50/50 transition-colors shrink-0" >
    + {boundariesPanelOpen && ( +
    + setBoundariesPanelOpen(false)} /> +
    + )}
    ); } diff --git a/src/app/map/[id]/components/controls/PrivateMapControls.tsx b/src/app/map/[id]/components/controls/PrivateMapControls.tsx index 516e5a6e3..75135baae 100644 --- a/src/app/map/[id]/components/controls/PrivateMapControls.tsx +++ b/src/app/map/[id]/components/controls/PrivateMapControls.tsx @@ -1,5 +1,11 @@ -import { PanelLeft } from "lucide-react"; +import { PanelLeft, Settings } from "lucide-react"; +import { useSetAtom } from "jotai"; +import { + inspectorSettingsModalOpenAtom, + inspectorSettingsInitialDataSourceIdAtom, + inspectorSettingsInitialTabAtom, +} from "@/app/map/[id]/atoms/inspectorAtoms"; import { useChoropleth } from "@/app/map/[id]/hooks/useChoropleth"; import { MapType } from "@/server/models/MapView"; import { Button } from "@/shadcn/ui/button"; @@ -15,6 +21,17 @@ export default function PrivateMapControls() { const [showControls, setShowControls] = useShowControlsAtom(); const { setBoundariesPanelOpen } = useChoropleth(); const { viewConfig } = useMapViews(); + const setSettingsOpen = useSetAtom(inspectorSettingsModalOpenAtom); + const setInitialDataSourceId = useSetAtom( + inspectorSettingsInitialDataSourceIdAtom, + ); + const setInitialTab = useSetAtom(inspectorSettingsInitialTabAtom); + + const openDataSettings = () => { + setInitialTab("general"); + setInitialDataSourceId(null); + setSettingsOpen(true); + }; const onToggleControls = () => { setShowControls(!showControls); @@ -49,14 +66,25 @@ export default function PrivateMapControls() { {/* Header */}

    Layers

    - +
    + + +
    {/* Content */} diff --git a/src/app/map/[id]/components/controls/VisualisationPanel/VisualisationPanel.tsx b/src/app/map/[id]/components/controls/VisualisationPanel/VisualisationPanel.tsx index 3d23a56aa..50bf28be4 100644 --- a/src/app/map/[id]/components/controls/VisualisationPanel/VisualisationPanel.tsx +++ b/src/app/map/[id]/components/controls/VisualisationPanel/VisualisationPanel.tsx @@ -132,13 +132,9 @@ function IncludeColumnsModal({ ); } -export default function VisualisationPanel({ - positionLeft, -}: { - positionLeft: number; -}) { +/** Choropleth settings form; use in VisualisationPanel or inside LegendMapWidget when expanded. */ +export function ChoroplethSettingsForm({ onClose }: { onClose: () => void }) { const { viewConfig, updateViewConfig } = useMapViews(); - const { boundariesPanelOpen, setBoundariesPanelOpen } = useChoropleth(); const { data: dataSources, getDataSourceById } = useDataSources(); const dataSource = useChoroplethDataSource(); @@ -146,8 +142,6 @@ export default function VisualisationPanel({ null, ); - if (!boundariesPanelOpen) return null; - const isCount = viewConfig.calculationType === CalculationType.Count; const columnOneIsNumber = @@ -174,21 +168,13 @@ export default function VisualisationPanel({ const canSetCategoryColors = isCategorical; return ( -
    +

    Create visualisation

    @@ -842,3 +828,23 @@ export default function VisualisationPanel({
    ); } + +export default function VisualisationPanel({ + positionLeft, +}: { + positionLeft: number; +}) { + const { boundariesPanelOpen, setBoundariesPanelOpen } = useChoropleth(); + if (!boundariesPanelOpen) return null; + return ( +
    + setBoundariesPanelOpen(false)} /> +
    + ); +} diff --git a/src/app/map/[id]/components/inspector/BoundaryDataPanel.tsx b/src/app/map/[id]/components/inspector/BoundaryDataPanel.tsx index 5d77da5c4..45e088a99 100644 --- a/src/app/map/[id]/components/inspector/BoundaryDataPanel.tsx +++ b/src/app/map/[id]/components/inspector/BoundaryDataPanel.tsx @@ -2,15 +2,15 @@ import { useQueries, useQuery } from "@tanstack/react-query"; import { List, Settings as SettingsIcon } from "lucide-react"; import { useMemo } from "react"; import TogglePanel from "@/app/map/[id]/components/TogglePanel"; +import { MarkerCollectionIcon } from "@/app/map/[id]/components/Icons"; import { useInspector } from "@/app/map/[id]/hooks/useInspector"; -import DataSourceIcon from "@/components/DataSourceIcon"; -import { getDataSourceType } from "@/components/DataSourceItem"; import { AreaSetCode } from "@/server/models/AreaSet"; import { useTRPC } from "@/services/trpc/react"; import { DataRecordMatchType } from "@/types"; import { buildName } from "@/utils/dataRecord"; import { useDataSources } from "../../hooks/useDataSources"; import { + DataSourceInspectorIcon, InspectorPanelIcon, getBarColorForLabel, getInspectorColorClass, @@ -30,6 +30,8 @@ export function BoundaryDataPanel({ defaultExpanded, expanded: controlledExpanded, onOpenInspectorSettings, + previewMode, + markerLayerColor, }: { config: Pick< InspectorBoundaryConfig, @@ -45,20 +47,32 @@ export function BoundaryDataPanel({ /** When provided, controls the expanded state externally. */ expanded?: boolean; onOpenInspectorSettings?: (dataSourceId: string) => void; + /** When true, falls back to first records from the data source if no boundary is selected. */ + previewMode?: boolean; + /** When set (marker/member layer), show MarkerCollectionIcon with this color to match the layer panel. */ + markerLayerColor?: string | null; }) { const trpc = useTRPC(); const { selectedBoundary } = useInspector(); const { getDataSourceById } = useDataSources(); const dataSource = getDataSourceById(dataSourceId); - const dataSourceType = dataSource ? getDataSourceType(dataSource) : null; - const panelIcon = config.icon ? ( + const panelIcon = markerLayerColor ? ( + + + + ) : config.icon ? ( - ) : dataSourceType ? ( - + ) : dataSource ? ( + ) : undefined; - const { data, isLoading } = useQuery( + const hasBoundary = Boolean(selectedBoundary?.areaSetCode); + + const { data: boundaryData, isLoading: boundaryLoading } = useQuery( trpc.dataRecord.byAreaCode.queryOptions( { dataSourceId, @@ -67,11 +81,25 @@ export function BoundaryDataPanel({ (selectedBoundary?.areaSetCode as AreaSetCode) || AreaSetCode.WMC24, }, { - enabled: Boolean(selectedBoundary?.areaSetCode && dataSourceId), + enabled: hasBoundary && Boolean(dataSourceId), }, ), ); + const { data: listData, isLoading: listLoading } = useQuery( + trpc.dataRecord.list.queryOptions( + { dataSourceId, page: 0 }, + { enabled: previewMode === true && !hasBoundary && Boolean(dataSourceId) }, + ), + ); + + const data = hasBoundary + ? boundaryData + : previewMode && listData + ? { records: listData.records.slice(0, 1), match: DataRecordMatchType.Exact } + : undefined; + const isLoading = hasBoundary ? boundaryLoading : previewMode ? listLoading : false; + const meta = useMemo(() => columnMetadata ?? {}, [columnMetadata]); const comparisonColumns = useMemo( () => diff --git a/src/app/map/[id]/components/inspector/InspectorDataTab.tsx b/src/app/map/[id]/components/inspector/InspectorDataTab.tsx index 8a7ed7d4b..be29fdd3b 100644 --- a/src/app/map/[id]/components/inspector/InspectorDataTab.tsx +++ b/src/app/map/[id]/components/inspector/InspectorDataTab.tsx @@ -7,6 +7,8 @@ import { useInspector } from "@/app/map/[id]/hooks/useInspector"; import { useMapRef } from "@/app/map/[id]/hooks/useMapCore"; import { useMapViews } from "@/app/map/[id]/hooks/useMapViews"; import { useTable } from "@/app/map/[id]/hooks/useTable"; +import { useMapConfig } from "@/app/map/[id]/hooks/useMapConfig"; +import { mapColors } from "@/app/map/[id]/styles"; import DataSourceIcon from "@/components/DataSourceIcon"; import { type DataSource } from "@/server/models/DataSource"; import { @@ -53,6 +55,7 @@ export default function InspectorDataTab({ const { setSelectedDataSourceId } = useTable(); const trpc = useTRPC(); const { view, viewConfig, updateView } = useMapViews(); + const { mapConfig } = useMapConfig(); const { getDataSourceById } = useDataSources(); const { selectedBoundary } = useInspector(); const initializationAttemptedRef = useRef(false); @@ -111,6 +114,21 @@ export default function InspectorDataTab({ [view?.inspectorConfig?.boundaries], ); + const markerLayerColors = useMemo(() => { + const out: Record = {}; + if (mapConfig.membersDataSourceId) { + out[mapConfig.membersDataSourceId] = mapColors.member.color; + } + mapConfig.markerDataSourceIds.forEach((id) => { + out[id] = mapConfig.markerColors?.[id] ?? mapColors.markers.color; + }); + return out; + }, [ + mapConfig.membersDataSourceId, + mapConfig.markerDataSourceIds, + mapConfig.markerColors, + ]); + const isBoundary = type === LayerType.Boundary; const boundaryData = useMemo(() => { @@ -120,8 +138,9 @@ export default function InspectorDataTab({ dataSourceId: config.dataSourceId, areaCode: selectedBoundary?.code ?? "", columns: getSelectedColumnsOrdered(config), + markerLayerColor: markerLayerColors[config.dataSourceId], })); - }, [isBoundary, selectedBoundary?.code, boundaryConfigs]); + }, [isBoundary, selectedBoundary?.code, boundaryConfigs, markerLayerColors]); // Initialise boundary inspector config from choropleth data source when empty useEffect(() => { @@ -183,6 +202,7 @@ export default function InspectorDataTab({ onOpenInspectorSettings={ onOpenInspectorSettingsForDataSource } + markerLayerColor={item.markerLayerColor} /> ))}
    diff --git a/src/app/map/[id]/components/inspector/InspectorFullPreview.tsx b/src/app/map/[id]/components/inspector/InspectorFullPreview.tsx index 74549523d..8bb406f8c 100644 --- a/src/app/map/[id]/components/inspector/InspectorFullPreview.tsx +++ b/src/app/map/[id]/components/inspector/InspectorFullPreview.tsx @@ -19,12 +19,16 @@ import { CSS } from "@dnd-kit/utilities"; import { GripVertical } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef } from "react"; import { useInspector } from "@/app/map/[id]/hooks/useInspector"; +import { useMapConfig } from "@/app/map/[id]/hooks/useMapConfig"; import { useMapViews } from "@/app/map/[id]/hooks/useMapViews"; +import { mapColors } from "@/app/map/[id]/styles"; +import { InspectorBoundaryConfigType } from "@/server/models/MapView"; import { cn } from "@/shadcn/utils"; import { BoundaryDataPanel } from "./BoundaryDataPanel"; import { getSelectedColumnsOrdered } from "./inspectorColumnOrder"; import InspectorOnMapSection from "./InspectorOnMapSection"; import type { DragEndEvent } from "@dnd-kit/core"; +import type { DataSource } from "@/server/models/DataSource"; import type { InspectorBoundaryConfig } from "@/server/models/MapView"; /** @@ -36,6 +40,7 @@ export function InspectorFullPreview({ className, selectedDataSourceId, onReorderColumns, + previewDataSource, }: { className?: string; selectedDataSourceId?: string | null; @@ -43,15 +48,29 @@ export function InspectorFullPreview({ dataSourceId: string, orderedColumnNames: string[], ) => void; + /** When set, renders an extra preview panel for a data source not yet on the map. */ + previewDataSource?: DataSource | null; }) { const scrollContainerRef = useRef(null); const { selectedBoundary } = useInspector(); const { view, getLatestView, updateView } = useMapViews(); + const { mapConfig } = useMapConfig(); const boundaryConfigs = useMemo( () => view?.inspectorConfig?.boundaries ?? [], [view?.inspectorConfig?.boundaries], ); + const markerLayerColors = useMemo(() => { + const out: Record = {}; + if (mapConfig.membersDataSourceId) { + out[mapConfig.membersDataSourceId] = mapColors.member.color; + } + mapConfig.markerDataSourceIds.forEach((id) => { + out[id] = mapConfig.markerColors?.[id] ?? mapColors.markers.color; + }); + return out; + }, [mapConfig.membersDataSourceId, mapConfig.markerDataSourceIds, mapConfig.markerColors]); + useEffect(() => { if (!selectedDataSourceId) return; const escaped = selectedDataSourceId @@ -70,10 +89,47 @@ export function InspectorFullPreview({ dataSourceId: config.dataSourceId, areaCode: selectedBoundary?.code ?? "", columns: getSelectedColumnsOrdered(config), + markerLayerColor: markerLayerColors[config.dataSourceId], })), - [boundaryConfigs, selectedBoundary?.code], + [boundaryConfigs, selectedBoundary?.code, markerLayerColors], ); + const previewConfig = useMemo((): { + config: InspectorBoundaryConfig; + dataSourceId: string; + areaCode: string; + columns: string[]; + } | null => { + if (!previewDataSource) return null; + const alreadyConfigured = boundaryConfigs.some( + (c) => c.dataSourceId === previewDataSource.id, + ); + if (alreadyConfigured) return null; + const cfg = previewDataSource.defaultInspectorConfig; + const allCols = previewDataSource.columnDefs.map((c) => c.name); + const columns = cfg?.columns?.length ? cfg.columns : allCols; + const config: InspectorBoundaryConfig = { + id: `preview-${previewDataSource.id}`, + dataSourceId: previewDataSource.id, + name: cfg?.name ?? previewDataSource.name ?? "Preview", + type: cfg?.type ?? InspectorBoundaryConfigType.Simple, + columns, + columnOrder: cfg?.columnOrder ?? columns, + columnItems: cfg?.columnItems, + columnMetadata: cfg?.columnMetadata, + columnGroups: cfg?.columnGroups, + layout: cfg?.layout ?? "single", + icon: cfg?.icon, + color: cfg?.color, + }; + return { + config, + dataSourceId: previewDataSource.id, + areaCode: selectedBoundary?.code ?? "", + columns: getSelectedColumnsOrdered(config), + }; + }, [previewDataSource, boundaryConfigs, selectedBoundary?.code]); + const reorderBoundaries = useCallback( (oldIndex: number, newIndex: number) => { if (oldIndex === newIndex) return; @@ -124,7 +180,7 @@ export function InspectorFullPreview({

    Preview

    - {selectedBoundary?.name ?? "Boundary"} + {selectedBoundary?.name ?? "Sample record"}

    Data in this area

    - {boundaryConfigs.length === 0 ? ( + {boundaryConfigs.length === 0 && !previewConfig ? (

    No data sources added yet @@ -144,30 +200,48 @@ export function InspectorFullPreview({

    ) : ( <> - - c.id)} - strategy={verticalListSortingStrategy} + {boundaryConfigs.length > 0 && ( + -
    -

    - Panels — drag to reorder -

    - {boundaryData.map((item) => ( - - ))} -
    -
    -
    + c.id)} + strategy={verticalListSortingStrategy} + > +
    +

    + Panels — drag to reorder +

    + {boundaryData.map((item) => ( + + ))} +
    +
    + + )} + {previewConfig && ( +
    + +
    + )} )} @@ -185,6 +259,7 @@ function SortableBoundaryPanel({ dataSourceId: string; areaCode: string; columns: string[]; + markerLayerColor?: string; }; selectedDataSourceId?: string | null; }) { @@ -230,6 +305,8 @@ function SortableBoundaryPanel({ layout={item.config.layout} defaultExpanded={false} expanded={isSelected} + previewMode + markerLayerColor={item.markerLayerColor} />
    diff --git a/src/app/map/[id]/components/inspector/InspectorPanel.tsx b/src/app/map/[id]/components/inspector/InspectorPanel.tsx index b834d18ff..d1ab3568a 100644 --- a/src/app/map/[id]/components/inspector/InspectorPanel.tsx +++ b/src/app/map/[id]/components/inspector/InspectorPanel.tsx @@ -9,6 +9,7 @@ import { v4 as uuidv4 } from "uuid"; import { inspectorSettingsModalOpenAtom, inspectorSettingsInitialDataSourceIdAtom, + inspectorSettingsInitialTabAtom, } from "@/app/map/[id]/atoms/inspectorAtoms"; import { useDisplayAreaStat } from "@/app/map/[id]/hooks/useDisplayAreaStats"; import { useInspector } from "@/app/map/[id]/hooks/useInspector"; @@ -43,6 +44,7 @@ export default function InspectorPanel({ const setSettingsInitialDataSourceId = useSetAtom( inspectorSettingsInitialDataSourceIdAtom, ); + const setSettingsInitialTab = useSetAtom(inspectorSettingsInitialTabAtom); const [hoverArea] = useHoverArea(); const boundaryHoverVisible = boundariesPanelOpen && !!hoverArea; @@ -128,6 +130,7 @@ export default function InspectorPanel({ const markerCount = selectedRecords?.length || 0; const openInspectorSettingsForDataSource = (dataSourceId: string) => { + setSettingsInitialTab("inspector"); setSettingsInitialDataSourceId(dataSourceId); setSettingsOpen(true); }; @@ -197,10 +200,11 @@ export default function InspectorPanel({ size="icon" className="h-8 w-8" onClick={() => { + setSettingsInitialTab("inspector"); setSettingsInitialDataSourceId(dataSource?.id ?? null); setSettingsOpen(true); }} - aria-label="Visualisation data settings" + aria-label="Data settings" > @@ -263,7 +267,10 @@ export default function InspectorPanel({ isDetailsView={isDetailsView} focusedRecord={focusedRecord} type={type} - onOpenInspectorSettings={() => setSettingsOpen(true)} + onOpenInspectorSettings={() => { + setSettingsInitialTab("inspector"); + setSettingsOpen(true); + }} onOpenInspectorSettingsForDataSource={ openInspectorSettingsForDataSource } diff --git a/src/app/map/[id]/components/inspector/InspectorSettingsModal/DataSourcesList.tsx b/src/app/map/[id]/components/inspector/InspectorSettingsModal/DataSourcesList.tsx index 97a1fbc9e..430d16c0e 100644 --- a/src/app/map/[id]/components/inspector/InspectorSettingsModal/DataSourcesList.tsx +++ b/src/app/map/[id]/components/inspector/InspectorSettingsModal/DataSourcesList.tsx @@ -1,107 +1,115 @@ "use client"; -import { Library, MapPin, XCircle } from "lucide-react"; -import { useMemo } from "react"; -import DataSourceIcon from "@/components/DataSourceIcon"; -import { getDataSourceType } from "@/components/DataSourceItem"; +import { Library, XCircle } from "lucide-react"; +import { useMemo, useState } from "react"; +import { MarkerCollectionIcon } from "@/app/map/[id]/components/Icons"; +import { DataSourceInspectorIcon } from "@/app/map/[id]/components/inspector/inspectorPanelOptions"; +import { mapColors } from "@/app/map/[id]/styles"; +import { DataSourceTypeLabels } from "@/labels"; import { Input } from "@/shadcn/ui/input"; import { cn } from "@/shadcn/utils"; import type { DataSource } from "@/server/models/DataSource"; -import type { InspectorBoundaryConfig } from "@/server/models/MapView"; // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- -interface InspectorEntry { - config: InspectorBoundaryConfig; - dataSource: DataSource; -} - interface DataSourcesListProps { searchQuery: string; onSearchChange: (value: string) => void; - inspectorOrdered: InspectorEntry[]; - otherSources: DataSource[]; - onMapId: string | null; + markerSources: DataSource[]; + visualisationSources: DataSource[]; + librarySources: DataSource[]; selectedDataSourceId: string | null; onSelectDataSource: (id: string) => void; - onRemoveFromInspector: (configId: string) => void; + onRemoveFromMap: (dataSourceId: string) => void; + /** For map sources that are marker/member layers: show MarkerCollectionIcon with this color. Key = dataSourceId. */ + markerLayerColors: Record; } // --------------------------------------------------------------------------- // Shared pieces // --------------------------------------------------------------------------- -function OnMapBadge() { +function DsIcon({ + ds, + markerColor, +}: { + ds: DataSource; + markerColor?: string | null; +}) { + if (markerColor) { + return ( + + + + ); + } return ( - - - On map - + ); } -function DsIcon({ ds }: { ds: DataSource }) { - return ( - - [0], - )} - /> - - ); +function dsSubtitle(ds: DataSource) { + return [ + DataSourceTypeLabels[ds.config.type], + ds.recordCount != null ? String(ds.recordCount) : null, + ] + .filter(Boolean) + .join(" · "); } // --------------------------------------------------------------------------- // Item renderers // --------------------------------------------------------------------------- -function InspectorItem({ - entry, - isOnMap, +function MapSourceItem({ + ds, isSelected, onSelect, - onRemove, + onRemoveFromMap, + markerColor, }: { - entry: InspectorEntry; - isOnMap: boolean; + ds: DataSource; isSelected: boolean; onSelect: () => void; - onRemove: () => void; + onRemoveFromMap: (dataSourceId: string) => void; + markerColor?: string | null; }) { - const { dataSource: ds } = entry; return (
    @@ -111,12 +119,10 @@ function InspectorItem({ function LibraryItem({ ds, - isOnMap, isSelected, onSelect, }: { ds: DataSource; - isOnMap: boolean; isSelected: boolean; onSelect: () => void; }) { @@ -125,56 +131,68 @@ function LibraryItem({ type="button" onClick={onSelect} className={cn( - "w-full text-left rounded-lg p-2.5 flex items-center gap-2 border transition-colors", - isSelected - ? "bg-primary/10 border-primary/30" - : "hover:bg-neutral-50 border-transparent", + "w-full text-left rounded-md px-3 py-1.5 flex items-center gap-2 transition-colors", + isSelected ? "bg-primary/10" : "hover:bg-neutral-50", )} > - - {ds.name} - -
    - {isSelected && ( - - )} - {isOnMap && } -
    + {ds.name} ); } -function LibraryGroup({ - label, - items, - onMapId, +function LibraryTabs({ + libraryUser, + libraryMovement, selectedDataSourceId, onSelectDataSource, - className, }: { - label: string; - items: DataSource[]; - onMapId: string | null; + libraryUser: DataSource[]; + libraryMovement: DataSource[]; selectedDataSourceId: string | null; onSelectDataSource: (id: string) => void; - className?: string; }) { - if (items.length === 0) return null; + const defaultTab = libraryUser.length > 0 ? "user" : "movement"; + const [tab, setTab] = useState<"user" | "movement">(defaultTab); + const items = tab === "user" ? libraryUser : libraryMovement; + return ( -
    -

    - {label} -

    +
    +
    + {libraryUser.length > 0 && ( + + )} + {libraryMovement.length > 0 && ( + + )} +
    {items.map((ds) => ( onSelectDataSource(ds.id)} /> @@ -191,23 +209,25 @@ function LibraryGroup({ export function DataSourcesList({ searchQuery, onSearchChange, - inspectorOrdered, - otherSources, - onMapId, + markerSources, + visualisationSources, + librarySources, selectedDataSourceId, onSelectDataSource, - onRemoveFromInspector, + onRemoveFromMap, + markerLayerColors, }: DataSourcesListProps) { - const { otherUser, otherPublic } = useMemo( + const { libraryUser, libraryMovement } = useMemo( () => ({ - otherUser: otherSources.filter((ds) => !ds.public), - otherPublic: otherSources.filter((ds) => ds.public), + libraryUser: librarySources.filter((ds) => !ds.public), + libraryMovement: librarySources.filter((ds) => ds.public), }), - [otherSources], + [librarySources], ); - const hasInspector = inspectorOrdered.length > 0; - const hasOther = otherUser.length > 0 || otherPublic.length > 0; + const hasMarkers = markerSources.length > 0; + const hasVisualisation = visualisationSources.length > 0; + const hasLibrary = libraryUser.length > 0 || libraryMovement.length > 0; return (
    @@ -221,55 +241,74 @@ export function DataSourcesList({
    - {/* On this map */} - {hasInspector && ( -
    -

    - On this map -

    -
    - {inspectorOrdered.map((entry) => ( - onSelectDataSource(entry.dataSource.id)} - onRemove={() => onRemoveFromInspector(entry.config.id)} + {/* Markers */} +
    +

    + Markers +

    + {hasMarkers ? ( +
    + {markerSources.map((ds) => ( + onSelectDataSource(ds.id)} + onRemoveFromMap={onRemoveFromMap} + markerColor={markerLayerColors[ds.id]} /> ))}
    -
    - )} + ) : ( +

    + No marker layers added yet. +

    + )} +
    + + {/* Visualisation data */} +
    +

    + Visualisation data +

    + {hasVisualisation ? ( +
    + {visualisationSources.map((ds) => ( + onSelectDataSource(ds.id)} + onRemoveFromMap={onRemoveFromMap} + markerColor={markerLayerColors[ds.id]} + /> + ))} +
    + ) : ( +

    + No visualisation data added yet. +

    + )} +
    {/* Library */} - {hasOther && ( -
    -

    + {hasLibrary && ( +
    +

    - Library + Add data from Library

    - - 0 && "mt-3")} />
    )} {/* Empty state */} - {!hasInspector && !hasOther && ( + {!hasMarkers && !hasVisualisation && !hasLibrary && (

    No data sources match.

    diff --git a/src/app/map/[id]/components/inspector/InspectorSettingsModal/GeneralColumnOptionsPanel.tsx b/src/app/map/[id]/components/inspector/InspectorSettingsModal/GeneralColumnOptionsPanel.tsx index d79e9464c..b9cfd7f2f 100644 --- a/src/app/map/[id]/components/inspector/InspectorSettingsModal/GeneralColumnOptionsPanel.tsx +++ b/src/app/map/[id]/components/inspector/InspectorSettingsModal/GeneralColumnOptionsPanel.tsx @@ -3,12 +3,22 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { toast } from "sonner"; +import { InspectorBoundaryConfigType } from "@/server/models/MapView"; import { useTRPC } from "@/services/trpc/react"; import { Input } from "@/shadcn/ui/input"; +import { Label } from "@/shadcn/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/shadcn/ui/select"; import { cn } from "@/shadcn/utils"; +import { INSPECTOR_ICON_OPTIONS } from "../inspectorPanelOptions"; import { GlobalColumnRow } from "./GlobalColumnRow"; +import { DEFAULT_SELECT_VALUE } from "./constants"; import { inferFormat } from "./constants"; -import { InspectorBoundaryConfigType } from "@/server/models/MapView"; import type { DataSource } from "@/server/models/DataSource"; import type { DefaultInspectorBoundaryConfig, @@ -84,6 +94,35 @@ export function GeneralColumnOptionsPanel({ [dataSource.id, dataSource.name, defaultConfig, localMetadata, saveConfig], ); + const [localDisplayName, setLocalDisplayName] = useState( + defaultConfig?.name ?? dataSource.name ?? "", + ); + const [localIcon, setLocalIcon] = useState( + defaultConfig?.icon ?? "", + ); + useEffect(() => { + setLocalDisplayName(defaultConfig?.name ?? dataSource.name ?? ""); + setLocalIcon(defaultConfig?.icon ?? ""); + }, [dataSource.id, defaultConfig?.name, defaultConfig?.icon, dataSource.name]); + + const saveDisplayNameAndIcon = useCallback( + (name: string, icon: string) => { + const nextDefault: DefaultInspectorBoundaryConfig = { + ...defaultConfig, + name: name || (dataSource.name ?? "Boundary Data"), + type: defaultConfig?.type ?? InspectorBoundaryConfigType.Simple, + columns: defaultConfig?.columns ?? [], + columnMetadata: defaultConfig?.columnMetadata ?? {}, + icon: icon || undefined, + }; + saveConfig({ + dataSourceId: dataSource.id, + defaultInspectorConfig: nextDefault, + }).catch(() => {}); + }, + [dataSource.id, dataSource.name, defaultConfig, saveConfig], + ); + const [search, setSearch] = useState(""); const filteredColumns = useMemo(() => { if (!search.trim()) return allColumnNames; @@ -156,9 +195,57 @@ export function GeneralColumnOptionsPanel({
    +
    +

    + Data source settings +

    +
    +
    + + { + const v = e.target.value; + setLocalDisplayName(v); + saveDisplayNameAndIcon(v, localIcon); + }} + placeholder="e.g. Main data" + className="h-9" + /> +
    +
    + + +
    +
    +

    - General column options + Column options

    Label, description, and format apply everywhere this data source is diff --git a/src/app/map/[id]/components/inspector/InspectorSettingsModal/GlobalColumnRow.tsx b/src/app/map/[id]/components/inspector/InspectorSettingsModal/GlobalColumnRow.tsx index c729591b8..53521344e 100644 --- a/src/app/map/[id]/components/inspector/InspectorSettingsModal/GlobalColumnRow.tsx +++ b/src/app/map/[id]/components/inspector/InspectorSettingsModal/GlobalColumnRow.tsx @@ -203,10 +203,10 @@ export function GlobalColumnRow({ const hasCustomSettings = (displayName ?? "") !== "" || (description ?? "") !== "" || - format !== "text" || (format === "scale" && scaleMax !== 3) || (format === "numberWithComparison" && comparisonStat !== undefined) || - ((format === "percentage" || format === "scale") && (barColor ?? "") !== ""); + ((format === "percentage" || format === "scale") && (barColor ?? "") !== "") || + format !== "text"; return (

    void; initialDataSourceId?: string | null; + /** Tab to show when opening: "general" (layer/column options) or "inspector". */ + initialTab?: "general" | "inspector"; }) { const [selectedDataSourceId, setSelectedDataSourceId] = useState(null); + const [activeTab, setActiveTab] = useState<"general" | "inspector">(initialTab); const [searchQuery, setSearchQuery] = useState(""); + const [hiddenFromInspector, setHiddenFromInspector] = useState>(new Set()); const debouncedSearchQuery = useDebouncedValue(searchQuery, 500); const { data: dataSources, getDataSourceById } = useDataSources(); - const { view, viewConfig, getLatestView, updateView } = useMapViews(); + const { mapConfig, updateMapConfig } = useMapConfig(); + const { view, viewConfig, getLatestView, updateView, updateViewConfig } = useMapViews(); const boundaryConfigs = useMemo( () => view?.inspectorConfig?.boundaries ?? [], [view?.inspectorConfig?.boundaries], ); - const onMapId = viewConfig.areaDataSourceId || null; + + // ---- Map data sources (same logic as VD panel) -------------------------- + + const mapDataSourceIds = useMemo(() => { + const ids = new Set(); + if (viewConfig.areaDataSourceId) ids.add(viewConfig.areaDataSourceId); + mapConfig.markerDataSourceIds.forEach((id) => ids.add(id)); + if (mapConfig.membersDataSourceId) ids.add(mapConfig.membersDataSourceId); + return ids; + }, [viewConfig.areaDataSourceId, mapConfig.markerDataSourceIds, mapConfig.membersDataSourceId]); // ---- Search / filtering ------------------------------------------------ @@ -60,36 +80,40 @@ export default function InspectorSettingsModal({ [debouncedSearchQuery], ); - const filteredSources = useMemo(() => { - const list = dataSources ?? []; - if (!debouncedSearchQuery.trim()) return list; - return list.filter(matchesSearch); - }, [dataSources, debouncedSearchQuery, matchesSearch]); - - const { inspectorOrdered, otherSources } = useMemo(() => { - const inInspector = boundaryConfigs - .map((config) => ({ - config, - dataSource: getDataSourceById(config.dataSourceId), - })) - .filter( - (x): x is { - config: InspectorBoundaryConfig; - dataSource: NonNullable>; - } => x.dataSource != null && matchesSearch(x.dataSource), - ); - const inIds = new Set(inInspector.map((x) => x.dataSource.id)); - const other = filteredSources.filter((ds) => !inIds.has(ds.id)); - return { inspectorOrdered: inInspector, otherSources: other }; - }, [boundaryConfigs, getDataSourceById, filteredSources, matchesSearch]); + const { markerSources, visualisationSources, librarySources } = useMemo(() => { + const all = (dataSources ?? []).filter(matchesSearch); + const markerIds = new Set(); + mapConfig.markerDataSourceIds.forEach((id) => markerIds.add(id)); + if (mapConfig.membersDataSourceId) markerIds.add(mapConfig.membersDataSourceId); + const visIds = new Set(); + if (viewConfig.areaDataSourceId) visIds.add(viewConfig.areaDataSourceId); + const onMap = all.filter((ds) => markerIds.has(ds.id) || visIds.has(ds.id)); + return { + markerSources: onMap.filter((ds) => markerIds.has(ds.id)), + visualisationSources: onMap.filter((ds) => visIds.has(ds.id)), + librarySources: all.filter((ds) => !markerIds.has(ds.id) && !visIds.has(ds.id)), + }; + }, [dataSources, matchesSearch, mapConfig.markerDataSourceIds, mapConfig.membersDataSourceId, viewConfig.areaDataSourceId]); - // ---- Initial selection ------------------------------------------------- + const markerLayerColors = useMemo(() => { + const out: Record = {}; + if (mapConfig.membersDataSourceId) { + out[mapConfig.membersDataSourceId] = mapColors.member.color; + } + mapConfig.markerDataSourceIds.forEach((id) => { + out[id] = mapConfig.markerColors?.[id] ?? mapColors.markers.color; + }); + return out; + }, [mapConfig.membersDataSourceId, mapConfig.markerDataSourceIds, mapConfig.markerColors]); + + // ---- Initial selection and tab ----------------------------------------- useEffect(() => { - if (open && initialDataSourceId != null) { - setSelectedDataSourceId(initialDataSourceId); + if (open) { + if (initialDataSourceId != null) setSelectedDataSourceId(initialDataSourceId); + setActiveTab(initialTab); } - }, [open, initialDataSourceId]); + }, [open, initialDataSourceId, initialTab]); // ---- Derived selection state ------------------------------------------- @@ -109,38 +133,56 @@ export default function InspectorSettingsModal({ [selectedDataSourceId, dataSources], ); - const isInInspector = !!selectedConfig; - // ---- Mutations --------------------------------------------------------- - const handleAddToInspector = useCallback(() => { - if (!view || !selectedDataSourceId) return; - const ds = (dataSources ?? []).find((d) => d.id === selectedDataSourceId); - if (!ds) return; - const cfg = ds.defaultInspectorConfig; - const allCols = ds.columnDefs.map((c) => c.name); - const defaultColumns = cfg?.columns?.length ? cfg.columns : allCols; - const raw: InspectorBoundaryConfig = { - id: uuidv4(), - dataSourceId: selectedDataSourceId, - name: cfg?.name ?? ds.name ?? "Boundary Data", - type: cfg?.type ?? InspectorBoundaryConfigType.Simple, - columns: defaultColumns, - columnOrder: cfg?.columnOrder ?? defaultColumns, - columnItems: cfg?.columnItems, - columnMetadata: cfg?.columnMetadata, - columnGroups: cfg?.columnGroups, - layout: cfg?.layout ?? "single", - icon: cfg?.icon, - color: cfg?.color, - }; - const newConfig = normalizeInspectorBoundaryConfig(raw, allCols) ?? raw; - const prev = view.inspectorConfig?.boundaries ?? []; - updateView({ - ...view, - inspectorConfig: { ...view.inspectorConfig, boundaries: [...prev, newConfig] }, - }); - }, [view, selectedDataSourceId, dataSources, updateView]); + const addToInspector = useCallback( + (dataSourceId: string) => { + if (!view) return; + const existing = (view.inspectorConfig?.boundaries ?? []).find( + (c) => c.dataSourceId === dataSourceId, + ); + if (existing) return; + const ds = (dataSources ?? []).find((d) => d.id === dataSourceId); + if (!ds) return; + const cfg = ds.defaultInspectorConfig; + const allCols = ds.columnDefs.map((c) => c.name); + const defaultColumns = cfg?.columns?.length ? cfg.columns : allCols; + const raw: InspectorBoundaryConfig = { + id: uuidv4(), + dataSourceId, + name: cfg?.name ?? ds.name ?? "Boundary Data", + type: cfg?.type ?? InspectorBoundaryConfigType.Simple, + columns: defaultColumns, + columnOrder: cfg?.columnOrder ?? defaultColumns, + columnItems: cfg?.columnItems, + columnMetadata: cfg?.columnMetadata, + columnGroups: cfg?.columnGroups, + layout: cfg?.layout ?? "single", + icon: cfg?.icon, + color: cfg?.color, + }; + const newConfig = normalizeInspectorBoundaryConfig(raw, allCols) ?? raw; + const prev = view.inspectorConfig?.boundaries ?? []; + updateView({ + ...view, + inspectorConfig: { ...view.inspectorConfig, boundaries: [...prev, newConfig] }, + }); + }, + [view, dataSources, updateView], + ); + + // Auto-add to inspector when a map data source is selected without a config + useEffect(() => { + if (!selectedDataSourceId) return; + if (!mapDataSourceIds.has(selectedDataSourceId)) return; + if (hiddenFromInspector.has(selectedDataSourceId)) return; + const hasConfig = boundaryConfigs.some( + (c) => c.dataSourceId === selectedDataSourceId, + ); + if (!hasConfig) { + addToInspector(selectedDataSourceId); + } + }, [selectedDataSourceId, mapDataSourceIds, boundaryConfigs, addToInspector, hiddenFromInspector]); const handleRemoveFromInspector = useCallback( (configId: string) => { @@ -156,19 +198,63 @@ export default function InspectorSettingsModal({ [view, boundaryConfigs, updateView], ); + const handleRemoveFromMap = useCallback( + (dataSourceId: string) => { + if (mapConfig.membersDataSourceId === dataSourceId) { + updateMapConfig({ membersDataSourceId: null }); + } + if (mapConfig.markerDataSourceIds.includes(dataSourceId)) { + updateMapConfig({ + markerDataSourceIds: mapConfig.markerDataSourceIds.filter( + (id) => id !== dataSourceId, + ), + }); + } + if (viewConfig.areaDataSourceId === dataSourceId) { + updateViewConfig({ areaDataSourceId: "", areaDataColumn: "" }); + } + const config = boundaryConfigs.find((c) => c.dataSourceId === dataSourceId); + if (config) { + handleRemoveFromInspector(config.id); + } + if (selectedDataSourceId === dataSourceId) { + setSelectedDataSourceId(null); + } + }, + [mapConfig, viewConfig, boundaryConfigs, updateMapConfig, updateViewConfig, handleRemoveFromInspector, selectedDataSourceId], + ); + + const handleAddToMap = useCallback( + (dataSourceId: string) => { + if (mapConfig.markerDataSourceIds.includes(dataSourceId)) return; + updateMapConfig({ + markerDataSourceIds: [...mapConfig.markerDataSourceIds, dataSourceId], + }); + }, + [mapConfig, updateMapConfig], + ); + + const isOnMap = selectedDataSourceId ? mapDataSourceIds.has(selectedDataSourceId) : false; + const onAppearInInspectorChange = useCallback( (checked: boolean) => { if (!selectedDataSourceId || !view) return; if (checked) { - handleAddToInspector(); + setHiddenFromInspector((prev) => { + const next = new Set(prev); + next.delete(selectedDataSourceId); + return next; + }); + addToInspector(selectedDataSourceId); } else { + setHiddenFromInspector((prev) => new Set(prev).add(selectedDataSourceId)); const config = boundaryConfigs.find( (c) => c.dataSourceId === selectedDataSourceId, ); if (config) handleRemoveFromInspector(config.id); } }, - [selectedDataSourceId, view, boundaryConfigs, handleAddToInspector, handleRemoveFromInspector], + [selectedDataSourceId, view, boundaryConfigs, addToInspector, handleRemoveFromInspector], ); const updateBoundaryConfig = useCallback( @@ -211,19 +297,6 @@ export default function InspectorSettingsModal({ [getLatestView, updateView], ); - // ---- Panel display name (debounced) ------------------------------------ - - const [panelDisplayName, setPanelDisplayName] = useState(selectedConfig?.name ?? ""); - - useEffect(() => { - setPanelDisplayName(selectedConfig?.name ?? selectedDataSource?.name ?? ""); - }, [selectedConfig?.name, selectedDataSource?.name]); - - const debouncedUpdatePanelName = useDebouncedCallback( - (value: string) => updateBoundaryConfig((prev) => ({ ...prev, name: value })), - 600, - ); - // ---- Render ------------------------------------------------------------ return ( @@ -234,29 +307,59 @@ export default function InspectorSettingsModal({ onPointerDownOutside={() => setSelectedDataSourceId(null)} > - Visualisation data settings + Data settings
    - {selectedDataSource ? ( - -
    - - General - Inspector settings - + {selectedDataSource && isOnMap ? ( + setActiveTab(v as "general" | "inspector")} + className="flex flex-col h-full min-h-0 gap-0" + > +
    +
    +

    + {selectedDataSource.name} +

    +
    +
    + + Layer + + Inspector + + + +
    + ) : selectedDataSource ? ( +
    +
    +

    + {selectedDataSource.name} +

    +
    +
    +
    +

    + This data source is not on the map yet. +

    + +
    +
    +
    ) : (
    Select a data source to edit general column options and @@ -295,6 +411,23 @@ export default function InspectorSettingsModal({
    )}
    + + {selectedDataSourceId && ( +
    +

    + Preview +

    + +
    + )}
    diff --git a/src/app/map/[id]/components/inspector/InspectorSettingsModal/InspectorSettingsTabContent.tsx b/src/app/map/[id]/components/inspector/InspectorSettingsModal/InspectorSettingsTabContent.tsx index e9456f323..83c520cc9 100644 --- a/src/app/map/[id]/components/inspector/InspectorSettingsModal/InspectorSettingsTabContent.tsx +++ b/src/app/map/[id]/components/inspector/InspectorSettingsModal/InspectorSettingsTabContent.tsx @@ -7,7 +7,6 @@ import { toast } from "sonner"; import { InspectorBoundaryConfigType } from "@/server/models/MapView"; import { useTRPC } from "@/services/trpc/react"; import { Checkbox } from "@/shadcn/ui/checkbox"; -import { Input } from "@/shadcn/ui/input"; import { Label } from "@/shadcn/ui/label"; import { Select, @@ -17,15 +16,11 @@ import { SelectValue, } from "@/shadcn/ui/select"; import { cn } from "@/shadcn/utils"; -import { - INSPECTOR_COLOR_OPTIONS, - INSPECTOR_ICON_OPTIONS, -} from "../inspectorPanelOptions"; +import { INSPECTOR_COLOR_OPTIONS } from "../inspectorPanelOptions"; import { getSelectedColumnsOrdered, normalizeInspectorBoundaryConfig, } from "../inspectorColumnOrder"; -import { InspectorFullPreview } from "../InspectorFullPreview"; import { ColumnOrderList } from "./ColumnOrderList"; import { DEFAULT_SELECT_VALUE } from "./constants"; import type { InspectorLayout } from "./constants"; @@ -42,20 +37,12 @@ import type { useMapViews } from "../../../hooks/useMapViews"; interface InspectorSettingsTabContentProps { dataSource: DataSource; - dataSourceName: string; boundaryConfig: InspectorBoundaryConfig | null; - isInInspector: boolean; - onAppearInInspectorChange: (checked: boolean) => void; - onAddToMap: () => void; updateBoundaryConfig: ( updater: (prev: InspectorBoundaryConfig) => InspectorBoundaryConfig, ) => void; - panelDisplayName: string; - setPanelDisplayName: (v: string) => void; - debouncedUpdatePanelName: (v: string) => void; getLatestView: ReturnType["getLatestView"]; updateView: ReturnType["updateView"]; - selectedDataSourceId: string | null; onReorderColumns: ( dataSourceId: string, orderedColumnNames: string[], @@ -66,92 +53,15 @@ interface InspectorSettingsTabContentProps { // Sub-components // --------------------------------------------------------------------------- -function NotOnMapPrompt({ - name, - onAddToMap, -}: { - name: string; - onAddToMap: () => void; -}) { - return ( -
    -

    {name}

    -
    -

    - This data source is not on the map. Add it to show its data in the - inspector. -

    - -
    -
    - ); -} - function PanelOptionsGrid({ boundaryConfig, - panelDisplayName, - setPanelDisplayName, - debouncedUpdatePanelName, updateBoundaryConfig, }: { boundaryConfig: InspectorBoundaryConfig; - panelDisplayName: string; - setPanelDisplayName: (v: string) => void; - debouncedUpdatePanelName: (v: string) => void; updateBoundaryConfig: InspectorSettingsTabContentProps["updateBoundaryConfig"]; }) { return ( -
    -
    - - { - const v = e.target.value; - setPanelDisplayName(v); - debouncedUpdatePanelName(v); - }} - placeholder="e.g. Main data" - className="h-9 max-w-full" - /> -
    - -
    - - -
    - +