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

@ -1,25 +1,65 @@
import { useCallback, useLayoutEffect, useState } from 'react';
import { useCallback, useLayoutEffect, useRef, useState } from 'react';
import type React from 'react';
export function useDropdownPosition(anchorRef: React.RefObject<HTMLElement | null>, open: boolean) {
const [pos, setPos] = useState<{ top: number; left: number; width: number } | null>(null);
const posRef = useRef(pos);
posRef.current = pos;
const update = useCallback(() => {
if (!anchorRef.current) return;
const rect = anchorRef.current.getBoundingClientRect();
setPos({ top: rect.bottom + 4, left: rect.left, width: rect.width });
const next = { top: rect.bottom + 4, left: rect.left, width: rect.width };
const prev = posRef.current;
if (
prev &&
Math.abs(prev.top - next.top) < 0.5 &&
Math.abs(prev.left - next.left) < 0.5 &&
Math.abs(prev.width - next.width) < 0.5
) {
return;
}
setPos(next);
}, [anchorRef]);
useLayoutEffect(() => {
if (!open) return;
update();
window.addEventListener('scroll', update, true);
window.addEventListener('resize', update);
return () => {
window.removeEventListener('scroll', update, true);
window.removeEventListener('resize', update);
const vv = window.visualViewport;
let raf = 0;
let frame = 0;
const anchor = anchorRef.current;
const updateNextFrame = () => {
if (raf) cancelAnimationFrame(raf);
raf = requestAnimationFrame(update);
};
}, [open, update]);
const trackAnchorMovement = () => {
update();
frame = requestAnimationFrame(trackAnchorMovement);
};
update();
frame = requestAnimationFrame(trackAnchorMovement);
window.addEventListener('scroll', update, true);
window.addEventListener('resize', updateNextFrame);
vv?.addEventListener('resize', updateNextFrame);
vv?.addEventListener('scroll', updateNextFrame);
const observer =
anchor && typeof ResizeObserver !== 'undefined' ? new ResizeObserver(updateNextFrame) : null;
if (anchor && observer) observer.observe(anchor);
return () => {
if (raf) cancelAnimationFrame(raf);
if (frame) cancelAnimationFrame(frame);
window.removeEventListener('scroll', update, true);
window.removeEventListener('resize', updateNextFrame);
vv?.removeEventListener('resize', updateNextFrame);
vv?.removeEventListener('scroll', updateNextFrame);
observer?.disconnect();
};
}, [anchorRef, open, update]);
return pos;
}

View file

@ -1,6 +1,13 @@
import { useState, useCallback, useMemo, useRef, useEffect } from 'react';
import type { FeatureMeta, FeatureFilters } from '../types';
import { trackEvent } from '../lib/analytics';
import {
SCHOOL_FILTER_NAME,
createSchoolFilterKey,
getDefaultSchoolFeatureName,
getSchoolFilterKeyId,
normalizeSchoolFilters,
} from '../lib/school-filter';
interface UseFiltersOptions {
initialFilters: FeatureFilters;
@ -8,7 +15,9 @@ interface UseFiltersOptions {
}
export function useFilters({ initialFilters, features }: UseFiltersOptions) {
const [filters, setFilters] = useState<FeatureFilters>(initialFilters);
const [filters, setFilters] = useState<FeatureFilters>(() =>
normalizeSchoolFilters(initialFilters)
);
const [activeFeature, setActiveFeature] = useState<string | null>(null);
const [dragValue, setDragValue] = useState<[number, number] | null>(null);
const [pinnedFeature, setPinnedFeature] = useState<string | null>(null);
@ -16,6 +25,7 @@ export function useFilters({ initialFilters, features }: UseFiltersOptions) {
const dragActiveRef = useRef<string | null>(null);
const dragValueRef = useRef<[number, number] | null>(null);
const undoStackRef = useRef<FeatureFilters[]>([]);
const schoolFilterIdRef = useRef(1);
const enabledFeatures = useMemo(() => new Set(Object.keys(filters)), [filters]);
@ -33,11 +43,31 @@ export function useFilters({ initialFilters, features }: UseFiltersOptions) {
const handleAddFilter = useCallback(
(name: string) => {
const meta = features.find((f) => f.name === name);
if (!meta) return;
if (name !== SCHOOL_FILTER_NAME && !meta) return;
trackEvent('Filter Add', { feature: name });
setFilters((prev) => {
undoStackRef.current.push(prev);
if (undoStackRef.current.length > 50) undoStackRef.current.shift();
if (name === SCHOOL_FILTER_NAME) {
const schoolKey = createSchoolFilterKey(
'primary',
'good',
2,
schoolFilterIdRef.current++
);
const defaultSchoolFeatureName = getDefaultSchoolFeatureName(features);
const defaultSchoolFeature = defaultSchoolFeatureName
? features.find((feature) => feature.name === defaultSchoolFeatureName)
: undefined;
return {
...prev,
[schoolKey]: [
defaultSchoolFeature?.histogram?.min ?? defaultSchoolFeature?.min ?? 0,
defaultSchoolFeature?.histogram?.max ?? defaultSchoolFeature?.max ?? 10,
],
};
}
if (!meta) return prev;
if (meta.type === 'enum' && meta.values) {
return { ...prev, [name]: [...meta.values!] };
} else if (meta.type === 'numeric' && meta.histogram) {
@ -75,9 +105,27 @@ export function useFilters({ initialFilters, features }: UseFiltersOptions) {
if (Array.isArray(value) && value.length === 0) {
const next = { ...prev };
delete next[name];
return next;
return normalizeSchoolFilters(next);
}
return { ...prev, [name]: value };
const schoolKeyId = getSchoolFilterKeyId(name);
if (schoolKeyId != null) {
let replaced = false;
const next: FeatureFilters = {};
for (const [existingName, existingValue] of Object.entries(prev)) {
if (getSchoolFilterKeyId(existingName) === schoolKeyId) {
if (!replaced) {
next[name] = value;
replaced = true;
}
continue;
}
next[existingName] = existingValue;
}
if (replaced) return normalizeSchoolFilters(next);
}
return normalizeSchoolFilters({ ...prev, [name]: value });
});
}, []);
@ -145,7 +193,7 @@ export function useFilters({ initialFilters, features }: UseFiltersOptions) {
}, []);
const handleSetFilters = useCallback((newFilters: FeatureFilters) => {
setFilters(newFilters);
setFilters(normalizeSchoolFilters(newFilters));
setActiveFeature(null);
setDragValue(null);
setPinnedFeature(null);

View file

@ -16,6 +16,7 @@ import {
authHeaders,
isAbortError,
} from '../lib/api';
import { getSchoolBackendFeatureName } from '../lib/school-filter';
import { POSTCODE_ZOOM_THRESHOLD } from '../lib/consts';
import { COLOR_RANGE_LOW_PERCENTILE, COLOR_RANGE_HIGH_PERCENTILE } from '../lib/consts';
import { type TravelTimeEntry } from './useTravelTime';
@ -74,11 +75,16 @@ export function useMapData({
const prevBoundsRef = useRef<string>('');
const usePostcodeView = zoom >= POSTCODE_ZOOM_THRESHOLD;
const dataViewFeature = useMemo(
() => (viewFeature ? (getSchoolBackendFeatureName(viewFeature) ?? viewFeature) : null),
[viewFeature]
);
// Determine if the current viewFeature is an enum (for enum_dist param)
const viewFeatureIsEnum = useMemo(
() => (viewFeature ? features.find((f) => f.name === viewFeature)?.type === 'enum' : false),
[viewFeature, features]
() =>
dataViewFeature ? features.find((f) => f.name === dataViewFeature)?.type === 'enum' : false,
[dataViewFeature, features]
);
const buildFilterParam = useCallback(
@ -130,17 +136,18 @@ export function useMapData({
const filtersStr = buildFilterString(filters, features, activeFeature);
const boundsStr = `${bounds.south},${bounds.west},${bounds.north},${bounds.east}`;
const isTravelTimeDrag = activeFeature.startsWith('tt_');
const dataActiveFeature = getSchoolBackendFeatureName(activeFeature) ?? activeFeature;
const dragTravelParam = isTravelTimeDrag ? buildTravelParam(activeFeature) : travelParam;
// Travel time fields are computed from the travel param, not regular feature columns.
// Sending a tt_* name as fields would cause a 400 (unknown field). Use empty string instead.
const fieldsParam = isTravelTimeDrag ? '' : activeFeature;
const fieldsParam = isTravelTimeDrag ? '' : dataActiveFeature;
if (usePostcodeView) {
const params = new URLSearchParams({ bounds: boundsStr });
if (filtersStr) params.set('filters', filtersStr);
params.set('fields', fieldsParam);
if (dragTravelParam) params.set('travel', dragTravelParam);
if (viewFeatureIsEnum && viewFeature) params.set('enum_dist', viewFeature);
if (viewFeatureIsEnum && dataViewFeature) params.set('enum_dist', dataViewFeature);
fetch(apiUrl('postcodes', params), authHeaders({ signal: dragAbortRef.current.signal }))
.then((res) => res.json())
@ -158,7 +165,7 @@ export function useMapData({
if (filtersStr) params.set('filters', filtersStr);
params.set('fields', fieldsParam);
if (dragTravelParam) params.set('travel', dragTravelParam);
if (viewFeatureIsEnum && viewFeature) params.set('enum_dist', viewFeature);
if (viewFeatureIsEnum && dataViewFeature) params.set('enum_dist', dataViewFeature);
fetch(apiUrl('hexagons', params), authHeaders({ signal: dragAbortRef.current.signal }))
.then((res) => res.json())
@ -185,7 +192,7 @@ export function useMapData({
usePostcodeView,
travelParam,
buildTravelParam,
viewFeature,
dataViewFeature,
viewFeatureIsEnum,
]);
@ -211,11 +218,14 @@ export function useMapData({
if (usePostcodeView) {
const params = new URLSearchParams({ bounds: boundsStr });
if (filtersStr) params.set('filters', filtersStr);
params.set('fields', viewFeature && !viewFeature.startsWith('tt_') ? viewFeature : '');
params.set(
'fields',
dataViewFeature && !dataViewFeature.startsWith('tt_') ? dataViewFeature : ''
);
if (travelParam) {
params.set('travel', travelParam);
}
if (viewFeatureIsEnum && viewFeature) params.set('enum_dist', viewFeature);
if (viewFeatureIsEnum && dataViewFeature) params.set('enum_dist', dataViewFeature);
const res = await fetch(
apiUrl('postcodes', params),
authHeaders({
@ -242,11 +252,14 @@ export function useMapData({
bounds: boundsStr,
});
if (filtersStr) params.set('filters', filtersStr);
params.set('fields', viewFeature && !viewFeature.startsWith('tt_') ? viewFeature : '');
params.set(
'fields',
dataViewFeature && !dataViewFeature.startsWith('tt_') ? dataViewFeature : ''
);
if (travelParam) {
params.set('travel', travelParam);
}
if (viewFeatureIsEnum && viewFeature) params.set('enum_dist', viewFeature);
if (viewFeatureIsEnum && dataViewFeature) params.set('enum_dist', dataViewFeature);
const res = await fetch(
apiUrl('hexagons', params),
authHeaders({
@ -294,7 +307,7 @@ export function useMapData({
bounds,
filters,
buildFilterParam,
viewFeature,
dataViewFeature,
viewFeatureIsEnum,
usePostcodeView,
travelParam,
@ -311,12 +324,12 @@ export function useMapData({
// Always uses rawData/postcodeData (not drag preview data) so the color
// scale stays stable while dragging a filter slider.
const dataRange = useMemo((): [number, number] | null => {
if (!viewFeature) return null;
if (!dataViewFeature) return null;
const isTravelTime = viewFeature.startsWith('tt_');
const isTravelTime = dataViewFeature.startsWith('tt_');
if (!isTravelTime) {
const meta = features.find((f) => f.name === viewFeature);
const meta = features.find((f) => f.name === dataViewFeature);
if (!meta || meta.type === 'enum') return null;
}
@ -330,7 +343,7 @@ export function useMapData({
if (lat < bounds.south || lat > bounds.north || lng < bounds.west || lng > bounds.east)
continue;
}
const val = feat.properties[`avg_${viewFeature}`];
const val = feat.properties[`avg_${dataViewFeature}`];
if (typeof val === 'number' && !isNaN(val)) vals.push(val);
}
} else {
@ -341,7 +354,7 @@ export function useMapData({
if (lat < bounds.south || lat > bounds.north || lon < bounds.west || lon > bounds.east)
continue;
}
const val = item[`avg_${viewFeature}`];
const val = item[`avg_${dataViewFeature}`];
if (typeof val === 'number' && !isNaN(val)) vals.push(val);
}
}
@ -352,18 +365,18 @@ export function useMapData({
percentile(vals, COLOR_RANGE_LOW_PERCENTILE),
percentile(vals, COLOR_RANGE_HIGH_PERCENTILE),
];
}, [viewFeature, rawData, postcodeData, usePostcodeView, features, bounds]);
}, [dataViewFeature, rawData, postcodeData, usePostcodeView, features, bounds]);
// Color range for the legend and hex coloring
const colorRange = useMemo((): [number, number] | null => {
if (!viewFeature) return null;
if (!dataViewFeature) return null;
// Travel time keys: use dataRange directly (no FeatureMeta)
if (viewFeature.startsWith('tt_')) {
if (dataViewFeature.startsWith('tt_')) {
return dataRange;
}
const meta = features.find((f) => f.name === viewFeature);
const meta = features.find((f) => f.name === dataViewFeature);
if (!meta) return null;
if (meta.type === 'enum' && meta.values && meta.values.length > 0) {
return [0, meta.values.length - 1];
@ -371,7 +384,7 @@ export function useMapData({
if (dataRange) return dataRange;
if (meta.min != null && meta.max != null) return [meta.min, meta.max];
return null;
}, [viewFeature, features, dataRange]);
}, [dataViewFeature, features, dataRange]);
const handleViewChange = useCallback(
({

View file

@ -14,6 +14,16 @@ const supermarket: POI = {
emoji: '🛒',
};
const waitrose: POI = {
id: 'poi-3',
name: 'Waitrose Marylebone',
category: 'Waitrose',
group: 'Groceries',
lat: 51.52,
lng: -0.15,
emoji: '🛒',
};
const busStop: POI = {
id: 'poi-2',
name: 'High Street Stop',
@ -45,6 +55,18 @@ describe('usePoiLayers', () => {
]);
});
it('uses POI category logos for map marker icons', () => {
const { result } = renderHook(() =>
usePoiLayers({ pois: [waitrose], zoom: 15, isDark: false })
);
const iconLayer = layerById(result.current.poiLayers, 'poi-icons');
const getIcon = iconLayer.props.getIcon as (poi: POI) => { url: string };
expect(getIcon(waitrose).url).toBe(
'https://geolytix.github.io/MapIcons/brands/waitrose_24px.svg'
);
});
it('hides minor POI categories until the configured zoom threshold', () => {
const { result, rerender } = renderHook(
({ zoom }) => usePoiLayers({ pois: [busStop], zoom, isDark: false }),

View file

@ -12,7 +12,7 @@ import {
POI_CLUSTER_RADIUS,
POI_CLUSTER_MAX_ZOOM,
} from '../lib/consts';
import { emojiToTwemojiUrl } from '../lib/map-utils';
import { getPoiIconUrl } from '../lib/map-utils';
export interface PopupInfo {
x: number;
@ -176,7 +176,7 @@ export function usePoiLayers({ pois, zoom, isDark }: UsePoiLayersProps) {
data: visiblePois,
getPosition: (d) => [d.lng, d.lat],
getIcon: (d) => ({
url: emojiToTwemojiUrl(d.emoji),
url: getPoiIconUrl(d.category, d.emoji),
width: 72,
height: 72,
}),

View file

@ -11,7 +11,7 @@ export const MODE_LABELS: Record<TransportMode, string> = {
car: 'Car',
bicycle: 'Bicycle',
walking: 'Walking',
transit: 'Transit',
transit: 'Public Transport',
};
export const MODE_DESCRIPTIONS: Record<TransportMode, string> = {