Tonight
This commit is contained in:
parent
28323f145e
commit
94f9c0d594
76 changed files with 3238 additions and 1230 deletions
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 &&
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
};
|
||||
|
||||
|
|
|
|||
116
frontend/src/lib/crime-filter.ts
Normal file
116
frontend/src/lib/crime-filter.ts
Normal 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))];
|
||||
}
|
||||
|
|
@ -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" />
|
||||
|
|
|
|||
46
frontend/src/lib/h3-selection.test.ts
Normal file
46
frontend/src/lib/h3-selection.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
128
frontend/src/lib/h3-selection.ts
Normal file
128
frontend/src/lib/h3-selection.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue