From b917b21b8bf471891623d007371e27b2290164f6 Mon Sep 17 00:00:00 2001 From: Joaquim de Souza Date: Wed, 23 Sep 2026 14:22:34 +0100 Subject: [PATCH] feat: high-contrast cluster markers over choropleths When a choropleth fill is painted, cluster markers switch to a high-contrast style: a white disc at 87.5% opacity with a 1px edge in the marker colour, and the count drawn in the marker colour. Pin strokes and icon halos widen from 1px to 2px. When no choropleth is painted, markers are unchanged. A white disc with a coloured edge is two-tone, so at least one tone contrasts with any background: white stands out on saturated fills and the edge on pale ones. This avoids per-scheme rules, which cannot account for the user-configurable marker colour. Pale marker colours are darkened by luminance for the edge and count so they still read against white. COOP-3729 Co-Authored-By: Claude Fable 5.1 --- .../[id]/components/Markers/ClustersLayer.tsx | 75 +++++++++++------ .../components/Markers/DataSourceMarkers.tsx | 13 ++- .../map/[id]/components/Markers/Markers.tsx | 12 +++ .../map/[id]/components/Markers/PinsLayer.tsx | 9 ++- src/utils/colors.ts | 80 +++++++++++++++++-- tests/unit/utils/colors.test.ts | 63 +++++++++++++++ 6 files changed, 217 insertions(+), 35 deletions(-) create mode 100644 tests/unit/utils/colors.test.ts diff --git a/src/app/(private)/map/[id]/components/Markers/ClustersLayer.tsx b/src/app/(private)/map/[id]/components/Markers/ClustersLayer.tsx index ce20fa6c..35da91b5 100644 --- a/src/app/(private)/map/[id]/components/Markers/ClustersLayer.tsx +++ b/src/app/(private)/map/[id]/components/Markers/ClustersLayer.tsx @@ -1,4 +1,5 @@ import { Layer } from "react-map-gl/mapbox"; +import { getContrastingRingColor } from "@/utils/colors"; import type { ExpressionSpecification } from "mapbox-gl"; export const UNCLUSTERED_FILTER: ExpressionSpecification = [ @@ -7,17 +8,56 @@ export const UNCLUSTERED_FILTER: ExpressionSpecification = [ ["==", ["get", "point_count"], 1], ]; +const CLUSTER_FILTER: ExpressionSpecification = ["has", "point_count"]; + +const CLUSTER_RADIUS: ExpressionSpecification = [ + "interpolate", + ["linear"], + ["get", "point_count"], + 1, + 15, + 10, + 25, + 100, + 35, + 1000, + 50, + 10000, + 70, +]; + +/** Clusters with no matched records are faded */ +const clusterOpacity = (opacity: number): ExpressionSpecification => [ + "case", + ["==", ["get", "matched_count"], 0], + 0.5, + opacity, +]; + +/** Thin edge in the marker colour so a white disc reads on pale fills */ +const EDGE_WIDTH = 1; + /** * Cluster circles and their point counts. Individual (unclustered) pins are * rendered separately by PinsLayer with the UNCLUSTERED_FILTER. + * + * Over a choropleth (`onChoropleth`) markers switch to a high-contrast + * style: a white disc with a thin edge and the count in the marker + * colour. The white disc stands out on saturated fills and the edge on + * pale ones. Otherwise the disc is a plain semi-transparent circle in + * the marker colour. */ export function ClustersLayer({ sourceId, color, + onChoropleth, }: { sourceId: string; color: string; + onChoropleth: boolean; }) { + const edgeColor = getContrastingRingColor(color); + const opacity = clusterOpacity(onChoropleth ? 0.875 : 0.8); return ( <> ); diff --git a/src/app/(private)/map/[id]/components/Markers/DataSourceMarkers.tsx b/src/app/(private)/map/[id]/components/Markers/DataSourceMarkers.tsx index 6b27b2ef..d9ce3815 100644 --- a/src/app/(private)/map/[id]/components/Markers/DataSourceMarkers.tsx +++ b/src/app/(private)/map/[id]/components/Markers/DataSourceMarkers.tsx @@ -63,6 +63,7 @@ export function DataSourceMarkers({ colorMappings, hideFilteredMarkers = false, filterTimeRange = null, + onChoropleth = false, }: { dataSourceMarkers: { dataSourceId: string; markers: MarkerFeature[] }; isMembers: boolean; @@ -75,6 +76,9 @@ export function DataSourceMarkers({ * sources with a date column and the timeline enabled. Features without * a parseable month are hidden while active. */ filterTimeRange?: { start: number; end: number } | null; + /** A choropleth fill is painted: markers switch to the high-contrast + * two-tone style (see ClustersLayer) */ + onChoropleth?: boolean; }) { const filteredRecords = useFilteredRecords(); const publicFilters = usePublicFilters(); @@ -339,7 +343,13 @@ export function DataSourceMarkers({ asJson: ["concat", ["concat", ["get", "asJson"], ","]], }} > - {clustered && } + {clustered && ( + + )} {isHeatmap && ( ); diff --git a/src/app/(private)/map/[id]/components/Markers/Markers.tsx b/src/app/(private)/map/[id]/components/Markers/Markers.tsx index bfc76e10..af4a658e 100644 --- a/src/app/(private)/map/[id]/components/Markers/Markers.tsx +++ b/src/app/(private)/map/[id]/components/Markers/Markers.tsx @@ -1,5 +1,6 @@ import { useEffect, useMemo } from "react"; +import { useAreaStats } from "@/app/(private)/map/[id]/data"; import { useMapConfig } from "@/app/(private)/map/[id]/hooks/useMapConfig"; import { useMapViews } from "@/app/(private)/map/[id]/hooks/useMapViews"; import { useMarkerQueries } from "@/app/(private)/map/[id]/hooks/useMarkerQueries"; @@ -18,6 +19,15 @@ export default function Markers() { const { getDataSourceById } = useDataSources(); const mapRef = useMapRef(); const { activeRange } = useTimelineFilter(); + const areaStats = useAreaStats().data; + + // A choropleth fill is painted only when it is switched on, boundaries are + // selected and there are stats to colour them with (Choropleth.tsx) + const hasAreaStats = Boolean(areaStats?.stats.length); + const onChoropleth = + Boolean(viewConfig.showChoropleth) && + Boolean(viewConfig.areaSetGroupCode) && + hasAreaStats; // The timeline filter only applies to sources with a date column, // matching the markers API @@ -81,6 +91,7 @@ export default function Markers() { colorMappings={viewConfig.colorMappings} hideFilteredMarkers={viewConfig.hideFilteredMarkers} filterTimeRange={getFilterRange(memberMarkers.dataSourceId)} + onChoropleth={onChoropleth} /> )} {otherMarkers.map((markers) => { @@ -106,6 +117,7 @@ export default function Markers() { colorMappings={viewConfig.colorMappings} hideFilteredMarkers={viewConfig.hideFilteredMarkers} filterTimeRange={getFilterRange(markers.dataSourceId)} + onChoropleth={onChoropleth} /> ); })} diff --git a/src/app/(private)/map/[id]/components/Markers/PinsLayer.tsx b/src/app/(private)/map/[id]/components/Markers/PinsLayer.tsx index 048b5982..247b86d7 100644 --- a/src/app/(private)/map/[id]/components/Markers/PinsLayer.tsx +++ b/src/app/(private)/map/[id]/components/Markers/PinsLayer.tsx @@ -30,6 +30,7 @@ export function PinsLayer({ filter, minzoom = 0, overdraw = false, + onChoropleth = false, }: { sourceId: string; color: string; @@ -43,11 +44,15 @@ export function PinsLayer({ /** Overlap styling: semi-transparent strokeless dots so density reads * through overdraw */ overdraw?: boolean; + /** A choropleth fill is painted beneath the pins: widen the white + * stroke/halo so pins keep a clear edge against saturated fills */ + onChoropleth?: boolean; }) { const pinColor = pinStyle?.color ?? color; const sizeFactor = pinStyle?.sizeFactor ?? 1; const opacity = pinStyle?.opacity ?? 1; const showLabels = pinStyle?.showLabels ?? true; + const haloWidth = onChoropleth ? 2 : 1; const pinOpacity: ExpressionSpecification = [ "*", @@ -97,7 +102,7 @@ export function PinsLayer({ "icon-color": pinColor, "icon-opacity": pinOpacity, "icon-halo-color": "#ffffff", - "icon-halo-width": 1, + "icon-halo-width": haloWidth, }} /> ) : ( @@ -121,7 +126,7 @@ export function PinsLayer({ ], "circle-color": pinColor, "circle-opacity": pinOpacity, - "circle-stroke-width": overdraw ? 0 : 1, + "circle-stroke-width": overdraw ? 0 : haloWidth, "circle-stroke-color": "#ffffff", "circle-stroke-opacity": opacity, }} diff --git a/src/utils/colors.ts b/src/utils/colors.ts index 5e25f870..95f72157 100644 --- a/src/utils/colors.ts +++ b/src/utils/colors.ts @@ -136,9 +136,11 @@ export const getCategoryColorScale = (values: string[]) => { PARTY_COLORS[value.toLowerCase()] ?? ordinalScale(value); }; -/** Converts a `#rgb`/`#rrggbb` hex or `rgb(...)` colour to an `rgba(...)` - * string with the given alpha. Returns null for unrecognised formats. */ -export const colorWithAlpha = (color: string, alpha: number): string | null => { +/** Parses a `#rgb`/`#rrggbb` hex or `rgb(...)` colour into its channels. + * Returns null for unrecognised formats. */ +export const parseRgb = ( + color: string, +): { r: number; g: number; b: number } | null => { const trimmed = color.trim(); const hexMatch = trimmed.match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/i); if (hexMatch) { @@ -146,16 +148,78 @@ export const colorWithAlpha = (color: string, alpha: number): string | null => { if (hex.length === 3) { hex = `${hex[0]}${hex[0]}${hex[1]}${hex[1]}${hex[2]}${hex[2]}`; } - const r = parseInt(hex.slice(0, 2), 16); - const g = parseInt(hex.slice(2, 4), 16); - const b = parseInt(hex.slice(4, 6), 16); - return `rgba(${r}, ${g}, ${b}, ${alpha})`; + return { + r: parseInt(hex.slice(0, 2), 16), + g: parseInt(hex.slice(2, 4), 16), + b: parseInt(hex.slice(4, 6), 16), + }; } const rgbMatch = trimmed.match( /^rgb\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*\)$/i, ); if (rgbMatch) { - return `rgba(${rgbMatch[1]}, ${rgbMatch[2]}, ${rgbMatch[3]}, ${alpha})`; + return { + r: Number(rgbMatch[1]), + g: Number(rgbMatch[2]), + b: Number(rgbMatch[3]), + }; } return null; }; + +/** Converts a `#rgb`/`#rrggbb` hex or `rgb(...)` colour to an `rgba(...)` + * string with the given alpha. Returns null for unrecognised formats. */ +export const colorWithAlpha = (color: string, alpha: number): string | null => { + const rgb = parseRgb(color); + if (!rgb) { + return null; + } + return `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${alpha})`; +}; + +/** WCAG relative luminance, 0 (black) to 1 (white). */ +const getRelativeLuminance = ({ + r, + g, + b, +}: { + r: number; + g: number; + b: number; +}) => { + const linear = (channel: number) => { + const c = channel / 255; + return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4); + }; + return 0.2126 * linear(r) + 0.7152 * linear(g) + 0.0722 * linear(b); +}; + +/** Luminance above which a colour is too pale to read against white. */ +const MAX_RING_LUMINANCE = 0.45; + +/** + * Returns a version of the marker colour that reads clearly against a white + * background, for use as the edge and count colour of a white cluster + * marker. Colours that are already dark enough are returned unchanged + * (normalised to `rgb(...)` if they were parsed); pale colours are darkened + * until they cross the luminance threshold, preserving hue. + */ +export const getContrastingRingColor = (color: string): string => { + const rgb = parseRgb(color); + if (!rgb) { + return color; + } + let { r, g, b } = rgb; + let luminance = getRelativeLuminance({ r, g, b }); + // Darken in small steps so the hue is preserved + while (luminance > MAX_RING_LUMINANCE && (r > 0 || g > 0 || b > 0)) { + r = Math.floor(r * 0.9); + g = Math.floor(g * 0.9); + b = Math.floor(b * 0.9); + luminance = getRelativeLuminance({ r, g, b }); + } + if (r === rgb.r && g === rgb.g && b === rgb.b) { + return color; + } + return `rgb(${r}, ${g}, ${b})`; +}; diff --git a/tests/unit/utils/colors.test.ts b/tests/unit/utils/colors.test.ts new file mode 100644 index 00000000..c016c7f7 --- /dev/null +++ b/tests/unit/utils/colors.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, test } from "vitest"; +import { + colorWithAlpha, + getContrastingRingColor, + parseRgb, +} from "@/utils/colors"; + +describe("parseRgb", () => { + test("parses 6-digit hex", () => { + expect(parseRgb("#678DE3")).toEqual({ r: 103, g: 141, b: 227 }); + }); + + test("parses 3-digit hex", () => { + expect(parseRgb("#fff")).toEqual({ r: 255, g: 255, b: 255 }); + }); + + test("parses rgb()", () => { + expect(parseRgb("rgb(1, 2, 3)")).toEqual({ r: 1, g: 2, b: 3 }); + }); + + test("returns null for other formats", () => { + expect(parseRgb("red")).toBeNull(); + expect(parseRgb("rgba(1, 2, 3, 0.5)")).toBeNull(); + }); +}); + +describe("colorWithAlpha", () => { + test("applies alpha to hex", () => { + expect(colorWithAlpha("#ff6b6b", 0.5)).toBe("rgba(255, 107, 107, 0.5)"); + }); + + test("returns null for unrecognised colours", () => { + expect(colorWithAlpha("tomato", 0.5)).toBeNull(); + }); +}); + +describe("getContrastingRingColor", () => { + test("keeps the default marker colours unchanged", () => { + expect(getContrastingRingColor("#678DE3")).toBe("#678DE3"); + expect(getContrastingRingColor("#FF6B6B")).toBe("#FF6B6B"); + }); + + test("darkens pale colours until they read against white", () => { + const ring = getContrastingRingColor("#ffff00"); + expect(ring).not.toBe("#ffff00"); + const rgb = parseRgb(ring); + expect(rgb).not.toBeNull(); + // Hue preserved: still yellow (no blue channel) + expect(rgb?.b).toBe(0); + expect(rgb?.r).toBe(rgb?.g); + // Noticeably darker than the input + expect(rgb?.r).toBeLessThan(200); + }); + + test("darkens white to a grey", () => { + const rgb = parseRgb(getContrastingRingColor("#ffffff")); + expect(rgb?.r).toBeLessThan(200); + }); + + test("returns unparseable colours unchanged", () => { + expect(getContrastingRingColor("hotpink")).toBe("hotpink"); + }); +});