This commit is contained in:
Andras Schmelczer 2026-05-06 22:40:46 +01:00
parent 28323f145e
commit 94f9c0d594
76 changed files with 3238 additions and 1230 deletions

View file

@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest';
import type { FeatureMeta } from '../types';
import { apiUrl, assertOk, buildFilterString, isAbortError } from './api';
import { createSchoolFilterKey } from './school-filter';
import { createSpecificCrimeFilterKey } from './crime-filter';
describe('api utilities', () => {
it('builds API URLs from endpoint names, paths, and params', () => {
@ -81,4 +82,21 @@ describe('api utilities', () => {
)
).toBe('Good+ primary schools within 2km:2:8');
});
it('serializes specific crime filters using their selected backend crime feature', () => {
const features: FeatureMeta[] = [
{ name: 'Burglary (avg/yr)', type: 'numeric', min: 0, max: 20 },
{ name: 'Vehicle crime (avg/yr)', type: 'numeric', min: 0, max: 30 },
];
expect(
buildFilterString(
{
[createSpecificCrimeFilterKey('Burglary (avg/yr)', 1)]: [0, 5],
[createSpecificCrimeFilterKey('Vehicle crime (avg/yr)', 2)]: [1, 10],
},
features
)
).toBe('Burglary (avg/yr):0:5;;Vehicle crime (avg/yr):1:10');
});
});

View file

@ -2,6 +2,7 @@ import type { FeatureMeta, FeatureFilters } from '../types';
import { INITIAL_RETRY_MS, MAX_RETRY_MS } from './consts';
import pb from './pocketbase';
import { getSchoolBackendFeatureName } from './school-filter';
import { getSpecificCrimeFeatureName } from './crime-filter';
export function logNonAbortError(label: string, error: unknown): void {
if (error instanceof Error && error.name === 'AbortError') {
@ -87,7 +88,8 @@ export function buildFilterString(
const merged = new Map<string, [number, number] | string[]>();
for (const [name, value] of entries) {
if (name === exclude) continue;
const backendName = getSchoolBackendFeatureName(name) ?? name;
const backendName =
getSchoolBackendFeatureName(name) ?? getSpecificCrimeFeatureName(name) ?? name;
const prev = merged.get(backendName);
if (
prev &&

View file

@ -35,6 +35,10 @@ export const ZOOM_TO_RESOLUTION_THRESHOLDS = [
{ maxZoom: 13, resolution: 9 },
] as const;
export const SMALLEST_VISIBLE_HEXAGON_RESOLUTION = Math.max(
...ZOOM_TO_RESOLUTION_THRESHOLDS.map(({ resolution }) => resolution)
);
export const POSTCODE_ZOOM_THRESHOLD = 15;
export const FEATURE_GRADIENT: { t: number; color: [number, number, number] }[] = [
@ -132,6 +136,9 @@ export const POI_CATEGORY_LOGOS: Record<string, string> = {
Aldi: 'https://geolytix.github.io/MapIcons/brands/aldi_24px.svg',
Amazon: 'https://geolytix.github.io/MapIcons/brands/amazon_fresh_alt_24px.svg',
Asda: 'https://geolytix.github.io/MapIcons/asda/asda_primary.svg',
'Asda Express': 'https://geolytix.github.io/MapIcons/asda/asda_express_24px.svg',
'Asda Living': 'https://geolytix.github.io/MapIcons/asda/asda_living_24px.svg',
'Asda PFS': 'https://geolytix.github.io/MapIcons/asda/asda_pfs_24px.svg',
Bakery: '/assets/twemoji/1f950.png',
Booths: 'https://geolytix.github.io/MapIcons/brands/booths_24px.svg',
Budgens: 'https://geolytix.github.io/MapIcons/brands/budgens_24px.svg',
@ -153,17 +160,28 @@ export const POI_CATEGORY_LOGOS: Record<string, string> = {
Lidl: 'https://geolytix.github.io/MapIcons/brands/lidl_24px.svg',
Makro: 'https://geolytix.github.io/MapIcons/brands/makro_24px.svg',
'M&S': 'https://geolytix.github.io/MapIcons/brands/mns_24px.svg',
'M&S Clothing': 'https://geolytix.github.io/MapIcons/brands/mns_high_street_24px.svg',
'M&S Food': 'https://geolytix.github.io/MapIcons/brands/mns_food_24px.svg',
'M&S Hospital': 'https://geolytix.github.io/MapIcons/brands/mns_hospital_24px.svg',
'M&S MSA': 'https://geolytix.github.io/MapIcons/brands/mns_moto_24px.svg',
'M&S Outlet': 'https://geolytix.github.io/MapIcons/brands/mns_outlet_24px.svg',
Morrisons: 'https://geolytix.github.io/MapIcons/brands/morrisons_24px.svg',
'Morrisons Daily': 'https://geolytix.github.io/MapIcons/brands/morrisons_daily_24px.svg',
'Off-Licence': '/assets/twemoji/1f377.png',
'Planet Organic': 'https://geolytix.github.io/MapIcons/logos/planet_organic_24px.svg',
'Rail station': '/assets/twemoji/1f686.png',
"Sainsbury's": 'https://geolytix.github.io/MapIcons/brands/sainsburys_24px.svg',
"Sainsbury's Local": 'https://geolytix.github.io/MapIcons/brands/sainsburys_local_24px.svg',
Spar: 'https://geolytix.github.io/MapIcons/brands/spar_24px.svg',
Supermarket: '/assets/twemoji/1f6d2.png',
Tesco: 'https://geolytix.github.io/MapIcons/brands/tesco_24px.svg',
'Tesco Express': 'https://geolytix.github.io/MapIcons/brands/tesco_express_24px.svg',
'Tesco Extra': 'https://geolytix.github.io/MapIcons/brands/tesco_extra_24px.svg',
'Taxi rank': '/assets/twemoji/1f695.png',
'The Food Warehouse': 'https://geolytix.github.io/MapIcons/brands/iceland_food_warehouse_24px.svg',
'Tube station': 'https://geolytix.github.io/MapIcons/public_transport/london_tube.svg',
Waitrose: 'https://geolytix.github.io/MapIcons/brands/waitrose_24px.svg',
'Little Waitrose': 'https://geolytix.github.io/MapIcons/brands/little_waitrose_24px.svg',
'Whole Foods Market': 'https://geolytix.github.io/MapIcons/brands/wholefoods_24px.svg',
};

View file

@ -0,0 +1,116 @@
import type { FeatureFilters, FeatureMeta } from '../types';
export const SPECIFIC_CRIMES_FILTER_NAME = 'Specific crimes';
export const SPECIFIC_CRIMES_FILTER_KEY_PREFIX = `${SPECIFIC_CRIMES_FILTER_NAME}:`;
export const SPECIFIC_CRIME_FEATURE_NAMES = [
'Violence and sexual offences (avg/yr)',
'Burglary (avg/yr)',
'Robbery (avg/yr)',
'Vehicle crime (avg/yr)',
'Anti-social behaviour (avg/yr)',
'Criminal damage and arson (avg/yr)',
'Other theft (avg/yr)',
'Theft from the person (avg/yr)',
'Shoplifting (avg/yr)',
'Bicycle theft (avg/yr)',
'Drugs (avg/yr)',
'Possession of weapons (avg/yr)',
'Public order (avg/yr)',
'Other crime (avg/yr)',
] as const;
const SPECIFIC_CRIME_FEATURE_NAME_SET = new Set<string>(SPECIFIC_CRIME_FEATURE_NAMES);
export function isSpecificCrimeFeatureName(name: string): boolean {
return SPECIFIC_CRIME_FEATURE_NAME_SET.has(name);
}
export function isSpecificCrimeFilterName(name: string): boolean {
return isSpecificCrimeFeatureName(name) || name.startsWith(SPECIFIC_CRIMES_FILTER_KEY_PREFIX);
}
export function createSpecificCrimeFilterKey(featureName: string, id: number | string): string {
return `${SPECIFIC_CRIMES_FILTER_KEY_PREFIX}${encodeURIComponent(featureName)}:${id}`;
}
export function getSpecificCrimeFilterKeyId(name: string): string | null {
if (!name.startsWith(SPECIFIC_CRIMES_FILTER_KEY_PREFIX)) return null;
const rest = name.substring(SPECIFIC_CRIMES_FILTER_KEY_PREFIX.length);
const lastColon = rest.lastIndexOf(':');
return lastColon === -1 ? null : rest.substring(lastColon + 1);
}
export function parseSpecificCrimeFilterKey(name: string): string | null {
if (!name.startsWith(SPECIFIC_CRIMES_FILTER_KEY_PREFIX)) return null;
const rest = name.substring(SPECIFIC_CRIMES_FILTER_KEY_PREFIX.length);
const lastColon = rest.lastIndexOf(':');
if (lastColon === -1) return null;
const decoded = decodeURIComponent(rest.substring(0, lastColon));
return isSpecificCrimeFeatureName(decoded) ? decoded : null;
}
export function getSpecificCrimeFeatureName(name: string): string | null {
if (isSpecificCrimeFeatureName(name)) return name;
return parseSpecificCrimeFilterKey(name);
}
export function replaceSpecificCrimeFilterKeySelection(key: string, featureName: string): string {
const id = getSpecificCrimeFilterKeyId(key) ?? '0';
return createSpecificCrimeFilterKey(featureName, id);
}
export function getDefaultSpecificCrimeFeatureName(features: FeatureMeta[]): string | null {
return (
SPECIFIC_CRIME_FEATURE_NAMES.find((name) =>
features.some((feature) => feature.name === name)
) ?? null
);
}
export function normalizeSpecificCrimeFilters(filters: FeatureFilters): FeatureFilters {
let changed = false;
const next: FeatureFilters = {};
for (const [name, value] of Object.entries(filters)) {
if (isSpecificCrimeFeatureName(name)) {
next[createSpecificCrimeFilterKey(name, Object.keys(next).length)] = value;
changed = true;
continue;
}
next[name] = value;
}
return changed ? next : filters;
}
export function getSpecificCrimeFilterMeta(features: FeatureMeta[]): FeatureMeta {
const sourceFeatureName = getDefaultSpecificCrimeFeatureName(features);
const sourceFeature = sourceFeatureName
? features.find((feature) => feature.name === sourceFeatureName)
: undefined;
return {
name: SPECIFIC_CRIMES_FILTER_NAME,
type: 'numeric',
group: 'Crime',
min: sourceFeature?.min ?? 0,
max: sourceFeature?.max ?? 100,
step: 1,
description:
'Violence, burglary, robbery, drugs, shoplifting, vehicle crime, anti-social behaviour, public order, theft, and other crime types',
detail: 'Filter by one street-level crime category at a time using yearly averages per LSOA.',
source: 'crime',
suffix: '/yr',
};
}
export function clampSpecificCrimeRange(
value: [number, number],
feature?: FeatureMeta
): [number, number] {
const min = feature?.histogram?.min ?? feature?.min ?? 0;
const max = feature?.histogram?.max ?? feature?.max ?? Math.max(1, value[1]);
return [Math.max(min, Math.min(value[0], max)), Math.max(min, Math.min(value[1], max))];
}

View file

@ -188,14 +188,14 @@ const FEATURE_ICON_PATHS: Record<string, ReactNode> = {
),
// ── Deprivation ──────────────────────────────
'Income Score (rate)': (
'Income Score': (
<>
<rect x="2" y="6" width="20" height="14" rx="2" />
<path d="M2 10h20" />
<path d="M6 14h4m4 0h4" />
</>
),
'Employment Score (rate)': (
'Employment Score': (
<>
<rect x="2" y="7" width="20" height="14" rx="2" />
<path d="M16 3h-8a2 2 0 00-2 2v2h12V5a2 2 0 00-2-2z" />
@ -207,19 +207,13 @@ const FEATURE_ICON_PATHS: Record<string, ReactNode> = {
<path d="M20.42 4.58a5.4 5.4 0 00-7.65 0L12 5.34l-.77-.76a5.4 5.4 0 00-7.65 7.65L12 20.65l8.42-8.42a5.4 5.4 0 000-7.65z" />
</>
),
'Living Environment Score': (
<>
<path d="M3 9l9-7 9 7v11a2 2 0 01-2 2H5a2 2 0 01-2-2z" />
<path d="M9 16l2 2 4-4" />
</>
),
'Indoors Sub-domain Score': (
'Housing Conditions Score': (
<>
<path d="M20 9V6a2 2 0 00-2-2H6a2 2 0 00-2 2v3" />
<path d="M2 11v5a2 2 0 002 2h1v3h2v-3h10v3h2v-3h1a2 2 0 002-2v-5a3 3 0 00-3-3H5a3 3 0 00-3 3z" />
</>
),
'Outdoors Sub-domain Score': (
'Air Quality and Road Safety Score': (
<>
<path d="M11 20A7 7 0 019.8 6.9C15.5 4.9 20 9 20 9s-3.4 5.4-3.4 9c0 .6 0 1.2-.1 1.8" />
<path d="M12 10a3.5 3.5 0 00-5 5" />

View file

@ -0,0 +1,46 @@
import { cellToChildren, cellToLatLng, latLngToCell } from 'h3-js';
import { describe, expect, it } from 'vitest';
import type { HexagonData } from '../types';
import { findOverlappingMatchingHexagon, hasMatchingHexagonAtResolution } from './h3-selection';
function hexagonData(h3: string, count: number): HexagonData {
const [lat, lon] = cellToLatLng(h3);
return { h3, count, lat, lon };
}
describe('h3 selection helpers', () => {
it('finds a matching higher-resolution hexagon that overlaps the previous hexagon', () => {
const parent = latLngToCell(51.5, -0.1, 8);
const children = cellToChildren(parent, 9);
const selected = findOverlappingMatchingHexagon(
parent,
[hexagonData(children[0], 0), hexagonData(children[1], 4)],
9
);
expect(selected?.h3).toBe(children[1]);
});
it('rejects candidates that do not overlap or have no matches', () => {
const parent = latLngToCell(51.5, -0.1, 8);
const nearbyChild = cellToChildren(parent, 9)[0];
const distant = latLngToCell(52.2, -0.1, 9);
expect(
findOverlappingMatchingHexagon(
parent,
[hexagonData(nearbyChild, 0), hexagonData(distant, 12)],
9
)
).toBeNull();
});
it('detects when target-resolution matching data is loaded', () => {
const parent = latLngToCell(51.5, -0.1, 8);
const child = cellToChildren(parent, 9)[0];
expect(hasMatchingHexagonAtResolution([hexagonData(child, 1)], 9)).toBe(true);
expect(hasMatchingHexagonAtResolution([hexagonData(child, 0)], 9)).toBe(false);
expect(hasMatchingHexagonAtResolution([hexagonData(parent, 1)], 9)).toBe(false);
});
});

View file

@ -0,0 +1,128 @@
import { cellToBoundary, cellToLatLng, getResolution } from 'h3-js';
import type { HexagonData } from '../types';
type Point = [number, number];
const EPSILON = 1e-12;
function samePoint(a: Point, b: Point): boolean {
return Math.abs(a[0] - b[0]) <= EPSILON && Math.abs(a[1] - b[1]) <= EPSILON;
}
function cellBoundary(h3: string): Point[] {
const boundary = cellToBoundary(h3, true) as Point[];
if (boundary.length > 1 && samePoint(boundary[0], boundary[boundary.length - 1])) {
return boundary.slice(0, -1);
}
return boundary;
}
function orientation(a: Point, b: Point, c: Point): number {
return (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0]);
}
function pointOnSegment(point: Point, start: Point, end: Point): boolean {
if (Math.abs(orientation(start, end, point)) > EPSILON) return false;
return (
point[0] >= Math.min(start[0], end[0]) - EPSILON &&
point[0] <= Math.max(start[0], end[0]) + EPSILON &&
point[1] >= Math.min(start[1], end[1]) - EPSILON &&
point[1] <= Math.max(start[1], end[1]) + EPSILON
);
}
function segmentsIntersect(a: Point, b: Point, c: Point, d: Point): boolean {
const abC = orientation(a, b, c);
const abD = orientation(a, b, d);
const cdA = orientation(c, d, a);
const cdB = orientation(c, d, b);
if (
((abC > EPSILON && abD < -EPSILON) || (abC < -EPSILON && abD > EPSILON)) &&
((cdA > EPSILON && cdB < -EPSILON) || (cdA < -EPSILON && cdB > EPSILON))
) {
return true;
}
return (
pointOnSegment(c, a, b) ||
pointOnSegment(d, a, b) ||
pointOnSegment(a, c, d) ||
pointOnSegment(b, c, d)
);
}
function pointInPolygon(point: Point, polygon: Point[]): boolean {
let inside = false;
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
const current = polygon[i];
const previous = polygon[j];
if (pointOnSegment(point, previous, current)) return true;
if (current[1] > point[1] !== previous[1] > point[1]) {
const x =
((previous[0] - current[0]) * (point[1] - current[1])) /
(previous[1] - current[1]) +
current[0];
if (point[0] < x) inside = !inside;
}
}
return inside;
}
export function polygonsOverlap(a: Point[], b: Point[]): boolean {
if (a.length < 3 || b.length < 3) return false;
if (a.some((point) => pointInPolygon(point, b))) return true;
if (b.some((point) => pointInPolygon(point, a))) return true;
for (let i = 0; i < a.length; i++) {
const aNext = (i + 1) % a.length;
for (let j = 0; j < b.length; j++) {
const bNext = (j + 1) % b.length;
if (segmentsIntersect(a[i], a[aNext], b[j], b[bNext])) return true;
}
}
return false;
}
export function hasMatchingHexagonAtResolution(
hexagons: HexagonData[],
resolution: number
): boolean {
return hexagons.some(
(hexagon) => hexagon.count > 0 && getResolution(hexagon.h3) === resolution
);
}
export function findOverlappingMatchingHexagon(
previousH3: string,
hexagons: HexagonData[],
resolution: number
): HexagonData | null {
const previousBoundary = cellBoundary(previousH3);
const [previousLat, previousLng] = cellToLatLng(previousH3);
let best: HexagonData | null = null;
let bestDistance = Infinity;
for (const hexagon of hexagons) {
if (hexagon.count <= 0 || getResolution(hexagon.h3) !== resolution) continue;
if (!polygonsOverlap(previousBoundary, cellBoundary(hexagon.h3))) continue;
const distance = (hexagon.lat - previousLat) ** 2 + (hexagon.lon - previousLng) ** 2;
if (
!best ||
distance < bestDistance - EPSILON ||
(Math.abs(distance - bestDistance) <= EPSILON &&
(hexagon.count > best.count ||
(hexagon.count === best.count && hexagon.h3.localeCompare(best.h3) < 0)))
) {
best = hexagon;
bestDistance = distance;
}
}
return best;
}

View file

@ -1,11 +1,17 @@
import { describe, expect, it } from 'vitest';
import { DENSITY_GRADIENT, ENUM_PALETTE, FEATURE_GRADIENT } from './consts';
import {
DENSITY_GRADIENT,
ENUM_PALETTE,
FEATURE_GRADIENT,
SMALLEST_VISIBLE_HEXAGON_RESOLUTION,
} from './consts';
import {
emojiToTwemojiUrl,
enumIndexToColor,
getBoundsFromViewState,
getFeatureFillColor,
getMapCenterForTargetScreenPoint,
getPoiIconUrl,
zoomToResolution,
} from './map-utils';
@ -16,6 +22,7 @@ describe('map utilities', () => {
expect(zoomToResolution(7)).toBe(6);
expect(zoomToResolution(10.6)).toBe(8);
expect(zoomToResolution(14)).toBe(9);
expect(SMALLEST_VISIBLE_HEXAGON_RESOLUTION).toBe(9);
});
it('computes buffered bounds around a view state', () => {
@ -31,6 +38,13 @@ describe('map utilities', () => {
expect(bounds.east).toBeGreaterThan(-0.1);
});
it('moves the map center so a target lands in the requested screen position', () => {
const centered = getMapCenterForTargetScreenPoint(51.5, -0.1, 17, 390, 844, 195, 42.2);
expect(centered.longitude).toBeCloseTo(-0.1, 6);
expect(centered.latitude).toBeLessThan(51.5);
});
it('builds twemoji URLs and wraps enum colors', () => {
expect(emojiToTwemojiUrl('🛒')).toBe('/assets/twemoji/1f6d2.png');
expect(emojiToTwemojiUrl('')).toBe('/assets/twemoji/1f4cd.png');

View file

@ -13,6 +13,59 @@ import {
type GradientStop,
} from './consts';
const ROAD_OPACITY = 0.4;
const TILE_SIZE = 512;
const MAX_MERCATOR_LATITUDE = 85;
function clampLatitude(latitude: number): number {
return Math.max(-MAX_MERCATOR_LATITUDE, Math.min(MAX_MERCATOR_LATITUDE, latitude));
}
function longitudeToWorldX(longitude: number, worldSize: number): number {
return ((longitude + 180) / 360) * worldSize;
}
function latitudeToWorldY(latitude: number, worldSize: number): number {
const clampedLat = clampLatitude(latitude);
const latRad = (clampedLat * Math.PI) / 180;
const mercatorY = (1 - Math.log(Math.tan(latRad) + 1 / Math.cos(latRad)) / Math.PI) / 2;
return mercatorY * worldSize;
}
function worldXToLongitude(pixelX: number, worldSize: number): number {
const longitude = (pixelX / worldSize) * 360 - 180;
return ((((longitude + 180) % 360) + 360) % 360) - 180;
}
function worldYToLatitude(pixelY: number, worldSize: number): number {
const mercY = Math.max(0.001, Math.min(0.999, pixelY / worldSize));
const latRadians = Math.atan(Math.sinh(Math.PI * (1 - 2 * mercY)));
return (latRadians * 180) / Math.PI;
}
export function getMapCenterForTargetScreenPoint(
targetLatitude: number,
targetLongitude: number,
zoom: number,
width: number,
height: number,
targetScreenX: number,
targetScreenY: number
): Pick<ViewState, 'latitude' | 'longitude'> {
if (width <= 0 || height <= 0) {
return { latitude: targetLatitude, longitude: targetLongitude };
}
const worldSize = TILE_SIZE * Math.pow(2, zoom);
const targetWorldX = longitudeToWorldX(targetLongitude, worldSize);
const targetWorldY = latitudeToWorldY(targetLatitude, worldSize);
const centerWorldX = targetWorldX + width / 2 - targetScreenX;
const centerWorldY = targetWorldY + height / 2 - targetScreenY;
return {
latitude: worldYToLatitude(centerWorldY, worldSize),
longitude: worldXToLongitude(centerWorldX, worldSize),
};
}
export function getMapStyle(theme: 'light' | 'dark'): StyleSpecification {
const flavor = namedFlavor(theme);
@ -158,8 +211,7 @@ export function getBoundsFromViewState(
height: number
): Bounds {
const { longitude, latitude, zoom } = viewState;
const clampedLat = Math.max(-85, Math.min(85, latitude));
const TILE_SIZE = 512;
const clampedLat = clampLatitude(latitude);
const scale = Math.pow(2, zoom);
const worldSize = TILE_SIZE * scale;
@ -169,21 +221,13 @@ export function getBoundsFromViewState(
const degreesPerPixelLng = 360 / worldSize;
const halfWidthDeg = (bufferedWidth / 2) * degreesPerPixelLng;
const latRad = (clampedLat * Math.PI) / 180;
const mercatorY = (1 - Math.log(Math.tan(latRad) + 1 / Math.cos(latRad)) / Math.PI) / 2;
const centerPixelY = mercatorY * worldSize;
const centerPixelY = latitudeToWorldY(clampedLat, worldSize);
const topPixelY = centerPixelY - bufferedHeight / 2;
const bottomPixelY = centerPixelY + bufferedHeight / 2;
const pixelYToLat = (pixelY: number): number => {
const mercY = Math.max(0.001, Math.min(0.999, pixelY / worldSize));
const latRadians = Math.atan(Math.sinh(Math.PI * (1 - 2 * mercY)));
return (latRadians * 180) / Math.PI;
};
const north = Math.min(85, pixelYToLat(topPixelY));
const south = Math.max(-85, pixelYToLat(bottomPixelY));
const north = Math.min(MAX_MERCATOR_LATITUDE, worldYToLatitude(topPixelY, worldSize));
const south = Math.max(-MAX_MERCATOR_LATITUDE, worldYToLatitude(bottomPixelY, worldSize));
const west = Math.max(-180, longitude - halfWidthDeg);
const east = Math.min(180, longitude + halfWidthDeg);

View file

@ -3,6 +3,7 @@ import { beforeEach, describe, expect, it } from 'vitest';
import type { FeatureMeta } from '../types';
import { parseUrlState, stateToParams } from './url-state';
import { createSchoolFilterKey } from './school-filter';
import { createSpecificCrimeFilterKey } from './crime-filter';
describe('url-state', () => {
beforeEach(() => {
@ -110,6 +111,36 @@ describe('url-state', () => {
});
});
it('round-trips repeated specific crime filters with dedicated URL params', () => {
const burglary = createSpecificCrimeFilterKey('Burglary (avg/yr)', 1);
const vehicleCrime = createSpecificCrimeFilterKey('Vehicle crime (avg/yr)', 2);
const params = stateToParams(
null,
{
[burglary]: [0, 5],
[vehicleCrime]: [1, 10],
},
[],
new Set(),
'area'
);
expect(params.getAll('crime')).toEqual([
'Burglary (avg/yr):0:5',
'Vehicle crime (avg/yr):1:10',
]);
expect(params.getAll('filter')).toEqual([]);
window.history.replaceState({}, '', `/?${params.toString()}`);
const state = parseUrlState();
expect(state.filters).toEqual({
[createSpecificCrimeFilterKey('Burglary (avg/yr)', 0)]: [0, 5],
[createSpecificCrimeFilterKey('Vehicle crime (avg/yr)', 1)]: [1, 10],
});
});
it('omits the default area tab', () => {
const params = stateToParams(null, {}, [], new Set(), 'area');

View file

@ -8,17 +8,28 @@ import {
import {
SCHOOL_FILTER_NAME,
createSchoolFilterKey,
getSchoolBackendFeatureName,
getSchoolFilterConfig,
isSchoolFilterName,
type SchoolDistance,
type SchoolPhase,
type SchoolRating,
} from './school-filter';
import {
SPECIFIC_CRIMES_FILTER_NAME,
createSpecificCrimeFilterKey,
getSpecificCrimeFeatureName,
isSpecificCrimeFeatureName,
isSpecificCrimeFilterName,
} from './crime-filter';
function parseFilters(params: URLSearchParams): FeatureFilters | undefined {
const filterParams = params.getAll('filter');
const schoolParams = params.getAll('school');
if (filterParams.length === 0 && schoolParams.length === 0) return undefined;
const crimeParams = params.getAll('crime');
if (filterParams.length === 0 && schoolParams.length === 0 && crimeParams.length === 0) {
return undefined;
}
const filters: FeatureFilters = {};
for (const entry of filterParams) {
@ -60,6 +71,18 @@ function parseFilters(params: URLSearchParams): FeatureFilters | undefined {
filters[createSchoolFilterKey(phase, rating, distance, index)] = [min, max];
});
crimeParams.forEach((entry, index) => {
const parts = entry.split(':');
if (parts.length < 3) return;
const featureName = parts.slice(0, -2).join(':');
const min = Number(parts[parts.length - 2]);
const max = Number(parts[parts.length - 1]);
if (!isSpecificCrimeFeatureName(featureName) || isNaN(min) || isNaN(max)) {
return;
}
filters[createSpecificCrimeFilterKey(featureName, index)] = [min, max];
});
return Object.keys(filters).length > 0 ? filters : undefined;
}
@ -180,6 +203,13 @@ export function stateToParams(
continue;
}
const specificCrimeFeatureName = getSpecificCrimeFeatureName(name);
if (specificCrimeFeatureName && isSpecificCrimeFilterName(name)) {
const [min, max] = value as [number, number];
params.append('crime', `${specificCrimeFeatureName}:${min}:${max}`);
continue;
}
const meta = features.find((f) => f.name === name);
if (meta?.type === 'enum') {
params.append('filter', `${name}:${(value as string[]).join('|')}`);
@ -225,14 +255,19 @@ export function summarizeParams(queryString: string): string {
const filterParams = params.getAll('filter');
const schoolParams = params.getAll('school');
if (filterParams.length > 0 || schoolParams.length > 0) {
const crimeParams = params.getAll('crime');
if (filterParams.length > 0 || schoolParams.length > 0 || crimeParams.length > 0) {
const filterNames = filterParams
.map((entry) => {
const colonIdx = entry.indexOf(':');
return colonIdx > 0 ? entry.substring(0, colonIdx) : entry;
const name = colonIdx > 0 ? entry.substring(0, colonIdx) : entry;
return isSpecificCrimeFeatureName(name) ? SPECIFIC_CRIMES_FILTER_NAME : name;
})
.filter((n) => n);
for (let i = 0; i < schoolParams.length; i++) filterNames.push(SCHOOL_FILTER_NAME);
for (let i = 0; i < crimeParams.length; i++) {
filterNames.push(SPECIFIC_CRIMES_FILTER_NAME);
}
if (filterNames.length > 0) {
parts.push(
filterNames.length <= 2