Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 51 additions & 24 deletions src/app/(private)/map/[id]/components/Markers/ClustersLayer.tsx
Original file line number Diff line number Diff line change
@@ -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 = [
Expand All @@ -7,61 +8,87 @@ 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 (
<>
<Layer
id={`${sourceId}-circles`}
key={`${sourceId}-circles`}
type="circle"
source={sourceId}
filter={["has", "point_count"]}
filter={CLUSTER_FILTER}
paint={{
"circle-radius": [
"interpolate",
["linear"],
["get", "point_count"],
1,
15,
10,
25,
100,
35,
1000,
50,
10000,
70,
],
"circle-color": color,
"circle-opacity": [
"case",
["==", ["get", "matched_count"], 0],
0.5,
0.8,
],
"circle-radius": CLUSTER_RADIUS,
"circle-color": onChoropleth ? "#ffffff" : color,
"circle-opacity": opacity,
"circle-stroke-width": onChoropleth ? EDGE_WIDTH : 0,
"circle-stroke-color": edgeColor,
"circle-stroke-opacity": opacity,
}}
/>
<Layer
id={`${sourceId}-counts`}
key={`${sourceId}-counts`}
type="symbol"
source={sourceId}
filter={["has", "point_count"]}
filter={CLUSTER_FILTER}
layout={{
"text-field": ["get", "point_count"],
"text-font": ["DIN Pro Medium", "Arial Unicode MS Bold"],
"text-size": 12,
}}
paint={{
"text-color": onChoropleth ? edgeColor : "#000000",
}}
/>
</>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ export function DataSourceMarkers({
colorMappings,
hideFilteredMarkers = false,
filterTimeRange = null,
onChoropleth = false,
}: {
dataSourceMarkers: { dataSourceId: string; markers: MarkerFeature[] };
isMembers: boolean;
Expand All @@ -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();
Expand Down Expand Up @@ -339,7 +343,13 @@ export function DataSourceMarkers({
asJson: ["concat", ["concat", ["get", "asJson"], ","]],
}}
>
{clustered && <ClustersLayer sourceId={sourceId} color={color} />}
{clustered && (
<ClustersLayer
sourceId={sourceId}
color={color}
onChoropleth={onChoropleth}
/>
)}
{isHeatmap && (
<HeatmapLayer
sourceId={sourceId}
Expand All @@ -353,6 +363,7 @@ export function DataSourceMarkers({
filter={clustered ? UNCLUSTERED_FILTER : undefined}
minzoom={isHeatmap ? 10 : undefined}
overdraw={displayMode === MarkerDisplayMode.Overlap}
onChoropleth={onChoropleth}
/>
</Source>
);
Expand Down
12 changes: 12 additions & 0 deletions src/app/(private)/map/[id]/components/Markers/Markers.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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
Expand Down Expand Up @@ -81,6 +91,7 @@ export default function Markers() {
colorMappings={viewConfig.colorMappings}
hideFilteredMarkers={viewConfig.hideFilteredMarkers}
filterTimeRange={getFilterRange(memberMarkers.dataSourceId)}
onChoropleth={onChoropleth}
/>
)}
{otherMarkers.map((markers) => {
Expand All @@ -106,6 +117,7 @@ export default function Markers() {
colorMappings={viewConfig.colorMappings}
hideFilteredMarkers={viewConfig.hideFilteredMarkers}
filterTimeRange={getFilterRange(markers.dataSourceId)}
onChoropleth={onChoropleth}
/>
);
})}
Expand Down
9 changes: 7 additions & 2 deletions src/app/(private)/map/[id]/components/Markers/PinsLayer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export function PinsLayer({
filter,
minzoom = 0,
overdraw = false,
onChoropleth = false,
}: {
sourceId: string;
color: string;
Expand All @@ -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 = [
"*",
Expand Down Expand Up @@ -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,
}}
/>
) : (
Expand All @@ -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,
}}
Expand Down
80 changes: 72 additions & 8 deletions src/utils/colors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,26 +136,90 @@ 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) {
let hex = hexMatch[1];
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})`;
};
Loading
Loading