diff --git a/package-lock.json b/package-lock.json index fa0e2a75..4fc1f761 100644 --- a/package-lock.json +++ b/package-lock.json @@ -139,6 +139,8 @@ "vitest": "^3.2.4" }, "optionalDependencies": { + "@floating-ui/core": "^1.7.3", + "@floating-ui/dom": "^1.7.4", "@img/sharp-linux-x64": "^0.34.1", "@ngrok/ngrok-linux-x64-gnu": "^1.5", "@rollup/rollup-linux-x64-gnu": "^4.44", @@ -3842,18 +3844,22 @@ } }, "node_modules/@floating-ui/core": { - "version": "0.7.3", - "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-0.7.3.tgz", - "integrity": "sha512-buc8BXHmG9l82+OQXOFU3Kr2XQx9ys01U/Q9HMIrZ300iLc8HLMgh7dcCqgYzAzf4BkoQvDcXf5Y+CuEZ5JBYg==", - "license": "MIT" + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz", + "integrity": "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.10" + } }, "node_modules/@floating-ui/dom": { - "version": "0.5.4", - "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-0.5.4.tgz", - "integrity": "sha512-419BMceRLq0RrmTSDxn8hf9R3VCJv2K9PUfugh5JyEFmdjzDo+e8U5EdR8nzKq8Yj1htzLm3b6eQEEam3/rrtg==", + "version": "1.7.4", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.4.tgz", + "integrity": "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==", "license": "MIT", "dependencies": { - "@floating-ui/core": "^0.7.3" + "@floating-ui/core": "^1.7.3", + "@floating-ui/utils": "^0.2.10" } }, "node_modules/@floating-ui/react-dom": { @@ -3869,25 +3875,6 @@ "react-dom": ">=16.8.0" } }, - "node_modules/@floating-ui/react-dom/node_modules/@floating-ui/core": { - "version": "1.7.3", - "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz", - "integrity": "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==", - "license": "MIT", - "dependencies": { - "@floating-ui/utils": "^0.2.10" - } - }, - "node_modules/@floating-ui/react-dom/node_modules/@floating-ui/dom": { - "version": "1.7.4", - "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.4.tgz", - "integrity": "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==", - "license": "MIT", - "dependencies": { - "@floating-ui/core": "^1.7.3", - "@floating-ui/utils": "^0.2.10" - } - }, "node_modules/@floating-ui/utils": { "version": "0.2.10", "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz", @@ -5048,6 +5035,21 @@ "mapbox-gl": ">=2.7.0" } }, + "node_modules/@mapbox/search-js-web/node_modules/@floating-ui/core": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-0.7.3.tgz", + "integrity": "sha512-buc8BXHmG9l82+OQXOFU3Kr2XQx9ys01U/Q9HMIrZ300iLc8HLMgh7dcCqgYzAzf4BkoQvDcXf5Y+CuEZ5JBYg==", + "license": "MIT" + }, + "node_modules/@mapbox/search-js-web/node_modules/@floating-ui/dom": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-0.5.4.tgz", + "integrity": "sha512-419BMceRLq0RrmTSDxn8hf9R3VCJv2K9PUfugh5JyEFmdjzDo+e8U5EdR8nzKq8Yj1htzLm3b6eQEEam3/rrtg==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^0.7.3" + } + }, "node_modules/@mapbox/sphericalmercator": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@mapbox/sphericalmercator/-/sphericalmercator-1.2.0.tgz", @@ -13660,27 +13662,6 @@ "@tiptap/pm": "^3.16.0" } }, - "node_modules/@tiptap/extension-bubble-menu/node_modules/@floating-ui/core": { - "version": "1.7.3", - "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz", - "integrity": "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==", - "license": "MIT", - "optional": true, - "dependencies": { - "@floating-ui/utils": "^0.2.10" - } - }, - "node_modules/@tiptap/extension-bubble-menu/node_modules/@floating-ui/dom": { - "version": "1.7.4", - "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.4.tgz", - "integrity": "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==", - "license": "MIT", - "optional": true, - "dependencies": { - "@floating-ui/core": "^1.7.3", - "@floating-ui/utils": "^0.2.10" - } - }, "node_modules/@tiptap/extension-bullet-list": { "version": "3.16.0", "resolved": "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-3.16.0.tgz", @@ -14019,29 +14000,6 @@ "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" } }, - "node_modules/@tiptap/react/node_modules/@floating-ui/core": { - "version": "1.7.3", - "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz", - "integrity": "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@floating-ui/utils": "^0.2.10" - } - }, - "node_modules/@tiptap/react/node_modules/@floating-ui/dom": { - "version": "1.7.4", - "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.4.tgz", - "integrity": "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@floating-ui/core": "^1.7.3", - "@floating-ui/utils": "^0.2.10" - } - }, "node_modules/@tiptap/react/node_modules/@tiptap/extension-floating-menu": { "version": "3.16.0", "resolved": "https://registry.npmjs.org/@tiptap/extension-floating-menu/-/extension-floating-menu-3.16.0.tgz", diff --git a/package.json b/package.json index fbb3294b..a3df55a8 100644 --- a/package.json +++ b/package.json @@ -151,6 +151,8 @@ }, "optionalDependencies": { "@img/sharp-linux-x64": "^0.34.1", + "@floating-ui/core": "^1.7.3", + "@floating-ui/dom": "^1.7.4", "@ngrok/ngrok-linux-x64-gnu": "^1.5", "@rollup/rollup-linux-x64-gnu": "^4.44", "lightningcss-linux-x64-gnu": "^1.30.1" diff --git a/src/app/map/[id]/colors.ts b/src/app/map/[id]/colors.ts index 83caddd8..31a80674 100644 --- a/src/app/map/[id]/colors.ts +++ b/src/app/map/[id]/colors.ts @@ -18,8 +18,10 @@ import { ColorScheme, type SteppedColorStep, } from "@/server/models/MapView"; +import { getChoroplethDataKey } from "./components/Choropleth/utils"; import { DEFAULT_FILL_COLOR, PARTY_COLORS } from "./constants"; import type { CombinedAreaStats } from "./data"; +import type { MapViewConfig } from "@/server/models/MapView"; import type { ScaleOrdinal, ScaleSequential } from "d3-scale"; import type { DataDrivenPropertyValueSpecification } from "mapbox-gl"; @@ -101,8 +103,24 @@ export const CHOROPLETH_COLOR_SCHEMES = [ }, ]; +export const calculateStepColor = ( + index: number, + totalSteps: number, + viewConfig: MapViewConfig, +) => { + const { colorScheme, customColor, reverseColorScheme } = viewConfig; + const interpolator = getInterpolator( + colorScheme || ColorScheme.RedBlue, + customColor, + ); + const gradientPosition = totalSteps > 1 ? index / (totalSteps - 1) : 0; + const t = reverseColorScheme ? 1 - gradientPosition : gradientPosition; + const clampedT = Math.max(0, Math.min(1, t)); + return interpolator(clampedT) || "#cccccc"; +}; + export const getInterpolator = ( - scheme: ColorScheme | undefined, + scheme: ColorScheme | null | undefined, customColor?: string, ) => { switch (scheme) { @@ -130,41 +148,26 @@ export const getInterpolator = ( export const useColorScheme = ({ areaStats, - scheme, - isReversed, - categoryColors, - customColor, + viewConfig, }: { areaStats: CombinedAreaStats | null; - scheme: ColorScheme; - isReversed: boolean; - categoryColors?: Record; - customColor?: string; + viewConfig: MapViewConfig; }): CategoricColorScheme | NumericColorScheme | null => { // useMemo to cache calculated scales return useMemo(() => { return getColorScheme({ areaStats, - scheme, - isReversed, - categoryColors, - customColor, + viewConfig, }); - }, [areaStats, scheme, isReversed, categoryColors, customColor]); + }, [areaStats, viewConfig]); }; const getColorScheme = ({ areaStats, - scheme, - isReversed, - categoryColors, - customColor, + viewConfig, }: { areaStats: CombinedAreaStats | null; - scheme: ColorScheme; - isReversed: boolean; - categoryColors?: Record; - customColor?: string; + viewConfig: MapViewConfig; }): CategoricColorScheme | NumericColorScheme | null => { if (!areaStats || !areaStats.stats.length) { return null; @@ -179,7 +182,8 @@ const getColorScheme = ({ const colorMap: Record = {}; distinctValues.forEach((v) => { // Use custom color if provided, otherwise use default - colorMap[v] = categoryColors?.[v] ?? getCategoricalColor(v, colorScale); + colorMap[v] = + viewConfig.categoryColors?.[v] ?? getCategoricalColor(v, colorScale); }); return { columnType: ColumnType.String, @@ -193,10 +197,13 @@ const getColorScheme = ({ const minValue = areaStats.primary.minValue; const maxValue = areaStats.primary.maxValue; if (minValue === maxValue) { - const domain = isReversed ? [1, 0] : [0, 1]; + const domain = viewConfig.reverseColorScheme ? [1, 0] : [0, 1]; // For count records, create a simple color scheme // Use a small range to ensure valid interpolation - const interpolator = getInterpolator(scheme, customColor); + const interpolator = getInterpolator( + viewConfig.colorScheme, + viewConfig.customColor, + ); const colorScale = scaleSequential() .domain(domain) // Use 0-1 range for single values .interpolator(interpolator); @@ -210,12 +217,14 @@ const getColorScheme = ({ }; } - const domain = (isReversed ? [maxValue, minValue] : [minValue, maxValue]) as [ - number, - number, - ]; + const domain = ( + viewConfig.reverseColorScheme ? [maxValue, minValue] : [minValue, maxValue] + ) as [number, number]; - const interpolator = getInterpolator(scheme, customColor); + const interpolator = getInterpolator( + viewConfig.colorScheme, + viewConfig.customColor, + ); const colorScale = scaleSequential() .domain(domain) .interpolator(interpolator); @@ -237,22 +246,12 @@ const getCategoricalColor = ( export const useFillColor = ({ areaStats, - scheme, - isReversed, + viewConfig, selectedBivariateBucket, - categoryColors, - colorScaleType, - steppedColorSteps, - customColor, }: { areaStats: CombinedAreaStats | null; - scheme: ColorScheme; - isReversed: boolean; + viewConfig: MapViewConfig; selectedBivariateBucket: string | null; - categoryColors?: Record; - colorScaleType?: ColorScaleType; - steppedColorSteps?: SteppedColorStep[]; - customColor?: string; }): DataDrivenPropertyValueSpecification => { // useMemo to cache calculated fillColor return useMemo(() => { @@ -263,10 +262,7 @@ export const useFillColor = ({ const isCount = areaStats?.calculationType === CalculationType.Count; const colorScheme = getColorScheme({ areaStats, - scheme, - isReversed, - categoryColors, - customColor, + viewConfig, }); if (!colorScheme) { return DEFAULT_FILL_COLOR; @@ -284,12 +280,14 @@ export const useFillColor = ({ } // ColumnType.Number - Check if stepped colors are enabled + const steppedColorSteps = + viewConfig.steppedColorStepsByKey?.[getChoroplethDataKey(viewConfig)]; if ( - colorScaleType === ColorScaleType.Stepped && + viewConfig.colorScaleType === ColorScaleType.Stepped && steppedColorSteps && steppedColorSteps.length > 0 ) { - return getSteppedFillColor(steppedColorSteps, isCount); + return getSteppedFillColor(steppedColorSteps, isCount, viewConfig); } // ColumnType.Number - Gradient (default) @@ -330,21 +328,13 @@ export const useFillColor = ({ : ["feature-state", "value"], ...interpolateColorStops, ]; - }, [ - areaStats, - isReversed, - scheme, - selectedBivariateBucket, - categoryColors, - colorScaleType, - steppedColorSteps, - customColor, - ]); + }, [areaStats, viewConfig, selectedBivariateBucket]); }; const getSteppedFillColor = ( steps: SteppedColorStep[], isCount: boolean, + viewConfig: MapViewConfig, ): DataDrivenPropertyValueSpecification => { // Sort steps by start value to ensure correct order const sortedSteps = [...steps].sort((a, b) => a.start - b.start); @@ -353,6 +343,10 @@ const getSteppedFillColor = ( return DEFAULT_FILL_COLOR; } + if (sortedSteps.length === 1) { + return calculateStepColor(0, sortedSteps.length, viewConfig); + } + // 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) @@ -365,7 +359,7 @@ const getSteppedFillColor = ( isCount ? ["coalesce", ["feature-state", "value"], 0] : ["feature-state", "value"], - sortedSteps[0]?.color || DEFAULT_FILL_COLOR, // Default color for values < first threshold + calculateStepColor(0, sortedSteps.length, viewConfig), ]; // Add thresholds and colors @@ -373,8 +367,9 @@ const getSteppedFillColor = ( // The color applies to values >= threshold for (let i = 1; i < sortedSteps.length; i++) { const step = sortedSteps[i]; + const color = calculateStepColor(i, sortedSteps.length, viewConfig); stepExpression.push(step.start); // Threshold - stepExpression.push(step.color); // Color for values >= threshold + stepExpression.push(color); // Color for values >= threshold } return stepExpression; diff --git a/src/app/map/[id]/components/AreaInfo.tsx b/src/app/map/[id]/components/AreaInfo.tsx index ab89bab8..a4b6b730 100644 --- a/src/app/map/[id]/components/AreaInfo.tsx +++ b/src/app/map/[id]/components/AreaInfo.tsx @@ -5,7 +5,7 @@ import { expression } from "mapbox-gl/dist/style-spec/index.cjs"; import { useMemo, useState } from "react"; import { ColumnType } from "@/server/models/DataSource"; -import { CalculationType, ColorScheme } from "@/server/models/MapView"; +import { CalculationType } from "@/server/models/MapView"; import { Table, TableBody, @@ -84,13 +84,8 @@ export default function AreaInfo() { const fillColor = useFillColor({ areaStats, - scheme: viewConfig.colorScheme || ColorScheme.RedBlue, - isReversed: Boolean(viewConfig.reverseColorScheme), + viewConfig, 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/AreaPopup.tsx b/src/app/map/[id]/components/AreaPopup.tsx index 5acb4759..e51cf204 100644 --- a/src/app/map/[id]/components/AreaPopup.tsx +++ b/src/app/map/[id]/components/AreaPopup.tsx @@ -1,7 +1,7 @@ import { expression } from "mapbox-gl/dist/style-spec/index.cjs"; import { Popup } from "react-map-gl/mapbox"; import { ColumnType } from "@/server/models/DataSource"; -import { CalculationType, ColorScheme } from "@/server/models/MapView"; +import { CalculationType } from "@/server/models/MapView"; import { formatNumber } from "@/utils/text"; import { useFillColor } from "../colors"; import { useAreaStats } from "../data"; @@ -56,8 +56,7 @@ function WrappedAreaPopup({ const fillColor = useFillColor({ areaStats, - scheme: viewConfig.colorScheme || ColorScheme.RedBlue, - isReversed: Boolean(viewConfig.reverseColorScheme), + viewConfig, selectedBivariateBucket: null, }); diff --git a/src/app/map/[id]/components/Choropleth/index.tsx b/src/app/map/[id]/components/Choropleth/index.tsx index 8d82915d..c6aee507 100644 --- a/src/app/map/[id]/components/Choropleth/index.tsx +++ b/src/app/map/[id]/components/Choropleth/index.tsx @@ -4,8 +4,10 @@ import { useMapViews } from "@/app/map/[id]/hooks/useMapViews"; import { MapType } from "@/server/models/MapView"; import { mapColors } from "../../styles"; import { getMapStyle } from "../../utils/map"; -import { useChoroplethAreaStats } from "./useChoroplethAreaStats"; -import { useChoroplethFeatureStatesEffect } from "./useChoroplethFeatureStatesEffect"; +import { + useChoroplethFeatureStatesEffect, + useChoroplethFillColor, +} from "./useChoroplethColors"; export default function Choropleth() { const { viewConfig } = useMapViews(); @@ -17,7 +19,7 @@ export default function Choropleth() { const choroplethTopLayerId = "choropleth-top"; // Custom hooks for effects - const fillColor = useChoroplethAreaStats(); + const fillColor = useChoroplethFillColor(); const opacity = (viewConfig.choroplethOpacityPct ?? 80) / 100; useChoroplethFeatureStatesEffect(); diff --git a/src/app/map/[id]/components/Choropleth/useChoroplethAreaStats.ts b/src/app/map/[id]/components/Choropleth/useChoroplethAreaStats.ts deleted file mode 100644 index 1aaabaea..00000000 --- a/src/app/map/[id]/components/Choropleth/useChoroplethAreaStats.ts +++ /dev/null @@ -1,27 +0,0 @@ -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"; - -export function useChoroplethAreaStats() { - const { selectedBivariateBucket } = useChoropleth(); - - const { viewConfig } = useMapViews(); - const areaStatsQuery = useAreaStats(); - const areaStats = areaStatsQuery.data; - - // 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, - }); - - return fillColor; -} diff --git a/src/app/map/[id]/components/Choropleth/useChoroplethFeatureStatesEffect.ts b/src/app/map/[id]/components/Choropleth/useChoroplethColors.ts similarity index 83% rename from src/app/map/[id]/components/Choropleth/useChoroplethFeatureStatesEffect.ts rename to src/app/map/[id]/components/Choropleth/useChoroplethColors.ts index dc7decd5..831c3f8b 100644 --- a/src/app/map/[id]/components/Choropleth/useChoroplethFeatureStatesEffect.ts +++ b/src/app/map/[id]/components/Choropleth/useChoroplethColors.ts @@ -1,7 +1,26 @@ 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 { useMapRef } from "../../hooks/useMapCore"; +import { useMapRef } from "@/app/map/[id]/hooks/useMapCore"; +import { useMapViews } from "@/app/map/[id]/hooks/useMapViews"; + +export function useChoroplethFillColor() { + const { selectedBivariateBucket } = useChoropleth(); + + const { viewConfig } = useMapViews(); + const areaStatsQuery = useAreaStats(); + const areaStats = areaStatsQuery.data; + + // Get fill color + const fillColor = useFillColor({ + areaStats, + viewConfig, + selectedBivariateBucket, + }); + + return fillColor; +} export function useChoroplethFeatureStatesEffect() { const mapRef = useMapRef(); diff --git a/src/app/map/[id]/components/Choropleth/utils.ts b/src/app/map/[id]/components/Choropleth/utils.ts new file mode 100644 index 00000000..8dd792d6 --- /dev/null +++ b/src/app/map/[id]/components/Choropleth/utils.ts @@ -0,0 +1,23 @@ +import { + CalculationType, + DEFAULT_CALCULATION_TYPE, +} from "@/server/models/MapView"; + +export const getChoroplethDataKey = (viewConfig: { + calculationType?: CalculationType | null | undefined; + areaDataSourceId: string; + areaDataColumn: string; + areaDataSecondaryColumn?: string | null | undefined; +}) => { + const calculationType = + viewConfig.calculationType || DEFAULT_CALCULATION_TYPE; + const parts: (string | null | undefined)[] = [ + viewConfig.areaDataSourceId, + calculationType, + ]; + if (calculationType !== CalculationType.Count) { + parts.push(viewConfig.areaDataColumn); + parts.push(viewConfig.areaDataSecondaryColumn); + } + return parts.filter(Boolean).join("-"); +}; diff --git a/src/app/map/[id]/components/Legend.tsx b/src/app/map/[id]/components/Legend.tsx index 7d212911..53693a09 100644 --- a/src/app/map/[id]/components/Legend.tsx +++ b/src/app/map/[id]/components/Legend.tsx @@ -4,15 +4,12 @@ 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, - ColorScaleType, - ColorScheme, -} from "@/server/models/MapView"; +import { CalculationType, ColorScaleType } from "@/server/models/MapView"; import { formatNumber } from "@/utils/text"; -import { useColorScheme } from "../colors"; +import { calculateStepColor, useColorScheme } from "../colors"; import { useAreaStats } from "../data"; import BivariateLegend from "./BivariateLagend"; +import { getChoroplethDataKey } from "./Choropleth/utils"; export default function Legend() { const { viewConfig, updateViewConfig } = useMapViews(); @@ -25,10 +22,7 @@ export default function Legend() { const colorScheme = useColorScheme({ areaStats, - scheme: viewConfig.colorScheme || ColorScheme.RedBlue, - isReversed: Boolean(viewConfig.reverseColorScheme), - categoryColors: viewConfig.categoryColors, - customColor: viewConfig.customColor, + viewConfig, }); const isLayerVisible = viewConfig.showChoropleth !== false; @@ -72,12 +66,14 @@ export default function Legend() { if (colorScheme.columnType === ColumnType.Number) { // Handle stepped colors + const steppedColorSteps = + viewConfig.steppedColorStepsByKey?.[getChoroplethDataKey(viewConfig)]; if ( viewConfig.colorScaleType === ColorScaleType.Stepped && - viewConfig.steppedColorSteps && - viewConfig.steppedColorSteps.length > 0 + steppedColorSteps && + steppedColorSteps.length > 0 ) { - const sortedSteps = [...viewConfig.steppedColorSteps].sort( + const sortedSteps = [...steppedColorSteps].sort( (a, b) => a.start - b.start, ); const range = colorScheme.maxValue - colorScheme.minValue; @@ -111,7 +107,11 @@ export default function Legend() { className="h-full border-r border-neutral-400 last:border-r-0" style={{ width: `${width}%`, - backgroundColor: step.color, + backgroundColor: calculateStepColor( + index, + sortedSteps.length, + viewConfig, + ), }} /> ); diff --git a/src/app/map/[id]/components/controls/BoundariesControl/useBoundariesControl.tsx b/src/app/map/[id]/components/controls/BoundariesControl/useBoundariesControl.tsx index 0eff8ae4..f0167835 100644 --- a/src/app/map/[id]/components/controls/BoundariesControl/useBoundariesControl.tsx +++ b/src/app/map/[id]/components/controls/BoundariesControl/useBoundariesControl.tsx @@ -7,7 +7,11 @@ import { useMapViews } from "@/app/map/[id]/hooks/useMapViews"; import { MAX_COLUMN_KEY } from "@/constants"; import { AreaSetGroupCodeLabels } from "@/labels"; import { AreaSetGroupCode } from "@/server/models/AreaSet"; -import { CalculationType, MapType } from "@/server/models/MapView"; +import { + CalculationType, + DEFAULT_CALCULATION_TYPE, + MapType, +} from "@/server/models/MapView"; export function useBoundariesControl() { const { viewConfig, updateViewConfig } = useMapViews(); @@ -107,7 +111,7 @@ export function useBoundariesControl() { updateViewConfig({ areaDataSourceId: voteShareDataSource.id, areaDataColumn: column.name, - calculationType: CalculationType.Avg, + calculationType: DEFAULT_CALCULATION_TYPE, }); }, })) diff --git a/src/app/map/[id]/components/controls/LayerHeader.tsx b/src/app/map/[id]/components/controls/LayerHeader.tsx index ff34e373..c26a81a3 100644 --- a/src/app/map/[id]/components/controls/LayerHeader.tsx +++ b/src/app/map/[id]/components/controls/LayerHeader.tsx @@ -38,7 +38,7 @@ export default function LayerHeader({ return (
diff --git a/src/app/map/[id]/components/controls/VisualisationPanel/CategoryColorEditor.tsx b/src/app/map/[id]/components/controls/VisualisationPanel/CategoryColorEditor.tsx index 923dceae..8f9aea5e 100644 --- a/src/app/map/[id]/components/controls/VisualisationPanel/CategoryColorEditor.tsx +++ b/src/app/map/[id]/components/controls/VisualisationPanel/CategoryColorEditor.tsx @@ -4,7 +4,6 @@ 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, @@ -22,10 +21,7 @@ export default function CategoryColorEditor() { const colorScheme = useColorScheme({ areaStats, - scheme: viewConfig.colorScheme || ColorScheme.RedBlue, - isReversed: Boolean(viewConfig.reverseColorScheme), - categoryColors: viewConfig.categoryColors, - customColor: viewConfig.customColor, + viewConfig, }); // Get unique categories from areaStats diff --git a/src/app/map/[id]/components/controls/VisualisationPanel/SteppedColorEditor.tsx b/src/app/map/[id]/components/controls/VisualisationPanel/SteppedColorEditor.tsx index f1fb7794..6f939b7a 100644 --- a/src/app/map/[id]/components/controls/VisualisationPanel/SteppedColorEditor.tsx +++ b/src/app/map/[id]/components/controls/VisualisationPanel/SteppedColorEditor.tsx @@ -1,14 +1,10 @@ import { Plus, Trash2 } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { getInterpolator } from "@/app/map/[id]/colors"; +import { calculateStepColor } 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 { ColorScaleType, type SteppedColorStep } from "@/server/models/MapView"; import { Button } from "@/shadcn/ui/button"; import { Dialog, @@ -17,6 +13,7 @@ import { DialogTitle, DialogTrigger, } from "@/shadcn/ui/dialog"; +import { getChoroplethDataKey } from "../../Choropleth/utils"; // Custom multi-handle range slider function RangeSlider({ @@ -152,118 +149,76 @@ function RangeSlider({ ); } +// Helper function to create default steps +function createDefaultSteps( + minValue: number, + maxValue: number, +): SteppedColorStep[] { + const stepSize = (maxValue - minValue) / 3; + const ranges = [ + { start: minValue, end: minValue + stepSize }, + { start: minValue + stepSize, end: minValue + stepSize * 2 }, + { start: minValue + stepSize * 2, end: maxValue }, + ]; + + return ranges.map((range) => ({ + start: range.start, + end: range.end, + })); +} + export default function SteppedColorEditor() { const { viewConfig, updateViewConfig } = useMapViews(); - const areaStatsQuery = useAreaStats(); - const areaStats = areaStatsQuery?.data; + const choroplethDataKey = getChoroplethDataKey(viewConfig); + const savedSteppedColorSteps = + viewConfig.steppedColorStepsByKey?.[choroplethDataKey]; + const { data: areaStats } = useAreaStats(); const [isOpen, setIsOpen] = useState(false); - const [localSteps, setLocalSteps] = useState([]); + const areaStatsDataKey = getChoroplethDataKey({ + calculationType: areaStats?.calculationType, + areaDataSourceId: areaStats?.dataSourceId || "", + areaDataColumn: areaStats?.primary?.column || "", + areaDataSecondaryColumn: areaStats?.secondary?.column, + }); const minValue = areaStats?.primary?.minValue ?? 0; const maxValue = areaStats?.primary?.maxValue ?? 0; - const colorScheme = viewConfig.colorScheme || ColorScheme.RedBlue; - const isReversed = Boolean(viewConfig.reverseColorScheme); + const hasValidRange = maxValue > minValue; - // Get step ranges (without colors) from config or defaults - const stepRanges = useMemo(() => { - if (minValue === 0 && maxValue === 0) { + // Compute default steps + const defaultSteps = useMemo(() => { + if (!hasValidRange) { 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]); + return createDefaultSteps(minValue, maxValue); + }, [hasValidRange, maxValue, minValue]); - // 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]); + const [localSteps, setLocalSteps] = useState( + savedSteppedColorSteps || [], + ); + // Initial setup 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", - }; + if ( + !savedSteppedColorSteps?.length && + choroplethDataKey === areaStatsDataKey + ) { + setLocalSteps(defaultSteps); + updateViewConfig({ + steppedColorStepsByKey: { + ...viewConfig.steppedColorStepsByKey, + [choroplethDataKey]: defaultSteps, + }, }); - setLocalSteps(updatedSteps); } - }, [colorScheme, isReversed, viewConfig.customColor, isOpen]); + }, [ + areaStatsDataKey, + choroplethDataKey, + defaultSteps, + savedSteppedColorSteps?.length, + updateViewConfig, + viewConfig.steppedColorStepsByKey, + ]); if ( !areaStats || @@ -294,16 +249,6 @@ export default function SteppedColorEditor() { 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); }; @@ -319,20 +264,9 @@ export default function SteppedColorEditor() { 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); }; @@ -349,21 +283,12 @@ export default function SteppedColorEditor() { } } - // 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 steps = savedSteppedColorSteps || defaultSteps; + setLocalSteps([...steps]); }; const handleApply = () => { @@ -382,7 +307,10 @@ export default function SteppedColorEditor() { } updateViewConfig({ - steppedColorSteps: stepsToApply.length > 0 ? stepsToApply : undefined, + steppedColorStepsByKey: { + ...viewConfig.steppedColorStepsByKey, + [choroplethDataKey]: stepsToApply, + }, }); setIsOpen(false); }; @@ -417,7 +345,12 @@ export default function SteppedColorEditor() {
{localSteps.map((step, index) => { const isFirst = index === 0; - const isLast = index === steps.length - 1; + const isLast = index === localSteps.length - 1; + const stepColor = calculateStepColor( + index, + localSteps.length, + viewConfig, + ); return (
@@ -470,7 +403,7 @@ export default function SteppedColorEditor() { } }} step={stepSize} - color={step.color} + color={stepColor} isFirst={isFirst} isLast={isLast} /> @@ -487,7 +420,7 @@ export default function SteppedColorEditor() { - {viewConfig.steppedColorSteps && ( + {localSteps && ( diff --git a/src/app/map/[id]/components/controls/VisualisationPanel/VisualisationPanel.tsx b/src/app/map/[id]/components/controls/VisualisationPanel/VisualisationPanel.tsx index c11b6301..8bf9e670 100644 --- a/src/app/map/[id]/components/controls/VisualisationPanel/VisualisationPanel.tsx +++ b/src/app/map/[id]/components/controls/VisualisationPanel/VisualisationPanel.tsx @@ -21,6 +21,7 @@ import { CalculationType, ColorScaleType, ColorScheme, + DEFAULT_CALCULATION_TYPE, } from "@/server/models/MapView"; import { Button } from "@/shadcn/ui/button"; import { Checkbox } from "@/shadcn/ui/checkbox"; @@ -275,7 +276,7 @@ export default function VisualisationPanel({ Visualisation

-
+
{!viewConfig.areaDataSourceId && ( @@ -553,7 +554,7 @@ export default function VisualisationPanel({ Style

-
+
{!viewConfig.areaDataSecondaryColumn && columnOneIsNumber && ( <>