diff --git a/src/app/(private)/map/[id]/components/DataSourceSelectButton.tsx b/src/app/(private)/map/[id]/components/DataSourceSelectButton.tsx index ad2e1290..3cf368ff 100644 --- a/src/app/(private)/map/[id]/components/DataSourceSelectButton.tsx +++ b/src/app/(private)/map/[id]/components/DataSourceSelectButton.tsx @@ -117,7 +117,7 @@ function DataSourceSelectButtonModalTrigger({ ); } -function DataSourceSelectModal({ +export function DataSourceSelectModal({ isModalOpen, setIsModalOpen, onSelect, diff --git a/src/app/(private)/map/[id]/components/Legend.tsx b/src/app/(private)/map/[id]/components/Legend.tsx index ed89fd96..774aba15 100644 --- a/src/app/(private)/map/[id]/components/Legend.tsx +++ b/src/app/(private)/map/[id]/components/Legend.tsx @@ -1,24 +1,56 @@ -import { ChevronRight, Eye, EyeOff, LoaderPinwheel } from "lucide-react"; -import { useChoropleth } from "@/app/(private)/map/[id]/hooks/useChoropleth"; -import { useChoroplethDataSource } from "@/app/(private)/map/[id]/hooks/useDataSources"; +import { + ChevronDown, + CornerDownRight, + Database, + LoaderPinwheel, +} from "lucide-react"; +import { useEffect, useState } from "react"; +import { + useChoroplethDataSource, + useDataSources, +} from "@/app/(private)/map/[id]/hooks/useDataSources"; import { useMapViews } from "@/app/(private)/map/[id]/hooks/useMapViews"; -import { MAX_COLUMN_KEY } from "@/constants"; +import { MAX_COLUMN_KEY, NULL_UUID } from "@/constants"; +import { AreaSetGroupCodeLabels, AreaSetGroupCodeYears } from "@/labels"; import { ColumnType } from "@/models/DataSource"; -import { CalculationType, ColorScaleType } from "@/models/MapView"; +import { CalculationType, ColorScaleType, MapType } from "@/models/MapView"; +import { Combobox } from "@/shadcn/ui/combobox"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/shadcn/ui/dialog"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/shadcn/ui/select"; import { cn } from "@/shadcn/utils"; import { resolveColumnMetadataEntry } from "@/utils/resolveColumnMetadata"; import { formatNumber } from "@/utils/text"; import { calculateStepColor, useColorScheme } from "../colors"; import { useAreaStats } from "../data"; import BivariateLegend from "./BivariateLagend"; +import { getValidAreaSetGroupCodes } from "./Choropleth/areas"; import { getChoroplethDataKey } from "./Choropleth/utils"; import ColumnMetadataIcons from "./ColumnMetadataIcons"; +import { DataSourceSelectModal } from "./DataSourceSelectButton"; import type { NumericColorScheme } from "../colors"; +import type { AreaSetGroupCode } from "@/models/AreaSet"; export default function Legend() { const { viewConfig, updateViewConfig } = useMapViews(); const dataSource = useChoroplethDataSource(); - const { setBoundariesPanelOpen } = useChoropleth(); + const { data: dataSources, getDataSourceById } = useDataSources(); + + const [isDataSourceModalOpen, setIsDataSourceModalOpen] = useState(false); + const [invalidDataSourceId, setInvalidDataSourceId] = useState( + null, + ); + const [bivariatePickerOpen, setBivariatePickerOpen] = useState(false); const areaStatsQuery = useAreaStats(); const areaStats = areaStatsQuery?.data; @@ -29,12 +61,6 @@ export default function Legend() { viewConfig, }); - const isLayerVisible = viewConfig.showChoropleth !== false; - - const toggleLayerVisibility = () => { - updateViewConfig({ showChoropleth: !isLayerVisible }); - }; - const hasDataSource = Boolean(viewConfig.areaDataSourceId); const hasColumn = Boolean( viewConfig.areaDataColumn || @@ -45,58 +71,93 @@ export default function Legend() { viewConfig.areaDataColumn && viewConfig.areaDataSecondaryColumn; - if (!hasDataSource) { - return null; - } + const isCount = viewConfig.calculationType === CalculationType.Count; + const canSelectColumn = !isCount && hasDataSource; - const getColumnLabel = () => { - if (!hasColumn) { - return

No column selected

; - } - if (viewConfig.areaDataColumn === MAX_COLUMN_KEY) { - return

Highest-value column

; - } - if (viewConfig.calculationType === CalculationType.Count) { - return

Count

; - } + const columnOneIsNumber = + Boolean(viewConfig.areaDataColumn) && + dataSource?.columnDefs.find((c) => c.name === viewConfig.areaDataColumn) + ?.type === ColumnType.Number; + const canSelectSecondaryColumn = + !isCount && Boolean(viewConfig.areaDataColumn) && columnOneIsNumber; - const primaryLabel = ( -
- {viewConfig.areaDataColumn} - -
- ); + const showSecondColumnRow = + canSelectSecondaryColumn && + (Boolean(viewConfig.areaDataSecondaryColumn) || bivariatePickerOpen); - if (!viewConfig.areaDataSecondaryColumn) { - return primaryLabel; + useEffect(() => { + if (!dataSource || !viewConfig.areaDataColumn) return; + const primaryIsNumber = + dataSource.columnDefs.find((c) => c.name === viewConfig.areaDataColumn) + ?.type === ColumnType.Number; + if (!primaryIsNumber) { + if (viewConfig.areaDataSecondaryColumn) { + updateViewConfig({ areaDataSecondaryColumn: undefined }); + } + setBivariatePickerOpen(false); } + }, [ + dataSource, + viewConfig.areaDataColumn, + viewConfig.areaDataSecondaryColumn, + updateViewConfig, + ]); - const secondaryLabel = ( -
- {viewConfig.areaDataSecondaryColumn} - -
- ); + const secondaryColumnComboboxOptions = [ + { value: NULL_UUID, label: "None" }, + ...(dataSources + ?.find((ds) => ds.id === viewConfig.areaDataSourceId) + ?.columnDefs.filter( + (col) => + col.type === ColumnType.Number && + col.name !== viewConfig.areaDataColumn, + ) + .map((col) => ({ + value: col.name, + label: `${col.name} (${col.type})`, + hint: resolveColumnMetadataEntry( + dataSource?.columnMetadata || [], + dataSource?.columnMetadataOverride, + col.name, + )?.description, + })) || []), + ]; - return ( -
- {primaryLabel} - vs - {secondaryLabel} -
- ); + const handleDataSourceSelect = (dataSourceId: string) => { + const selectedAreaSetGroup = viewConfig.areaSetGroupCode; + if (!selectedAreaSetGroup) { + updateViewConfig({ + areaDataSourceId: dataSourceId, + areaDataSecondaryColumn: undefined, + }); + setIsDataSourceModalOpen(false); + return; + } + const ds = getDataSourceById(dataSourceId); + const validAreaSetGroups = getValidAreaSetGroupCodes(ds?.geocodingConfig); + if (validAreaSetGroups.includes(selectedAreaSetGroup)) { + updateViewConfig({ + areaDataSourceId: dataSourceId, + areaDataSecondaryColumn: undefined, + }); + setIsDataSourceModalOpen(false); + return; + } + setIsDataSourceModalOpen(false); + setInvalidDataSourceId(dataSourceId); + }; + + const toggleBivariatePicker = () => { + if (viewConfig.areaDataSecondaryColumn) { + updateViewConfig({ areaDataSecondaryColumn: undefined }); + setBivariatePickerOpen(false); + return; + } + if (bivariatePickerOpen) { + setBivariatePickerOpen(false); + return; + } + setBivariatePickerOpen(true); }; const makeBars = () => { @@ -132,7 +193,7 @@ export default function Legend() { )?.valueLabels || {}; return ( -
+
{Object.keys(colorScheme.colorMap) .filter((key) => categoriesInData.has(key)) .toSorted((a, b) => { @@ -367,78 +428,264 @@ export default function Legend() { }; return ( -
-
setBoundariesPanelOpen(true)} - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - setBoundariesPanelOpen(true); - } - }} - className="flex items-start justify-between hover:bg-neutral-50 transition-colors cursor-pointer text-left w-full" - > -
-
-
-

- {dataSource?.name} -

- -
- {getColumnLabel()} +
+ {/* Data source + column */} +
+

+ Data source +

+ + + + {hasDataSource && canSelectColumn && ( +
+
+ +
+ ds.id === viewConfig.areaDataSourceId) + ?.columnDefs.map((col) => ({ + value: col.name, + label: `${col.name} (${col.type})`, + hint: resolveColumnMetadataEntry( + dataSource?.columnMetadata || [], + dataSource?.columnMetadataOverride, + col.name, + )?.description, + })) || []), + ]} + value={viewConfig.areaDataColumn || NULL_UUID} + onValueChange={(value) => { + const col = value === NULL_UUID ? "" : value; + const primaryIsNumber = + Boolean(col) && + dataSource?.columnDefs.find((c) => c.name === col) + ?.type === ColumnType.Number; + updateViewConfig({ + areaDataColumn: col, + ...(col === viewConfig.areaDataSecondaryColumn + ? { areaDataSecondaryColumn: undefined } + : {}), + ...(!primaryIsNumber + ? { areaDataSecondaryColumn: undefined } + : {}), + }); + if (!primaryIsNumber) { + setBivariatePickerOpen(false); + } + }} + placeholder="Column…" + searchPlaceholder="Search columns…" + />
+ {viewConfig.areaDataColumn && + viewConfig.areaDataColumn !== NULL_UUID && + viewConfig.areaDataColumn !== MAX_COLUMN_KEY && ( + + )}
- + + {canSelectSecondaryColumn && ( + + )} + + {showSecondColumnRow && ( +
+ +
+ { + updateViewConfig({ + areaDataSecondaryColumn: + value === NULL_UUID ? undefined : value, + }); + if (value === NULL_UUID) { + setBivariatePickerOpen(false); + } + }} + placeholder="Column 2…" + searchPlaceholder="Search columns…" + /> +
+ {viewConfig.areaDataSecondaryColumn && ( + + )} +
+ )}
- {isLoading ? ( -
- -
- ) : isBivariate ? ( -
e.stopPropagation()}> - -
- ) : hasColumn && colorScheme ? ( -
{makeBars()}
- ) : null} -
+ )}
+ + {/* Colour bars */} + {isLoading ? ( +
+ +
+ ) : isBivariate ? ( +
+ +
+ ) : hasColumn && colorScheme ? ( +
{makeBars()}
+ ) : null} + + {viewConfig.mapType !== MapType.Hex && ( +
+

+ Boundaries +

+ +
+ )} + {/* Data source modal */} + + + {/* Invalid boundary dialog */} + { + if (!o) setInvalidDataSourceId(null); + }} + > + + + Select new boundaries + +

+ The data source you have selected does not fit into your selected + boundaries ( + {viewConfig.areaSetGroupCode + ? AreaSetGroupCodeLabels[viewConfig.areaSetGroupCode] + : "unknown"} + ). Please select alternative boundaries, or cancel. +

+ +
+
); } - -const VisibilityToggle = ({ - isLayerVisible, - toggleLayerVisibility, -}: { - isLayerVisible: boolean; - toggleLayerVisibility: () => void; -}) => ( -
-
{ - e.stopPropagation(); - toggleLayerVisibility(); - }} - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - e.stopPropagation(); - toggleLayerVisibility(); - } - }} - > - {isLayerVisible ? : } -
-
-); diff --git a/src/app/(private)/map/[id]/components/controls/BoundariesControl/BoundariesControl.tsx b/src/app/(private)/map/[id]/components/controls/BoundariesControl/BoundariesControl.tsx index c7fd9f37..ff3b5046 100644 --- a/src/app/(private)/map/[id]/components/controls/BoundariesControl/BoundariesControl.tsx +++ b/src/app/(private)/map/[id]/components/controls/BoundariesControl/BoundariesControl.tsx @@ -1,10 +1,9 @@ -import { SettingsIcon } from "lucide-react"; +import { PaintbrushIcon } from "lucide-react"; import { useState } from "react"; import { useChoropleth } from "@/app/(private)/map/[id]/hooks/useChoropleth"; import IconButtonWithTooltip from "@/components/IconButtonWithTooltip"; import { LayerType } from "@/types"; import LayerControlWrapper from "../LayerControlWrapper"; -import EmptyLayer from "../LayerEmptyMessage"; import LayerHeader from "../LayerHeader"; import LegendControl from "./LegendControl"; import { useBoundariesControl } from "./useBoundariesControl"; @@ -23,31 +22,23 @@ export default function BoundariesControl() { setExpanded={setExpanded} enableVisibilityToggle={hasDataSource} > - setBoundariesPanelOpen(!boundariesPanelOpen)} - > - - + {hasDataSource && ( + setBoundariesPanelOpen(!boundariesPanelOpen)} + > + + + )} {expanded && (
- {/* Controls removed from here 2025-12-08. */} - {/* Potentially could be restored. Remove if still not restored by 2025-03-01 */} - {/* - - */} - {!hasDataSource && ( - setBoundariesPanelOpen(true)} - showAsButton - /> - )} - {hasDataSource && } +
)} diff --git a/src/app/(private)/map/[id]/components/controls/BoundariesControl/LegendControl.tsx b/src/app/(private)/map/[id]/components/controls/BoundariesControl/LegendControl.tsx index c9bed91f..4847035c 100644 --- a/src/app/(private)/map/[id]/components/controls/BoundariesControl/LegendControl.tsx +++ b/src/app/(private)/map/[id]/components/controls/BoundariesControl/LegendControl.tsx @@ -1,20 +1,5 @@ import Legend from "@/app/(private)/map/[id]/components/Legend"; -import { useMapViews } from "@/app/(private)/map/[id]/hooks/useMapViews"; -import { useBoundariesControl } from "./useBoundariesControl"; export default function LegendControl() { - const { viewConfig } = useMapViews(); - const { hasShape } = useBoundariesControl(); - - return ( -
-
- -
-
- ); + return ; } diff --git a/src/app/(private)/map/[id]/components/controls/VisualisationPanel/VisualisationPanel.tsx b/src/app/(private)/map/[id]/components/controls/VisualisationPanel/VisualisationPanel.tsx index 0e791ccc..d024e6cf 100644 --- a/src/app/(private)/map/[id]/components/controls/VisualisationPanel/VisualisationPanel.tsx +++ b/src/app/(private)/map/[id]/components/controls/VisualisationPanel/VisualisationPanel.tsx @@ -1,13 +1,9 @@ -import { CircleAlert, Database, Palette, PieChart, X } from "lucide-react"; +import { Palette, PieChart, X } from "lucide-react"; import { useState } from "react"; import { useChoropleth } from "@/app/(private)/map/[id]/hooks/useChoropleth"; -import { - useChoroplethDataSource, - useDataSources, -} from "@/app/(private)/map/[id]/hooks/useDataSources"; +import { useChoroplethDataSource } from "@/app/(private)/map/[id]/hooks/useDataSources"; import { useMapViews } from "@/app/(private)/map/[id]/hooks/useMapViews"; -import { DEFAULT_CUSTOM_COLOR, MAX_COLUMN_KEY, NULL_UUID } from "@/constants"; -import { AreaSetGroupCodeLabels, AreaSetGroupCodeYears } from "@/labels"; +import { DEFAULT_CUSTOM_COLOR, MAX_COLUMN_KEY } from "@/constants"; import { ColumnType } from "@/models/DataSource"; import { CalculationType, @@ -17,7 +13,6 @@ import { } from "@/models/MapView"; import { Button } from "@/shadcn/ui/button"; import { Checkbox } from "@/shadcn/ui/checkbox"; -import { Combobox } from "@/shadcn/ui/combobox"; import { Dialog, DialogContent, @@ -37,16 +32,10 @@ import { import { Separator } from "@/shadcn/ui/separator"; import { Switch } from "@/shadcn/ui/switch"; import { cn } from "@/shadcn/utils"; -import { resolveColumnMetadataEntry } from "@/utils/resolveColumnMetadata"; import { CHOROPLETH_COLOR_SCHEMES } from "../../../colors"; import { useEditColumnMetadata } from "../../../hooks/useEditColumnMetadata"; -import { - dataRecordsWillAggregate, - getValidAreaSetGroupCodes, -} from "../../Choropleth/areas"; -import DataSourceSelectButton from "../../DataSourceSelectButton"; +import { dataRecordsWillAggregate } from "../../Choropleth/areas"; import SteppedColorEditor from "./SteppedColorEditor"; -import type { AreaSetGroupCode } from "@/models/AreaSet"; import type { DataSource } from "@/models/DataSource"; const SELECT_TO_BUTTON_CLASSES = @@ -166,13 +155,8 @@ export default function VisualisationPanel({ }) { const { viewConfig, updateViewConfig } = useMapViews(); const { boundariesPanelOpen, setBoundariesPanelOpen } = useChoropleth(); - const { data: dataSources, getDataSourceById } = useDataSources(); const dataSource = useChoroplethDataSource(); - const [invalidDataSourceId, setInvalidDataSourceId] = useState( - null, - ); - if (!boundariesPanelOpen) return null; const isCount = viewConfig.calculationType === CalculationType.Count; @@ -186,8 +170,6 @@ export default function VisualisationPanel({ viewConfig.colorScaleType === ColorScaleType.Categorical || columnOneIsNotNumber; - const canSelectColumn = !isCount && viewConfig.areaDataSourceId; - const canSelectSecondaryColumn = !isCount && columnOneIsNumber; const canSelectAggregation = !isCount && columnOneIsNumber && @@ -200,6 +182,8 @@ export default function VisualisationPanel({ const canSelectColorScheme = canSelectColorScale && !isCategorical; const canSetCategoryColors = isCategorical; + const hasDataSource = Boolean(viewConfig.areaDataSourceId); + return (
-

Create visualisation

+

Style settings

-
- {viewConfig.areaDataSecondaryColumn && ( -
- -
- )} - - )} - - {canSelectAggregation && ( - <> - - - )} -
- {!viewConfig.areaDataSourceId && ( -
- -

- No data source selected. Please select a data source to create a - choropleth. -

-
- )} - {viewConfig.areaDataSourceId && !viewConfig.areaSetGroupCode && ( -
- -

- No locality shapes selected. Please select a locality set to - render the filled map. -

-
- )} - {/* Include Columns Modal - only show when MAX_COLUMN_KEY is selected */} - {viewConfig.areaDataColumn === MAX_COLUMN_KEY && dataSource && ( - v.trim()) - .filter(Boolean) - : [] - } - onColumnsChange={(columns) => { - updateViewConfig({ - includeColumnsString: - columns.length > 0 ? columns.join(",") : undefined, - }); - }} - /> - )} -
- - {showStyle && ( -
- {/* Color Scheme Selection */} - -

- - Style -

+ {canSelectAggregation && ( + <> + + + + )} +
-
- {canSelectColorScale && ( - <> - + {/* Include Columns Modal - only show when MAX_COLUMN_KEY is selected */} + {viewConfig.areaDataColumn === MAX_COLUMN_KEY && dataSource && ( + v.trim()) + .filter(Boolean) + : [] + } + onColumnsChange={(columns) => { + updateViewConfig({ + includeColumnsString: + columns.length > 0 ? columns.join(",") : undefined, + }); + }} + /> + )} +
- + {showStyle && ( +
+

+ + Style +

- {canSelectColorScheme && ( +
+ {canSelectColorScale && ( <> - {viewConfig.colorScheme === ColorScheme.Custom && ( + {canSelectColorScheme && ( <> -
-
+ updateViewConfig({ + colorScheme: value as ColorScheme, + }) + } + > + - - updateViewConfig({ - customColor: e.target.value, - }) - } - className="absolute inset-0 w-full h-full opacity-0 cursor-pointer" - title="Choose color for max value" - /> -
- - updateViewConfig({ customColor: e.target.value }) - } - className="flex-1" - placeholder={DEFAULT_CUSTOM_COLOR} - /> -
- - )} + + + + {CHOROPLETH_COLOR_SCHEMES.map((option, index) => { + const isCustom = + option.value === ColorScheme.Custom; + const customColorValue = isCustom + ? viewConfig.customColor || "#3b82f6" + : undefined; + return ( + + {isCustom && customColorValue ? ( +
+ ) : ( +
+ )} + + {option.label} + + + ); + })} + + + + {viewConfig.colorScheme === ColorScheme.Custom && ( + <> + +
+
+ + updateViewConfig({ + customColor: e.target.value, + }) + } + className="absolute inset-0 w-full h-full opacity-0 cursor-pointer" + title="Choose color for max value" + /> +
+ + updateViewConfig({ + customColor: e.target.value, + }) + } + className="flex-1" + placeholder={DEFAULT_CUSTOM_COLOR} + /> +
+ + )} - + - - updateViewConfig({ reverseColorScheme: v }) - } - /> + + updateViewConfig({ reverseColorScheme: v }) + } + /> - {viewConfig.colorScaleType === ColorScaleType.Stepped && ( - <> - -
- -
+ {viewConfig.colorScaleType === + ColorScaleType.Stepped && ( + <> + +
+ +
+ + )} )} )} - - )} - {canSetCategoryColors && ( - <> -
-
+ )} + )} - - {/* Modal for handling invalid data source / boundary combination */} - { - if (!o) { - setInvalidDataSourceId(null); - } - }} - > - - - Select new boundaries - - -

- The data source you have selected does not fit into your selected - boundaries ( - {viewConfig.areaSetGroupCode - ? AreaSetGroupCodeLabels[viewConfig.areaSetGroupCode] - : "unknown"} - ). Please select alternative boundaries, or cancel. -

- - -
-
); } diff --git a/src/shadcn/ui/combobox.tsx b/src/shadcn/ui/combobox.tsx index b519df37..68318ffb 100644 --- a/src/shadcn/ui/combobox.tsx +++ b/src/shadcn/ui/combobox.tsx @@ -29,6 +29,9 @@ interface ComboboxProps { placeholder?: string; searchPlaceholder?: string; emptyMessage?: string; + /** Merged onto the trigger button (e.g. typography to match Select) */ + triggerClassName?: string; + size?: React.ComponentProps["size"]; } /** @@ -53,6 +56,8 @@ export const Combobox = React.forwardRef( placeholder = "Select an option...", searchPlaceholder = "Search...", emptyMessage = "No options found.", + triggerClassName, + size = "default", }, ref, ) => { @@ -83,9 +88,10 @@ export const Combobox = React.forwardRef(