Skip to content
Closed
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
27 changes: 23 additions & 4 deletions scripts/tests/crime_ui_contracts.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,32 @@ import { readFile } from 'node:fs/promises';
import { store } from '../../src/state/store.js';
import { attachDistrictPopup } from '../../src/map/ui_popup_district.js';

test('dense Crime clusters switch to a high-contrast white count label', async () => {
const { clusterTextColorExpression } = await import('../../src/map/points.js');
assert.deepEqual(clusterTextColorExpression(), [
test('Crime cluster count scale adapts to the currently filtered point total', async () => {
const {
clusterColorExpression,
clusterCountBreaks,
clusterRadiusExpression,
clusterTextColorExpression,
} = await import('../../src/map/points.js');

assert.equal(clusterColorExpression(0), '#9cdcf6');
assert.equal(clusterRadiusExpression(0), 14);
assert.equal(clusterTextColorExpression(0), '#112');
assert.deepEqual(clusterCountBreaks(10_000), [10, 100, 1000]);
assert.deepEqual(clusterCountBreaks(16), [2, 4, 8]);
assert.deepEqual(clusterColorExpression(16), [
'step',
['get', 'point_count'],
'#9cdcf6',
2, '#52b5e9',
4, '#2f83c9',
8, '#1f497b',
]);
assert.deepEqual(clusterTextColorExpression(16), [
'step',
['get', 'point_count'],
'#112',
100,
8,
'#fff',
]);
});
Expand Down
45 changes: 45 additions & 0 deletions scripts/tests/points_lifecycle.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,51 @@ test('refreshPoints forwards AbortSignal and stale success cannot mutate the map
assert.deepEqual(map.mutations, []);
});

test('refreshPoints reapplies the cluster count scale when filtered totals change', async () => {
const paintMutations = [];
const source = { setData() {} };
const map = {
getBounds: () => ({
getWest: () => -75.2,
getSouth: () => 39.9,
getEast: () => -75.1,
getNorth: () => 40,
}),
getSource: (id) => (id === 'crime-points' ? source : null),
getLayer: (id) => ['clusters', 'cluster-count', 'unclustered'].includes(id) ? { id } : null,
setPaintProperty: (...args) => paintMutations.push(args),
};
const originalDocument = globalThis.document;
globalThis.document = { getElementById: () => null };
try {
await refreshPoints(map, {
start: '2026-01-01',
end: '2026-02-01',
types: ['Burglary Non-Residential'],
fetchPointsImpl: async () => ({
type: 'FeatureCollection',
features: Array.from({ length: 16 }, (_, id) => ({ id })),
}),
});
} finally {
globalThis.document = originalDocument;
}

assert.deepEqual(paintMutations, [
['clusters', 'circle-color', [
'step', ['get', 'point_count'], '#9cdcf6',
2, '#52b5e9', 4, '#2f83c9', 8, '#1f497b',
]],
['clusters', 'circle-radius', [
'step', ['get', 'point_count'], 14,
2, 18, 4, 24, 8, 30,
]],
['cluster-count', 'text-color', [
'step', ['get', 'point_count'], '#112', 8, '#fff',
]],
]);
});

test('a newer refresh aborts the superseded request', async () => {
const map = createMap();
const requests = [];
Expand Down
67 changes: 48 additions & 19 deletions src/map/points.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,12 +37,47 @@ function unclusteredColorExpression() {
return expr;
}

export function clusterTextColorExpression() {
const CLUSTER_COLORS = ['#9cdcf6', '#52b5e9', '#2f83c9', '#1f497b'];
const CLUSTER_RADII = [14, 18, 24, 30];

export function clusterCountBreaks(pointCount) {
const total = Math.max(0, Math.floor(Number(pointCount) || 0));
if (total < 2) return [];
const breaks = [];
for (let index = 1; index < CLUSTER_COLORS.length; index += 1) {
const threshold = Math.min(total, Math.max(2, Math.round(total ** (index / CLUSTER_COLORS.length))));
if (threshold !== breaks[breaks.length - 1]) breaks.push(threshold);
}
return breaks;
}

function clusterStepExpression(pointCount, stops) {
const breaks = clusterCountBreaks(pointCount);
if (breaks.length === 0) return stops[0];
const expression = ['step', ['get', 'point_count'], stops[0]];
const stopOffset = stops.length - breaks.length;
for (let index = 0; index < breaks.length; index += 1) {
expression.push(breaks[index], stops[stopOffset + index]);
}
return expression;
}

export function clusterColorExpression(pointCount) {
return clusterStepExpression(pointCount, CLUSTER_COLORS);
}

export function clusterRadiusExpression(pointCount) {
return clusterStepExpression(pointCount, CLUSTER_RADII);
}

export function clusterTextColorExpression(pointCount) {
const breaks = clusterCountBreaks(pointCount);
if (breaks.length === 0) return '#112';
return [
'step',
['get', 'point_count'],
'#112',
100,
breaks[breaks.length - 1],
'#fff',
];
}
Expand Down Expand Up @@ -123,6 +158,9 @@ export async function refreshPoints(map, {
const geo = await fetchPointsImpl({ start, end, types, bbox, dc_dist, signal });
if (signal?.aborted || !shouldApply()) return { applied: false };
const count = Array.isArray(geo?.features) ? geo.features.length : 0;
const clusterColor = clusterColorExpression(count);
const clusterRadius = clusterRadiusExpression(count);
const clusterTextColor = clusterTextColorExpression(count);

// Add or update source
if (map.getSource(srcId)) {
Expand All @@ -145,25 +183,14 @@ export async function refreshPoints(map, {
source: srcId,
filter: ['has', 'point_count'],
paint: {
'circle-color': [
'step',
['get', 'point_count'],
'#9cdcf6',
10, '#52b5e9',
50, '#2f83c9',
100, '#1f497b'
],
'circle-radius': [
'step',
['get', 'point_count'],
14,
10, 18,
50, 24,
100, 30
],
'circle-color': clusterColor,
'circle-radius': clusterRadius,
'circle-opacity': 0.85
}
});
} else {
map.setPaintProperty(clusterId, 'circle-color', clusterColor);
map.setPaintProperty(clusterId, 'circle-radius', clusterRadius);
}

// Cluster count labels
Expand All @@ -179,9 +206,11 @@ export async function refreshPoints(map, {
'text-size': 12
},
paint: {
'text-color': clusterTextColorExpression()
'text-color': clusterTextColor
}
});
} else {
map.setPaintProperty(clusterCountId, 'text-color', clusterTextColor);
}

// Unclustered single points
Expand Down