From c513659ce0445bbed8f5bce532a4b8f8785dee48 Mon Sep 17 00:00:00 2001 From: Joaquim d'Souza Date: Thu, 22 Jan 2026 15:25:59 +0100 Subject: [PATCH 1/3] feat: add clusters to inspector panel --- .../inspector/ClusterMarkersList.tsx | 48 ++++++++++++ .../inspector/InspectorMarkersTab.tsx | 2 + .../components/inspector/InspectorPanel.tsx | 75 ++++++++++++------- .../components/inspector/MarkersLists.tsx | 2 +- src/app/map/[id]/hooks/useInspector.ts | 18 +++-- src/types.ts | 1 + 6 files changed, 115 insertions(+), 31 deletions(-) create mode 100644 src/app/map/[id]/components/inspector/ClusterMarkersList.tsx diff --git a/src/app/map/[id]/components/inspector/ClusterMarkersList.tsx b/src/app/map/[id]/components/inspector/ClusterMarkersList.tsx new file mode 100644 index 00000000..ee4a0a6e --- /dev/null +++ b/src/app/map/[id]/components/inspector/ClusterMarkersList.tsx @@ -0,0 +1,48 @@ +import { useDataSources } from "@/app/map/[id]/hooks/useDataSources"; +import { useInspector } from "@/app/map/[id]/hooks/useInspector"; +import { DataSourceRecordType } from "@/server/models/DataSource"; +import { MarkersList, MembersList } from "./MarkersLists"; +import type { MarkerFeature } from "@/types"; + +export default function ClusterMarkersList() { + const { getDataSourceById } = useDataSources(); + const { selectedRecords, inspectorContent } = useInspector(); + const dataSource = getDataSourceById(inspectorContent?.dataSource?.id); + const recordType = dataSource?.recordType; + const markerFeatures = selectedRecords + .map((r): MarkerFeature | null => { + if (!r.dataSourceId) { + // Should never happen for markers in a cluster + return null; + } + return { + type: "Feature", + geometry: { + // [0, 0] should never happen because these records are in a cluster on the map + coordinates: [r.geocodePoint?.lng || 0, r.geocodePoint?.lat || 0], + type: "Point", + }, + properties: { + id: r.id, + name: r.name, + dataSourceId: r.dataSourceId, + matched: true, + }, + }; + }) + .filter((r) => r !== null); + + return ( +
+ {recordType === DataSourceRecordType.Members ? ( + + ) : ( + + )} +
+ ); +} diff --git a/src/app/map/[id]/components/inspector/InspectorMarkersTab.tsx b/src/app/map/[id]/components/inspector/InspectorMarkersTab.tsx index 197a65f3..0f984a3f 100644 --- a/src/app/map/[id]/components/inspector/InspectorMarkersTab.tsx +++ b/src/app/map/[id]/components/inspector/InspectorMarkersTab.tsx @@ -1,5 +1,6 @@ import { LayerType } from "@/types"; import BoundaryMarkersList from "./BoundaryMarkersList"; +import ClusterMarkersList from "./ClusterMarkersList"; import TurfMarkersList from "./TurfMarkersList"; interface InspectorMarkersTabProps { @@ -11,6 +12,7 @@ export default function InspectorMarkersTab({ }: InspectorMarkersTabProps) { return (
+ {type === LayerType.Cluster && } {type === LayerType.Turf && } {type === LayerType.Boundary && }
diff --git a/src/app/map/[id]/components/inspector/InspectorPanel.tsx b/src/app/map/[id]/components/inspector/InspectorPanel.tsx index fc0ca039..5f6318a1 100644 --- a/src/app/map/[id]/components/inspector/InspectorPanel.tsx +++ b/src/app/map/[id]/components/inspector/InspectorPanel.tsx @@ -1,5 +1,5 @@ import { ArrowLeftIcon, SettingsIcon, XIcon } from "lucide-react"; -import { useState } from "react"; +import { useMemo, useState } from "react"; import { useInspector } from "@/app/map/[id]/hooks/useInspector"; import { cn } from "@/shadcn/utils"; @@ -26,12 +26,26 @@ export default function InspectorPanel() { setFocusedRecord, selectedRecords, } = useInspector(); + const { dataSource, properties, type } = inspectorContent ?? {}; + + const safeActiveTab = useMemo(() => { + if (activeTab === "data" && type === LayerType.Cluster) { + return "markers"; + } + const isMarkers = type === LayerType.Marker || type === LayerType.Member; + if (activeTab === "markers" && isMarkers) { + return "data"; + } + if (activeTab === "config" && type !== LayerType.Boundary) { + return type === LayerType.Cluster ? "markers" : "data"; + } + return activeTab; + }, [activeTab, type]); if (!Boolean(inspectorContent)) { return <>; } - const { dataSource, properties, type } = inspectorContent ?? {}; const isDetailsView = Boolean( (selectedTurf && type !== LayerType.Turf) || (selectedBoundary && type !== LayerType.Boundary), @@ -49,17 +63,17 @@ export default function InspectorPanel() { className={cn( "absolute top-0 bottom-0 right-4 / flex flex-col gap-6 py-5", "bottom-24", // to avoid clash with bug report button - activeTab === "config" ? "h-full" : "h-fit max-h-full", + safeActiveTab === "config" ? "h-full" : "h-fit max-h-full", )} style={{ - minWidth: activeTab === "config" ? "400px" : "250px", + minWidth: safeActiveTab === "config" ? "400px" : "250px", maxWidth: "450px", }} >
@@ -98,12 +112,14 @@ export default function InspectorPanel() { - Data + {type !== LayerType.Cluster && ( + Data + )} Notes 0 - - - + {type === LayerType.Boundary && ( + + + + )} - - - + {type !== LayerType.Cluster && ( + + + + )} - - - + {type === LayerType.Boundary && ( + + + + )}
diff --git a/src/app/map/[id]/components/inspector/MarkersLists.tsx b/src/app/map/[id]/components/inspector/MarkersLists.tsx index c22195b4..26b6c77c 100644 --- a/src/app/map/[id]/components/inspector/MarkersLists.tsx +++ b/src/app/map/[id]/components/inspector/MarkersLists.tsx @@ -14,7 +14,7 @@ export const MembersList = ({ }: { markers: MarkerFeature[]; dataSource: DataSource | null; - areaType: "area" | "boundary"; + areaType: "area" | "boundary" | "cluster"; }) => { const { setSelectedRecords } = useInspector(); diff --git a/src/app/map/[id]/hooks/useInspector.ts b/src/app/map/[id]/hooks/useInspector.ts index 8653c7b9..22e4301a 100644 --- a/src/app/map/[id]/hooks/useInspector.ts +++ b/src/app/map/[id]/hooks/useInspector.ts @@ -71,26 +71,34 @@ export function useInspector() { } const dataSourceId = focusedRecord.dataSourceId; - const dataSource = dataSourceId ? getDataSourceById(dataSourceId) : null; + if (selectedRecords.length > 1) { + return { + type: LayerType.Cluster, + name: "Cluster", + properties: null, + dataSource, + }; + } + const type = dataSourceId === mapConfig.membersDataSourceId ? LayerType.Member : LayerType.Marker; return { - type: type, + type, name: focusedRecord.name, properties: null, - dataSource: dataSource, + dataSource, }; }, [ focusedRecord, getDataSourceById, mapConfig.membersDataSourceId, selectedBoundary, - selectedTurf?.id, - selectedTurf?.name, + selectedRecords.length, + selectedTurf, ]); const resetInspector = useCallback(() => { diff --git a/src/types.ts b/src/types.ts index cf4066a7..e96179a5 100644 --- a/src/types.ts +++ b/src/types.ts @@ -86,6 +86,7 @@ export interface FilterField { export enum LayerType { Boundary = "Boundary", + Cluster = "Cluster", Member = "Member", Marker = "Marker", Turf = "Turf", From 112648c2a3312e7ec894f89998de3b02599b4a11 Mon Sep 17 00:00:00 2001 From: joaquimds Date: Thu, 22 Jan 2026 15:36:49 +0100 Subject: [PATCH 2/3] Update src/app/map/[id]/components/inspector/ClusterMarkersList.tsx Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/app/map/[id]/components/inspector/ClusterMarkersList.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/app/map/[id]/components/inspector/ClusterMarkersList.tsx b/src/app/map/[id]/components/inspector/ClusterMarkersList.tsx index ee4a0a6e..6ff6dec7 100644 --- a/src/app/map/[id]/components/inspector/ClusterMarkersList.tsx +++ b/src/app/map/[id]/components/inspector/ClusterMarkersList.tsx @@ -1,13 +1,11 @@ -import { useDataSources } from "@/app/map/[id]/hooks/useDataSources"; import { useInspector } from "@/app/map/[id]/hooks/useInspector"; import { DataSourceRecordType } from "@/server/models/DataSource"; import { MarkersList, MembersList } from "./MarkersLists"; import type { MarkerFeature } from "@/types"; export default function ClusterMarkersList() { - const { getDataSourceById } = useDataSources(); const { selectedRecords, inspectorContent } = useInspector(); - const dataSource = getDataSourceById(inspectorContent?.dataSource?.id); + const dataSource = inspectorContent?.dataSource; const recordType = dataSource?.recordType; const markerFeatures = selectedRecords .map((r): MarkerFeature | null => { From f8e83b774857f5dbce1ee50e4a2c9bbab02a88c0 Mon Sep 17 00:00:00 2001 From: Joaquim d'Souza Date: Thu, 22 Jan 2026 15:25:59 +0100 Subject: [PATCH 3/3] feat: add clusters to inspector panel --- .../inspector/ClusterMarkersList.tsx | 47 ++++++++++++ .../inspector/InspectorMarkersTab.tsx | 2 + .../components/inspector/InspectorPanel.tsx | 75 ++++++++++++------- .../components/inspector/MarkersLists.tsx | 2 +- src/app/map/[id]/hooks/useInspector.ts | 18 +++-- src/types.ts | 1 + 6 files changed, 114 insertions(+), 31 deletions(-) create mode 100644 src/app/map/[id]/components/inspector/ClusterMarkersList.tsx diff --git a/src/app/map/[id]/components/inspector/ClusterMarkersList.tsx b/src/app/map/[id]/components/inspector/ClusterMarkersList.tsx new file mode 100644 index 00000000..28effaef --- /dev/null +++ b/src/app/map/[id]/components/inspector/ClusterMarkersList.tsx @@ -0,0 +1,47 @@ +import { useDataSources } from "@/app/map/[id]/hooks/useDataSources"; +import { useInspector } from "@/app/map/[id]/hooks/useInspector"; +import { DataSourceRecordType } from "@/server/models/DataSource"; +import { MarkersList, MembersList } from "./MarkersLists"; +import type { MarkerFeature } from "@/types"; + +export default function ClusterMarkersList() { + const { getDataSourceById } = useDataSources(); + const { selectedRecords, inspectorContent } = useInspector(); + const dataSource = getDataSourceById(inspectorContent?.dataSource?.id); + const recordType = dataSource?.recordType; + const markerFeatures = selectedRecords + .map((r): MarkerFeature | null => { + if (!r.dataSourceId || !r.geocodePoint) { + // Should never happen for markers in a cluster + return null; + } + return { + type: "Feature", + geometry: { + coordinates: [r.geocodePoint.lng, r.geocodePoint.lat], + type: "Point", + }, + properties: { + id: r.id, + name: r.name, + dataSourceId: r.dataSourceId, + matched: true, + }, + }; + }) + .filter((r) => r !== null); + + return ( +
+ {recordType === DataSourceRecordType.Members ? ( + + ) : ( + + )} +
+ ); +} diff --git a/src/app/map/[id]/components/inspector/InspectorMarkersTab.tsx b/src/app/map/[id]/components/inspector/InspectorMarkersTab.tsx index 197a65f3..0f984a3f 100644 --- a/src/app/map/[id]/components/inspector/InspectorMarkersTab.tsx +++ b/src/app/map/[id]/components/inspector/InspectorMarkersTab.tsx @@ -1,5 +1,6 @@ import { LayerType } from "@/types"; import BoundaryMarkersList from "./BoundaryMarkersList"; +import ClusterMarkersList from "./ClusterMarkersList"; import TurfMarkersList from "./TurfMarkersList"; interface InspectorMarkersTabProps { @@ -11,6 +12,7 @@ export default function InspectorMarkersTab({ }: InspectorMarkersTabProps) { return (
+ {type === LayerType.Cluster && } {type === LayerType.Turf && } {type === LayerType.Boundary && }
diff --git a/src/app/map/[id]/components/inspector/InspectorPanel.tsx b/src/app/map/[id]/components/inspector/InspectorPanel.tsx index fc0ca039..5f6318a1 100644 --- a/src/app/map/[id]/components/inspector/InspectorPanel.tsx +++ b/src/app/map/[id]/components/inspector/InspectorPanel.tsx @@ -1,5 +1,5 @@ import { ArrowLeftIcon, SettingsIcon, XIcon } from "lucide-react"; -import { useState } from "react"; +import { useMemo, useState } from "react"; import { useInspector } from "@/app/map/[id]/hooks/useInspector"; import { cn } from "@/shadcn/utils"; @@ -26,12 +26,26 @@ export default function InspectorPanel() { setFocusedRecord, selectedRecords, } = useInspector(); + const { dataSource, properties, type } = inspectorContent ?? {}; + + const safeActiveTab = useMemo(() => { + if (activeTab === "data" && type === LayerType.Cluster) { + return "markers"; + } + const isMarkers = type === LayerType.Marker || type === LayerType.Member; + if (activeTab === "markers" && isMarkers) { + return "data"; + } + if (activeTab === "config" && type !== LayerType.Boundary) { + return type === LayerType.Cluster ? "markers" : "data"; + } + return activeTab; + }, [activeTab, type]); if (!Boolean(inspectorContent)) { return <>; } - const { dataSource, properties, type } = inspectorContent ?? {}; const isDetailsView = Boolean( (selectedTurf && type !== LayerType.Turf) || (selectedBoundary && type !== LayerType.Boundary), @@ -49,17 +63,17 @@ export default function InspectorPanel() { className={cn( "absolute top-0 bottom-0 right-4 / flex flex-col gap-6 py-5", "bottom-24", // to avoid clash with bug report button - activeTab === "config" ? "h-full" : "h-fit max-h-full", + safeActiveTab === "config" ? "h-full" : "h-fit max-h-full", )} style={{ - minWidth: activeTab === "config" ? "400px" : "250px", + minWidth: safeActiveTab === "config" ? "400px" : "250px", maxWidth: "450px", }} >
@@ -98,12 +112,14 @@ export default function InspectorPanel() { - Data + {type !== LayerType.Cluster && ( + Data + )} Notes 0 - - - + {type === LayerType.Boundary && ( + + + + )} - - - + {type !== LayerType.Cluster && ( + + + + )} - - - + {type === LayerType.Boundary && ( + + + + )}
diff --git a/src/app/map/[id]/components/inspector/MarkersLists.tsx b/src/app/map/[id]/components/inspector/MarkersLists.tsx index c22195b4..26b6c77c 100644 --- a/src/app/map/[id]/components/inspector/MarkersLists.tsx +++ b/src/app/map/[id]/components/inspector/MarkersLists.tsx @@ -14,7 +14,7 @@ export const MembersList = ({ }: { markers: MarkerFeature[]; dataSource: DataSource | null; - areaType: "area" | "boundary"; + areaType: "area" | "boundary" | "cluster"; }) => { const { setSelectedRecords } = useInspector(); diff --git a/src/app/map/[id]/hooks/useInspector.ts b/src/app/map/[id]/hooks/useInspector.ts index 8653c7b9..22e4301a 100644 --- a/src/app/map/[id]/hooks/useInspector.ts +++ b/src/app/map/[id]/hooks/useInspector.ts @@ -71,26 +71,34 @@ export function useInspector() { } const dataSourceId = focusedRecord.dataSourceId; - const dataSource = dataSourceId ? getDataSourceById(dataSourceId) : null; + if (selectedRecords.length > 1) { + return { + type: LayerType.Cluster, + name: "Cluster", + properties: null, + dataSource, + }; + } + const type = dataSourceId === mapConfig.membersDataSourceId ? LayerType.Member : LayerType.Marker; return { - type: type, + type, name: focusedRecord.name, properties: null, - dataSource: dataSource, + dataSource, }; }, [ focusedRecord, getDataSourceById, mapConfig.membersDataSourceId, selectedBoundary, - selectedTurf?.id, - selectedTurf?.name, + selectedRecords.length, + selectedTurf, ]); const resetInspector = useCallback(() => { diff --git a/src/types.ts b/src/types.ts index cf4066a7..e96179a5 100644 --- a/src/types.ts +++ b/src/types.ts @@ -86,6 +86,7 @@ export interface FilterField { export enum LayerType { Boundary = "Boundary", + Cluster = "Cluster", Member = "Member", Marker = "Marker", Turf = "Turf",