diff --git a/package-lock.json b/package-lock.json index 66cc8bea..292dda1b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -68,7 +68,7 @@ "jotai": "^2.16.0", "kysely": "^0.27.6", "lucide": "^0.544.0", - "lucide-react": "^0.484.0", + "lucide-react": "^0.562.0", "mapbox-gl": "^3.10.0", "minio": "^8.0.5", "next": "^15.5.7", @@ -22018,7 +22018,9 @@ "license": "ISC" }, "node_modules/lucide-react": { - "version": "0.484.0", + "version": "0.562.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.562.0.tgz", + "integrity": "sha512-82hOAu7y0dbVuFfmO4bYF1XEwYk/mEbM5E+b1jgci/udUBEE/R7LF5Ip0CCEmXe8AybRM8L+04eP+LGZeDvkiw==", "license": "ISC", "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" diff --git a/package.json b/package.json index 78e71c71..d6cd21a2 100644 --- a/package.json +++ b/package.json @@ -79,7 +79,7 @@ "jotai": "^2.16.0", "kysely": "^0.27.6", "lucide": "^0.544.0", - "lucide-react": "^0.484.0", + "lucide-react": "^0.562.0", "mapbox-gl": "^3.10.0", "minio": "^8.0.5", "next": "^15.5.7", diff --git a/src/app/(private)/data-sources/[id]/DataSourceDashboard.tsx b/src/app/(private)/data-sources/[id]/DataSourceDashboard.tsx index df44d8bf..579f7dcb 100644 --- a/src/app/(private)/data-sources/[id]/DataSourceDashboard.tsx +++ b/src/app/(private)/data-sources/[id]/DataSourceDashboard.tsx @@ -10,21 +10,11 @@ import { toast } from "sonner"; import DataSourceBadge from "@/components/DataSourceBadge"; import DataSourceRecordTypeIcon from "@/components/DataSourceRecordTypeIcon"; import DefinitionList from "@/components/DefinitionList"; +import DeleteConfirmationDialog from "@/components/DeleteConfirmationDialog"; import { Link } from "@/components/Link"; import { DataSourceConfigLabels } from "@/labels"; import { JobStatus } from "@/server/models/DataSource"; import { useTRPC } from "@/services/trpc/react"; -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, - AlertDialogTrigger, -} from "@/shadcn/ui/alert-dialog"; import { Breadcrumb, BreadcrumbItem, @@ -251,6 +241,7 @@ function DeleteDataSourceButton({ }) { const router = useRouter(); const trpc = useTRPC(); + const [open, setOpen] = useState(false); const { mutate, isPending } = useMutation( trpc.dataSource.delete.mutationOptions({ onSuccess: () => { @@ -264,31 +255,19 @@ function DeleteDataSourceButton({ ); return ( - - - - - - - Are you absolutely sure? - - This action cannot be undone. This will permanently delete your data - source. - - - - Cancel - mutate({ dataSourceId: dataSource.id })} - > - {isPending ? "Deleting..." : "Continue"} - - - - + <> + + mutate({ dataSourceId: dataSource.id })} + isPending={isPending} + confirmButtonText="Continue" + /> + ); } diff --git a/src/app/map/[id]/colors.ts b/src/app/map/[id]/colors.ts index 2066efae..83caddd8 100644 --- a/src/app/map/[id]/colors.ts +++ b/src/app/map/[id]/colors.ts @@ -10,13 +10,36 @@ import { schemeCategory10, } from "d3-scale-chromatic"; import { useMemo } from "react"; +import { DEFAULT_CUSTOM_COLOR } from "@/constants"; import { ColumnType } from "@/server/models/DataSource"; -import { CalculationType, ColorScheme } from "@/server/models/MapView"; +import { + CalculationType, + ColorScaleType, + ColorScheme, + type SteppedColorStep, +} from "@/server/models/MapView"; import { DEFAULT_FILL_COLOR, PARTY_COLORS } from "./constants"; import type { CombinedAreaStats } from "./data"; import type { ScaleOrdinal, ScaleSequential } from "d3-scale"; import type { DataDrivenPropertyValueSpecification } from "mapbox-gl"; +// Simple RGB interpolation helper (white to target color) +const interpolateWhiteToColor = (targetColor: string) => { + // Parse hex color to RGB + const hex = targetColor.replace("#", ""); + const r = parseInt(hex.substring(0, 2), 16); + const g = parseInt(hex.substring(2, 4), 16); + const b = parseInt(hex.substring(4, 6), 16); + + return (t: number) => { + // Interpolate from white (255, 255, 255) to target color + const newR = Math.round(255 + t * (r - 255)); + const newG = Math.round(255 + t * (g - 255)); + const newB = Math.round(255 + t * (b - 255)); + return `rgb(${newR}, ${newG}, ${newB})`; + }; +}; + export interface CategoricColorScheme { columnType: ColumnType.String; colorMap: Record; @@ -71,9 +94,17 @@ export const CHOROPLETH_COLOR_SCHEMES = [ value: ColorScheme.Diverging, color: "bg-gradient-to-r from-brown-500 via-yellow-500 to-teal-500", }, + { + label: "Custom", + value: ColorScheme.Custom, + color: "bg-gradient-to-r from-white to-blue-500", + }, ]; -const getInterpolator = (scheme: ColorScheme | undefined) => { +export const getInterpolator = ( + scheme: ColorScheme | undefined, + customColor?: string, +) => { switch (scheme) { case ColorScheme.RedBlue: return interpolateRdBu; @@ -88,6 +119,10 @@ const getInterpolator = (scheme: ColorScheme | undefined) => { return interpolateBrBG; case ColorScheme.Sequential: return interpolateBlues; + case ColorScheme.Custom: + // Interpolate from white to custom color + const targetColor = customColor || DEFAULT_CUSTOM_COLOR; + return interpolateWhiteToColor(targetColor); default: return interpolateOrRd; } @@ -97,25 +132,39 @@ export const useColorScheme = ({ areaStats, scheme, isReversed, + categoryColors, + customColor, }: { areaStats: CombinedAreaStats | null; scheme: ColorScheme; isReversed: boolean; + categoryColors?: Record; + customColor?: string; }): CategoricColorScheme | NumericColorScheme | null => { // useMemo to cache calculated scales return useMemo(() => { - return getColorScheme({ areaStats, scheme, isReversed }); - }, [areaStats, scheme, isReversed]); + return getColorScheme({ + areaStats, + scheme, + isReversed, + categoryColors, + customColor, + }); + }, [areaStats, scheme, isReversed, categoryColors, customColor]); }; const getColorScheme = ({ areaStats, scheme, isReversed, + categoryColors, + customColor, }: { areaStats: CombinedAreaStats | null; scheme: ColorScheme; isReversed: boolean; + categoryColors?: Record; + customColor?: string; }): CategoricColorScheme | NumericColorScheme | null => { if (!areaStats || !areaStats.stats.length) { return null; @@ -129,7 +178,8 @@ const getColorScheme = ({ const colorScale = scaleOrdinal(schemeCategory10).domain(distinctValues); const colorMap: Record = {}; distinctValues.forEach((v) => { - colorMap[v] = getCategoricalColor(v, colorScale); + // Use custom color if provided, otherwise use default + colorMap[v] = categoryColors?.[v] ?? getCategoricalColor(v, colorScale); }); return { columnType: ColumnType.String, @@ -146,7 +196,7 @@ const getColorScheme = ({ const domain = isReversed ? [1, 0] : [0, 1]; // For count records, create a simple color scheme // Use a small range to ensure valid interpolation - const interpolator = getInterpolator(scheme); + const interpolator = getInterpolator(scheme, customColor); const colorScale = scaleSequential() .domain(domain) // Use 0-1 range for single values .interpolator(interpolator); @@ -165,7 +215,7 @@ const getColorScheme = ({ number, ]; - const interpolator = getInterpolator(scheme); + const interpolator = getInterpolator(scheme, customColor); const colorScale = scaleSequential() .domain(domain) .interpolator(interpolator); @@ -190,11 +240,19 @@ export const useFillColor = ({ scheme, isReversed, selectedBivariateBucket, + categoryColors, + colorScaleType, + steppedColorSteps, + customColor, }: { areaStats: CombinedAreaStats | null; scheme: ColorScheme; isReversed: boolean; selectedBivariateBucket: string | null; + categoryColors?: Record; + colorScaleType?: ColorScaleType; + steppedColorSteps?: SteppedColorStep[]; + customColor?: string; }): DataDrivenPropertyValueSpecification => { // useMemo to cache calculated fillColor return useMemo(() => { @@ -203,7 +261,13 @@ export const useFillColor = ({ } const isCount = areaStats?.calculationType === CalculationType.Count; - const colorScheme = getColorScheme({ areaStats, scheme, isReversed }); + const colorScheme = getColorScheme({ + areaStats, + scheme, + isReversed, + categoryColors, + customColor, + }); if (!colorScheme) { return DEFAULT_FILL_COLOR; } @@ -219,7 +283,16 @@ export const useFillColor = ({ return ["match", ["feature-state", "value"], ...ordinalColorStops]; } - // ColumnType.Number + // ColumnType.Number - Check if stepped colors are enabled + if ( + colorScaleType === ColorScaleType.Stepped && + steppedColorSteps && + steppedColorSteps.length > 0 + ) { + return getSteppedFillColor(steppedColorSteps, isCount); + } + + // ColumnType.Number - Gradient (default) if (colorScheme.isSingleValue) { // When all values are the same, map the value to our 0-1 range // This ensures count data is visible even when all counts are equal @@ -257,7 +330,54 @@ export const useFillColor = ({ : ["feature-state", "value"], ...interpolateColorStops, ]; - }, [areaStats, isReversed, scheme, selectedBivariateBucket]); + }, [ + areaStats, + isReversed, + scheme, + selectedBivariateBucket, + categoryColors, + colorScaleType, + steppedColorSteps, + customColor, + ]); +}; + +const getSteppedFillColor = ( + steps: SteppedColorStep[], + isCount: boolean, +): DataDrivenPropertyValueSpecification => { + // Sort steps by start value to ensure correct order + const sortedSteps = [...steps].sort((a, b) => a.start - b.start); + + if (sortedSteps.length === 0) { + return DEFAULT_FILL_COLOR; + } + + // Build a step expression: ["step", input, default, threshold1, color1, threshold2, color2, ...] + // Mapbox step expression: if value < threshold1, use default, else if value < threshold2, use color1, etc. + // For stepped colors, we want: if value < step1.start, use step1.color (or default) + // if step1.start <= value < step2.start, use step1.color + // if step2.start <= value < step3.start, use step2.color + // etc. + + const stepExpression: DataDrivenPropertyValueSpecification = [ + "step", + isCount + ? ["coalesce", ["feature-state", "value"], 0] + : ["feature-state", "value"], + sortedSteps[0]?.color || DEFAULT_FILL_COLOR, // Default color for values < first threshold + ]; + + // Add thresholds and colors + // For each step after the first, use its start value as the threshold + // The color applies to values >= threshold + for (let i = 1; i < sortedSteps.length; i++) { + const step = sortedSteps[i]; + stepExpression.push(step.start); // Threshold + stepExpression.push(step.color); // Color for values >= threshold + } + + return stepExpression; }; const getBivariateFillColor = ( diff --git a/src/app/map/[id]/components/AreaInfo.tsx b/src/app/map/[id]/components/AreaInfo.tsx index c136bf1b..ab89bab8 100644 --- a/src/app/map/[id]/components/AreaInfo.tsx +++ b/src/app/map/[id]/components/AreaInfo.tsx @@ -87,6 +87,10 @@ export default function AreaInfo() { scheme: viewConfig.colorScheme || ColorScheme.RedBlue, isReversed: Boolean(viewConfig.reverseColorScheme), selectedBivariateBucket: null, + categoryColors: viewConfig.categoryColors, + colorScaleType: viewConfig.colorScaleType, + steppedColorSteps: viewConfig.steppedColorSteps, + customColor: viewConfig.customColor, }); // Combine selected areas and hover area, avoiding duplicates diff --git a/src/app/map/[id]/components/Choropleth/areas.ts b/src/app/map/[id]/components/Choropleth/areas.ts index 3c6ee8a3..af3e82fd 100644 --- a/src/app/map/[id]/components/Choropleth/areas.ts +++ b/src/app/map/[id]/components/Choropleth/areas.ts @@ -9,7 +9,7 @@ export const getValidAreaSetGroupCodes = ( dataSourceGeocodingConfig: GeocodingConfig | null | undefined, ): AreaSetGroupCode[] => { if (!dataSourceGeocodingConfig) { - return []; + return Object.values(AreaSetGroupCode); } const areaSetCode = diff --git a/src/app/map/[id]/components/Choropleth/index.tsx b/src/app/map/[id]/components/Choropleth/index.tsx index bebcd604..8d82915d 100644 --- a/src/app/map/[id]/components/Choropleth/index.tsx +++ b/src/app/map/[id]/components/Choropleth/index.tsx @@ -5,6 +5,7 @@ import { MapType } from "@/server/models/MapView"; import { mapColors } from "../../styles"; import { getMapStyle } from "../../utils/map"; import { useChoroplethAreaStats } from "./useChoroplethAreaStats"; +import { useChoroplethFeatureStatesEffect } from "./useChoroplethFeatureStatesEffect"; export default function Choropleth() { const { viewConfig } = useMapViews(); @@ -19,6 +20,8 @@ export default function Choropleth() { const fillColor = useChoroplethAreaStats(); const opacity = (viewConfig.choroplethOpacityPct ?? 80) / 100; + useChoroplethFeatureStatesEffect(); + return ( <> {/* Position layer */} diff --git a/src/app/map/[id]/components/Choropleth/useChoroplethAreaStats.ts b/src/app/map/[id]/components/Choropleth/useChoroplethAreaStats.ts index 61a9a2e7..1aaabaea 100644 --- a/src/app/map/[id]/components/Choropleth/useChoroplethAreaStats.ts +++ b/src/app/map/[id]/components/Choropleth/useChoroplethAreaStats.ts @@ -1,104 +1,27 @@ -import { useEffect, useRef } from "react"; import { useFillColor } from "@/app/map/[id]/colors"; import { useAreaStats } from "@/app/map/[id]/data"; import { useChoropleth } from "@/app/map/[id]/hooks/useChoropleth"; import { useMapViews } from "@/app/map/[id]/hooks/useMapViews"; import { ColorScheme } from "@/server/models/MapView"; -import { useMapRef } from "../../hooks/useMapCore"; export function useChoroplethAreaStats() { - const mapRef = useMapRef(); - const { choroplethLayerConfig, lastLoadedSourceId, selectedBivariateBucket } = - useChoropleth(); - const { - mapbox: { sourceId, layerId }, - } = choroplethLayerConfig; + const { selectedBivariateBucket } = useChoropleth(); const { viewConfig } = useMapViews(); const areaStatsQuery = useAreaStats(); const areaStats = areaStatsQuery.data; - // Keep track of area codes that have feature state, to clean if necessary - const areaCodesToClean = useRef>({}); - // Track previous values to avoid re-setting feature state for unchanged areas - const prevAreaStatValues = useRef< - Map - >(new Map()); - // Get fill color const fillColor = useFillColor({ areaStats, scheme: viewConfig.colorScheme || ColorScheme.RedBlue, isReversed: Boolean(viewConfig.reverseColorScheme), selectedBivariateBucket, + categoryColors: viewConfig.categoryColors, + colorScaleType: viewConfig.colorScaleType, + steppedColorSteps: viewConfig.steppedColorSteps, + customColor: viewConfig.customColor, }); - useEffect(() => { - const map = mapRef?.current; - if (!areaStats || !map) { - return; - } - - // Check if the source exists before proceeding - const source = map.getSource(sourceId); - if (!source) { - return; - } - - const nextAreaCodesToClean: Record = {}; - const nextStatValues = new Map< - string, - { primary: unknown; secondary: unknown } - >(); - - // Only set feature state when the values actually change to avoid expensive re-renders - areaStats.stats.forEach((stat) => { - const key = stat.areaCode; - const prev = prevAreaStatValues.current.get(key); - const next = { - primary: - stat.primary !== null && stat.primary !== undefined - ? stat.primary - : null, - secondary: - stat.secondary !== null && stat.secondary !== undefined - ? stat.secondary - : null, - }; - nextStatValues.set(key, next); - - if ( - !prev || - prev.primary !== next.primary || - prev.secondary !== next.secondary - ) { - map.setFeatureState( - { - source: sourceId, - sourceLayer: layerId, - id: stat.areaCode, - }, - { value: stat.primary, secondaryValue: stat.secondary }, - ); - } - - nextAreaCodesToClean[stat.areaCode] = true; - }); - - // Remove lingering feature states for areas no longer present - for (const areaCode of Object.keys(areaCodesToClean.current)) { - if (!nextAreaCodesToClean[areaCode]) { - map.removeFeatureState({ - source: sourceId, - sourceLayer: layerId, - id: areaCode, - }); - } - } - - areaCodesToClean.current = nextAreaCodesToClean; - prevAreaStatValues.current = nextStatValues; - }, [areaStats, lastLoadedSourceId, layerId, mapRef, sourceId]); - return fillColor; } diff --git a/src/app/map/[id]/components/Choropleth/useChoroplethFeatureStatesEffect.ts b/src/app/map/[id]/components/Choropleth/useChoroplethFeatureStatesEffect.ts new file mode 100644 index 00000000..dc7decd5 --- /dev/null +++ b/src/app/map/[id]/components/Choropleth/useChoroplethFeatureStatesEffect.ts @@ -0,0 +1,97 @@ +import { useEffect, useRef } from "react"; +import { useAreaStats } from "@/app/map/[id]/data"; +import { useChoropleth } from "@/app/map/[id]/hooks/useChoropleth"; +import { useMapRef } from "../../hooks/useMapCore"; + +export function useChoroplethFeatureStatesEffect() { + const mapRef = useMapRef(); + const { choroplethLayerConfig, lastLoadedSourceId } = useChoropleth(); + const { + mapbox: { sourceId, layerId }, + } = choroplethLayerConfig; + + const areaStatsQuery = useAreaStats(); + const areaStats = areaStatsQuery.data; + + // Keep track of area codes that have feature state, to clean if necessary + const areaCodesToClean = useRef>({}); + // Track previous values to avoid re-setting feature state for unchanged areas + const prevAreaStatValues = useRef< + Map + >(new Map()); + + useEffect(() => { + const map = mapRef?.current; + if (!areaStats || !map) { + return; + } + + // Check if the source exists before proceeding + const source = map.getSource(sourceId); + if (!source) { + return; + } + + const nextAreaCodesToClean: Record = {}; + const nextStatValues = new Map< + string, + { primary: unknown; secondary: unknown } + >(); + + // Only set feature state when the values actually change to avoid expensive re-renders + areaStats.stats.forEach((stat) => { + const key = stat.areaCode; + const prev = prevAreaStatValues.current.get(key); + const next = { + primary: + stat.primary !== null && stat.primary !== undefined + ? stat.primary + : null, + secondary: + stat.secondary !== null && stat.secondary !== undefined + ? stat.secondary + : null, + }; + nextStatValues.set(key, next); + + if ( + !prev || + prev.primary !== next.primary || + prev.secondary !== next.secondary + ) { + map.setFeatureState( + { + source: sourceId, + sourceLayer: layerId, + id: stat.areaCode, + }, + { value: stat.primary, secondaryValue: stat.secondary }, + ); + } + + nextAreaCodesToClean[stat.areaCode] = true; + }); + + // Remove lingering feature states for areas no longer present + for (const areaCode of Object.keys(areaCodesToClean.current)) { + if (!nextAreaCodesToClean[areaCode]) { + map.setFeatureState( + { + source: sourceId, + sourceLayer: layerId, + id: areaCode, + }, + { value: null, secondaryValue: null }, + ); + } + } + + areaCodesToClean.current = nextAreaCodesToClean; + prevAreaStatValues.current = nextStatValues; + + return () => { + areaCodesToClean.current = {}; + prevAreaStatValues.current = new Map(); + }; + }, [areaStats, lastLoadedSourceId, layerId, mapRef, sourceId]); +} diff --git a/src/app/map/[id]/components/LayerTypeIcon.tsx b/src/app/map/[id]/components/LayerTypeIcon.tsx index 8cf1cb9a..efdd197e 100644 --- a/src/app/map/[id]/components/LayerTypeIcon.tsx +++ b/src/app/map/[id]/components/LayerTypeIcon.tsx @@ -1,8 +1,8 @@ import { + CircleIcon, LayoutDashboardIcon, - MapPinIcon, - SquareIcon, UsersIcon, + VectorSquareIcon, } from "lucide-react"; import { cn } from "@/shadcn/utils"; import { LayerType } from "@/types"; @@ -21,9 +21,9 @@ export default function LayerTypeIcon({ case LayerType.Member: return ; case LayerType.Marker: - return ; + return ; case LayerType.Turf: - return ; + return ; case LayerType.Boundary: return ; default: diff --git a/src/app/map/[id]/components/Legend.tsx b/src/app/map/[id]/components/Legend.tsx index b43692fc..8e948af9 100644 --- a/src/app/map/[id]/components/Legend.tsx +++ b/src/app/map/[id]/components/Legend.tsx @@ -1,43 +1,161 @@ -import { Database } from "lucide-react"; +import { ChevronRight, Eye, EyeOff, LoaderPinwheel } from "lucide-react"; +import { useChoropleth } from "@/app/map/[id]/hooks/useChoropleth"; import { useChoroplethDataSource } from "@/app/map/[id]/hooks/useDataSources"; import { useMapViews } from "@/app/map/[id]/hooks/useMapViews"; import { MAX_COLUMN_KEY } from "@/constants"; import { ColumnType } from "@/server/models/DataSource"; -import { CalculationType, ColorScheme } from "@/server/models/MapView"; import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuLabel, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from "@/shadcn/ui/dropdown-menu"; -import { Switch } from "@/shadcn/ui/switch"; -import { cn } from "@/shadcn/utils"; + CalculationType, + ColorScaleType, + ColorScheme, +} from "@/server/models/MapView"; import { formatNumber } from "@/utils/text"; -import { CHOROPLETH_COLOR_SCHEMES, useColorScheme } from "../colors"; +import { useColorScheme } from "../colors"; import { useAreaStats } from "../data"; import BivariateLegend from "./BivariateLagend"; export default function Legend() { const { viewConfig, updateViewConfig } = useMapViews(); const dataSource = useChoroplethDataSource(); + const { setBoundariesPanelOpen } = useChoropleth(); const areaStatsQuery = useAreaStats(); const areaStats = areaStatsQuery?.data; + const isLoading = areaStatsQuery?.isFetching; const colorScheme = useColorScheme({ areaStats, scheme: viewConfig.colorScheme || ColorScheme.RedBlue, isReversed: Boolean(viewConfig.reverseColorScheme), + categoryColors: viewConfig.categoryColors, + customColor: viewConfig.customColor, }); - if (!colorScheme) { + + const isLayerVisible = viewConfig.showChoropleth !== false; + + const toggleLayerVisibility = () => { + updateViewConfig({ showChoropleth: !isLayerVisible }); + }; + + const hasDataSource = Boolean(viewConfig.areaDataSourceId); + const hasColumn = Boolean( + viewConfig.areaDataColumn || + viewConfig.calculationType === CalculationType.Count, + ); + const isBivariate = + areaStats?.calculationType !== CalculationType.Count && + viewConfig.areaDataColumn && + viewConfig.areaDataSecondaryColumn; + + if (!hasDataSource) { return null; } + const getColumnLabel = () => { + if (!hasColumn) { + return "No column selected"; + } + if (viewConfig.areaDataColumn === MAX_COLUMN_KEY) { + return "Highest-value column"; + } + if (viewConfig.calculationType === CalculationType.Count) { + return "Count"; + } + if (viewConfig.areaDataSecondaryColumn) { + return `${viewConfig.areaDataColumn} vs ${viewConfig.areaDataSecondaryColumn}`; + } + return viewConfig.areaDataColumn; + }; + const makeBars = () => { - let bars; + if (!colorScheme) return null; + if (colorScheme.columnType === ColumnType.Number) { + // Handle stepped colors + if ( + viewConfig.colorScaleType === ColorScaleType.Stepped && + viewConfig.steppedColorSteps && + viewConfig.steppedColorSteps.length > 0 + ) { + const sortedSteps = [...viewConfig.steppedColorSteps].sort( + (a, b) => a.start - b.start, + ); + const range = colorScheme.maxValue - colorScheme.minValue; + + // Collect all unique boundary positions + const boundaries = new Set(); + boundaries.add(colorScheme.minValue); + sortedSteps.forEach((step) => { + boundaries.add(step.start); + boundaries.add(step.end); + }); + boundaries.add(colorScheme.maxValue); + const sortedBoundaries = Array.from(boundaries).sort((a, b) => a - b); + + return ( +
+
+ {sortedSteps.map((step, index) => { + const stepStart = Math.max(step.start, colorScheme.minValue); + const stepEnd = + index < sortedSteps.length - 1 + ? sortedSteps[index + 1].start + : Math.min(step.end, colorScheme.maxValue); + const width = + range > 0 + ? ((stepEnd - stepStart) / range) * 100 + : 100 / sortedSteps.length; + return ( +
+ ); + })} +
+
+ {sortedBoundaries.map((boundary, index) => { + const isFirst = index === 0; + const isLast = index === sortedBoundaries.length - 1; + const position = + range > 0 + ? ((boundary - colorScheme.minValue) / range) * 100 + : (index / (sortedBoundaries.length - 1)) * 100; + + return ( +
+
+ {formatNumber(boundary)} +
+
+ ); + })} +
+
+ ); + } + + // Handle gradient colors (default) const numStops = 24; const stops = new Array(numStops + 1) .fill(null) @@ -54,7 +172,7 @@ export default function Legend() { const numTicks = 5 as number; // number of numeric step labels const denom = Math.max(numTicks - 1, 1); - bars = ( + return (
); } else { - bars = Object.keys(colorScheme.colorMap) - .toSorted() - .map((key) => ( -
- {key} -
- )); + // Filter to only show categories that actually appear in the data + const categoriesInData = new Set( + areaStats?.stats + .map((stat) => String(stat.primary)) + .filter((v) => v && v !== "null" && v !== "undefined"), + ); + + return ( +
+ {Object.keys(colorScheme.colorMap) + .filter((key) => categoriesInData.has(key)) + .toSorted() + .map((key) => ( +
+
+ {key} +
+ ))} +
+ ); } - return bars; }; + const VisibilityToggle = () => ( +
+
{ + e.stopPropagation(); + toggleLayerVisibility(); + }} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + e.stopPropagation(); + toggleLayerVisibility(); + } + }} + > + {isLayerVisible ? : } +
+
+ ); + return ( -
-

- - Locality Data Legend -

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

+

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

-

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

-
{makeBars()}
+ +

{getColumnLabel()}

- - {areaStats?.primary?.columnType === ColumnType.Number && ( - - Choose colour scheme - - {CHOROPLETH_COLOR_SCHEMES.map((option, index) => ( - - updateViewConfig({ colorScheme: option.value }) - } - className="flex items-center gap-2" - > -
- {option.label} - - ))} - -
- - updateViewConfig({ - reverseColorScheme: !viewConfig?.reverseColorScheme, - }) - } - onCheckedChange={() => - updateViewConfig({ - reverseColorScheme: !viewConfig?.reverseColorScheme, - }) - } - /> - -
- - )} - - )} + +
+ {isLoading ? ( +
+ +
+ ) : isBivariate ? ( +
e.stopPropagation()}> + +
+ ) : hasColumn && colorScheme ? ( +
{makeBars()}
+ ) : null} +
+
); } diff --git a/src/app/map/[id]/components/MapMarkerAndAreaControls.tsx b/src/app/map/[id]/components/MapMarkerAndAreaControls.tsx index 7ef79049..174aca24 100644 --- a/src/app/map/[id]/components/MapMarkerAndAreaControls.tsx +++ b/src/app/map/[id]/components/MapMarkerAndAreaControls.tsx @@ -1,5 +1,4 @@ -import { ChartBar, MapPin } from "lucide-react"; -import VectorSquare from "@/components/icons/VectorSquare"; +import { ChartBar, MapPin, VectorSquareIcon } from "lucide-react"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/shadcn/ui/tooltip"; import { useMapControls } from "../hooks/useMapControls"; import { useHandleDropPin } from "../hooks/usePlacedMarkers"; @@ -54,7 +53,7 @@ export default function MapMarkerAndAreaControls() { }`} onClick={handleAddAreaClick} > - + Add area diff --git a/src/app/map/[id]/components/MapWrapper.tsx b/src/app/map/[id]/components/MapWrapper.tsx index 042d920e..fc9e07fc 100644 --- a/src/app/map/[id]/components/MapWrapper.tsx +++ b/src/app/map/[id]/components/MapWrapper.tsx @@ -88,13 +88,12 @@ export default function MapWrapper({ {children}
diff --git a/src/app/map/[id]/components/controls/BoundariesControl/BoundariesControl.tsx b/src/app/map/[id]/components/controls/BoundariesControl/BoundariesControl.tsx index d92a1621..95ab120a 100644 --- a/src/app/map/[id]/components/controls/BoundariesControl/BoundariesControl.tsx +++ b/src/app/map/[id]/components/controls/BoundariesControl/BoundariesControl.tsx @@ -4,6 +4,7 @@ import { useChoropleth } from "@/app/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"; @@ -20,6 +21,7 @@ export default function BoundariesControl() { type={LayerType.Boundary} expanded={expanded} setExpanded={setExpanded} + enableVisibilityToggle={hasDataSource} > */} + {!hasDataSource && ( + setBoundariesPanelOpen(true)} + showAsButton + /> + )} {hasDataSource && }
)} diff --git a/src/app/map/[id]/components/controls/BoundariesControl/LegendControl.tsx b/src/app/map/[id]/components/controls/BoundariesControl/LegendControl.tsx index 683a8a6b..ebe76a43 100644 --- a/src/app/map/[id]/components/controls/BoundariesControl/LegendControl.tsx +++ b/src/app/map/[id]/components/controls/BoundariesControl/LegendControl.tsx @@ -1,16 +1,11 @@ -import { Eye } from "lucide-react"; import Legend from "@/app/map/[id]/components/Legend"; import { useMapViews } from "@/app/map/[id]/hooks/useMapViews"; import { useBoundariesControl } from "./useBoundariesControl"; export default function LegendControl() { - const { viewConfig, updateViewConfig } = useMapViews(); + const { viewConfig } = useMapViews(); const { hasShape } = useBoundariesControl(); - const toggleChoropleth = () => { - updateViewConfig({ showChoropleth: !viewConfig.showChoropleth }); - }; - return (
-
diff --git a/src/app/map/[id]/components/controls/DataSourceItem.tsx b/src/app/map/[id]/components/controls/DataSourceItem.tsx index bad6a92f..01f5c021 100644 --- a/src/app/map/[id]/components/controls/DataSourceItem.tsx +++ b/src/app/map/[id]/components/controls/DataSourceItem.tsx @@ -1,6 +1,31 @@ +"use client"; + +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { EyeIcon, EyeOffIcon, PencilIcon, TrashIcon } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; +import { toast } from "sonner"; +import ContextMenuContentWithFocus from "@/components/ContextMenuContentWithFocus"; import DataSourceIcon from "@/components/DataSourceIcon"; +import { useTRPC } from "@/services/trpc/react"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/shadcn/ui/alert-dialog"; +import { + ContextMenu, + ContextMenuItem, + ContextMenuSeparator, + ContextMenuTrigger, +} from "@/shadcn/ui/context-menu"; import { LayerType } from "@/types"; import { useLayers } from "../../hooks/useLayers"; +import { useMapConfig } from "../../hooks/useMapConfig"; import { mapColors } from "../../styles"; import ControlWrapper from "./ControlWrapper"; import type { DataSourceType } from "@/server/models/DataSource"; @@ -23,6 +48,15 @@ export default function DataSourceItem({ layerType: LayerType; }) { const { setDataSourceVisibility, getDataSourceVisibility } = useLayers(); + const { mapConfig, updateMapConfig } = useMapConfig(); + const trpc = useTRPC(); + const queryClient = useQueryClient(); + const [isRenaming, setIsRenaming] = useState(false); + const [editName, setEditName] = useState(dataSource.name); + const [showRemoveDialog, setShowRemoveDialog] = useState(false); + const inputRef = useRef(null); + const isFocusing = useRef(false); + const layerColor = layerType === LayerType.Member ? mapColors.member.color @@ -30,42 +64,189 @@ export default function DataSourceItem({ const isVisible = getDataSourceVisibility(dataSource?.id); + // Focus management for rename input + useEffect(() => { + if (isRenaming) { + isFocusing.current = true; + setTimeout(() => { + inputRef.current?.focus(); + inputRef.current?.select(); + }, 10); + setTimeout(() => { + isFocusing.current = false; + }, 500); + } + }, [isRenaming]); + + // Update editName when dataSource.name changes + useEffect(() => { + setEditName(dataSource.name); + }, [dataSource.name]); + + // Update name mutation + const { mutate: updateName } = useMutation( + trpc.dataSource.updateConfig.mutationOptions({ + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: trpc.dataSource.listReadable.queryKey(), + }); + toast.success("Data source renamed successfully"); + setIsRenaming(false); + }, + onError: () => { + toast.error("Failed to rename data source"); + setEditName(dataSource.name); // Reset on error + }, + }), + ); + + const handleSaveRename = () => { + if (editName.trim() && editName !== dataSource.name) { + updateName({ + dataSourceId: dataSource.id, + name: editName.trim(), + }); + } else { + setEditName(dataSource.name); + setIsRenaming(false); + } + }; + + const handleRemoveFromMap = () => { + if (layerType === LayerType.Member) { + // Remove members data source + updateMapConfig({ membersDataSourceId: null }); + toast.success("Data source removed from map"); + } else if (layerType === LayerType.Marker) { + // Remove from marker data sources array + updateMapConfig({ + markerDataSourceIds: mapConfig.markerDataSourceIds.filter( + (id) => id !== dataSource.id, + ), + }); + toast.success("Data source removed from map"); + } + setShowRemoveDialog(false); + }; + return ( - - setDataSourceVisibility(dataSource?.id, !isVisible) - } - > - + + + setIsRenaming(true)}> + + Rename + + setDataSourceVisibility(dataSource.id, !isVisible)} + > + {isVisible ? ( + <> + + Hide + ) : ( -

No records

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

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

+ <> + + Show + )} -
-
-
- - + + + setShowRemoveDialog(true)} + > + + Remove from map + + + + + + + + + Remove data source from map? + + This will remove "{dataSource.name}" from this map. The + data source will not be deleted and can be added back later. + + + + Cancel + + Remove from map + + + + + ); } diff --git a/src/app/map/[id]/components/controls/LayerEmptyMessage.tsx b/src/app/map/[id]/components/controls/LayerEmptyMessage.tsx index d459294f..4bc158c3 100644 --- a/src/app/map/[id]/components/controls/LayerEmptyMessage.tsx +++ b/src/app/map/[id]/components/controls/LayerEmptyMessage.tsx @@ -1,14 +1,92 @@ -import { CornerRightUp } from "lucide-react"; +import { CornerRightUp, PlusIcon } from "lucide-react"; import React from "react"; +import MultiDropdownMenu from "@/components/MultiDropdownMenu"; +import { Button } from "@/shadcn/ui/button"; +import { cn } from "@/shadcn/utils"; +import type { DropdownMenuItemType } from "@/components/MultiDropdownMenu"; + +// Shared button content component to ensure consistent styling +const AddLayerButtonContent = ({ message }: { message: React.ReactNode }) => ( +
+ {message} +
+); + +// Shared button styling classes - must match exactly +const sharedButtonClasses = + "text-sm text-neutral-400 w-full hover:text-neutral-600 hover:bg-neutral-50 transition-colors"; + +// Shared button wrapper component that both cases use +const AddLayerButton = ({ + message, + onClick, + dropdownItems, +}: { + message: React.ReactNode; + onClick?: () => void; + dropdownItems?: DropdownMenuItemType[]; +}) => { + const buttonContent = ; + + if (dropdownItems) { + return ( + + {buttonContent} + + ); + } + + return ( + + ); +}; export default function LayerEmptyMessage({ message, + onClick, + dropdownItems, + showAsButton = false, }: { message: React.ReactNode; + onClick?: () => void; + dropdownItems?: DropdownMenuItemType[]; + showAsButton?: boolean; }) { + if (dropdownItems || showAsButton) { + return ( + + ); + } + + const Component = onClick ? "button" : "div"; return ( -
- {message} -
+ + {message} + ); } diff --git a/src/app/map/[id]/components/controls/LayerHeader.tsx b/src/app/map/[id]/components/controls/LayerHeader.tsx index d4198445..ff34e373 100644 --- a/src/app/map/[id]/components/controls/LayerHeader.tsx +++ b/src/app/map/[id]/components/controls/LayerHeader.tsx @@ -37,7 +37,10 @@ export default function LayerHeader({ }; return ( -
+
- + + + + + + + + Rename + + + {isFolderVisible ? ( + <> + + Hide + + ) : ( + <> + + Show + + )} + + + setShowDeleteDialog(true)} + > + + Delete + + + )} @@ -199,6 +239,13 @@ export default function SortableFolderItem({ /> )} + +
); } diff --git a/src/app/map/[id]/components/controls/MarkersControl/SortableMarkerItem.tsx b/src/app/map/[id]/components/controls/MarkersControl/SortableMarkerItem.tsx index 5fe0017c..44bfbc7b 100644 --- a/src/app/map/[id]/components/controls/MarkersControl/SortableMarkerItem.tsx +++ b/src/app/map/[id]/components/controls/MarkersControl/SortableMarkerItem.tsx @@ -1,6 +1,18 @@ +"use client"; + import { useSortable } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; -import { useState } from "react"; +import { EyeIcon, EyeOffIcon, PencilIcon, TrashIcon } from "lucide-react"; +import { useEffect, useState } from "react"; +import { toast } from "sonner"; +import DeleteConfirmationDialog from "@/components/DeleteConfirmationDialog"; +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuSeparator, + ContextMenuTrigger, +} from "@/shadcn/ui/context-menu"; import { LayerType } from "@/types"; import { useMapRef } from "../../../hooks/useMapCore"; import { @@ -8,7 +20,6 @@ import { usePlacedMarkerState, } from "../../../hooks/usePlacedMarkers"; import ControlEditForm from "../ControlEditForm"; -import ControlHoverMenu from "../ControlHoverMenu"; import ControlWrapper from "../ControlWrapper"; import type { PlacedMarker } from "@/server/models/PlacedMarker"; @@ -39,6 +50,7 @@ export default function SortableMarkerItem({ const mapRef = useMapRef(); const [isEditing, setEditing] = useState(false); const [editText, setEditText] = useState(marker.label); + const [showDeleteDialog, setShowDeleteDialog] = useState(false); // Check if this marker is the one being dragged (even outside its container) const isCurrentlyDragging = isDragging || activeId === `marker-${marker.id}`; @@ -64,6 +76,11 @@ export default function SortableMarkerItem({ }); }; + // Update editText when marker.label changes + useEffect(() => { + setEditText(marker.label); + }, [marker.label]); + const onEdit = () => { setEditText(marker.label); setEditing(true); @@ -71,50 +88,104 @@ export default function SortableMarkerItem({ }; const onSubmit = () => { - updatePlacedMarker({ - ...marker, - label: editText, - }); + if (editText.trim() && editText !== marker.label) { + updatePlacedMarker({ + ...marker, + label: editText.trim(), + }); + toast.success("Marker renamed successfully"); + } setEditing(false); setKeyboardCapture(false); }; + const handleDelete = () => { + deletePlacedMarker(marker.id); + setShowDeleteDialog(false); + toast.success("Marker deleted successfully"); + }; + return ( -
- - setPlacedMarkerVisibility(marker.id, !isVisible) - } + <> +
- {isEditing ? ( - - ) : ( - onEdit()} - onDelete={() => deletePlacedMarker(marker.id)} - > - - - )} - -
+ + setPlacedMarkerVisibility(marker.id, !isVisible) + } + > + {isEditing ? ( + + ) : ( + + + + + + + + Rename + + + setPlacedMarkerVisibility(marker.id, !isVisible) + } + > + {isVisible ? ( + <> + + Hide + + ) : ( + <> + + Show + + )} + + + setShowDeleteDialog(true)} + > + + Delete + + + + )} + +
+ + + ); } diff --git a/src/app/map/[id]/components/controls/PrivateMapControls.tsx b/src/app/map/[id]/components/controls/PrivateMapControls.tsx index 0a4449b8..7144b670 100644 --- a/src/app/map/[id]/components/controls/PrivateMapControls.tsx +++ b/src/app/map/[id]/components/controls/PrivateMapControls.tsx @@ -9,7 +9,6 @@ import { CONTROL_PANEL_WIDTH } from "../../styles"; import BoundariesControl from "./BoundariesControl/BoundariesControl"; import MarkersControl from "./MarkersControl/MarkersControl"; -import MembersControl from "./MembersControl/MembersControl"; import TurfsControl from "./TurfsControl/TurfsControl"; export default function PrivateMapControls() { @@ -67,7 +66,6 @@ export default function PrivateMapControls() { > {viewConfig.mapType !== MapType.Hex && ( <> - diff --git a/src/app/map/[id]/components/controls/TurfsControl/TurfItem.tsx b/src/app/map/[id]/components/controls/TurfsControl/TurfItem.tsx index 4dd9522c..f130dc4b 100644 --- a/src/app/map/[id]/components/controls/TurfsControl/TurfItem.tsx +++ b/src/app/map/[id]/components/controls/TurfsControl/TurfItem.tsx @@ -1,5 +1,17 @@ +"use client"; + import * as turfLib from "@turf/turf"; -import { useState } from "react"; +import { EyeIcon, EyeOffIcon, PencilIcon, TrashIcon } from "lucide-react"; +import { useEffect, useState } from "react"; +import { toast } from "sonner"; +import DeleteConfirmationDialog from "@/components/DeleteConfirmationDialog"; +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuSeparator, + ContextMenuTrigger, +} from "@/shadcn/ui/context-menu"; import { LayerType } from "@/types"; import { useShowControls } from "../../../hooks/useMapControls"; import { useMapRef } from "../../../hooks/useMapCore"; @@ -7,7 +19,6 @@ import { useTurfMutations } from "../../../hooks/useTurfMutations"; import { useTurfState } from "../../../hooks/useTurfState"; import { CONTROL_PANEL_WIDTH } from "../../../styles"; import ControlEditForm from "../ControlEditForm"; -import ControlHoverMenu from "../ControlHoverMenu"; import ControlWrapper from "../ControlWrapper"; import type { Turf } from "@/server/models/Turf"; @@ -19,6 +30,7 @@ export default function TurfItem({ turf }: { turf: Turf }) { const [isEditing, setEditing] = useState(false); const [editText, setEditText] = useState(turf.label); + const [showDeleteDialog, setShowDeleteDialog] = useState(false); const handleFlyTo = (turf: Turf) => { const map = mapRef?.current; @@ -47,41 +59,93 @@ export default function TurfItem({ turf }: { turf: Turf }) { const isVisible = getTurfVisibility(turf.id); + // Update editText when turf.label changes + useEffect(() => { + setEditText(turf.label); + }, [turf.label]); + const onEdit = () => { + setEditText(turf.label); setEditing(true); }; const onSubmit = () => { - updateTurf({ ...turf, label: editText }); + if (editText.trim() && editText !== turf.label) { + updateTurf({ ...turf, label: editText.trim() }); + toast.success("Area renamed successfully"); + } setEditing(false); }; + const handleDelete = () => { + deleteTurf(turf.id); + setShowDeleteDialog(false); + toast.success("Area deleted successfully"); + }; + return ( - setTurfVisibility(turf.id, !isVisible)} - > - {isEditing ? ( - - ) : ( - deleteTurf(turf.id)} - onEdit={() => onEdit()} - > - - - )} - + <> + setTurfVisibility(turf.id, !isVisible)} + > + {isEditing ? ( + + ) : ( + + + + + + + + Rename + + setTurfVisibility(turf.id, !isVisible)} + > + {isVisible ? ( + <> + + Hide + + ) : ( + <> + + Show + + )} + + + setShowDeleteDialog(true)} + > + + Delete + + + + )} + + + + ); } diff --git a/src/app/map/[id]/components/controls/TurfsControl/TurfsControl.tsx b/src/app/map/[id]/components/controls/TurfsControl/TurfsControl.tsx index be00020d..df06a96b 100644 --- a/src/app/map/[id]/components/controls/TurfsControl/TurfsControl.tsx +++ b/src/app/map/[id]/components/controls/TurfsControl/TurfsControl.tsx @@ -51,9 +51,13 @@ export default function AreasControl() { {expanded && ( -
+
{turfs && turfs.length === 0 && ( - + )}
    {turfs.map((turf) => ( diff --git a/src/app/map/[id]/components/controls/VisualisationPanel/CategoryColorEditor.tsx b/src/app/map/[id]/components/controls/VisualisationPanel/CategoryColorEditor.tsx new file mode 100644 index 00000000..923dceae --- /dev/null +++ b/src/app/map/[id]/components/controls/VisualisationPanel/CategoryColorEditor.tsx @@ -0,0 +1,142 @@ +import { X } from "lucide-react"; +import { useMemo, useState } from "react"; +import { useColorScheme } from "@/app/map/[id]/colors"; +import { useAreaStats } from "@/app/map/[id]/data"; +import { useMapViews } from "@/app/map/[id]/hooks/useMapViews"; +import { ColumnType } from "@/server/models/DataSource"; +import { ColorScheme } from "@/server/models/MapView"; +import { Button } from "@/shadcn/ui/button"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/shadcn/ui/dialog"; + +export default function CategoryColorEditor() { + const { viewConfig, updateViewConfig } = useMapViews(); + const areaStatsQuery = useAreaStats(); + const areaStats = areaStatsQuery?.data; + const [isOpen, setIsOpen] = useState(false); + + const colorScheme = useColorScheme({ + areaStats, + scheme: viewConfig.colorScheme || ColorScheme.RedBlue, + isReversed: Boolean(viewConfig.reverseColorScheme), + categoryColors: viewConfig.categoryColors, + customColor: viewConfig.customColor, + }); + + // Get unique categories from areaStats + const categories = useMemo(() => { + if ( + !areaStats || + !colorScheme || + colorScheme.columnType === ColumnType.Number + ) { + return []; + } + return Object.keys(colorScheme.colorMap).sort(); + }, [areaStats, colorScheme]); + + const handleColorChange = (category: string, color: string) => { + const currentColors = viewConfig.categoryColors || {}; + updateViewConfig({ + categoryColors: { + ...currentColors, + [category]: color, + }, + }); + }; + + const handleResetColor = (category: string) => { + const currentColors = viewConfig.categoryColors || {}; + const newColors = Object.fromEntries( + Object.entries(currentColors).filter(([key]) => key !== category), + ); + updateViewConfig({ + categoryColors: Object.keys(newColors).length > 0 ? newColors : undefined, + }); + }; + + if ( + !colorScheme || + colorScheme.columnType === ColumnType.Number || + categories.length === 0 + ) { + return null; + } + + return ( + + + + + + + Set category colors + +
    + {categories.map((category) => { + const currentColor = + viewConfig.categoryColors?.[category] || + colorScheme.colorMap[category]; + return ( +
    +
    + +
    + {viewConfig.categoryColors?.[category] && ( + + )} +
    + ); + })} + {Object.keys(viewConfig.categoryColors || {}).length > 0 && ( +
    + +
    + )} +
    +
    +
    + ); +} diff --git a/src/app/map/[id]/components/controls/VisualisationPanel/SteppedColorEditor.tsx b/src/app/map/[id]/components/controls/VisualisationPanel/SteppedColorEditor.tsx new file mode 100644 index 00000000..f1fb7794 --- /dev/null +++ b/src/app/map/[id]/components/controls/VisualisationPanel/SteppedColorEditor.tsx @@ -0,0 +1,525 @@ +import { Plus, Trash2 } from "lucide-react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { getInterpolator } from "@/app/map/[id]/colors"; +import { useAreaStats } from "@/app/map/[id]/data"; +import { useMapViews } from "@/app/map/[id]/hooks/useMapViews"; +import { ColumnType } from "@/server/models/DataSource"; +import { + ColorScaleType, + ColorScheme, + type SteppedColorStep, +} from "@/server/models/MapView"; +import { Button } from "@/shadcn/ui/button"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/shadcn/ui/dialog"; + +// Custom multi-handle range slider +function RangeSlider({ + min, + max, + values, + onChange, + step, + color, + isFirst, + isLast, + className, +}: { + min: number; + max: number; + values: [number, number?]; + onChange: (values: [number, number?]) => void; + step: number; + color: string; + isFirst: boolean; + isLast: boolean; + className?: string; +}) { + const [minVal, maxVal] = values; + const sliderRef = useRef(null); + const [activeHandle, setActiveHandle] = useState<"min" | "max" | null>(null); + + const getPercentage = useCallback( + (value: number) => ((value - min) / (max - min)) * 100, + [min, max], + ); + + const getValueFromPosition = useCallback( + (clientX: number) => { + if (!sliderRef.current) return min; + const rect = sliderRef.current.getBoundingClientRect(); + const percentage = Math.max( + 0, + Math.min(1, (clientX - rect.left) / rect.width), + ); + const value = min + percentage * (max - min); + return Math.round(value / step) * step; + }, + [min, max, step], + ); + + const handleMouseDown = useCallback((handle: "min" | "max") => { + setActiveHandle(handle); + }, []); + + useEffect(() => { + if (!activeHandle) return; + + const handleMouseMove = (e: MouseEvent) => { + const newValue = getValueFromPosition(e.clientX); + + if (activeHandle === "min") { + const clampedMin = Math.max( + min, + Math.min(maxVal !== undefined ? maxVal - step : max, newValue), + ); + onChange([clampedMin, maxVal]); + } else if (activeHandle === "max" && maxVal !== undefined) { + const clampedMax = Math.max(minVal + step, Math.min(max, newValue)); + onChange([minVal, clampedMax]); + } + }; + + const handleMouseUp = () => { + setActiveHandle(null); + }; + + window.addEventListener("mousemove", handleMouseMove); + window.addEventListener("mouseup", handleMouseUp); + + return () => { + window.removeEventListener("mousemove", handleMouseMove); + window.removeEventListener("mouseup", handleMouseUp); + }; + }, [ + activeHandle, + min, + max, + minVal, + maxVal, + step, + onChange, + getValueFromPosition, + ]); + + const minPercent = getPercentage(minVal); + const maxPercent = maxVal !== undefined ? getPercentage(maxVal) : 100; + + return ( +
    + {/* Background track */} +
    + + {/* Active range */} +
    + + {/* Min handle (only if not first step) */} + {!isFirst && ( +
    { + e.preventDefault(); + handleMouseDown("min"); + }} + /> + )} + + {/* Max handle (only if not last step) */} + {!isLast && ( +
    { + e.preventDefault(); + handleMouseDown("max"); + }} + /> + )} +
    + ); +} + +export default function SteppedColorEditor() { + const { viewConfig, updateViewConfig } = useMapViews(); + const areaStatsQuery = useAreaStats(); + const areaStats = areaStatsQuery?.data; + const [isOpen, setIsOpen] = useState(false); + const [localSteps, setLocalSteps] = useState([]); + + const minValue = areaStats?.primary?.minValue ?? 0; + const maxValue = areaStats?.primary?.maxValue ?? 0; + const colorScheme = viewConfig.colorScheme || ColorScheme.RedBlue; + const isReversed = Boolean(viewConfig.reverseColorScheme); + + // Get step ranges (without colors) from config or defaults + const stepRanges = useMemo(() => { + if (minValue === 0 && maxValue === 0) { + return []; + } + if ( + viewConfig.steppedColorSteps && + viewConfig.steppedColorSteps.length > 0 + ) { + const ranges = viewConfig.steppedColorSteps.map((s) => ({ + start: s.start, + end: s.end, + })); + + // Ensure boundaries are connected + for (let i = 0; i < ranges.length - 1; i++) { + ranges[i].end = ranges[i + 1].start; + } + + // Ensure first starts at minValue and last ends at maxValue + if (ranges.length > 0) { + ranges[0].start = minValue; + ranges[ranges.length - 1].end = maxValue; + } + + return ranges; + } + // Default: 3 steps evenly distributed + const stepSize = (maxValue - minValue) / 3; + return [ + { start: minValue, end: minValue + stepSize }, + { start: minValue + stepSize, end: minValue + stepSize * 2 }, + { start: minValue + stepSize * 2, end: maxValue }, + ]; + }, [viewConfig.steppedColorSteps, minValue, maxValue]); + + // Calculate steps with colors from gradient + const steps = useMemo(() => { + const interpolator = getInterpolator(colorScheme, viewConfig.customColor); + + return stepRanges.map((rangeItem, index) => { + const numSteps = stepRanges.length; + const gradientPosition = numSteps > 1 ? index / (numSteps - 1) : 0; + + const t = isReversed ? 1 - gradientPosition : gradientPosition; + const clampedT = Math.max(0, Math.min(1, t)); + + const color = interpolator(clampedT); + + return { + start: rangeItem.start, + end: rangeItem.end, + color: color || "#cccccc", + }; + }); + }, [stepRanges, colorScheme, isReversed, viewConfig.customColor]); + + // Set initial steps + useEffect(() => { + if (!viewConfig.steppedColorSteps?.length && steps) { + updateViewConfig({ steppedColorSteps: steps }); + } + }, [steps, updateViewConfig, viewConfig.steppedColorSteps]); + + // Initialize local steps when dialog opens + useEffect(() => { + if (isOpen) { + if ( + viewConfig.steppedColorSteps && + viewConfig.steppedColorSteps.length > 0 + ) { + setLocalSteps(viewConfig.steppedColorSteps); + } else { + setLocalSteps(steps); + } + } + }, [isOpen, viewConfig.steppedColorSteps, steps]); + + // Recalculate colors when color scheme changes (but don't auto-apply) + const localStepsRef = useRef(localSteps); + useEffect(() => { + localStepsRef.current = localSteps; + }, [localSteps]); + + useEffect(() => { + if (isOpen && localStepsRef.current.length > 0) { + const interpolator = getInterpolator(colorScheme, viewConfig.customColor); + const numSteps = localStepsRef.current.length; + const updatedSteps = localStepsRef.current.map((step, index) => { + const gradientPosition = numSteps > 1 ? index / (numSteps - 1) : 0; + const t = isReversed ? 1 - gradientPosition : gradientPosition; + const clampedT = Math.max(0, Math.min(1, t)); + return { + ...step, + color: interpolator(clampedT) || "#cccccc", + }; + }); + setLocalSteps(updatedSteps); + } + }, [colorScheme, isReversed, viewConfig.customColor, isOpen]); + + if ( + !areaStats || + areaStats.primary?.columnType !== ColumnType.Number || + viewConfig.colorScaleType !== ColorScaleType.Stepped + ) { + return null; + } + + const handleStepChange = ( + index: number, + newStart: number, + newEnd?: number, + ) => { + const newSteps = [...localSteps]; + + // Update current step + newSteps[index].start = newStart; + if (newEnd !== undefined) { + newSteps[index].end = newEnd; + } + + // Connect boundaries: end of step N = start of step N+1 + if (index < newSteps.length - 1) { + newSteps[index + 1].start = newSteps[index].end; + } + if (index > 0) { + newSteps[index - 1].end = newSteps[index].start; + } + + // Recalculate colors + const interpolator = getInterpolator(colorScheme, viewConfig.customColor); + const numSteps = newSteps.length; + newSteps.forEach((step, i) => { + const gradientPosition = numSteps > 1 ? i / (numSteps - 1) : 0; + const t = isReversed ? 1 - gradientPosition : gradientPosition; + const clampedT = Math.max(0, Math.min(1, t)); + step.color = interpolator(clampedT) || "#cccccc"; + }); + + setLocalSteps(newSteps); + }; + + const handleAddStep = () => { + const lastStep = localSteps[localSteps.length - 1]; + const midpoint = lastStep + ? (lastStep.start + lastStep.end) / 2 + : (minValue + maxValue) / 2; + + const newSteps = [...localSteps]; + newSteps[newSteps.length - 1].end = midpoint; + + const newStep: SteppedColorStep = { + start: midpoint, + end: maxValue, + color: "#cccccc", + }; + newSteps.push(newStep); + + // Recalculate colors + const interpolator = getInterpolator(colorScheme, viewConfig.customColor); + const numSteps = newSteps.length; + newSteps.forEach((step, i) => { + const gradientPosition = numSteps > 1 ? i / (numSteps - 1) : 0; + const t = isReversed ? 1 - gradientPosition : gradientPosition; + const clampedT = Math.max(0, Math.min(1, t)); + step.color = interpolator(clampedT) || "#cccccc"; + }); + + setLocalSteps(newSteps); + }; + + const handleRemoveStep = (index: number) => { + const newSteps = localSteps.filter((_, i) => i !== index); + + if (index > 0 && newSteps.length > 0) { + newSteps[index - 1].end = localSteps[index].end; + } + if (index < localSteps.length - 1 && newSteps.length > 0) { + const nextIndex = index < newSteps.length ? index : newSteps.length - 1; + if (nextIndex < newSteps.length) { + newSteps[nextIndex].start = localSteps[index].start; + } + } + + // Recalculate colors + const interpolator = getInterpolator(colorScheme, viewConfig.customColor); + const numSteps = newSteps.length; + newSteps.forEach((step, i) => { + const gradientPosition = numSteps > 1 ? i / (numSteps - 1) : 0; + const t = isReversed ? 1 - gradientPosition : gradientPosition; + const clampedT = Math.max(0, Math.min(1, t)); + step.color = interpolator(clampedT) || "#cccccc"; + }); + + setLocalSteps(newSteps); + }; + + const handleReset = () => { + setLocalSteps(steps); + }; + + const handleApply = () => { + // Ensure boundaries are connected before applying + const stepsToApply = [...localSteps]; + + // Connect boundaries: end of step N = start of step N+1 + for (let i = 0; i < stepsToApply.length - 1; i++) { + stepsToApply[i].end = stepsToApply[i + 1].start; + } + + // Ensure first starts at minValue and last ends at maxValue + if (stepsToApply.length > 0) { + stepsToApply[0].start = minValue; + stepsToApply[stepsToApply.length - 1].end = maxValue; + } + + updateViewConfig({ + steppedColorSteps: stepsToApply.length > 0 ? stepsToApply : undefined, + }); + setIsOpen(false); + }; + + const stepSize = (maxValue - minValue) / 1000; + const range = maxValue - minValue; + const showDecimals = range <= 10; + const formatValue = (value: number) => + showDecimals ? value.toFixed(2) : Math.round(value).toString(); + + return ( + + + + + + + Configure color steps + +
    +
    +

    + Value range: {formatValue(minValue)} to {formatValue(maxValue)} +

    +

    + Colors are automatically calculated from the selected color scheme + gradient. +

    +
    +
    + {localSteps.map((step, index) => { + const isFirst = index === 0; + const isLast = index === steps.length - 1; + + return ( +
    +
    +
    +
    +
    + + Step {index + 1} + +
    +
    +
    + + {isFirst + ? formatValue(minValue) + : formatValue(step.start)} + + + {isLast + ? formatValue(maxValue) + : formatValue(step.end)} + +
    + + { + if (isFirst) { + handleStepChange(index, minValue, newEnd); + } else if (isLast) { + handleStepChange(index, newStart, maxValue); + } else { + handleStepChange(index, newStart, newEnd); + } + }} + step={stepSize} + color={step.color} + isFirst={isFirst} + isLast={isLast} + /> +
    +
    +
    + + Range: {formatValue(step.start)} -{" "} + {formatValue(step.end)} + +
    +
    + {localSteps.length > 1 && ( + + )} +
    + ); + })} +
    +
    + + {viewConfig.steppedColorSteps && ( + + )} +
    +
    + +
    +
    + +
    + ); +} diff --git a/src/app/map/[id]/components/controls/VisualisationPanel/VisualisationPanel.tsx b/src/app/map/[id]/components/controls/VisualisationPanel/VisualisationPanel.tsx index e37037c1..c11b6301 100644 --- a/src/app/map/[id]/components/controls/VisualisationPanel/VisualisationPanel.tsx +++ b/src/app/map/[id]/components/controls/VisualisationPanel/VisualisationPanel.tsx @@ -4,6 +4,7 @@ import { Palette, PieChart, PlusIcon, + RotateCwIcon, X, } from "lucide-react"; import { useMemo, useState } from "react"; @@ -13,11 +14,16 @@ import { useDataSources, } from "@/app/map/[id]/hooks/useDataSources"; import { useMapViews } from "@/app/map/[id]/hooks/useMapViews"; -import { MAX_COLUMN_KEY, NULL_UUID } from "@/constants"; +import { DEFAULT_CUSTOM_COLOR, MAX_COLUMN_KEY, NULL_UUID } from "@/constants"; import { AreaSetGroupCodeLabels } from "@/labels"; import { ColumnType } from "@/server/models/DataSource"; -import { CalculationType, ColorScheme } from "@/server/models/MapView"; +import { + CalculationType, + ColorScaleType, + ColorScheme, +} from "@/server/models/MapView"; import { Button } from "@/shadcn/ui/button"; +import { Checkbox } from "@/shadcn/ui/checkbox"; import { Combobox } from "@/shadcn/ui/combobox"; import { Dialog, @@ -25,6 +31,7 @@ import { DialogHeader, DialogTitle, } from "@/shadcn/ui/dialog"; +import { DialogTrigger } from "@/shadcn/ui/dialog"; import { Input } from "@/shadcn/ui/input"; import { Label } from "@/shadcn/ui/label"; import { @@ -42,8 +49,92 @@ import { dataRecordsWillAggregate, getValidAreaSetGroupCodes, } from "../../Choropleth/areas"; +import CategoryColorEditor from "./CategoryColorEditor"; import { DataSourceItem } from "./DataSourceItem"; +import SteppedColorEditor from "./SteppedColorEditor"; import type { AreaSetGroupCode } from "@/server/models/AreaSet"; +import type { DataSource } from "@/server/models/DataSource"; + +function IncludeColumnsModal({ + dataSource, + selectedColumns, + onColumnsChange, +}: { + dataSource: DataSource; + selectedColumns: string[]; + onColumnsChange: (columns: string[]) => void; +}) { + const [isOpen, setIsOpen] = useState(false); + const numericColumns = dataSource.columnDefs.filter( + (c) => c.type === ColumnType.Number, + ); + + const handleToggle = (columnName: string, checked: boolean) => { + if (checked) { + onColumnsChange([...selectedColumns, columnName]); + } else { + onColumnsChange(selectedColumns.filter((c) => c !== columnName)); + } + }; + + return ( + +
    + + + + +
    + + + Select columns to include + +
    +

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

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

    + No numeric columns found in this data source. +

    + )} +
    +
    +
    + ); +} export default function VisualisationPanel({ positionLeft, @@ -117,26 +208,53 @@ export default function VisualisationPanel({ {/* Data Source Selection */} -
    +
    {viewConfig.areaDataSourceId && dataSource ? ( // Show selected data source as a card - + className="group-hover:bg-neutral-100 transition-colors cursor-pointer rounded-lg" + > + + +
    + + +
    +
    ) : ( // Show button to open modal when no data source selected @@ -233,7 +351,10 @@ export default function VisualisationPanel({ htmlFor="choropleth-column-1-select" className="text-sm text-muted-foreground font-normal" > - Column 1 + {viewConfig.areaDataSecondaryColumn !== undefined && + viewConfig.areaDataSecondaryColumn !== "" + ? "Column 1" + : "Column"} - ds.id === viewConfig.areaDataSourceId) - ?.columnDefs.filter( - (col) => col.type === ColumnType.Number, - ) - .map((col) => ({ - value: col.name, - label: `${col.name} (${col.type})`, - })) || []), - ]} - value={viewConfig.areaDataSecondaryColumn || NULL_UUID} - onValueChange={(value) => - updateViewConfig({ - areaDataSecondaryColumn: value === NULL_UUID ? "" : value, - }) - } - placeholder="Choose a column..." - searchPlaceholder="Search columns..." - /> +
    + ds.id === viewConfig.areaDataSourceId) + ?.columnDefs.filter( + (col) => col.type === ColumnType.Number, + ) + .map((col) => ({ + value: col.name, + label: `${col.name} (${col.type})`, + })) || []), + ]} + value={viewConfig.areaDataSecondaryColumn || NULL_UUID} + onValueChange={(value) => + updateViewConfig({ + areaDataSecondaryColumn: + value === NULL_UUID ? undefined : value, + }) + } + placeholder="Choose a column..." + searchPlaceholder="Search columns..." + /> + +
    +
    + +
    )} @@ -373,22 +524,25 @@ export default function VisualisationPanel({
    )} - {/* Exclude Columns Input - only show when MAX_COLUMN_KEY is selected */} - {viewConfig.areaDataColumn === MAX_COLUMN_KEY && ( -
    - - - updateViewConfig({ - excludeColumnsString: e.target.value, - }) - } - placeholder="Comma-separated columns to exclude" - value={viewConfig.excludeColumnsString} - className="w-full px-3 py-2 text-sm border border-neutral-200 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" - /> -
    + {/* Include Columns Modal - only show when MAX_COLUMN_KEY is selected */} + {viewConfig.areaDataColumn === MAX_COLUMN_KEY && dataSource && ( + v.trim()) + .filter(Boolean) + : [] + } + onColumnsChange={(columns) => { + updateViewConfig({ + includeColumnsString: + columns.length > 0 ? columns.join(",") : undefined, + }); + }} + /> )}
    @@ -402,6 +556,69 @@ export default function VisualisationPanel({
    {!viewConfig.areaDataSecondaryColumn && columnOneIsNumber && ( <> + + + +