- {markerDataSources &&
- markerDataSources.length === 0 &&
- placedMarkers.length === 0 && (
-
- )}
+ {!hasMarkers && (
+
+ )}
+
+ {/* Member data source */}
+ {membersDataSource && (
+
+
+
+ )}
- {/* Data sources */}
+ {/* Marker data sources */}
{markerDataSources && markerDataSources.length > 0 && (
{markerDataSources.map((dataSource) => (
diff --git a/src/app/map/[id]/components/controls/MarkersControl/SortableFolderItem.tsx b/src/app/map/[id]/components/controls/MarkersControl/SortableFolderItem.tsx
index e2339706..58ffbe6b 100644
--- a/src/app/map/[id]/components/controls/MarkersControl/SortableFolderItem.tsx
+++ b/src/app/map/[id]/components/controls/MarkersControl/SortableFolderItem.tsx
@@ -7,18 +7,28 @@ import {
import { CSS } from "@dnd-kit/utilities";
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 DeleteConfirmationDialog from "@/components/DeleteConfirmationDialog";
+import {
+ ContextMenu,
+ ContextMenuContent,
+ ContextMenuItem,
+ ContextMenuSeparator,
+ ContextMenuTrigger,
+} from "@/shadcn/ui/context-menu";
import { cn } from "@/shadcn/utils";
import { LayerType } from "@/types";
import { useFolderMutations } from "../../../hooks/useFolders";
import { usePlacedMarkerState } from "../../../hooks/usePlacedMarkers";
import ControlEditForm from "../ControlEditForm";
-import ControlHoverMenu from "../ControlHoverMenu";
import ControlWrapper from "../ControlWrapper";
import SortableMarkerItem from "./SortableMarkerItem";
import type { Folder } from "@/server/models/Folder";
@@ -72,6 +82,7 @@ export default function SortableFolderItem({
const [isExpanded, setExpanded] = useState(false);
const [isEditing, setEditing] = useState(false);
const [editText, setEditText] = useState(folder.name);
+ const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const sortedMarkers = useMemo(() => {
return sortByPositionAndId(markers);
@@ -85,15 +96,9 @@ export default function SortableFolderItem({
setExpanded(!isExpanded);
};
- const onDelete = () => {
- if (
- !window.confirm(
- "Are you sure you want to delete this folder? This action cannot be undone, and any markers in the folder will be lost.",
- )
- ) {
- return;
- }
+ const handleDelete = () => {
deleteFolder(folder.id);
+ setShowDeleteDialog(false);
};
const onEdit = () => {
@@ -138,26 +143,61 @@ export default function SortableFolderItem({
onSubmit={onSubmit}
/>
) : (
-
onDelete()} onEdit={() => onEdit()}>
- onClickFolder()}
- className={cn(
- "flex items-center gap-1 / w-full min-h-full p-1 rounded / transition-colors hover:bg-neutral-100 / text-left cursor-pointer",
- isHeaderOver ? "bg-blue-50" : "",
- )}
- >
- {isExpanded ? (
-
- ) : (
-
- )}
-
- {sortedMarkers.length}
-
- {folder.name}
-
-
+
+
+ onClickFolder()}
+ className={cn(
+ "flex items-center gap-1 / w-full min-h-full p-1 rounded / transition-colors hover:bg-neutral-100 / text-left cursor-pointer",
+ isHeaderOver ? "bg-blue-50" : "",
+ )}
+ onContextMenu={(e) => {
+ // Prevent context menu during drag
+ if (isCurrentlyDragging) {
+ e.preventDefault();
+ }
+ }}
+ >
+ {isExpanded ? (
+
+ ) : (
+
+ )}
+
+ {sortedMarkers.length}
+
+ {folder.name}
+
+
+
+
+
+ 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)}
- >
- flyToMarker()}
- >
- {marker.label}
-
-
- )}
-
-
+
+ setPlacedMarkerVisibility(marker.id, !isVisible)
+ }
+ >
+ {isEditing ? (
+
+ ) : (
+
+
+ flyToMarker()}
+ onContextMenu={(e) => {
+ // Prevent context menu during drag
+ if (isCurrentlyDragging) {
+ e.preventDefault();
+ }
+ }}
+ >
+ {marker.label}
+
+
+
+
+
+ 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()}
- >
- handleFlyTo(turf)}
- >
- {turf.label || `Area: ${turf.area?.toFixed(2)}m²`}
-
-
- )}
-
+ <>
+
setTurfVisibility(turf.id, !isVisible)}
+ >
+ {isEditing ? (
+
+ ) : (
+
+
+ handleFlyTo(turf)}
+ >
+ {turf.label || `Area: ${turf.area?.toFixed(2)}m²`}
+
+
+
+
+
+ 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
+
+
+
+
+ Set category colors
+
+
+ {categories.map((category) => {
+ const currentColor =
+ viewConfig.categoryColors?.[category] ||
+ colorScheme.colorMap[category];
+ return (
+
+
+
+
+
+ handleColorChange(category, e.target.value)
+ }
+ className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
+ title={`Change color for ${category}`}
+ />
+
+
+ {category}
+
+
+
+ {viewConfig.categoryColors?.[category] && (
+
handleResetColor(category)}
+ title="Reset to default color"
+ >
+
+
+ )}
+
+ );
+ })}
+ {Object.keys(viewConfig.categoryColors || {}).length > 0 && (
+
+ updateViewConfig({ categoryColors: undefined })}
+ >
+ Reset all colors
+
+
+ )}
+
+
+
+ );
+}
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
+
+
+
+
+ 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 && (
+
handleRemoveStep(index)}
+ title="Remove step"
+ >
+
+
+ )}
+
+ );
+ })}
+
+
+
+
+ Add step
+
+ {viewConfig.steppedColorSteps && (
+
+ Reset
+
+ )}
+
+
+
+ Apply
+
+
+
+
+
+ );
+}
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 (
+
+
+
+ Include columns (leave empty to use all numeric columns)
+
+
+
+ {selectedColumns.length > 0
+ ? `${selectedColumns.length} column${
+ selectedColumns.length !== 1 ? "s" : ""
+ } selected`
+ : "Select columns to include"}
+
+
+
+
+
+ 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)
+ }
+ />
+
+ {column.name}
+
+
+ ))}
+
+ {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 */}
-
+
Data source
{viewConfig.areaDataSourceId && dataSource ? (
// Show selected data source as a card
-
{
- setIsModalOpen(true);
- }}
- >
-
+ {
+ setIsModalOpen(true);
}}
- />
-
+ className="group-hover:bg-neutral-100 transition-colors cursor-pointer rounded-lg"
+ >
+
+
+
+ setIsModalOpen(true)}
+ >
+ Change data source
+
+
+ {
+ updateViewConfig({
+ areaDataSourceId: "",
+ areaDataColumn: "",
+ calculationType: undefined,
+ });
+ }}
+ >
+ Remove
+
+
+
+
) : (
// 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"}
Column 2
- 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..."
+ />
+ {
+ updateViewConfig({ areaDataSecondaryColumn: undefined });
+ }}
+ title="Remove column 2"
+ >
+
+
+
+
+ {
+ updateViewConfig({ areaDataSecondaryColumn: undefined });
+ }}
+ >
+
+ Remove bivariate visualization
+
+
>
)}
@@ -373,22 +524,25 @@ export default function VisualisationPanel({
)}
- {/* Exclude Columns Input - only show when MAX_COLUMN_KEY is selected */}
- {viewConfig.areaDataColumn === MAX_COLUMN_KEY && (
-
- Exclude columns
-
- 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 && (
<>
+
+ Color scale
+
+
+
+ updateViewConfig({
+ colorScaleType: value as ColorScaleType,
+ })
+ }
+ >
+
+
+ {viewConfig.colorScaleType === ColorScaleType.Gradient
+ ? "Gradient"
+ : viewConfig.colorScaleType === ColorScaleType.Stepped
+ ? "Stepped"
+ : "Gradient"}
+
+
+
+
+
+
+
+
+
+
+
+
- {CHOROPLETH_COLOR_SCHEMES.map((option, index) => (
-
-
- {option.label}
-
- ))}
+ {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 && (
+ <>
+
+ Max color
+
+
+ >
+ )}
+
+
+ {viewConfig.colorScaleType === ColorScaleType.Stepped && (
+ <>
+
+ Color steps
+
+
+
+
+ >
+ )}
+ >
+ )}
+ {!viewConfig.areaDataSecondaryColumn && !columnOneIsNumber && (
+ <>
+
+ Category colors
+
+
+
+
>
)}
- {
- const v = Number(e.target.value);
- const choroplethOpacityPct = isNaN(v)
- ? 80
- : Math.max(0, Math.min(v, 100));
- updateViewConfig({ choroplethOpacityPct });
- }}
- />
+
+
+ {/* Bivariate visualization button at the bottom */}
+ {viewConfig.calculationType !== CalculationType.Count &&
+ viewConfig.areaDataSourceId &&
+ viewConfig.areaDataColumn &&
+ columnOneIsNumber &&
+ !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 →
+
+
+
+
+
+
+ )}
{/* Modal for data source selection */}
@@ -521,7 +929,10 @@ export default function VisualisationPanel({
onClick={() => {
const selectedAreaSetGroup = viewConfig.areaSetGroupCode;
if (!selectedAreaSetGroup) {
- updateViewConfig({ areaDataSourceId: ds.id });
+ updateViewConfig({
+ areaDataSourceId: ds.id,
+ areaDataSecondaryColumn: undefined,
+ });
setIsModalOpen(false);
return;
}
@@ -530,7 +941,10 @@ export default function VisualisationPanel({
dataSource?.geocodingConfig,
);
if (validAreaSetGroups.includes(selectedAreaSetGroup)) {
- updateViewConfig({ areaDataSourceId: ds.id });
+ updateViewConfig({
+ areaDataSourceId: ds.id,
+ areaDataSecondaryColumn: undefined,
+ });
setIsModalOpen(false);
return;
}
diff --git a/src/app/map/[id]/data.ts b/src/app/map/[id]/data.ts
index eae666f0..8db647a8 100644
--- a/src/app/map/[id]/data.ts
+++ b/src/app/map/[id]/data.ts
@@ -89,12 +89,14 @@ export const useAreaStats = () => {
stats: Record;
} | null>();
- const excludeColumns = useMemo(() => {
- return viewConfig.excludeColumnsString
+ const includeColumns = useMemo(() => {
+ if (!viewConfig.includeColumnsString) return null;
+ const columns = viewConfig.includeColumnsString
.split(",")
.map((v) => v.trim())
.filter(Boolean);
- }, [viewConfig.excludeColumnsString]);
+ return columns.length > 0 ? columns : null;
+ }, [viewConfig.includeColumnsString]);
// The results of this query aren't used directly, as data for different
// bounding boxes needs to be added together. Instead, useEffect is used
@@ -108,7 +110,7 @@ export const useAreaStats = () => {
column: columnOrCount,
secondaryColumn: secondaryColumn,
nullIsZero,
- excludeColumns,
+ includeColumns,
boundingBox: requiresBoundingBox ? boundingBox : null,
},
{ enabled: !skipCondition },
@@ -124,7 +126,7 @@ export const useAreaStats = () => {
column,
secondaryColumn,
nullIsZero,
- excludeColumns,
+ includeColumns,
]);
useEffect(() => {
diff --git a/src/app/map/[id]/hooks/useLayers.ts b/src/app/map/[id]/hooks/useLayers.ts
index 23d1d060..111f0258 100644
--- a/src/app/map/[id]/hooks/useLayers.ts
+++ b/src/app/map/[id]/hooks/useLayers.ts
@@ -6,6 +6,8 @@ import { useMapConfig } from "@/app/map/[id]/hooks/useMapConfig";
import { LayerType } from "@/types";
import { hiddenLayersAtom } from "../atoms/layerAtoms";
import { dataSourceVisibilityAtom } from "../atoms/markerAtoms";
+import { usePlacedMarkersQuery } from "./usePlacedMarkers";
+import { usePlacedMarkerState } from "./usePlacedMarkers";
import { useTurfsQuery } from "./useTurfsQuery";
import { useTurfState } from "./useTurfState";
@@ -13,6 +15,8 @@ export function useLayers() {
const { mapConfig } = useMapConfig();
const { data: turfs = [] } = useTurfsQuery();
const { setTurfVisibility, visibleTurfs } = useTurfState();
+ const { data: placedMarkers = [] } = usePlacedMarkersQuery();
+ const { setPlacedMarkerVisibility } = usePlacedMarkerState();
const [hiddenLayers, setHiddenLayers] = useAtom(hiddenLayersAtom);
const [dataSourceVisibility, _setDataSourceVisibility] = useAtom(
@@ -40,11 +44,14 @@ export function useLayers() {
(layer: LayerType) => {
setHiddenLayers((prev) => prev.filter((l) => l !== layer));
- // TODO: add logic for markers
if (layer === LayerType.Member) {
if (mapConfig.membersDataSourceId) {
setDataSourceVisibility(mapConfig.membersDataSourceId, true);
}
+ } else if (layer === LayerType.Marker) {
+ placedMarkers.forEach((marker) =>
+ setPlacedMarkerVisibility(marker.id, true),
+ );
} else if (layer === LayerType.Turf) {
turfs.forEach((t) => setTurfVisibility(t.id, true));
}
@@ -53,6 +60,8 @@ export function useLayers() {
setHiddenLayers,
mapConfig.membersDataSourceId,
setDataSourceVisibility,
+ placedMarkers,
+ setPlacedMarkerVisibility,
turfs,
setTurfVisibility,
],
@@ -62,11 +71,14 @@ export function useLayers() {
(layer: LayerType) => {
setHiddenLayers((prev) => [...prev, layer]);
- // TODO: add logic for markers
if (layer === LayerType.Member) {
if (mapConfig.membersDataSourceId) {
setDataSourceVisibility(mapConfig.membersDataSourceId, false);
}
+ } else if (layer === LayerType.Marker) {
+ placedMarkers.forEach((marker) =>
+ setPlacedMarkerVisibility(marker.id, false),
+ );
} else if (layer === LayerType.Turf) {
turfs.forEach((t) => setTurfVisibility(t.id, false));
}
@@ -75,6 +87,8 @@ export function useLayers() {
setHiddenLayers,
mapConfig.membersDataSourceId,
setDataSourceVisibility,
+ placedMarkers,
+ setPlacedMarkerVisibility,
turfs,
setTurfVisibility,
],
diff --git a/src/app/map/[id]/hooks/useMapViews.ts b/src/app/map/[id]/hooks/useMapViews.ts
index 9465c6e9..b469309b 100644
--- a/src/app/map/[id]/hooks/useMapViews.ts
+++ b/src/app/map/[id]/hooks/useMapViews.ts
@@ -5,7 +5,11 @@ import { useAtom, useAtomValue, useSetAtom } from "jotai";
import { useCallback, useContext, useMemo } from "react";
import { toast } from "sonner";
import { AreaSetGroupCode } from "@/server/models/AreaSet";
-import { MapType, type MapViewConfig } from "@/server/models/MapView";
+import {
+ CalculationType,
+ MapType,
+ type MapViewConfig,
+} from "@/server/models/MapView";
import { useTRPC } from "@/services/trpc/react";
import { dirtyViewIdsAtom, viewIdAtom } from "../atoms/mapStateAtoms";
import { createNewViewConfig } from "../utils/mapView";
@@ -195,14 +199,18 @@ export function useMapViews() {
viewConfig.showChoropleth = true;
}
+ // Fallback to the default calculation type if a data column has been selected
+ if (viewConfig.areaDataColumn && !viewConfig.calculationType) {
+ viewConfig.calculationType = CalculationType.Avg;
+ }
+
// Clear the selected columns when the user changes the data source
if (viewConfig.areaDataSourceId) {
if (!viewConfig.areaDataColumn) {
viewConfig.areaDataColumn = "";
}
- if (!viewConfig.areaDataSecondaryColumn) {
- viewConfig.areaDataSecondaryColumn = "";
- }
+ // Don't automatically set areaDataSecondaryColumn - let it be explicitly managed
+ // Only clear it if explicitly set to undefined in the update
}
// Set boundaries if the view is a hex map and no boundaries are set
diff --git a/src/app/map/[id]/utils/mapView.ts b/src/app/map/[id]/utils/mapView.ts
index 12358b27..b32476ed 100644
--- a/src/app/map/[id]/utils/mapView.ts
+++ b/src/app/map/[id]/utils/mapView.ts
@@ -11,7 +11,6 @@ export const createNewViewConfig = (): MapViewConfig => {
areaDataColumn: "",
areaDataNullIsZero: true,
areaSetGroupCode: null,
- excludeColumnsString: "",
mapStyleName: MapStyleName.Light,
showLabels: true,
showBoundaryOutline: false,
diff --git a/src/components/DeleteConfirmationDialog.tsx b/src/components/DeleteConfirmationDialog.tsx
new file mode 100644
index 00000000..a21ff3b6
--- /dev/null
+++ b/src/components/DeleteConfirmationDialog.tsx
@@ -0,0 +1,53 @@
+"use client";
+
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from "@/shadcn/ui/alert-dialog";
+
+interface DeleteConfirmationDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ title?: string;
+ description: string;
+ onConfirm: () => void;
+ isPending?: boolean;
+ confirmButtonText?: string;
+}
+
+export default function DeleteConfirmationDialog({
+ open,
+ onOpenChange,
+ title = "Are you absolutely sure?",
+ description,
+ onConfirm,
+ isPending = false,
+ confirmButtonText = "Delete",
+}: DeleteConfirmationDialogProps) {
+ return (
+
+
+
+ {title}
+ {description}
+
+
+ Cancel
+
+ {isPending ? "Deleting..." : confirmButtonText}
+
+
+
+
+ );
+}
diff --git a/src/components/icons/VectorSquare.tsx b/src/components/icons/VectorSquare.tsx
deleted file mode 100644
index df95cd01..00000000
--- a/src/components/icons/VectorSquare.tsx
+++ /dev/null
@@ -1,25 +0,0 @@
-export default function VectorSquare({ size = 24 }) {
- return (
-
-
-
-
-
-
-
-
-
-
- );
-}
diff --git a/src/constants.ts b/src/constants.ts
index a0ac3661..939d01dd 100644
--- a/src/constants.ts
+++ b/src/constants.ts
@@ -5,13 +5,15 @@ export const DATA_RECORDS_PAGE_SIZE = 100;
export const DATA_SOURCE_JOB_BATCH_SIZE = 100;
-export const DEFAULT_ZOOM = 5;
-
// This is smaller than the external data source
// maximum page size (e.g. Airtable = 100) to simplify
// importDataRecords / enrichDataRecords code (no pagination)
export const DATA_RECORDS_JOB_BATCH_SIZE = 100;
+export const DEFAULT_ZOOM = 5;
+
+export const DEFAULT_CUSTOM_COLOR = "#3b82f6";
+
export const DEV_NEXT_PUBLIC_BASE_URL = "https://localhost:3000";
// Different database derived column name because underscores get mangled by camelCase translation
diff --git a/src/server/commands/ensureOrganisationMap.ts b/src/server/commands/ensureOrganisationMap.ts
index d275cdfa..b1990c66 100644
--- a/src/server/commands/ensureOrganisationMap.ts
+++ b/src/server/commands/ensureOrganisationMap.ts
@@ -89,7 +89,6 @@ const ensureOrganisationMap = async (orgId: string): Promise => {
areaSetGroupCode: AreaSetGroupCode.WMC24,
calculationType: null,
colorScheme: ColorScheme.GreenYellowRed,
- excludeColumnsString: "",
mapStyleName: MapStyleName.Light,
reverseColorScheme: false,
showBoundaryOutline: true,
diff --git a/src/server/models/MapView.ts b/src/server/models/MapView.ts
index 5ea5b1de..f2428465 100644
--- a/src/server/models/MapView.ts
+++ b/src/server/models/MapView.ts
@@ -82,9 +82,16 @@ export enum ColorScheme {
Plasma = "Plasma",
Diverging = "Diverging",
Sequential = "Sequential",
+ Custom = "Custom",
}
export const colorSchemes = Object.values(ColorScheme);
+export enum ColorScaleType {
+ Gradient = "Gradient",
+ Stepped = "Stepped",
+}
+export const colorScaleTypes = Object.values(ColorScaleType);
+
export enum MapType {
Geo = "Geo",
Hex = "Hex",
@@ -99,6 +106,14 @@ export enum MapStyleName {
}
export const mapStyleNames = Object.values(MapStyleName);
+export const steppedColorStepSchema = z.object({
+ start: z.number(),
+ end: z.number(),
+ color: z.string(),
+});
+
+export type SteppedColorStep = z.infer;
+
export const mapViewConfigSchema = z.object({
areaDataSourceId: z.string(),
areaDataColumn: z.string(),
@@ -106,7 +121,7 @@ export const mapViewConfigSchema = z.object({
areaDataNullIsZero: z.boolean().optional(),
areaSetGroupCode: areaSetGroupCode.nullish(),
choroplethOpacityPct: z.number().optional(),
- excludeColumnsString: z.string(),
+ includeColumnsString: z.string().optional(),
mapStyleName: z.nativeEnum(MapStyleName),
mapType: z.nativeEnum(MapType).optional(),
showBoundaryOutline: z.boolean(),
@@ -118,6 +133,10 @@ export const mapViewConfigSchema = z.object({
calculationType: z.nativeEnum(CalculationType).nullish(),
colorScheme: z.nativeEnum(ColorScheme).nullish(),
reverseColorScheme: z.boolean().nullish(),
+ categoryColors: z.record(z.string(), z.string()).optional(),
+ colorScaleType: z.nativeEnum(ColorScaleType).optional(),
+ steppedColorSteps: z.array(steppedColorStepSchema).optional(),
+ customColor: z.string().optional(),
});
export type MapViewConfig = z.infer;
diff --git a/src/server/stats/index.ts b/src/server/stats/index.ts
index 630cd327..c4d1761e 100644
--- a/src/server/stats/index.ts
+++ b/src/server/stats/index.ts
@@ -46,7 +46,7 @@ export const getAreaStats = async ({
column,
secondaryColumn,
nullIsZero,
- excludeColumns,
+ includeColumns,
boundingBox = null,
}: {
areaSetCode: AreaSetCode;
@@ -55,7 +55,7 @@ export const getAreaStats = async ({
column: string;
secondaryColumn?: string;
nullIsZero?: boolean;
- excludeColumns: string[];
+ includeColumns?: string[] | null;
boundingBox?: BoundingBox | null;
}): Promise => {
const areaStats: AreaStats = {
@@ -68,7 +68,7 @@ export const getAreaStats = async ({
const stats = await getMaxColumnByArea(
areaSetCode,
dataSourceId,
- excludeColumns,
+ includeColumns,
boundingBox,
);
const { maxValue, minValue } = getValueRange(stats);
@@ -146,17 +146,24 @@ export const getAreaStats = async ({
export const getMaxColumnByArea = async (
areaSetCode: string,
dataSourceId: string,
- excludeColumns: string[],
+ includeColumns: string[] | null = null,
boundingBox: BoundingBox | null = null,
) => {
const dataSource = await findDataSourceById(dataSourceId);
if (!dataSource) return [];
const columnNames = dataSource.columnDefs
- .filter(
- ({ name, type }) =>
- !excludeColumns.includes(name) && type === ColumnType.Number,
- )
+ .filter(({ name, type }) => {
+ // Must be a number column
+ if (type !== ColumnType.Number) return false;
+
+ // If includeColumns is specified, only include those columns
+ if (includeColumns && includeColumns.length > 0) {
+ return includeColumns.includes(name);
+ }
+
+ return true;
+ })
.map((c) => c.name);
if (!columnNames.length) {
@@ -172,14 +179,15 @@ export const getMaxColumnByArea = async (
| CaseWhenBuilder,
column: string,
) => {
+ // Cast to float for numeric comparison, not text comparison
return caseBuilder
.when(
db.fn(
"GREATEST",
- columnNames.map((c) => sql`json->>${c}`),
+ columnNames.map((c) => sql`(json->>${c})::float`),
),
"=",
- sql`json->>${column}`,
+ sql`(json->>${column})::float`,
)
.then(column);
};
@@ -202,6 +210,14 @@ export const getMaxColumnByArea = async (
//
// Finally, SELECT DISTINCT ON ("area_code") to return only this first row
// for each area.
+ //
+ // We filter out records where all columns are NULL by checking that
+ // at least one column has a non-NULL numeric value.
+ const hasNonNullValueCondition = sql`(${sql.join(
+ columnNames.map((c) => sql`(json->>${c})::float IS NOT NULL`),
+ sql` OR `,
+ )})`;
+
const q = sql`
WITH data_record_with_max_column AS (
SELECT
@@ -210,6 +226,8 @@ export const getMaxColumnByArea = async (
FROM data_record
WHERE data_source_id = ${dataSourceId}
AND ${getBoundingBoxSQL(boundingBox)}
+ AND ${hasNonNullValueCondition}
+ AND ${caseWhen.end()} IS NOT NULL
)
SELECT DISTINCT ON (area_code)
area_code as "areaCode",
@@ -217,6 +235,7 @@ export const getMaxColumnByArea = async (
FROM (
SELECT area_code, max_column, COUNT(*) AS count
FROM data_record_with_max_column
+ WHERE max_column IS NOT NULL
GROUP BY area_code, max_column
ORDER BY area_code, count DESC
) subquery;
diff --git a/src/server/trpc/routers/area.ts b/src/server/trpc/routers/area.ts
index 0a24a919..e9aa9b40 100644
--- a/src/server/trpc/routers/area.ts
+++ b/src/server/trpc/routers/area.ts
@@ -25,7 +25,7 @@ export const areaRouter = router({
column: z.string(),
secondaryColumn: z.string().optional(),
nullIsZero: z.boolean().optional(),
- excludeColumns: z.array(z.string()),
+ includeColumns: z.array(z.string()).optional().nullable(),
boundingBox: boundingBoxSchema.nullish(),
}),
)
diff --git a/src/server/trpc/routers/dataSource.ts b/src/server/trpc/routers/dataSource.ts
index 1d4d8d1f..6418735e 100644
--- a/src/server/trpc/routers/dataSource.ts
+++ b/src/server/trpc/routers/dataSource.ts
@@ -201,6 +201,7 @@ export const dataSourceRouter = router({
const adaptor = getDataSourceAdaptor(ctx.dataSource);
const update = {
+ name: input.name,
columnRoles: input.columnRoles,
enrichments: input.enrichments,
geocodingConfig: input.geocodingConfig,
@@ -239,9 +240,18 @@ export const dataSourceRouter = router({
`Updated ${ctx.dataSource.config.type} data source config: ${ctx.dataSource.id}`,
);
- await enqueue("importDataSource", ctx.dataSource.id, {
- dataSourceId: ctx.dataSource.id,
- });
+ // Only trigger import if config fields changed (not just name)
+ const configFieldsChanged =
+ input.columnRoles !== undefined ||
+ input.enrichments !== undefined ||
+ input.geocodingConfig !== undefined ||
+ input.dateFormat !== undefined;
+
+ if (configFieldsChanged) {
+ await enqueue("importDataSource", ctx.dataSource.id, {
+ dataSourceId: ctx.dataSource.id,
+ });
+ }
return true;
}),
diff --git a/src/server/trpc/routers/map.ts b/src/server/trpc/routers/map.ts
index f9868d89..1f1480a6 100644
--- a/src/server/trpc/routers/map.ts
+++ b/src/server/trpc/routers/map.ts
@@ -75,7 +75,6 @@ export const mapRouter = router({
areaSetGroupCode: AreaSetGroupCode.WMC24,
calculationType: CalculationType.Count,
colorScheme: null,
- excludeColumnsString: "",
mapStyleName: MapStyleName.Light,
reverseColorScheme: false,
showBoundaryOutline: true,
diff --git a/tests/feature/importDataSource.test.ts b/tests/feature/importDataSource.test.ts
index 6ee853df..576d01dd 100644
--- a/tests/feature/importDataSource.test.ts
+++ b/tests/feature/importDataSource.test.ts
@@ -228,7 +228,6 @@ describe("importDataSource tests", () => {
dataSourceId: dataSource.id,
calculationType: CalculationType.Sum,
column: "Electorate",
- excludeColumns: [],
});
const stats = areaStats.primary?.stats;