diff --git a/migrations/1770299199726_area_geom.ts b/migrations/1770299199726_area_geom.ts index f19f5ef2..8046de9b 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 00000000..edf448d0 --- /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/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 00000000..c62e0e15 --- /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/migrations/1772203924531_data_source_default_inspector_config.ts b/migrations/1772203924531_data_source_default_inspector_config.ts new file mode 100644 index 00000000..edf448d0 --- /dev/null +++ b/migrations/1772203924531_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/migrations/1772203924532_data_source_default_inspector_config_updated_at.ts b/migrations/1772203924532_data_source_default_inspector_config_updated_at.ts new file mode 100644 index 00000000..c62e0e15 --- /dev/null +++ b/migrations/1772203924532_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 c63c0867..6e0bb183 100644 --- a/src/app/(private)/data-sources/[id]/DataSourceDashboard.tsx +++ b/src/app/(private)/data-sources/[id]/DataSourceDashboard.tsx @@ -30,8 +30,9 @@ import { } from "@/shadcn/ui/breadcrumb"; import { Button } from "@/shadcn/ui/button"; import { Separator } from "@/shadcn/ui/separator"; -import ColumnMetadataForm from "./components/ColumnMetadataForm"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/shadcn/ui/tabs"; import ConfigurationForm from "./components/ConfigurationForm"; +import { DefaultInspectorConfigSection } from "./components/DefaultInspectorConfigSection"; import type { RouterOutputs } from "@/services/trpc/react"; export function DataSourceDashboard({ @@ -39,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( @@ -222,39 +224,54 @@ export function DataSourceDashboard({ )} -
-
-

About this data source

- - -
- -
-

Configuration

- - -

Danger zone

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

Configuration

+ + +

Danger zone

+
+ +
+
+
+

About this data source

+ +
-
-
+ + + + +
); } 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 00000000..c7389590 --- /dev/null +++ b/src/app/(private)/data-sources/[id]/components/DefaultInspectorConfigSection.tsx @@ -0,0 +1,457 @@ +"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 { + getAllColumnsSorted, + getColumnOrderState, + normalizeInspectorBoundaryConfig, +} from "@/app/map/[id]/components/inspector/inspectorColumnOrder"; +import { + INSPECTOR_COLOR_OPTIONS, + INSPECTOR_ICON_OPTIONS, +} from "@/app/map/[id]/components/inspector/inspectorPanelOptions"; +import { ColumnsSection } from "@/app/map/[id]/components/inspector/InspectorSettingsModal/ColumnsSection"; +import { DEFAULT_SELECT_VALUE } from "@/app/map/[id]/components/inspector/InspectorSettingsModal/constants"; +import { inferFormat } from "@/app/map/[id]/components/inspector/InspectorSettingsModal/constants"; +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 { DefaultInspectorPreview } from "./DefaultInspectorPreview"; +import type { InspectorLayout } from "@/app/map/[id]/components/inspector/InspectorSettingsModal/constants"; +import type { DataSource } from "@/server/models/DataSource"; +import type { + DefaultInspectorBoundaryConfig, + InspectorBoundaryConfig, +} from "@/server/models/MapView"; + +const PLACEHOLDER_ID = "__default_inspector_edit__"; + +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", + }; + } + return { + id: PLACEHOLDER_ID, + dataSourceId, + name: defaultConfig.name, + type: defaultConfig.type, + columns: defaultConfig.columns ?? [], + columnOrder: defaultConfig.columnOrder, + columnItems: defaultConfig.columnItems, + columnMetadata: defaultConfig.columnMetadata, + columnGroups: defaultConfig.columnGroups, + layout: defaultConfig.layout ?? "single", + icon: defaultConfig.icon, + color: defaultConfig.color, + }; +} + +function toDefaultConfig( + config: InspectorBoundaryConfig, +): DefaultInspectorBoundaryConfig { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { id, dataSourceId, ...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, + }), + }); + }, + 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 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); + + const handleSave = useCallback(async () => { + setIsDirty(false); + setIsSaving(true); + try { + await onSave(toDefaultConfig(localConfig)); + } catch { + setIsDirty(true); + } finally { + setIsSaving(false); + } + }, [localConfig, onSave]); + + const handleCancel = useCallback(() => { + const raw = toEditingConfig( + dataSource.id, + dataSource.defaultInspectorConfig, + dataSource.name, + ); + setLocalConfig( + normalizeInspectorBoundaryConfig(raw, allColumnNames) ?? raw, + ); + setIsDirty(false); + }, [ + dataSource.id, + dataSource.defaultInspectorConfig, + dataSource.name, + allColumnNames, + ]); + + const allColumnsSorted = useMemo( + () => getAllColumnsSorted(allColumnNames), + [allColumnNames], + ); + const { + allColumnsInOrder, + selectedColumnsInOrder, + selectedItemsInOrder, + availableColumns, + columnIds, + } = useMemo( + () => getColumnOrderState(localConfig, allColumnNames), + [localConfig, allColumnNames], + ); + + const updateConfig = useCallback( + (updater: (prev: InspectorBoundaryConfig) => InspectorBoundaryConfig) => { + setLocalConfig((prev) => { + const next = updater(prev); + return normalizeInspectorBoundaryConfig(next, allColumnNames) ?? next; + }); + setIsDirty(true); + }, + [allColumnNames], + ); + + 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 +

+

+ 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). +

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

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

+ )} +
+ {isDirty && ( +
+ + +
+ )} +
+
+ + + updateConfig((prev) => ({ ...prev, name: e.target.value })) + } + placeholder="e.g. Main data" + className="max-w-sm" + /> +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ +
+
+ ); +} 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 00000000..b6a83d5e --- /dev/null +++ b/src/app/(private)/data-sources/[id]/components/DefaultInspectorPreview.tsx @@ -0,0 +1,211 @@ +"use client"; + +import { useQueries, useQuery } from "@tanstack/react-query"; +import { useMemo } from "react"; +import { getSelectedItemsOrdered } from "@/app/map/[id]/components/inspector/inspectorColumnOrder"; +import { + InspectorPanelIcon, + getInspectorColorClass, +} from "@/app/map/[id]/components/inspector/inspectorPanelOptions"; +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 { cn } from "@/shadcn/utils"; +import type { DataSource } from "@/server/models/DataSource"; +import type { InspectorBoundaryConfig } from "@/server/models/MapView"; + +function isDivider( + item: unknown, +): item is { type: "divider"; id: string; label: string } { + return ( + typeof item === "object" && + item !== null && + (item as { type?: string }).type === "divider" + ); +} + +const COMPARISON_STAT_LABEL: Record = { + average: "Average", + median: "Median", + min: "Min", + max: "Max", +}; + +/** + * 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 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 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, + ), + 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, + comparisonBaselines, + comparisonBaselineLoading, + ]); + + const dataSourceType = dataSource.config?.type ?? "unknown"; + 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/(private)/data-sources/[id]/page.tsx b/src/app/(private)/data-sources/[id]/page.tsx index 410f87fc..eb69911d 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/(private)/data-sources/components/UserDataSourcesList.tsx b/src/app/(private)/data-sources/components/UserDataSourcesList.tsx index a3f3d137..56b07222 100644 --- a/src/app/(private)/data-sources/components/UserDataSourcesList.tsx +++ b/src/app/(private)/data-sources/components/UserDataSourcesList.tsx @@ -1,7 +1,9 @@ "use client"; -import { Boxes, Database, PlusIcon, Users, UsersIcon } from "lucide-react"; +import { Boxes, Database, PlusIcon, Users } from "lucide-react"; import { useMemo, useState } from "react"; +import { MarkerCollectionIcon } from "@/app/map/[id]/components/Icons"; +import { mapColors } from "@/app/map/[id]/styles"; import { DataSourceItem } from "@/components/DataSourceItem"; import DataSourceRecordTypeIcon, { dataSourceRecordTypeColors, @@ -116,8 +118,8 @@ export default function UserDataSourcesList({ {/* Member Collections Section */}
-

- +

+ Member data sources

@@ -142,7 +144,8 @@ export default function UserDataSourcesList({ {/* Other Data Sources Section */}
-

+

+ Other data sources

diff --git a/src/app/map/[id]/atoms/inspectorAtoms.ts b/src/app/map/[id]/atoms/inspectorAtoms.ts index 6f847339..9a4f02db 100644 --- a/src/app/map/[id]/atoms/inspectorAtoms.ts +++ b/src/app/map/[id]/atoms/inspectorAtoms.ts @@ -11,3 +11,14 @@ 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, +); +/** 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/Choropleth/index.tsx b/src/app/map/[id]/components/Choropleth/index.tsx index c7d06e68..8fc9c878 100644 --- a/src/app/map/[id]/components/Choropleth/index.tsx +++ b/src/app/map/[id]/components/Choropleth/index.tsx @@ -160,23 +160,29 @@ export default function Choropleth() { }} /> diff --git a/src/app/map/[id]/components/DataSourceSelectButton.tsx b/src/app/map/[id]/components/DataSourceSelectButton.tsx index ad2e1290..e2ede8eb 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/Icons.tsx b/src/app/map/[id]/components/Icons.tsx index 5dc34926..0cc05583 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/MapWrapper.tsx b/src/app/map/[id]/components/MapWrapper.tsx index b72a330f..2244f37e 100644 --- a/src/app/map/[id]/components/MapWrapper.tsx +++ b/src/app/map/[id]/components/MapWrapper.tsx @@ -1,6 +1,12 @@ +import { useAtomValue, useSetAtom } from "jotai"; import { XIcon } from "lucide-react"; import { useEffect, useState } from "react"; import { MapType } from "@/server/models/MapView"; +import { + inspectorSettingsModalOpenAtom, + inspectorSettingsInitialDataSourceIdAtom, + inspectorSettingsInitialTabAtom, +} from "../atoms/inspectorAtoms"; import { useChoropleth } from "../hooks/useChoropleth"; import { useInspector } from "../hooks/useInspector"; import { @@ -12,6 +18,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"; @@ -28,8 +36,22 @@ export default function MapWrapper({ }) { const showControls = useShowControls(); const { viewConfig } = useMapViews(); - const { inspectorContent } = useInspector(); - const inspectorVisible = Boolean(inspectorContent); + useInspector(); + const settingsOpen = useAtomValue(inspectorSettingsModalOpenAtom); + const settingsInitialDataSourceId = useAtomValue( + inspectorSettingsInitialDataSourceIdAtom, + ); + const settingsInitialTab = useAtomValue(inspectorSettingsInitialTabAtom); + 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(); const compareGeographiesMode = useCompareGeographiesMode(); const { @@ -89,6 +111,8 @@ export default function MapWrapper({
{children} + + + + {!hideDrawControls && ( <> diff --git a/src/app/map/[id]/components/PrivateMap.tsx b/src/app/map/[id]/components/PrivateMap.tsx index 385f7c03..5fdb2014 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/TogglePanel.tsx b/src/app/map/[id]/components/TogglePanel.tsx index c6ad3748..d935e2b6 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,41 +7,53 @@ 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; + wrapperClassName?: string; } export default function TogglePanel({ label, icon: Icon, - defaultExpanded = false, + 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 ( -
-
+
+
{headerRight && ( @@ -61,7 +73,7 @@ export default function TogglePanel({ )}
- {expanded &&
{children}
} + {expanded &&
{children}
}
); } diff --git a/src/app/map/[id]/components/controls/ControlWrapper.tsx b/src/app/map/[id]/components/controls/ControlWrapper.tsx index 3f121690..1a9f810a 100644 --- a/src/app/map/[id]/components/controls/ControlWrapper.tsx +++ b/src/app/map/[id]/components/controls/ControlWrapper.tsx @@ -1,16 +1,13 @@ import { EyeIcon, EyeOffIcon } from "lucide-react"; import { cn } from "@/shadcn/utils"; -import { LayerType } from "@/types"; -import { mapColors } from "../../styles"; +import type { LayerType } from "@/types"; import type { ReactNode } from "react"; export default function ControlWrapper({ children, - layerType, name, isVisible, onVisibilityToggle, - color, }: { children: ReactNode; name: string; @@ -19,23 +16,6 @@ export default function ControlWrapper({ layerType?: LayerType; color?: string; }) { - const getLayerColor = () => { - // Use custom color if provided, otherwise use default layer color - if (color) { - return color; - } - switch (layerType) { - case LayerType.Member: - return mapColors.member.color; - case LayerType.Marker: - return mapColors.markers.color; - case LayerType.Turf: - return mapColors.areas.color; - default: - return "var(--color-neutral-200)"; - } - }; - return (
-
- -
{children}
- +
{children}
+ ); +} + +export default function DataControl() { + const [expanded, setExpanded] = useState(true); + const setModalOpen = useSetAtom(inspectorSettingsModalOpenAtom); + const setInitialDataSourceId = useSetAtom( + inspectorSettingsInitialDataSourceIdAtom, + ); + const setInitialTab = useSetAtom(inspectorSettingsInitialTabAtom); + const mapDataSources = useMapDataSources(); + + const openDataSettings = (dataSourceId: string | null, tab: "general" | "inspector" = "general") => { + setInitialTab(tab); + setInitialDataSourceId(dataSourceId); + setModalOpen(true); + }; + + return ( + +
+
+ +
+ +
+ {expanded && ( +
+ {mapDataSources.length === 0 ? ( +

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

+ ) : ( + mapDataSources.map((ds) => ( + openDataSettings(ds.id)} + /> + )) + )} +
+ )} +
+ ); +} diff --git a/src/app/map/[id]/components/controls/DataSourceItem.tsx b/src/app/map/[id]/components/controls/DataSourceItem.tsx index 5a8b03fa..b6488def 100644 --- a/src/app/map/[id]/components/controls/DataSourceItem.tsx +++ b/src/app/map/[id]/components/controls/DataSourceItem.tsx @@ -1,12 +1,14 @@ "use client"; import { useMutation, useQueryClient } from "@tanstack/react-query"; -import { EyeIcon, EyeOffIcon, PencilIcon, TrashIcon } from "lucide-react"; -import { useEffect, useMemo, useRef, useState } from "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"; import ContextMenuContentWithFocus from "@/components/ContextMenuContentWithFocus"; -import DataSourceIcon from "@/components/DataSourceIcon"; +import { dataSourceRecordTypeLabels } from "@/components/DataSourceRecordTypeIcon"; +import { DataSourceTypeLabels } from "@/labels"; import { MarkerDisplayMode } from "@/server/models/Map"; import { useTRPC } from "@/services/trpc/react"; import { @@ -30,18 +32,23 @@ import { ContextMenuSubTrigger, ContextMenuTrigger, } from "@/shadcn/ui/context-menu"; +import { + inspectorSettingsInitialDataSourceIdAtom, + inspectorSettingsModalOpenAtom, +} from "@/app/map/[id]/atoms/inspectorAtoms"; import { LayerType } from "@/types"; -import { useDataRecords } from "../../hooks/useDataRecords"; import { useLayers } from "../../hooks/useLayers"; import { useMapConfig } from "../../hooks/useMapConfig"; -import { useMapViews } from "../../hooks/useMapViews"; import { mapColors } from "../../styles"; import ControlWrapper from "./ControlWrapper"; -import type { DataSourceType } from "@/server/models/DataSource"; +import LayerIcon from "./LayerIcon"; +import type { + DataSourceRecordType, + DataSourceType, +} from "@/server/models/DataSource"; export default function DataSourceItem({ dataSource, - isSelected, handleDataSourceSelect, layerType, }: { @@ -51,39 +58,30 @@ export default function DataSourceItem({ config: { type: DataSourceType }; recordCount?: number; createdAt?: Date; + recordType?: DataSourceRecordType; }; - isSelected: boolean; handleDataSourceSelect: (id: string) => void; layerType: LayerType; }) { + const setSettingsOpen = useSetAtom(inspectorSettingsModalOpenAtom); + const setSettingsInitialDataSourceId = useSetAtom( + inspectorSettingsInitialDataSourceIdAtom, + ); const { setDataSourceVisibility, getDataSourceVisibility } = useLayers(); const { mapConfig, updateMapConfig } = useMapConfig(); - const { view } = useMapViews(); const trpc = useTRPC(); const queryClient = useQueryClient(); - const dataSourceView = useMemo( - () => - view?.dataSourceViews.find((dsv) => dsv.dataSourceId === dataSource.id), - [dataSource.id, view?.dataSourceViews], - ); - - const hasActiveFilter = - (dataSourceView?.filter?.children?.length ?? 0) > 0 || - Boolean(dataSourceView?.search); - - const { data: dataRecordsResult, isFetching: matchedCountLoading } = - useDataRecords(hasActiveFilter ? dataSource.id : "", 0); const [isRenaming, setIsRenaming] = useState(false); const [editName, setEditName] = useState(dataSource.name); const [showRemoveDialog, setShowRemoveDialog] = useState(false); - const inputRef = useRef(null); - const isFocusing = useRef(false); - - const layerColor = + const [layerColor] = useState( layerType === LayerType.Member ? mapColors.member.color - : mapColors.markers.color; + : mapColors.markers.color, + ); + const inputRef = useRef(null); + const isFocusing = useRef(false); const isVisible = getDataSourceVisibility(dataSource?.id); @@ -177,8 +175,10 @@ export default function DataSourceItem({ setShowRemoveDialog(false); }; - const matchedRecordCount = - dataRecordsResult?.count?.matched ?? dataSource.recordCount; + const openInspectorSettings = () => { + setSettingsInitialDataSourceId(dataSource.id); + setSettingsOpen(true); + }; return ( <> @@ -193,68 +193,71 @@ export default function DataSourceItem({ > -
- + +
)} + + + Inspector settings + @@ -309,7 +316,7 @@ export default function DataSourceItem({ >
@@ -329,7 +336,7 @@ export default function DataSourceItem({ >
+ ); + case "folder-open": + return ( + + ); + case "marker-collection": + return ; + case "marker-individual": + return ; + case "turf": + case "area": + return ( + + ); + default: + return ; + } +} + +// 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 "area"; + } + 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, + ); + + return ( + + + + + e.stopPropagation()} + > +
+ {COLOR_PALETTE.map((color) => ( + + ))} +
+
+
+ ); +} 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 00000000..563d7f24 --- /dev/null +++ b/src/app/map/[id]/components/controls/LegendMapWidget.tsx @@ -0,0 +1,85 @@ +"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; + +/** + * 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 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)} /> +
+ )} +
+ ); + } + + return ( +
+
{ + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + toggleExpanded(); + } + }} + className="cursor-pointer hover:bg-neutral-50/50 transition-colors shrink-0" + > + +
+ {boundariesPanelOpen && ( +
+ setBoundariesPanelOpen(false)} /> +
+ )} +
+ ); +} 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 00000000..317bc611 --- /dev/null +++ b/src/app/map/[id]/components/controls/MarkersControl/DataSourceSelectionModal.tsx @@ -0,0 +1,248 @@ +"use client"; + +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 { 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 7ba531b3..60cd0976 100644 --- a/src/app/map/[id]/components/controls/MarkersControl/MarkersControl.tsx +++ b/src/app/map/[id]/components/controls/MarkersControl/MarkersControl.tsx @@ -1,5 +1,4 @@ -import { Check, FolderPlusIcon, LoaderPinwheel, PlusIcon } from "lucide-react"; -import { useRouter } from "next/navigation"; +import { FolderPlusIcon, LoaderPinwheel, PlusIcon, Search } from "lucide-react"; import { useState } from "react"; import { v4 as uuidv4 } from "uuid"; @@ -18,17 +17,15 @@ import { usePlacedMarkerMutations, usePlacedMarkersQuery, } 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 { CollectionIcon } from "../../Icons"; +import { MarkerCollectionIcon, MarkerIndividualIcon } from "../../Icons"; import LayerControlWrapper from "../LayerControlWrapper"; import LayerHeader from "../LayerHeader"; +import DataSourceSelectionModal from "./DataSourceSelectionModal"; import MarkersList from "./MarkersList"; export default function MarkersControl() { - const router = useRouter(); const { mapConfig, updateMapConfig } = useMapConfig(); const { data: folders = [] } = useFoldersQuery(); const { isMutating: isPlacedMarkersMutating } = usePlacedMarkerMutations(); @@ -39,6 +36,7 @@ export default function MarkersControl() { const markerDataSources = useMarkerDataSources() || []; const membersDataSource = useMembersDataSource(); const [expanded, setExpanded] = useState(true); + const [isDataSourceModalOpen, setIsDataSourceModalOpen] = useState(false); const createFolder = () => { const newFolder = { @@ -54,7 +52,7 @@ export default function MarkersControl() { setTimeout(() => { const geocoderInput = document.querySelector( 'mapbox-search-box [class$="--Input"]', - ) as HTMLInputElement; + ) as HTMLInputElement | null; if (geocoderInput) { geocoderInput.focus(); geocoderInput.addEventListener( @@ -69,49 +67,20 @@ export default function MarkersControl() { }, 200); }; - 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], }); }; @@ -119,61 +88,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(), }, @@ -210,6 +150,16 @@ export default function MarkersControl() {
)} + + ); } diff --git a/src/app/map/[id]/components/controls/MarkersControl/MarkersList.tsx b/src/app/map/[id]/components/controls/MarkersControl/MarkersList.tsx index f3ebe878..78889fb8 100644 --- a/src/app/map/[id]/components/controls/MarkersControl/MarkersList.tsx +++ b/src/app/map/[id]/components/controls/MarkersControl/MarkersList.tsx @@ -32,13 +32,14 @@ export default function MarkersList({ const { viewConfig } = useMapViews(); const { data: folders = [] } = useFoldersQuery(); const { data: placedMarkers = [] } = usePlacedMarkersQuery(); - const { selectedDataSourceId, handleDataSourceSelect } = useTable(); + const { handleDataSourceSelect } = useTable(); const markerDataSources = useMarkerDataSources(); const membersDataSource = useMembersDataSource(); - const markerFolders = useMemo(() => { - return folders.filter((f) => !f.type || f.type === "placedMarker"); - }, [folders]); + const markerFolders = useMemo( + () => folders.filter((f) => !f.type || f.type === "placedMarker"), + [folders], + ); const { activeId, @@ -57,9 +58,10 @@ export default function MarkersList({ [activeId, placedMarkers], ); - const sortedFolders = useMemo(() => { - return sortByPositionAndId(markerFolders); - }, [markerFolders]); + const sortedFolders = useMemo( + () => sortByPositionAndId(markerFolders), + [markerFolders], + ); const hasMarkers = membersDataSource || @@ -68,7 +70,7 @@ export default function MarkersList({ markerFolders.length; return ( -
+
{!hasMarkers && ( @@ -95,7 +99,6 @@ export default function MarkersList({ @@ -109,7 +112,6 @@ export default function MarkersList({ diff --git a/src/app/map/[id]/components/controls/MarkersControl/SortableMarkerItem.tsx b/src/app/map/[id]/components/controls/MarkersControl/SortableMarkerItem.tsx index 5eeb234e..fffbebb8 100644 --- a/src/app/map/[id]/components/controls/MarkersControl/SortableMarkerItem.tsx +++ b/src/app/map/[id]/components/controls/MarkersControl/SortableMarkerItem.tsx @@ -5,16 +5,12 @@ import { CSS } from "@dnd-kit/utilities"; import { EyeIcon, EyeOffIcon, PencilIcon, TrashIcon } from "lucide-react"; import { useEffect, useState } from "react"; import { toast } from "sonner"; -import ColorPalette from "@/components/ColorPalette"; import DeleteConfirmationDialog from "@/components/DeleteConfirmationDialog"; import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuSeparator, - ContextMenuSub, - ContextMenuSubContent, - ContextMenuSubTrigger, ContextMenuTrigger, } from "@/shadcn/ui/context-menu"; import { LayerType } from "@/types"; @@ -27,6 +23,7 @@ import { 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({ @@ -59,7 +56,6 @@ export default function SortableMarkerItem({ const [editText, setEditText] = useState(marker.label); const [showDeleteDialog, setShowDeleteDialog] = useState(false); - // Get current color (defaults to marker color) const currentColor = mapConfig.placedMarkerColors?.[marker.id] ?? mapColors.markers.color; @@ -152,18 +148,31 @@ export default function SortableMarkerItem({ ) : ( - +
+ + +
@@ -188,24 +197,6 @@ export default function SortableMarkerItem({ )} - - -
-
- Color -
- - - - - - setShowDeleteDialog(true)} diff --git a/src/app/map/[id]/components/controls/PrivateMapControls.tsx b/src/app/map/[id]/components/controls/PrivateMapControls.tsx index 7144b670..75135baa 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"; @@ -7,7 +13,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"; @@ -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 */} @@ -64,13 +92,17 @@ export default function PrivateMapControls() { className="flex-1 overflow-y-auto / flex flex-col" style={{ width: `${CONTROL_PANEL_WIDTH}px` }} > - {viewConfig.mapType !== MapType.Hex && ( - <> - - - - )} - +
+ + +
+
diff --git a/src/app/map/[id]/components/controls/SortableList/SortableFolderItem.tsx b/src/app/map/[id]/components/controls/SortableList/SortableFolderItem.tsx index ab8bb69a..f28163f2 100644 --- a/src/app/map/[id]/components/controls/SortableList/SortableFolderItem.tsx +++ b/src/app/map/[id]/components/controls/SortableList/SortableFolderItem.tsx @@ -9,23 +9,17 @@ import { 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 ColorPalette from "@/components/ColorPalette"; import DeleteConfirmationDialog from "@/components/DeleteConfirmationDialog"; import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuSeparator, - ContextMenuSub, - ContextMenuSubContent, - ContextMenuSubTrigger, ContextMenuTrigger, } from "@/shadcn/ui/context-menu"; import { cn } from "@/shadcn/utils"; @@ -38,6 +32,7 @@ import { useTurfState } from "../../../hooks/useTurfState"; import { mapColors } from "../../../styles"; import ControlEditForm from "../ControlEditForm"; import ControlWrapper from "../ControlWrapper"; +import LayerIcon from "../LayerIcon"; import SortableMarkerItem from "../MarkersControl/SortableMarkerItem"; import SortableTurfItem from "../TurfsControl/SortableTurfItem"; import type { Folder } from "@/server/models/Folder"; @@ -199,30 +194,38 @@ export default function SortableFolderItem({ ) : ( - + + +
@@ -243,24 +246,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 4b2017e9..a6087401 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/TurfsControl.tsx b/src/app/map/[id]/components/controls/TurfsControl/TurfsControl.tsx index d1a101b8..9c754874 100644 --- a/src/app/map/[id]/components/controls/TurfsControl/TurfsControl.tsx +++ b/src/app/map/[id]/components/controls/TurfsControl/TurfsControl.tsx @@ -98,7 +98,7 @@ export default function AreasControl() {

Area colour

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

@@ -441,6 +427,31 @@ export default function VisualisationPanel({ )} + {canSelectSecondaryColumn && !viewConfig.areaDataSecondaryColumn && ( +
+ +
+ )}
{!viewConfig.areaDataSourceId && (
@@ -764,94 +775,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 → -
-
-
-
-
-
- )}
)} @@ -905,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 edac585f..45e088a9 100644 --- a/src/app/map/[id]/components/inspector/BoundaryDataPanel.tsx +++ b/src/app/map/[id]/components/inspector/BoundaryDataPanel.tsx @@ -1,39 +1,78 @@ -import { useQuery } from "@tanstack/react-query"; +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 { getDisplayValue } from "../../utils/stats"; -import PropertiesList from "./PropertiesList"; -import type { ColumnDef, ColumnMetadata } from "@/server/models/DataSource"; +import { + DataSourceInspectorIcon, + InspectorPanelIcon, + getBarColorForLabel, + getInspectorColorClass, +} from "./inspectorPanelOptions"; +import PropertiesList, { type PropertyEntry } from "./PropertiesList"; + +import type { InspectorBoundaryConfig } from "@/server/models/MapView"; export function BoundaryDataPanel({ config, dataSourceId, areaCode, columns, + columnMetadata, + columnGroups, + layout, defaultExpanded, + expanded: controlledExpanded, + onOpenInspectorSettings, + previewMode, + markerLayerColor, }: { - config: { name: string; dataSourceId: string }; + config: Pick< + InspectorBoundaryConfig, + "name" | "dataSourceId" | "icon" | "color" | "columnItems" + >; dataSourceId: string; areaCode: string; columns: string[]; - defaultExpanded: boolean; + columnMetadata?: InspectorBoundaryConfig["columnMetadata"]; + columnGroups?: InspectorBoundaryConfig["columnGroups"]; + layout?: InspectorBoundaryConfig["layout"]; + defaultExpanded?: boolean; + /** 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 = markerLayerColor ? ( + + + + ) : config.icon ? ( + + ) : dataSource ? ( + + ) : undefined; + + const hasBoundary = Boolean(selectedBoundary?.areaSetCode); - const { data, isLoading } = useQuery( + const { data: boundaryData, isLoading: boundaryLoading } = useQuery( trpc.dataRecord.byAreaCode.queryOptions( { dataSourceId, @@ -42,50 +81,151 @@ 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( + () => + 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; + return ( : undefined + icon={panelIcon} + defaultExpanded={defaultExpanded ?? true} + expanded={controlledExpanded} + wrapperClassName={getInspectorColorClass(config.color)} + headerRight={ +
+ {isList && ( + + + {recordCount} records + + )} + {onOpenInspectorSettings && ( + + )} +
} - defaultExpanded={defaultExpanded} > {isLoading ? (

Loading...

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

+ {recordCount} records in this area +

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

No data available

@@ -95,51 +235,176 @@ export function BoundaryDataPanel({ ); } +function isColumnItemDivider( + item: unknown, +): item is { type: "divider"; id: string; label: string } { + return ( + typeof item === "object" && + item !== null && + (item as { type?: string }).type === "divider" + ); +} + +const COMPARISON_STAT_LABEL: Record = { + average: "Average", + median: "Median", + min: "Min", + max: "Max", +}; + function BoundaryDataProperties({ json, columns, - columnDefs, columnMetadata, + columnGroups, + columnItems, + layout, match, + dividerBackgroundClassName, + comparisonBaselines = {}, + comparisonBaselineLoading = {}, }: { json: Record; columns: string[]; - columnDefs?: ColumnDef[]; - columnMetadata?: ColumnMetadata[]; - match: DataRecordMatchType; + columnMetadata?: InspectorBoundaryConfig["columnMetadata"]; + columnGroups?: InspectorBoundaryConfig["columnGroups"]; + columnItems?: InspectorBoundaryConfig["columnItems"]; + layout?: InspectorBoundaryConfig["layout"]; + match?: DataRecordMatchType | null | undefined; + /** 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 filteredProperties = useMemo(() => { - const filtered: Record = {}; - columns.forEach((columnName) => { - if (json[columnName] !== undefined) { - const valueLabels = columnMetadata?.find( - (c) => c.name === columnName, - )?.valueLabels; - filtered[columnName] = getDisplayValue( - json[columnName], - { - columnType: columnDefs?.find((cd) => cd.name === columnName)?.type, - }, - valueLabels, - ); + 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)) { + let currentGroupLabel: string | undefined; + for (const item of columnItems) { + if (isColumnItemDivider(item)) { + ordered.push({ + key: `__divider_${item.id}`, + label: item.label, + isDivider: true, + }); + currentGroupLabel = item.label; + } else if (typeof item === "string" && columnsSet.has(item)) { + if (json[item] === undefined) continue; + const m = meta[item]; + const label = m?.displayName ?? item; + ordered.push({ + key: `${item}-${ordered.length}`, + label, + value: json[item], + groupLabel: currentGroupLabel, + format: m?.format, + scaleMax: m?.scaleMax, + barColor: getBarColorForLabel( + label, + item, + ordered.length, + 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, + }), + }); + } } + return ordered; + } + + const groups = columnGroups ?? []; + const keyToGroup = new Map(); + groups.forEach((g) => { + g.columnNames.forEach((col) => keyToGroup.set(col, g.label)); }); - return filtered; - }, [columnDefs, columns, json, columnMetadata]); + groups.forEach((g) => { + g.columnNames.forEach((col) => { + if (json[col] === undefined) return; + addEntry(col, { groupLabel: g.label }); + }); + }); + columns.forEach((col) => { + if (keyToGroup.has(col)) return; + if (json[col] === undefined) return; + addEntry(col, {}); + }); + return ordered; + }, [ + json, + columns, + columnMetadata, + columnGroups, + columnItems, + comparisonBaselines, + comparisonBaselineLoading, + ]); return ( -
+
{match === DataRecordMatchType.Approximate && (

Approximate boundary match

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

+ No columns added. Click the settings icon to add columns from this + data source. +

) : ( -

No data available

+

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 d297e222..00000000 --- a/src/app/map/[id]/components/inspector/InspectorConfigTab.tsx +++ /dev/null @@ -1,93 +0,0 @@ -import { useCallback } 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, updateView } = useMapViews(); - const { getDataSourceById } = useDataSources(); - - const boundaryStatsConfig = view?.inspectorConfig?.boundaries || []; - - 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], - ); - - 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 4d7cf384..be29fdd3 100644 --- a/src/app/map/[id]/components/inspector/InspectorDataTab.tsx +++ b/src/app/map/[id]/components/inspector/InspectorDataTab.tsx @@ -1,20 +1,32 @@ 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"; 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 { AreaSetCodeLabels } from "@/labels"; 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 { useDisplayAreaStat } from "../../hooks/useDisplayAreaStats"; import { useSelectedSecondaryArea } from "../../hooks/useSelectedSecondaryArea"; +import DataSourceSelectButton from "../DataSourceSelectButton"; import { BoundaryDataPanel } from "./BoundaryDataPanel"; +import { + getSelectedColumnsOrdered, + normalizeInspectorBoundaryConfig, +} from "./inspectorColumnOrder"; +import InspectorOnMapSection from "./InspectorOnMapSection"; import PropertiesList from "./PropertiesList"; import type { SelectedRecord } from "@/app/map/[id]/types/inspector"; @@ -24,6 +36,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({ @@ -32,16 +48,54 @@ export default function InspectorDataTab({ isDetailsView, focusedRecord, type, + onOpenInspectorSettings, + onOpenInspectorSettingsForDataSource, }: InspectorDataTabProps) { const mapRef = useMapRef(); const { setSelectedDataSourceId } = useTable(); const trpc = useTRPC(); - const { view } = useMapViews(); + const { view, viewConfig, updateView } = useMapViews(); + const { mapConfig } = useMapConfig(); const { getDataSourceById } = useDataSources(); const { selectedBoundary } = useInspector(); - const { areaToDisplay, primaryLabel, secondaryLabel, columnMetadata } = - useDisplayAreaStat(selectedBoundary); - const [selectedSecondaryArea] = useSelectedSecondaryArea(); + const initializationAttemptedRef = useRef(false); + useDisplayAreaStat(selectedBoundary); + useSelectedSecondaryArea(); + + const addDataSourceToConfig = useCallback( + (dataSourceId: string) => { + if (!view) return; + const ds = getDataSourceById(dataSourceId); + 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", + type: defaultConfig?.type ?? InspectorBoundaryConfigType.Simple, + columns: defaultConfig?.columns ?? [], + columnOrder: defaultConfig?.columnOrder, + 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, + inspectorConfig: { + ...view.inspectorConfig, + boundaries: [...prev, newBoundaryConfig], + }, + }); + }, + [getDataSourceById, updateView, view], + ); const { data: recordData, isFetching: recordLoading } = useQuery( trpc.dataRecord.byId.queryOptions( @@ -60,49 +114,54 @@ 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(() => { - if (!isBoundary || !selectedBoundary) return []; - - return boundaryConfigs.map((config) => { - const ds = getDataSourceById(config.dataSourceId); - - return { - config, - dataSource: ds, - dataSourceId: config.dataSourceId, - areaCode: selectedBoundary.code, - areaSetCode: selectedBoundary.areaSetCode, - columns: config.columns, - }; - }); - }, [isBoundary, selectedBoundary, boundaryConfigs, getDataSourceById]); + if (!isBoundary) return []; + return boundaryConfigs.map((config) => ({ + config, + dataSourceId: config.dataSourceId, + areaCode: selectedBoundary?.code ?? "", + columns: getSelectedColumnsOrdered(config), + markerLayerColor: markerLayerColors[config.dataSourceId], + })); + }, [isBoundary, selectedBoundary?.code, boundaryConfigs, markerLayerColors]); - const boundaryProperties = useMemo(() => { - if (!areaToDisplay) { - return properties; - } - const propertiesWithData = { ...properties }; - if (primaryLabel) { - propertiesWithData[primaryLabel] = areaToDisplay.primaryDisplayValue; - } - if (secondaryLabel) { - propertiesWithData[secondaryLabel] = areaToDisplay.secondaryDisplayValue; + // 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); } - if (selectedSecondaryArea) { - propertiesWithData[ - AreaSetCodeLabels[selectedSecondaryArea.areaSetCode] || - "Secondary boundary" - ] = selectedSecondaryArea.name; - } - return propertiesWithData; }, [ - areaToDisplay, - properties, - primaryLabel, - secondaryLabel, - selectedSecondaryArea, + view, + type, + viewConfig.areaDataSourceId, + boundaryConfigs.length, + addDataSourceToConfig, ]); const flyToMarker = () => { @@ -117,24 +176,57 @@ export default function InspectorDataTab({
{isBoundary ? ( <> - - {boundaryData.length > 0 && - boundaryData.map((item, index) => ( - - ))} + +
+

+ Data in this area +

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

+ No data sources added yet +

+ )} + {boundaryConfigs.length > 0 && ( +
+ {boundaryData.map((item) => ( + + ))} +
+ )} +
+ {onOpenInspectorSettings ? ( + + ) : ( + + )} +
+
) : ( - // Show default data source and properties <> {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 00000000..8bb406f8 --- /dev/null +++ b/src/app/map/[id]/components/inspector/InspectorFullPreview.tsx @@ -0,0 +1,314 @@ +"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, 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"; + +/** + * 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. + */ +export function InspectorFullPreview({ + className, + selectedDataSourceId, + onReorderColumns, + previewDataSource, +}: { + className?: string; + selectedDataSourceId?: string | null; + onReorderColumns?: ( + 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 + .replace(/\\/g, "\\\\") + .replace(/"/g, '\\"'); + const el = scrollContainerRef.current?.querySelector( + `[data-data-source-id="${escaped}"]`, + ); + el?.scrollIntoView({ block: "nearest", behavior: "smooth" }); + }, [selectedDataSourceId]); + + const boundaryData = useMemo( + () => + boundaryConfigs.map((config) => ({ + config, + dataSourceId: config.dataSourceId, + areaCode: selectedBoundary?.code ?? "", + columns: getSelectedColumnsOrdered(config), + markerLayerColor: markerLayerColors[config.dataSourceId], + })), + [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; + const latestView = getLatestView(); + if (!latestView) return; + const boundaries = latestView.inspectorConfig?.boundaries ?? []; + const next = [...boundaries]; + const [removed] = next.splice(oldIndex, 1); + next.splice(newIndex, 0, removed); + updateView({ + ...latestView, + inspectorConfig: { + ...latestView.inspectorConfig, + boundaries: next, + }, + }); + }, + [getLatestView, updateView], + ); + + const handlePanelDragEnd = 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 ?? "Sample record"} +

+
+
+ +
+

+ Data in this area +

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

+ No data sources added yet +

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

+ Panels — drag to reorder +

+ {boundaryData.map((item) => ( + + ))} +
+
+
+ )} + {previewConfig && ( +
+ +
+ )} + + )} +
+
+
+ ); +} + +function SortableBoundaryPanel({ + item, + selectedDataSourceId, +}: { + item: { + config: InspectorBoundaryConfig; + dataSourceId: string; + areaCode: string; + columns: string[]; + markerLayerColor?: string; + }; + selectedDataSourceId?: string | null; +}) { + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id: item.config.id }); + const style = { + transform: CSS.Transform.toString(transform), + transition, + }; + const isSelected = + selectedDataSourceId != null && + item.config.dataSourceId === selectedDataSourceId; + 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 00000000..029b55d9 --- /dev/null +++ b/src/app/map/[id]/components/inspector/InspectorOnMapSection.tsx @@ -0,0 +1,94 @@ +import { LayoutDashboardIcon } from "lucide-react"; +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"; +import { getBoundaryDatasetName } from "./helpers"; + +/** + * 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(); + const { selectedBoundary } = useInspector(); + const choroplethDataSource = useChoroplethDataSource(); + const areaStatsQuery = useAreaStats(); + const areaStats = areaStatsQuery.data; + + if (!selectedBoundary?.code) { + return 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 + ? `${choroplethDataSource?.name ?? "Data"} count` + : viewConfig.areaDataColumn || "Value"; + const hasSecondary = Boolean(viewConfig.areaDataSecondaryColumn); + const boundarySetName = getBoundaryDatasetName( + selectedBoundary?.sourceLayerId, + ); + + return ( +
+
+ +

+ 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 +

+ ) + ) : null} +
+
+ ); +} diff --git a/src/app/map/[id]/components/inspector/InspectorPanel.tsx b/src/app/map/[id]/components/inspector/InspectorPanel.tsx index ed5ba414..d1ab3568 100644 --- a/src/app/map/[id]/components/inspector/InspectorPanel.tsx +++ b/src/app/map/[id]/components/inspector/InspectorPanel.tsx @@ -1,10 +1,16 @@ 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, + inspectorSettingsInitialTabAtom, +} 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"; @@ -14,7 +20,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"; @@ -31,6 +36,15 @@ export default function InspectorPanel({ boundariesPanelOpen?: boolean; } = {}) { const [activeTab, setActiveTab] = useState("data"); + const settingsOpen = useAtomValue(inspectorSettingsModalOpenAtom); + const settingsInitialDataSourceId = useAtomValue( + inspectorSettingsInitialDataSourceIdAtom, + ); + const setSettingsOpen = useSetAtom(inspectorSettingsModalOpenAtom); + const setSettingsInitialDataSourceId = useSetAtom( + inspectorSettingsInitialDataSourceIdAtom, + ); + const setSettingsInitialTab = useSetAtom(inspectorSettingsInitialTabAtom); const [hoverArea] = useHoverArea(); const boundaryHoverVisible = boundariesPanelOpen && !!hoverArea; @@ -77,8 +91,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 to inspect its data, or open a + data source from the Visualisation Data layer to configure the inspector. +

+
+
+ ); } const isDetailsView = Boolean( @@ -88,6 +129,12 @@ export default function InspectorPanel({ const markerCount = selectedRecords?.length || 0; + const openInspectorSettingsForDataSource = (dataSourceId: string) => { + setSettingsInitialTab("inspector"); + setSettingsInitialDataSourceId(dataSourceId); + setSettingsOpen(true); + }; + const onCloseDetailsView = () => { setFocusedRecord(null); }; @@ -124,8 +171,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", @@ -139,22 +185,37 @@ export default function InspectorPanel({ )} >
-

+

{type === LayerType.Boundary && areaToDisplay?.backgroundColor && ( )} {inspectorContent?.name as string}

- +
+ + +
{isDetailsView && ( @@ -196,11 +257,6 @@ export default function InspectorPanel({ Notes 0 - {hasConfig && ( - - - - )} {hasData && ( @@ -211,6 +267,13 @@ export default function InspectorPanel({ isDetailsView={isDetailsView} focusedRecord={focusedRecord} type={type} + onOpenInspectorSettings={() => { + setSettingsInitialTab("inspector"); + setSettingsOpen(true); + }} + onOpenInspectorSettingsForDataSource={ + openInspectorSettingsForDataSource + } /> )} @@ -224,12 +287,6 @@ export default function InspectorPanel({ - - {hasConfig && ( - - - - )} {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 00000000..5c7f9a53 --- /dev/null +++ b/src/app/map/[id]/components/inspector/InspectorPreview.tsx @@ -0,0 +1,72 @@ +"use client"; + +import TogglePanel from "@/app/map/[id]/components/TogglePanel"; +import DataSourceIcon from "@/components/DataSourceIcon"; +import { getDataSourceType } from "@/components/DataSourceItem"; +import { cn } from "@/shadcn/utils"; +import type { DataSourceWithImportInfo } from "@/components/DataSourceItem"; +import type { InspectorBoundaryConfig } from "@/server/models/MapView"; + +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/AvailableColumnRow.tsx b/src/app/map/[id]/components/inspector/InspectorSettingsModal/AvailableColumnRow.tsx new file mode 100644 index 00000000..b4499f34 --- /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 new file mode 100644 index 00000000..ea2ed86f --- /dev/null +++ b/src/app/map/[id]/components/inspector/InspectorSettingsModal/AvailableColumnsCheckboxList.tsx @@ -0,0 +1,50 @@ +"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/AvailableListWithDividers.tsx b/src/app/map/[id]/components/inspector/InspectorSettingsModal/AvailableListWithDividers.tsx new file mode 100644 index 00000000..f53746c2 --- /dev/null +++ b/src/app/map/[id]/components/inspector/InspectorSettingsModal/AvailableListWithDividers.tsx @@ -0,0 +1,130 @@ +"use client"; + +import { useDroppable } from "@dnd-kit/core"; +import { + SortableContext, + verticalListSortingStrategy, +} from "@dnd-kit/sortable"; +import { cn } from "@/shadcn/utils"; +import { AvailableColumnRow } from "./AvailableColumnRow"; +import { SELECTED_LEFT_DROPPABLE_ID } from "./constants"; +import { SortableAvailableRow } from "./SortableAvailableRow"; +import { SortableDividerRow } from "./SortableDividerRow"; +import type { InspectorColumnItem } from "@/server/models/MapView"; + +export function AvailableListWithDividers({ + selectedItemsInOrder, + selectedSectionIds, + availableColumns, + onAddColumn, + onRemoveColumn, + onAddDivider, + onDividerLabelChange, + onRemoveDivider, + activeId, + mode = "both", +}: { + selectedItemsInOrder: InspectorColumnItem[]; + selectedSectionIds: string[]; + availableColumns: string[]; + onAddColumn: (columnName: string) => void; + onRemoveColumn: (columnName: string) => void; + onAddDivider: () => void; + onDividerLabelChange: (id: string, label: string) => void; + onRemoveDivider: (id: string) => void; + 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: 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]} + /> + ), + ) + )} +
+
+
+ + )} + + {/* Available (bottom): simple list, no sorting */} + {mode !== "selected" && ( +
+
+ {availableColumns.length === 0 ? ( +

+ All columns selected +

+ ) : ( + availableColumns.map((col) => ( + onAddColumn(col)} + /> + )) + )} +
+
+ )} +
+ ); +} 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 00000000..e97b1223 --- /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/ColumnsSection.tsx b/src/app/map/[id]/components/inspector/InspectorSettingsModal/ColumnsSection.tsx new file mode 100644 index 00000000..a3b9853b --- /dev/null +++ b/src/app/map/[id]/components/inspector/InspectorSettingsModal/ColumnsSection.tsx @@ -0,0 +1,394 @@ +"use client"; + +import { + DndContext, + DragOverlay, + KeyboardSensor, + PointerSensor, + closestCenter, + useSensor, + useSensors, +} from "@dnd-kit/core"; +import { sortableKeyboardCoordinates } from "@dnd-kit/sortable"; +import { useCallback, useMemo, useState } from "react"; +import { createPortal } from "react-dom"; +import { v4 as uuidv4 } from "uuid"; +import { Label } from "@/shadcn/ui/label"; +import { AvailableListWithDividers } from "./AvailableListWithDividers"; +import { + SELECTED_DROPPABLE_ID, + SELECTED_LEFT_DROPPABLE_ID, + inferFormat, +} from "./constants"; +import { + AvailableDragPreview, + ColumnDragPreview, + DividerDragPreview, +} from "./DragPreviews"; +import { DroppableSelectedColumns } from "./DroppableSelectedColumns"; +import type { + InspectorBoundaryConfig, + InspectorColumnItem, +} from "@/server/models/MapView"; +import type { DragEndEvent } from "@dnd-kit/core"; + +function isDivider( + item: InspectorColumnItem, +): item is { type: "divider"; id: string; label: string } { + return typeof item === "object" && item !== null && item.type === "divider"; +} + +export function ColumnsSection({ + allColumnsInOrder, + selectedColumnsInOrder, + selectedItemsInOrder, + availableColumns, + columnIds, + columns, + columnMetadata, + updateConfig, + handleAddColumn, + handleRemoveColumn, + handleRemoveColumnFromRight, +}: { + config: InspectorBoundaryConfig; + allColumnsInOrder: string[]; + selectedColumnsInOrder: string[]; + selectedItemsInOrder: InspectorColumnItem[]; + availableColumns: 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 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) => { + const { active, over } = event; + setActiveId(null); + const activeStr = String(active.id); + const overStr = over ? String(over.id) : null; + + if (isLeftSelectedItem(activeStr) && overStr) { + const oldIndex = selectedSectionIds.indexOf(activeStr); + if (oldIndex === -1) return; + const newIndex = + overStr === SELECTED_LEFT_DROPPABLE_ID + ? selectedSectionIds.length + : selectedSectionIds.indexOf(overStr); + if ( + !isLeftSelectedItem(overStr) && + overStr !== SELECTED_LEFT_DROPPABLE_ID + ) + return; + 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"), + ...availableColumns, + ]; + const nextColumnItems = next.some((i) => isDivider(i)) + ? next + : undefined; + updateConfig((prev) => ({ + ...prev, + columnOrder: nextColumnOrder, + ...(nextColumnItems !== undefined && { + columnItems: nextColumnItems, + }), + })); + return; + } + + const isSelectedItem = (s: string) => s.startsWith("col-"); + if (isSelectedItem(activeStr) && overStr) { + const oldIndex = columnIds.indexOf(activeStr); + if (oldIndex === -1) return; + const newIndex = + overStr === SELECTED_DROPPABLE_ID + ? columnIds.length + : columnIds.indexOf(overStr); + if (!isSelectedItem(overStr) && overStr !== SELECTED_DROPPABLE_ID) + return; + if (newIndex === -1 && overStr !== SELECTED_DROPPABLE_ID) 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) => { + const base = { + ...prev, + columns: next, + columnOrder: newColumnOrder, + }; + if (prev.columnItems?.length && prev.columnItems.some(isDivider)) { + let colIdx = 0; + base.columnItems = prev.columnItems.map((i) => + isDivider(i) ? i : next[colIdx++], + ); + } + return base; + }); + return; + } + }, + [ + columnIds, + selectedSectionIds, + selectedItemsInOrder, + availableColumns, + 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} + > +
+ {/* Available columns */} +
+

+ Available 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="available" + /> +
+ + {/* 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 +

+ +
+
+ {typeof document !== "undefined" && + createPortal( + ({ + ...transform, + x: transform.x, + y: transform.y, + }), + ]} + > + {activeId && String(activeId).startsWith("col-") ? ( + + ) : 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" + ); + })()} + /> + ) : activeId && + (String(activeId).startsWith("available-") || + String(activeId).startsWith("left-selected-")) ? ( + + ) : 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 00000000..430d16c0 --- /dev/null +++ b/src/app/map/[id]/components/inspector/InspectorSettingsModal/DataSourcesList.tsx @@ -0,0 +1,319 @@ +"use client"; + +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"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +interface DataSourcesListProps { + searchQuery: string; + onSearchChange: (value: string) => void; + markerSources: DataSource[]; + visualisationSources: DataSource[]; + librarySources: DataSource[]; + selectedDataSourceId: string | null; + onSelectDataSource: (id: 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 DsIcon({ + ds, + markerColor, +}: { + ds: DataSource; + markerColor?: string | null; +}) { + if (markerColor) { + return ( + + + + ); + } + return ( + + ); +} + +function dsSubtitle(ds: DataSource) { + return [ + DataSourceTypeLabels[ds.config.type], + ds.recordCount != null ? String(ds.recordCount) : null, + ] + .filter(Boolean) + .join(" · "); +} + +// --------------------------------------------------------------------------- +// Item renderers +// --------------------------------------------------------------------------- + +function MapSourceItem({ + ds, + isSelected, + onSelect, + onRemoveFromMap, + markerColor, +}: { + ds: DataSource; + isSelected: boolean; + onSelect: () => void; + onRemoveFromMap: (dataSourceId: string) => void; + markerColor?: string | null; +}) { + return ( +
+ + +
+ ); +} + +function LibraryItem({ + ds, + isSelected, + onSelect, +}: { + ds: DataSource; + isSelected: boolean; + onSelect: () => void; +}) { + return ( + + ); +} + +function LibraryTabs({ + libraryUser, + libraryMovement, + selectedDataSourceId, + onSelectDataSource, +}: { + libraryUser: DataSource[]; + libraryMovement: DataSource[]; + selectedDataSourceId: string | null; + onSelectDataSource: (id: string) => void; +}) { + const defaultTab = libraryUser.length > 0 ? "user" : "movement"; + const [tab, setTab] = useState<"user" | "movement">(defaultTab); + const items = tab === "user" ? libraryUser : libraryMovement; + + return ( +
+
+ {libraryUser.length > 0 && ( + + )} + {libraryMovement.length > 0 && ( + + )} +
+
+ {items.map((ds) => ( + onSelectDataSource(ds.id)} + /> + ))} +
+
+ ); +} + +// --------------------------------------------------------------------------- +// Main component +// --------------------------------------------------------------------------- + +export function DataSourcesList({ + searchQuery, + onSearchChange, + markerSources, + visualisationSources, + librarySources, + selectedDataSourceId, + onSelectDataSource, + onRemoveFromMap, + markerLayerColors, +}: DataSourcesListProps) { + const { libraryUser, libraryMovement } = useMemo( + () => ({ + libraryUser: librarySources.filter((ds) => !ds.public), + libraryMovement: librarySources.filter((ds) => ds.public), + }), + [librarySources], + ); + + const hasMarkers = markerSources.length > 0; + const hasVisualisation = visualisationSources.length > 0; + const hasLibrary = libraryUser.length > 0 || libraryMovement.length > 0; + + return ( +
+
+ onSearchChange(e.target.value)} + className="h-9" + /> +
+ +
+ {/* 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 */} + {hasLibrary && ( +
+

+ + Add data from Library +

+ +
+ )} + + {/* Empty state */} + {!hasMarkers && !hasVisualisation && !hasLibrary && ( +

+ 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 00000000..9aa0df80 --- /dev/null +++ b/src/app/map/[id]/components/inspector/InspectorSettingsModal/DragPreviews.tsx @@ -0,0 +1,68 @@ +"use client"; + +import { GripVertical, Minus } 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 DividerDragPreview({ label }: { label: string }) { + return ( +
+ + + + {label || "Section label"} + +
+ ); +} + +export function AvailableDragPreview({ activeId }: { activeId: string }) { + 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 ( +
+ + + {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 00000000..77e43214 --- /dev/null +++ b/src/app/map/[id]/components/inspector/InspectorSettingsModal/DroppableSelectedColumns.tsx @@ -0,0 +1,174 @@ +"use client"; + +import { useDroppable } from "@dnd-kit/core"; +import { + SortableContext, + verticalListSortingStrategy, +} from "@dnd-kit/sortable"; +import { useMemo } from "react"; +import { cn } from "@/shadcn/utils"; +import { SortableColumnRow } from "../SortableColumnRow"; +import { SELECTED_DROPPABLE_ID } from "./constants"; +import type { InspectorBoundaryConfig } from "@/server/models/MapView"; + +export function DroppableSelectedColumns({ + columns, + columnMetadata, + updateConfig, + onRemoveColumn, + activeId, +}: { + columns: string[]; + columnMetadata?: InspectorBoundaryConfig["columnMetadata"]; + updateConfig: ( + updater: (prev: InspectorBoundaryConfig) => InspectorBoundaryConfig, + ) => void; + onRemoveColumn?: (columnName: string) => void; + activeId: string | null; +}) { + const meta = columnMetadata ?? {}; + const { setNodeRef, isOver } = useDroppable({ id: SELECTED_DROPPABLE_ID }); + const columnIds = useMemo( + () => columns.map((c, i) => `col-${i}-${c}`), + [columns], + ); + + const isEmpty = columns.length === 0; + + return ( +
+ {isEmpty ? ( +

+ No columns — tick Available to add +

+ ) : ( + +
+ {columns.map((col, i) => ( + + 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, + }, + }, + })) + } + format={meta[col]?.format ?? "text"} + onFormatChange={(format) => + updateConfig((prev) => ({ + ...prev, + columnMetadata: { + ...(prev.columnMetadata ?? {}), + [col]: { + ...(prev.columnMetadata?.[col] ?? {}), + format, + }, + }, + })) + } + comparisonStat={meta[col]?.comparisonStat} + onComparisonStatChange={(comparisonStat) => + updateConfig((prev) => ({ + ...prev, + columnMetadata: { + ...(prev.columnMetadata ?? {}), + [col]: { + ...(prev.columnMetadata?.[col] ?? {}), + comparisonStat, + }, + }, + })) + } + 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, + }, + }, + })) + } + 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, + ); + } + return { ...prev, ...base }; + }) + } + isDragging={activeId === columnIds[i]} + /> + ))} +
+
+ )} +
+ ); +} 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 00000000..b9cfd7f2 --- /dev/null +++ b/src/app/map/[id]/components/inspector/InspectorSettingsModal/GeneralColumnOptionsPanel.tsx @@ -0,0 +1,331 @@ +"use client"; + +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 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 [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; + 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 ( +
+ +
+
+

+ Data source settings +

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

+ 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 new file mode 100644 index 00000000..53521344 --- /dev/null +++ b/src/app/map/[id]/components/inspector/InspectorSettingsModal/GlobalColumnRow.tsx @@ -0,0 +1,396 @@ +"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, + alwaysExpanded = 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; + /** When true, show form fields always (no accordion). */ + alwaysExpanded?: 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 === "scale" && scaleMax !== 3) || + (format === "numberWithComparison" && comparisonStat !== undefined) || + ((format === "percentage" || format === "scale") && (barColor ?? "") !== "") || + format !== "text"; + + return ( +
+ {!alwaysExpanded && ( +
setExpanded((e) => !e)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + setExpanded((prev) => !prev); + } + }} + className="w-full flex items-center gap-3 px-3 py-2.5 text-left hover:bg-neutral-50 transition-colors cursor-pointer" + > + {onShowInInspectorChange != null && ( +
e.stopPropagation()} + > + + onShowInInspectorChange(checked === true) + } + onClick={(e) => e.stopPropagation()} + aria-label={`Show ${columnName} in inspector`} + /> +
+ )} + + {columnName} + + {hasCustomSettings && ( + + Custom + + )} + + ▼ + +
+ )} + {(alwaysExpanded || expanded) && ( +
+ {alwaysExpanded && ( +

+ {columnName} +

+ )} +
+ + { + 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 00000000..fa03e84e --- /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 new file mode 100644 index 00000000..128eed02 --- /dev/null +++ b/src/app/map/[id]/components/inspector/InspectorSettingsModal/InspectorSettingsModal.tsx @@ -0,0 +1,435 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useState } from "react"; +import { Plus } from "lucide-react"; +import { v4 as uuidv4 } from "uuid"; +import { InspectorBoundaryConfigType } from "@/server/models/MapView"; +import { Button } from "@/shadcn/ui/button"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/shadcn/ui/dialog"; +import { Checkbox } from "@/shadcn/ui/checkbox"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/shadcn/ui/tabs"; +import { useDataSources } from "../../../hooks/useDataSources"; +import { useDebouncedValue } from "../../../hooks/useDebouncedValue"; +import { useMapConfig } from "../../../hooks/useMapConfig"; +import { useMapViews } from "../../../hooks/useMapViews"; +import { mapColors } from "../../../styles"; +import { normalizeInspectorBoundaryConfig } from "../inspectorColumnOrder"; +import { InspectorFullPreview } from "../InspectorFullPreview"; +import { DataSourcesList } from "./DataSourcesList"; +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, + initialDataSourceId, + initialTab = "general", +}: { + open: boolean; + onOpenChange: (open: boolean) => 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 { mapConfig, updateMapConfig } = useMapConfig(); + const { view, viewConfig, getLatestView, updateView, updateViewConfig } = useMapViews(); + + const boundaryConfigs = useMemo( + () => view?.inspectorConfig?.boundaries ?? [], + [view?.inspectorConfig?.boundaries], + ); + + // ---- 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 ------------------------------------------------ + + 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 { 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]); + + 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) { + if (initialDataSourceId != null) setSelectedDataSourceId(initialDataSourceId); + setActiveTab(initialTab); + } + }, [open, initialDataSourceId, initialTab]); + + // ---- Derived selection state ------------------------------------------- + + 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], + ); + + // ---- Mutations --------------------------------------------------------- + + 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) => { + if (!view) return; + updateView({ + ...view, + inspectorConfig: { + ...view.inspectorConfig, + boundaries: boundaryConfigs.filter((c) => c.id !== configId), + }, + }); + }, + [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) { + 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, addToInspector, 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 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 next = [...boundaries]; + next[index] = { + ...boundaries[index], + columns: orderedColumnNames, + columnOrder: orderedColumnNames, + columnItems: orderedColumnNames, + }; + updateView({ + ...latestView, + inspectorConfig: { ...latestView.inspectorConfig, boundaries: next }, + }); + }, + [getLatestView, updateView], + ); + + // ---- Render ------------------------------------------------------------ + + return ( + + setSelectedDataSourceId(null)} + > + + Data 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 + inspector settings. +
+ )} +
+ + {selectedDataSourceId && ( +
+

+ Preview +

+ +
+ )} +
+
+
+ ); +} 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 00000000..83c520cc --- /dev/null +++ b/src/app/map/[id]/components/inspector/InspectorSettingsModal/InspectorSettingsTabContent.tsx @@ -0,0 +1,325 @@ +"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 { Label } from "@/shadcn/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/shadcn/ui/select"; +import { cn } from "@/shadcn/utils"; +import { INSPECTOR_COLOR_OPTIONS } from "../inspectorPanelOptions"; +import { + getSelectedColumnsOrdered, + normalizeInspectorBoundaryConfig, +} from "../inspectorColumnOrder"; +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; + boundaryConfig: InspectorBoundaryConfig | null; + updateBoundaryConfig: ( + updater: (prev: InspectorBoundaryConfig) => InspectorBoundaryConfig, + ) => void; + getLatestView: ReturnType["getLatestView"]; + updateView: ReturnType["updateView"]; + onReorderColumns: ( + dataSourceId: string, + orderedColumnNames: string[], + ) => void; +} + +// --------------------------------------------------------------------------- +// Sub-components +// --------------------------------------------------------------------------- + +function PanelOptionsGrid({ + boundaryConfig, + updateBoundaryConfig, +}: { + boundaryConfig: InspectorBoundaryConfig; + updateBoundaryConfig: InspectorSettingsTabContentProps["updateBoundaryConfig"]; +}) { + return ( +
+
+ + +
+ +
+ + +
+
+ ); +} + +// --------------------------------------------------------------------------- +// Main component +// --------------------------------------------------------------------------- + +export function InspectorSettingsTabContent({ + dataSource, + boundaryConfig, + updateBoundaryConfig, + getLatestView, + updateView, + 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, + ], + ); + + if (!boundaryConfig) { + return ( +
+

+ Enable “Show in inspector” to configure columns. +

+
+ ); + } + + return ( + <> + {/* Inspector panel options (per-map overrides) */} +
+ +
+ + {/* 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} + /> +
+
+
+ + ); +} 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 00000000..270fc198 --- /dev/null +++ b/src/app/map/[id]/components/inspector/InspectorSettingsModal/InspectorSourceConfigPanel.tsx @@ -0,0 +1,419 @@ +"use client"; + +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { LayoutGrid, LayoutList, PlusIcon } from "lucide-react"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { toast } from "sonner"; +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 { useDebouncedCallback } from "../../../hooks/useDebouncedCallback"; +import { + getAllColumnsSorted, + getColumnOrderState, + normalizeInspectorBoundaryConfig, +} from "../inspectorColumnOrder"; +import { + INSPECTOR_COLOR_OPTIONS, + INSPECTOR_ICON_OPTIONS, +} from "../inspectorPanelOptions"; +import { ColumnsSection } from "./ColumnsSection"; +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, + config, + onAddToInspector, + isInInspector, + getLatestView, + updateView, +}: { + dataSource: ReadableDataSource; + config: InspectorBoundaryConfig | null; + onAddToInspector: () => void; + isInInspector: boolean; + 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(), + }); + }, + 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, + ); + + // 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], + ); + const allColumnsSorted = useMemo( + () => getAllColumnsSorted(allColumnNames), + [allColumnNames], + ); + const { + allColumnsInOrder, + selectedColumnsInOrder, + selectedItemsInOrder, + availableColumns, + columnIds, + } = useMemo( + () => getColumnOrderState(localConfig ?? config, allColumnNames), + [localConfig, config, allColumnNames], + ); + + const updateConfig = useCallback( + (updater: (prev: InspectorBoundaryConfig) => InspectorBoundaryConfig) => { + if (!config) return; + const prevConfig = localConfig ?? config; + const updated = updater(prevConfig); + const normalized = + normalizeInspectorBoundaryConfig(updated, allColumnNames) ?? updated; + 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, + updateView, + allColumnNames, + ], + ); + + 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) => { + 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], + ); + + if (!isInInspector) { + return ( +
+

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

+ +
+ ); + } + + if (!config) return null; + + 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 ( +
+
+
+
+ + { + const v = e.target.value; + setDisplayName(v); + debouncedUpdateName(v); + }} + placeholder="e.g. Main data" + className="max-w-sm" + /> +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ +
+ + {"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/SortableAvailableRow.tsx b/src/app/map/[id]/components/inspector/InspectorSettingsModal/SortableAvailableRow.tsx new file mode 100644 index 00000000..87689b5c --- /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/SortableDividerRow.tsx b/src/app/map/[id]/components/inspector/InspectorSettingsModal/SortableDividerRow.tsx new file mode 100644 index 00000000..91ca39cf --- /dev/null +++ b/src/app/map/[id]/components/inspector/InspectorSettingsModal/SortableDividerRow.tsx @@ -0,0 +1,89 @@ +"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 SortableDividerRow({ + id, + label, + onLabelChange, + onRemove, + isDragging, +}: { + id: string; + label: string; + onLabelChange: (value: string) => void; + onRemove?: () => void; + isDragging?: boolean; +}) { + const [localLabel, setLocalLabel] = useState(label); + useEffect(() => setLocalLabel(label), [label]); + const debouncedChange = useDebouncedCallback(onLabelChange, 600); + + 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 ( +
+ + { + 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 new file mode 100644 index 00000000..2a329217 --- /dev/null +++ b/src/app/map/[id]/components/inspector/InspectorSettingsModal/constants.ts @@ -0,0 +1,19 @@ +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__"; + +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 00000000..9361f7cb --- /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/PropertiesList.tsx b/src/app/map/[id]/components/inspector/PropertiesList.tsx index 7c4f1f3f..cfb6c5f4 100644 --- a/src/app/map/[id]/components/inspector/PropertiesList.tsx +++ b/src/app/map/[id]/components/inspector/PropertiesList.tsx @@ -1,53 +1,313 @@ -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" + | "numberWithComparison" + | "percentage" + | "scale"; + +export interface PropertyEntry { + key: string; + label: string; + /** 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; + /** 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 { + 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 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); + + if (format === "number" && num !== null) { + return ( + {formatNumber(num)} + ); + } + + 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 + ? 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, columnMetadata, + entries: entriesProp, + layout = "single", + dividerBackgroundClassName, }: { - properties: Record | null | undefined; + properties?: Record | null; columnMetadata?: ColumnMetadata[]; + entries?: PropertyEntry[] | null; + layout?: "single" | "twoColumn"; + /** Background class for divider labels (to cover vertical line). Inherits from panel color. */ + dividerBackgroundClassName?: string; }) { - if (!properties || !Object.keys(properties as object)?.length) { - return <>; - } + const entries: PropertyEntry[] = entriesProp + ? 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, + label: key, + value, + description: columnMetadata?.find((c) => c.name === key)?.description, + })) + : []; + + if (!entries.length) return <>; + + const isTwoColumn = layout === "twoColumn"; + const renderEntry = (e: PropertyEntry) => ( +
+
+ {e.label} + {e.description ? ( + + + + + +

{e.description}

+
+
+ ) : null} +
+
+ +
+
+ ); + + const byGroup = entries.reduce<{ group?: string; items: PropertyEntry[] }[]>( + (acc, e) => { + const last = acc[acc.length - 1]; + 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 { + if (last && last.group === undefined) last.items.push(e); + else acc.push({ items: [e] }); + } + return acc; + }, + [], + ); return ( -
- {Object.keys(properties as object).map((label) => { - const value = `${properties?.[label]}`; - - if (!value) return ; - - const description = columnMetadata?.find( - (c) => c.name === label, - )?.description; - - return ( -
-
- {label} - {description ? ( - - - - - -

{description}

-
-
- ) : null} -
-
{value}
-
- ); - })} +
+ {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 00000000..86fdb976 --- /dev/null +++ b/src/app/map/[id]/components/inspector/SortableColumnRow.tsx @@ -0,0 +1,379 @@ +"use client"; + +import { useSortable } from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; +import { GripVertical, X } from "lucide-react"; +import { type MouseEvent, useEffect, useState } from "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 { 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" }, +]; + +/** 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, + description, + onDescriptionChange, + format = "text", + onFormatChange, + comparisonStat, + onComparisonStatChange, + scaleMax = 3, + onScaleMaxChange, + barColor, + onBarColorChange, + onRemove, + isDragging, +}: { + id: string; + 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; + onRemove?: () => void; + isDragging?: boolean; +}) { + 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]); + const debouncedScaleMax = useDebouncedCallback( + (v: number) => onScaleMaxChange?.(v), + 600, + ); + + 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} + + {onRemove && ( + + )} +
+
+ + { + const v = e.target.value; + setLocalDisplayName(v); + debouncedChange(v); + }} + onClick={(e) => e.stopPropagation()} + /> +
+ {onDescriptionChange && ( +
+ + { + const v = e.target.value; + setLocalDescription(v); + debouncedDescriptionChange(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 === "numberWithComparison" && onComparisonStatChange && ( +
+ + +
+ )} + {(format === "percentage" || format === "scale") && onBarColorChange && ( + e.stopPropagation()} + /> + )} +
+ ); +} diff --git a/src/app/map/[id]/components/inspector/inspectorColumnOrder.ts b/src/app/map/[id]/components/inspector/inspectorColumnOrder.ts new file mode 100644 index 00000000..6bb1f9e7 --- /dev/null +++ b/src/app/map/[id]/components/inspector/inspectorColumnOrder.ts @@ -0,0 +1,215 @@ +"use client"; + +import type { + InspectorBoundaryConfig, + InspectorColumnItem, +} from "@/server/models/MapView"; + +function isDivider( + item: InspectorColumnItem, +): item is { type: "divider"; id: string; label: string } { + return typeof item === "object" && item !== null && item.type === "divider"; +} + +/** + * Normalize inspector config so columns, columnOrder, and columnItems + * never contain duplicate column names. Call after load and after every update + * so the rest of the code can assume unique columns. + */ +export function normalizeInspectorBoundaryConfig( + config: InspectorBoundaryConfig | null, + allColumnNames: string[], +): InspectorBoundaryConfig | null { + if (!config) return null; + const validSet = new Set(allColumnNames); + + const columns = (config.columns ?? []).filter((c) => validSet.has(c)); + const seenColumns = new Set(); + const columnsUnique: string[] = []; + for (const c of columns) { + if (seenColumns.has(c)) continue; + seenColumns.add(c); + columnsUnique.push(c); + } + + const columnOrder = (config.columnOrder ?? []).filter((c) => validSet.has(c)); + const seenOrder = new Set(); + const columnOrderUnique: string[] = []; + for (const c of columnOrder) { + if (seenOrder.has(c)) continue; + seenOrder.add(c); + columnOrderUnique.push(c); + } + + let columnItems = config.columnItems; + if (columnItems?.length) { + const seenItems = new Set(); + columnItems = columnItems.filter((i) => { + if (typeof i === "string") { + if (!validSet.has(i) || !columnsUnique.includes(i)) return false; + if (seenItems.has(i)) return false; + seenItems.add(i); + return true; + } + return true; + }); + } + + return { + ...config, + columns: columnsUnique, + columnOrder: + (config.columnOrder?.length ?? 0) > 0 ? columnOrderUnique : undefined, + columnItems: columnItems ?? config.columnItems, + }; +} + +/** + * 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" }), + ); +} + +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[]; +} { + const normalized = config + ? normalizeInspectorBoundaryConfig(config, allColumnNames) + : null; + const columns = normalized?.columns ?? []; + const columnOrder = normalized?.columnOrder; + const columnItems = normalized?.columnItems; + const allColumnsSorted = getAllColumnsSorted(allColumnNames); + + const allColumnsInOrder = + columnOrder?.length === allColumnNames.length + ? columnOrder + : (() => { + const selected = columns.filter((c) => allColumnNames.includes(c)); + const rest = allColumnsSorted.filter((c) => !selected.includes(c)); + return [...selected, ...rest]; + })(); + + const selectedColumnsInOrder = allColumnsInOrder.filter((c) => + columns.includes(c), + ); + + const selectedItemsInOrder: InspectorColumnItem[] = + columnItems?.length && columnItems.some((i) => isDivider(i)) + ? columnItems.filter((i) => + typeof i === "string" + ? allColumnNames.includes(i) && columns.includes(i) + : true, + ) + : selectedColumnsInOrder; + + const availableColumns = allColumnsInOrder.filter( + (c) => !columns.includes(c), + ); + + const allItemsInOrder: InspectorColumnItem[] = + columnItems?.length && columnItems.some((i) => isDivider(i)) + ? [ + ...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 = selectedColumnsInOrder.map( + (colName, index) => `col-${index}-${colName}`, + ); + + return { + allColumnsInOrder, + selectedColumnsInOrder, + selectedItemsInOrder, + allItemsInOrder, + availableColumns, + availableIds, + columnIds, + }; +} + +/** + * Returns selected columns in their canonical display order. + * Uses columnItems only when it contains a divider (explicit order); else columnOrder; else config.columns. + * This keeps inspector order in sync with "Columns to show" whether or not dividers are used. + */ +export function getSelectedColumnsOrdered( + config: Pick< + InspectorBoundaryConfig, + "columns" | "columnOrder" | "columnItems" + >, +): string[] { + const { columns, columnOrder, columnItems } = config; + const columnsSet = new Set(columns ?? []); + + if (columnItems?.length && columnItems.some((i) => isDivider(i))) { + 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. + * Normalizes config first so duplicates never appear. + */ +export function getSelectedItemsOrdered( + config: Pick< + InspectorBoundaryConfig, + "columns" | "columnOrder" | "columnItems" + >, + allColumnNames: string[], +): InspectorColumnItem[] { + const normalized = config + ? normalizeInspectorBoundaryConfig( + config as InspectorBoundaryConfig, + allColumnNames, + ) + : null; + const c = normalized ?? config; + const { columns, columnItems } = c; + const columnsSet = new Set(columns ?? []); + + 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(c); + return ordered.filter((c) => allColumnNames.includes(c)); +} 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 00000000..0ad9181c --- /dev/null +++ b/src/app/map/[id]/components/inspector/inspectorPanelOptions.tsx @@ -0,0 +1,314 @@ +"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; +} + +/** Universal data-source icon derived from defaultInspectorConfig.icon, falling back to Database. */ +export function DataSourceInspectorIcon({ + dataSource, + className, +}: { + dataSource: { defaultInspectorConfig?: { icon?: string | null } | null }; + className?: string; +}) { + const iconName = dataSource.defaultInspectorConfig?.icon; + const Icon = iconName ? getInspectorIcon(iconName) : null; + return React.createElement(Icon ?? Database, { className }); +} + +/** 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/app/map/[id]/components/table/MapTable.tsx b/src/app/map/[id]/components/table/MapTable.tsx index 2e2d252b..ccf7a39e 100644 --- a/src/app/map/[id]/components/table/MapTable.tsx +++ b/src/app/map/[id]/components/table/MapTable.tsx @@ -1,7 +1,12 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useSetAtom } from "jotai"; import { useEffect, useState } from "react"; import { toast } from "sonner"; +import { + inspectorSettingsInitialDataSourceIdAtom, + inspectorSettingsModalOpenAtom, +} from "@/app/map/[id]/atoms/inspectorAtoms"; import { useDataRecords } from "@/app/map/[id]/hooks/useDataRecords"; import { useDataSources } from "@/app/map/[id]/hooks/useDataSources"; import { useInspector } from "@/app/map/[id]/hooks/useInspector"; @@ -139,6 +144,10 @@ export default function MapTable() { ]); const dataSource = getDataSourceById(selectedDataSourceId); + const setSettingsOpen = useSetAtom(inspectorSettingsModalOpenAtom); + const setSettingsInitialDataSourceId = useSetAtom( + inspectorSettingsInitialDataSourceIdAtom, + ); const enableSyncToCRM = useFeatureFlagEnabled("sync-to-crm") && dataSource && @@ -148,6 +157,11 @@ export default function MapTable() { return null; } + const openInspectorSettings = () => { + setSettingsInitialDataSourceId(dataSource.id); + setSettingsOpen(true); + }; + const handleRowClick = (row: DataRecord) => { if (!row.geocodePoint) return; mapRef?.current?.flyTo({ @@ -210,11 +224,22 @@ export default function MapTable() { ) : null; + const inspectorSettingsButton = ( + + ); + return (
( + fn: (...args: A) => R, + delay: number, +): (...args: A) => void { + const timeoutRef = useRef | null>(null); + const fnRef = useRef(fn); + + useEffect(() => { + fnRef.current = fn; + }, [fn]); + + useEffect(() => { + return () => { + if (timeoutRef.current) clearTimeout(timeoutRef.current); + }; + }, []); + + 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 00000000..a1e95c26 --- /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/app/map/[id]/hooks/useMapViews.ts b/src/app/map/[id]/hooks/useMapViews.ts index 31e839f0..9e33eb91 100644 --- a/src/app/map/[id]/hooks/useMapViews.ts +++ b/src/app/map/[id]/hooks/useMapViews.ts @@ -151,32 +151,43 @@ export function useMapViews() { }), ); + /** Read the current view from the cache (avoids stale closure when updating from modal/preview). */ + const getLatestView = useCallback((): View | null => { + if (!mapId) return null; + const data = queryClient.getQueryData(trpc.map.byId.queryKey({ mapId })) as + | { views?: View[] } + | undefined; + const list = data?.views ?? []; + return list.find((v) => v.id === viewId) ?? null; + }, [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 }); } }, [ @@ -185,7 +196,6 @@ export function useMapViews() { queryClient, trpc.map.byId, updateViewMutate, - views, publicMap, ], ); @@ -279,6 +289,7 @@ export function useMapViews() { views: views || [], view, viewConfig, + getLatestView, updateViewConfig, insertView, updateView, diff --git a/src/components/ColorPalette.tsx b/src/components/ColorPalette.tsx index ca35dd50..52776ccf 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/DataSource.ts b/src/server/models/DataSource.ts index dbe84588..61a756b0 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,10 @@ export const dataSourceSchema = z.object({ createdAt: z.date(), dateFormat: z.string(), naIsNull: z.boolean().optional(), + defaultInspectorConfig: defaultInspectorBoundaryConfigSchema + .nullable() + .optional(), + defaultInspectorConfigUpdatedAt: z.coerce.date().nullable().optional(), nullIsZero: z.boolean().optional(), }); diff --git a/src/server/models/MapView.ts b/src/server/models/MapView.ts index c3a36e61..43a1b4d1 100644 --- a/src/server/models/MapView.ts +++ b/src/server/models/MapView.ts @@ -173,12 +173,89 @@ export const inspectorBoundaryTypes = Object.values( InspectorBoundaryConfigType, ); +/** + * 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 + * - numberWithComparison: number plus variance % vs a chosen statistic (average, median, etc.) + */ +export const inspectorColumnFormatSchema = z.enum([ + "text", + "number", + "percentage", + "scale", + "numberWithComparison", +]); +export type InspectorColumnFormat = z.infer; + +/** 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) + */ +export const inspectorColumnMetaSchema = z.object({ + displayName: z.string().optional(), + /** Shown as tooltip on the column label in the inspector. */ + description: 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. Empty = primary. */ + barColor: z.string().optional(), + /** For format "numberWithComparison": which statistic to compare against. */ + comparisonStat: inspectorComparisonStatSchema.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; + +/** + * 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; + /** * 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) + * - icon: optional Lucide icon name for custom panel icon + * - color: optional Tailwind color name for panel background (e.g. "blue" -> bg-blue-50) + * - columnOrder: optional display order for all columns (used for Available list order; when set, reorderable) */ export const inspectorBoundaryConfigSchema = z.object({ id: z.string(), @@ -186,12 +263,38 @@ 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(), + /** 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() + .nullable(), + columnGroups: z.array(inspectorColumnGroupSchema).optional().nullable(), + layout: z.enum(["single", "twoColumn"]).optional().nullable(), + icon: z.string().optional().nullable(), + color: z.string().optional().nullable(), }); 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/repositories/DataRecord.ts b/src/server/repositories/DataRecord.ts index 4f2ad1f8..ab5c983f 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/services/database/schema.ts b/src/server/services/database/schema.ts index 427b0576..3786ed72 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,8 @@ 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 + defaultInspectorConfigUpdatedAt: Date | null; // timestamptz, NULL – when default inspector config was last saved // FOREIGN KEYS: // - organisationId -> organisation.id (CASCADE DELETE, CASCADE UPDATE) diff --git a/src/server/trpc/routers/dataRecord.ts b/src/server/trpc/routers/dataRecord.ts index 2c946919..50e13717 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,14 @@ 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), + ), }); diff --git a/src/server/trpc/routers/dataSource.ts b/src/server/trpc/routers/dataSource.ts index 211717ef..f2c9e722 100644 --- a/src/server/trpc/routers/dataSource.ts +++ b/src/server/trpc/routers/dataSource.ts @@ -41,12 +41,12 @@ export const dataSourceRouter = router({ const organisations = ctx.user ? await findOrganisationsByUserId(ctx.user.id) : []; + const organisationIds = organisations.map((o) => o.id); const dataSources = await db .selectFrom("dataSource") .leftJoin("organisation", "dataSource.organisationId", "organisation.id") .where((eb) => { const filter = [eb("public", "=", true)]; - const organisationIds = organisations.map((o) => o.id); if (organisationIds.length > 0) { filter.push(eb("organisation.id", "in", organisationIds)); } @@ -55,7 +55,11 @@ export const dataSourceRouter = router({ .selectAll("dataSource") .execute(); - return addImportInfo(dataSources); + const withImportInfo = await addImportInfo(dataSources); + return withImportInfo.map((ds) => ({ + ...ds, + isOwner: organisationIds.includes(ds.organisationId), + })); }), byOrganisation: organisationProcedure.query(async ({ ctx }) => { const dataSources = await db @@ -239,6 +243,10 @@ export const dataSourceRouter = router({ dateFormat: input.dateFormat, public: input.public, naIsNull: input.naIsNull, + defaultInspectorConfig: input.defaultInspectorConfig, + ...(input.defaultInspectorConfig !== undefined && { + defaultInspectorConfigUpdatedAt: new Date(), + }), nullIsZero: input.nullIsZero, } as DataSourceUpdate; diff --git a/tsconfig.json b/tsconfig.json index 40424caa..6dfb703d 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" + ] }