More
Some checks failed
CI / Check (push) Failing after 2m14s
Build and publish Docker image / build-and-push (push) Failing after 2m38s

This commit is contained in:
Andras Schmelczer 2026-05-04 17:21:26 +01:00
parent cd34ee693f
commit 05a1f316e1
58 changed files with 3113 additions and 1277 deletions

View file

@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest';
import type { FeatureMeta } from '../types';
import { apiUrl, assertOk, buildFilterString, isAbortError } from './api';
import { createSchoolFilterKey } from './school-filter';
describe('api utilities', () => {
it('builds API URLs from endpoint names, paths, and params', () => {
@ -64,4 +65,20 @@ describe('api utilities', () => {
)
).toBe('Property type:Flat');
});
it('deduplicates repeated synthetic school filters before backend routes', () => {
const features: FeatureMeta[] = [
{ name: 'Good+ primary schools within 2km', type: 'numeric', min: 0, max: 10 },
];
expect(
buildFilterString(
{
[createSchoolFilterKey('primary', 'good', 2, 1)]: [1, 10],
[createSchoolFilterKey('primary', 'good', 2, 2)]: [2, 8],
},
features
)
).toBe('Good+ primary schools within 2km:2:8');
});
});

View file

@ -1,6 +1,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';
export function logNonAbortError(label: string, error: unknown): void {
if (error instanceof Error && error.name === 'AbortError') {
@ -82,8 +83,29 @@ export function buildFilterString(
): string {
const entries = Object.entries(filters);
if (entries.length === 0) return '';
return entries
.filter(([name]) => name !== exclude)
const merged = new Map<string, [number, number] | string[]>();
for (const [name, value] of entries) {
if (name === exclude) continue;
const backendName = getSchoolBackendFeatureName(name) ?? name;
const prev = merged.get(backendName);
if (
prev &&
Array.isArray(prev) &&
Array.isArray(value) &&
typeof prev[0] === 'number' &&
typeof value[0] === 'number'
) {
merged.set(backendName, [
Math.max(prev[0] as number, value[0] as number),
Math.min(prev[1] as number, value[1] as number),
]);
} else if (!prev) {
merged.set(backendName, value);
}
}
return [...merged.entries()]
.map(([name, value]) => {
const meta = features.find((f) => f.name === name);
if (meta?.type === 'enum') {

View file

@ -126,6 +126,47 @@ export const POI_GROUP_COLORS: Record<string, [number, number, number]> = {
/** Default color for unknown POI groups */
export const POI_DEFAULT_COLOR: [number, number, number] = [107, 114, 128];
/** POI category → icon/logo URL for branded and transport categories */
export const POI_CATEGORY_LOGOS: Record<string, string> = {
Airport: '/assets/twemoji/2708.png',
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',
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',
'Bus station': '/assets/twemoji/1f68c.png',
'Bus stop': '/assets/twemoji/1f68f.png',
'Butcher & Fishmonger': '/assets/twemoji/1f969.png',
Centra: 'https://geolytix.github.io/MapIcons/brands/centra_24px.svg',
'Co-op': 'https://geolytix.github.io/MapIcons/brands/coop_24px.svg',
COOK: 'https://geolytix.github.io/MapIcons/brands/cook.svg',
'Convenience Store': '/assets/twemoji/1f3ea.png',
Costco: 'https://geolytix.github.io/MapIcons/brands/costco_24px.svg',
'Deli & Specialty': '/assets/twemoji/1f9c6.png',
'Dunnes Stores': 'https://geolytix.github.io/MapIcons/brands/dunnes_stores_24px.svg',
Farmfoods: 'https://geolytix.github.io/MapIcons/brands/farmfoods_updated_24px.svg',
Ferry: '/assets/twemoji/26f4.png',
Greengrocer: '/assets/twemoji/1f96c.png',
'Heron Foods': 'https://geolytix.github.io/MapIcons/brands/heron_24px.svg',
Iceland: 'https://geolytix.github.io/MapIcons/brands/iceland_24px.svg',
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',
Morrisons: 'https://geolytix.github.io/MapIcons/brands/morrisons_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',
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',
'Taxi rank': '/assets/twemoji/1f695.png',
'Tube station': 'https://geolytix.github.io/MapIcons/public_transport/london_tube.svg',
Waitrose: 'https://geolytix.github.io/MapIcons/brands/waitrose_24px.svg',
'Whole Foods Market': 'https://geolytix.github.io/MapIcons/brands/wholefoods_24px.svg',
};
/** Categories only shown when zoomed in past MINOR_POI_ZOOM_THRESHOLD */
export const MINOR_POI_CATEGORIES = new Set(['Bus stop', 'Taxi rank', 'EV Charging', 'Playground']);

View file

@ -103,8 +103,7 @@ export function buildPropertySearchUrls({
const radiusMiles = isPostcode ? 0 : (H3_RADIUS_MILES[resolution] ?? 1);
const priceFilter =
filters['Estimated current price'] ?? filters['Last known price'];
const priceFilter = filters['Estimated current price'] ?? filters['Last known price'];
const minPrice =
Array.isArray(priceFilter) && typeof priceFilter[0] === 'number' ? priceFilter[0] : undefined;
const maxPrice =

View file

@ -6,6 +6,7 @@ import {
enumIndexToColor,
getBoundsFromViewState,
getFeatureFillColor,
getPoiIconUrl,
zoomToResolution,
} from './map-utils';
@ -36,6 +37,13 @@ describe('map utilities', () => {
expect(enumIndexToColor(ENUM_PALETTE.length)).toEqual(ENUM_PALETTE[0]);
});
it('prefers POI category logos before falling back to emoji icons', () => {
expect(getPoiIconUrl('Waitrose', '🛒')).toBe(
'https://geolytix.github.io/MapIcons/brands/waitrose_24px.svg'
);
expect(getPoiIconUrl('Unknown category', '🛒')).toBe('/assets/twemoji/1f6d2.png');
});
it('returns fallback, filtered, enum, feature, and density colors', () => {
expect(
getFeatureFillColor(

View file

@ -9,6 +9,7 @@ import {
TWEMOJI_BASE,
BUFFER_MULTIPLIER,
ENUM_PALETTE,
POI_CATEGORY_LOGOS,
type GradientStop,
} from './consts';
const ROAD_OPACITY = 0.4;
@ -196,6 +197,10 @@ export function emojiToTwemojiUrl(emoji: string): string {
return `${TWEMOJI_BASE}${hex}.png`;
}
export function getPoiIconUrl(category: string, emoji: string): string {
return POI_CATEGORY_LOGOS[category] ?? emojiToTwemojiUrl(emoji);
}
/** Look up a discrete color from the enum palette by index (wraps if > palette size). */
export function enumIndexToColor(
index: number,

View file

@ -0,0 +1,216 @@
import type { FeatureFilters, FeatureMeta } from '../types';
export const SCHOOL_FILTER_NAME = 'Schools';
export const SCHOOL_FILTER_KEY_PREFIX = `${SCHOOL_FILTER_NAME}:`;
export type SchoolPhase = 'primary' | 'secondary';
export type SchoolRating = 'good' | 'outstanding';
export type SchoolDistance = 2 | 5;
export interface SchoolFilterConfig {
phase: SchoolPhase;
rating: SchoolRating;
distance: SchoolDistance;
featureName: string;
}
export const SCHOOL_FILTERS: SchoolFilterConfig[] = [
{
phase: 'primary',
rating: 'good',
distance: 2,
featureName: 'Good+ primary schools within 2km',
},
{
phase: 'secondary',
rating: 'good',
distance: 2,
featureName: 'Good+ secondary schools within 2km',
},
{
phase: 'primary',
rating: 'outstanding',
distance: 2,
featureName: 'Outstanding primary schools within 2km',
},
{
phase: 'secondary',
rating: 'outstanding',
distance: 2,
featureName: 'Outstanding secondary schools within 2km',
},
{
phase: 'primary',
rating: 'good',
distance: 5,
featureName: 'Good+ primary schools within 5km',
},
{
phase: 'secondary',
rating: 'good',
distance: 5,
featureName: 'Good+ secondary schools within 5km',
},
{
phase: 'primary',
rating: 'outstanding',
distance: 5,
featureName: 'Outstanding primary schools within 5km',
},
{
phase: 'secondary',
rating: 'outstanding',
distance: 5,
featureName: 'Outstanding secondary schools within 5km',
},
];
const SCHOOL_FEATURE_NAMES = new Set(SCHOOL_FILTERS.map((filter) => filter.featureName));
export function isBackendSchoolFeatureName(name: string): boolean {
return SCHOOL_FEATURE_NAMES.has(name);
}
export function isSchoolFilterName(name: string): boolean {
return isBackendSchoolFeatureName(name) || name.startsWith(SCHOOL_FILTER_KEY_PREFIX);
}
export function getSchoolFilterConfig(name: string): SchoolFilterConfig | null {
const synthetic = parseSchoolFilterKey(name);
if (synthetic) return synthetic;
return SCHOOL_FILTERS.find((filter) => filter.featureName === name) ?? null;
}
export function getSchoolFeatureName(
phase: SchoolPhase,
rating: SchoolRating,
distance: SchoolDistance
): string {
return (
SCHOOL_FILTERS.find(
(filter) => filter.phase === phase && filter.rating === rating && filter.distance === distance
)?.featureName ?? SCHOOL_FILTERS[0].featureName
);
}
export function createSchoolFilterKey(
phase: SchoolPhase,
rating: SchoolRating,
distance: SchoolDistance,
id: number | string
): string {
return `${SCHOOL_FILTER_KEY_PREFIX}${phase}:${rating}:${distance}:${id}`;
}
export function getSchoolFilterKeyId(name: string): string | null {
if (!name.startsWith(SCHOOL_FILTER_KEY_PREFIX)) return null;
return name.split(':')[4] ?? null;
}
export function parseSchoolFilterKey(name: string): SchoolFilterConfig | null {
if (!name.startsWith(SCHOOL_FILTER_KEY_PREFIX)) return null;
const [, phaseRaw, ratingRaw, distanceRaw] = name.split(':');
const phase = phaseRaw as SchoolPhase;
const rating = ratingRaw as SchoolRating;
const distance = Number(distanceRaw) as SchoolDistance;
if (
(phase !== 'primary' && phase !== 'secondary') ||
(rating !== 'good' && rating !== 'outstanding') ||
(distance !== 2 && distance !== 5)
) {
return null;
}
return {
phase,
rating,
distance,
featureName: getSchoolFeatureName(phase, rating, distance),
};
}
export function getSchoolBackendFeatureName(name: string): string | null {
if (isBackendSchoolFeatureName(name)) return name;
return parseSchoolFilterKey(name)?.featureName ?? null;
}
export function replaceSchoolFilterKeySelection(
key: string,
next: {
phase?: SchoolPhase;
rating?: SchoolRating;
distance?: SchoolDistance;
}
): string {
const config = getSchoolFilterConfig(key) ?? SCHOOL_FILTERS[0];
const parts = key.startsWith(SCHOOL_FILTER_KEY_PREFIX) ? key.split(':') : [];
const id = parts[4] ?? '0';
return createSchoolFilterKey(
next.phase ?? config.phase,
next.rating ?? config.rating,
next.distance ?? config.distance,
id
);
}
export function getDefaultSchoolFeatureName(features: FeatureMeta[]): string | null {
return (
SCHOOL_FILTERS.find((filter) => features.some((feature) => feature.name === filter.featureName))
?.featureName ?? null
);
}
export function getActiveSchoolFeatureName(filters: FeatureFilters): string | null {
return Object.keys(filters).find(isSchoolFilterName) ?? null;
}
export function normalizeSchoolFilters(filters: FeatureFilters): FeatureFilters {
let changed = false;
const next: FeatureFilters = {};
for (const [name, value] of Object.entries(filters)) {
if (isBackendSchoolFeatureName(name)) {
const config = getSchoolFilterConfig(name);
if (!config) continue;
next[
createSchoolFilterKey(
config.phase,
config.rating,
config.distance,
Object.keys(next).length
)
] = value;
changed = true;
continue;
}
next[name] = value;
}
return changed ? next : filters;
}
export function getSchoolFilterMeta(features: FeatureMeta[]): FeatureMeta {
const sourceFeatureName = getDefaultSchoolFeatureName(features);
const sourceFeature = sourceFeatureName
? features.find((feature) => feature.name === sourceFeatureName)
: undefined;
return {
name: SCHOOL_FILTER_NAME,
type: 'numeric',
group: 'Education',
min: sourceFeature?.min ?? 0,
max: sourceFeature?.max ?? 10,
step: 1,
description: 'Rated primary and secondary schools nearby',
detail:
'Filter by primary or secondary schools, Ofsted rating, and whether schools are within 2km or 5km.',
source: 'ofsted',
raw: true,
};
}
export function clampSchoolRange(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

@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it } from 'vitest';
import type { FeatureMeta } from '../types';
import { parseUrlState, stateToParams } from './url-state';
import { createSchoolFilterKey } from './school-filter';
describe('url-state', () => {
beforeEach(() => {
@ -79,6 +80,36 @@ describe('url-state', () => {
expect(params.getAll('tt')).toEqual(['bicycle:bank:Bank:5:25']);
});
it('round-trips repeated school filters with dedicated URL params', () => {
const schoolOne = createSchoolFilterKey('primary', 'good', 2, 1);
const schoolTwo = createSchoolFilterKey('secondary', 'outstanding', 5, 2);
const params = stateToParams(
null,
{
[schoolOne]: [1, 10],
[schoolTwo]: [2, 15],
},
[],
new Set(),
'area'
);
expect(params.getAll('school')).toEqual([
'primary:good:2:1:10',
'secondary:outstanding:5:2:15',
]);
expect(params.getAll('filter')).toEqual([]);
window.history.replaceState({}, '', `/?${params.toString()}`);
const state = parseUrlState();
expect(state.filters).toEqual({
[createSchoolFilterKey('primary', 'good', 2, 0)]: [1, 10],
[createSchoolFilterKey('secondary', 'outstanding', 5, 1)]: [2, 15],
});
});
it('omits the default area tab', () => {
const params = stateToParams(null, {}, [], new Set(), 'area');

View file

@ -5,10 +5,20 @@ import {
type TravelTimeEntry,
type TravelTimeInitial,
} from '../hooks/useTravelTime';
import {
SCHOOL_FILTER_NAME,
createSchoolFilterKey,
getSchoolFilterConfig,
isSchoolFilterName,
type SchoolDistance,
type SchoolPhase,
type SchoolRating,
} from './school-filter';
function parseFilters(params: URLSearchParams): FeatureFilters | undefined {
const filterParams = params.getAll('filter');
if (filterParams.length === 0) return undefined;
const schoolParams = params.getAll('school');
if (filterParams.length === 0 && schoolParams.length === 0) return undefined;
const filters: FeatureFilters = {};
for (const entry of filterParams) {
@ -29,6 +39,27 @@ function parseFilters(params: URLSearchParams): FeatureFilters | undefined {
filters[name] = [rest];
}
}
schoolParams.forEach((entry, index) => {
const parts = entry.split(':');
if (parts.length !== 5) return;
const phase = parts[0] as SchoolPhase;
const rating = parts[1] as SchoolRating;
const distance = Number(parts[2]) as SchoolDistance;
const min = Number(parts[3]);
const max = Number(parts[4]);
if (
(phase !== 'primary' && phase !== 'secondary') ||
(rating !== 'good' && rating !== 'outstanding') ||
(distance !== 2 && distance !== 5) ||
isNaN(min) ||
isNaN(max)
) {
return;
}
filters[createSchoolFilterKey(phase, rating, distance, index)] = [min, max];
});
return Object.keys(filters).length > 0 ? filters : undefined;
}
@ -126,6 +157,16 @@ export function stateToParams(
}
for (const [name, value] of Object.entries(filters)) {
const schoolConfig = getSchoolFilterConfig(name);
if (schoolConfig && isSchoolFilterName(name)) {
const [min, max] = value as [number, number];
params.append(
'school',
`${schoolConfig.phase}:${schoolConfig.rating}:${schoolConfig.distance}:${min}:${max}`
);
continue;
}
const meta = features.find((f) => f.name === name);
if (meta?.type === 'enum') {
params.append('filter', `${name}:${(value as string[]).join('|')}`);
@ -170,13 +211,15 @@ export function summarizeParams(queryString: string): string {
const parts: string[] = [];
const filterParams = params.getAll('filter');
if (filterParams.length > 0) {
const schoolParams = params.getAll('school');
if (filterParams.length > 0 || schoolParams.length > 0) {
const filterNames = filterParams
.map((entry) => {
const colonIdx = entry.indexOf(':');
return colonIdx > 0 ? entry.substring(0, colonIdx) : entry;
})
.filter((n) => n);
for (let i = 0; i < schoolParams.length; i++) filterNames.push(SCHOOL_FILTER_NAME);
if (filterNames.length > 0) {
parts.push(
filterNames.length <= 2