More
This commit is contained in:
parent
cd34ee693f
commit
05a1f316e1
58 changed files with 3113 additions and 1277 deletions
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
({
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -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> = {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue