seems alright

This commit is contained in:
Andras Schmelczer 2026-05-17 13:52:11 +01:00
parent ebe7bbb51d
commit eac1bd0d13
58 changed files with 23125 additions and 153505 deletions

View file

@ -4,9 +4,16 @@ import { apiUrl, authHeaders, logNonAbortError } from '../lib/api';
const DEBOUNCE_MS = 200;
export function useActualListings(bounds: Bounds | null) {
interface UseActualListingsOptions {
filterParam?: string;
travelParam?: string;
}
export function useActualListings(
bounds: Bounds | null,
{ filterParam = '', travelParam = '' }: UseActualListingsOptions = {}
) {
const [listings, setListings] = useState<ActualListing[]>([]);
const [truncated, setTruncated] = useState(false);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const abortControllerRef = useRef<AbortController | null>(null);
const requestIdRef = useRef(0);
@ -18,7 +25,6 @@ export function useActualListings(bounds: Bounds | null) {
if (!bounds) {
abortControllerRef.current?.abort();
if (listings.length !== 0) setListings([]);
if (truncated) setTruncated(false);
return;
}
@ -30,6 +36,8 @@ export function useActualListings(bounds: Bounds | null) {
try {
const boundsStr = `${bounds.south},${bounds.west},${bounds.north},${bounds.east}`;
const params = new URLSearchParams({ bounds: boundsStr });
if (filterParam) params.set('filters', filterParam);
if (travelParam) params.set('travel', travelParam);
const res = await fetch(
apiUrl('actual-listings', params),
authHeaders({ signal: abortControllerRef.current.signal })
@ -38,7 +46,6 @@ export function useActualListings(bounds: Bounds | null) {
const json: ActualListingsResponse = await res.json();
if (requestIdRef.current !== requestId) return;
setListings(json.listings || []);
setTruncated(Boolean(json.truncated));
} catch (err) {
logNonAbortError('Failed to fetch actual listings', err);
}
@ -48,9 +55,9 @@ export function useActualListings(bounds: Bounds | null) {
if (debounceRef.current) clearTimeout(debounceRef.current);
abortControllerRef.current?.abort();
};
// listings/truncated intentionally excluded — they're internal state, not inputs.
// listings intentionally excluded — it's internal state, not an input.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [bounds]);
}, [bounds, filterParam, travelParam]);
return { listings, truncated };
return { listings };
}

View file

@ -111,7 +111,6 @@ export function useDeckLayers({
isDark,
hexagonData: data,
postcodeData,
resolution: usePostcodeView ? 0 : Math.round(zoom),
usePostcodeView,
});
@ -280,21 +279,33 @@ export function useDeckLayers({
const isEnum = enumCountRef.current > 0;
const distKey = viewFeatureRef.current ? `dist_${viewFeatureRef.current}` : '';
// Per-render memo: each of getRatios0/1/2 would otherwise call distToRatios
// on the same row, tripling the work. Cache by row reference.
const ratiosCache = new WeakMap<HexagonData, number[]>();
const getRatios = (d: HexagonData): number[] => {
let r = ratiosCache.get(d);
if (!r) {
r = distToRatios(d[distKey]);
ratiosCache.set(d, r);
}
return r;
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const pieProps: any = isEnum
? {
extensions: [new PieHexExtension(requireEnumPalette(enumPaletteRef.current))],
getCenter: (d: HexagonData) => [d.lon, d.lat],
getRatios0: (d: HexagonData) => {
const r = distToRatios(d[distKey]);
const r = getRatios(d);
return [r[0], r[1], r[2], r[3]];
},
getRatios1: (d: HexagonData) => {
const r = distToRatios(d[distKey]);
const r = getRatios(d);
return [r[4], r[5], r[6], r[7]];
},
getRatios2: (d: HexagonData) => {
const r = distToRatios(d[distKey]);
const r = getRatios(d);
return [r[8], r[9]];
},
updateTriggers: {

View file

@ -1,12 +1,6 @@
import { useState, useEffect, useRef, useMemo } from 'react';
import type { FeatureMeta, FeatureFilters, Bounds } from '../types';
import {
apiUrl,
buildFilterString,
logNonAbortError,
authHeaders,
isAbortError,
} from '../lib/api';
import { apiUrl, buildFilterString, logNonAbortError, authHeaders, isAbortError } from '../lib/api';
import type { TravelTimeEntry } from './useTravelTime';
import { buildTravelParam } from '../lib/travel-params';

View file

@ -45,31 +45,46 @@ export function useListingLayers({
}: UseListingLayersProps) {
const [popupInfo, setPopupInfo] = useState<ListingPopupInfo | null>(null);
const visibleListings = useMemo(() => {
if (listings.length === 0) return listings;
if (usePostcodeView) {
const allowed = new Set<string>();
for (const feature of postcodeData) {
if (feature.properties.count > 0) {
allowed.add(normalizePostcode(feature.properties.postcode));
}
}
if (allowed.size === 0) return [];
return listings.filter((listing) => allowed.has(normalizePostcode(listing.postcode)));
}
// Split into two memos so the inactive view's data changes don't invalidate
// the active filtered list. (e.g. in postcode view, hexagonData updates must
// not retrigger filtering / downstream layer rebuilds.)
const postcodeFilteredListings = useMemo(() => {
if (!usePostcodeView || listings.length === 0) return null;
const allowed = new Set<string>();
for (const cell of hexagonData) {
if (cell.count > 0) allowed.add(cell.h3);
for (const feature of postcodeData) {
if (feature.properties.count > 0) {
allowed.add(normalizePostcode(feature.properties.postcode));
}
}
if (allowed.size === 0) return [];
return listings.filter((listing) => allowed.has(normalizePostcode(listing.postcode)));
}, [listings, postcodeData, usePostcodeView]);
const hexFilteredListings = useMemo(() => {
if (usePostcodeView || listings.length === 0) return null;
const allowed = new Set<string>();
let cellResolution: number | null = null;
for (const cell of hexagonData) {
if (cell.count > 0) {
allowed.add(cell.h3);
if (cellResolution == null) cellResolution = getResolution(cell.h3);
}
}
if (allowed.size === 0 || cellResolution == null) return [];
const resolutionForLookup = cellResolution;
return listings.filter((listing) => {
try {
return allowed.has(latLngToCell(listing.lat, listing.lon, resolution));
return allowed.has(latLngToCell(listing.lat, listing.lon, resolutionForLookup));
} catch {
return false;
}
});
}, [listings, hexagonData, postcodeData, resolution, usePostcodeView]);
}, [listings, hexagonData, usePostcodeView]);
const visibleListings = useMemo(() => {
if (listings.length === 0) return listings;
return (usePostcodeView ? postcodeFilteredListings : hexFilteredListings) ?? [];
}, [listings, usePostcodeView, postcodeFilteredListings, hexFilteredListings]);
const handleHover = useCallback((info: PickingInfo<ActualListing>) => {
if (info.object && info.x !== undefined && info.y !== undefined) {

View file

@ -26,8 +26,8 @@ import { COLOR_RANGE_LOW_PERCENTILE, COLOR_RANGE_HIGH_PERCENTILE } from '../lib/
import { type TravelTimeEntry } from './useTravelTime';
import { buildTravelParam as serializeTravelParam } from '../lib/travel-params';
/** Return the p-th percentile (0100) from a sorted array via linear interpolation. */
function percentile(sorted: number[], p: number): number {
/** Return the p-th percentile (0100) from a sorted typed array via linear interpolation. */
function percentile(sorted: Float64Array, p: number): number {
if (sorted.length === 0) return 0;
if (sorted.length === 1) return sorted[0];
const idx = (p / 100) * (sorted.length - 1);
@ -262,10 +262,20 @@ export function useMapData({
useEffect(() => {
if (!activeFeature || !activeDragRequest) return;
// Abort any in-flight previous drag fetch before starting a new one.
if (dragAbortRef.current) dragAbortRef.current.abort();
dragAbortRef.current = new AbortController();
// Capture the controller locally so this effect's cleanup unambiguously
// aborts THIS request's controller, even if `dragAbortRef.current` has
// been swapped by a subsequent effect run.
const controller = new AbortController();
dragAbortRef.current = controller;
const { signal } = controller;
const { boundsStr, dragTravelParam, fieldsParam, filtersStr, requestKey } = activeDragRequest;
// Capture activeFeature in a local so the async .then() callback cannot
// observe a stale-or-newer value via closure surprise.
const effectActiveFeature = activeFeature;
latestDragRequestKeyRef.current = requestKey;
setDragDataKey('');
dragFeatureRef.current = null;
@ -278,14 +288,15 @@ export function useMapData({
if (viewFeatureIsEnum && dataViewFeature) params.set('enum_dist', dataViewFeature);
if (shareCode) params.set('share', shareCode);
fetch(apiUrl('postcodes', params), authHeaders({ signal: dragAbortRef.current.signal }))
fetch(apiUrl('postcodes', params), authHeaders({ signal }))
.then((res) => res.json())
.then((json: { features: PostcodeFeature[] }) => {
if (signal.aborted) return;
if (latestDragRequestKeyRef.current !== requestKey) return;
setDragPostcodeData(json.features);
setDragHexData(null);
setDragDataKey(requestKey);
dragFeatureRef.current = activeFeature;
dragFeatureRef.current = effectActiveFeature;
})
.catch((err) => logNonAbortError('Failed to fetch drag postcode data', err));
} else {
@ -299,31 +310,36 @@ export function useMapData({
if (viewFeatureIsEnum && dataViewFeature) params.set('enum_dist', dataViewFeature);
if (shareCode) params.set('share', shareCode);
fetch(apiUrl('hexagons', params), authHeaders({ signal: dragAbortRef.current.signal }))
fetch(apiUrl('hexagons', params), authHeaders({ signal }))
.then((res) => res.json())
.then((json: ApiResponse) => {
if (signal.aborted) return;
if (latestDragRequestKeyRef.current !== requestKey) return;
setDragHexData(json.features);
setDragPostcodeData(null);
setDragDataKey(requestKey);
dragFeatureRef.current = activeFeature;
dragFeatureRef.current = effectActiveFeature;
})
.catch((err) => logNonAbortError('Failed to fetch drag hex data', err));
}
return () => {
if (dragAbortRef.current) {
dragAbortRef.current.abort();
// Abort the controller captured by THIS effect run rather than reading
// from the ref (which may already have been replaced by a newer run).
controller.abort();
if (dragAbortRef.current === controller) {
dragAbortRef.current = null;
}
if (latestDragRequestKeyRef.current === requestKey) {
latestDragRequestKeyRef.current = '';
}
// Do not clear latestDragRequestKeyRef here: a newer effect run will
// overwrite it with its own requestKey, and clearing it would create a
// brief window in which a late-resolving fetch from this run could pass
// the staleness check against an empty key.
};
}, [
activeFeature,
activeDragRequest,
dataViewFeature,
resolution,
usePostcodeView,
viewFeatureIsEnum,
shareCode,
@ -538,10 +554,14 @@ export function useMapData({
}
if (vals.length === 0) return null;
vals.sort((a, b) => a - b);
// Typed-array sort uses the engine's optimized numeric sort with no
// per-element comparator call — measurably faster than `vals.sort((a,b)=>a-b)`
// for the 5k10k samples a busy viewport produces.
const sorted = Float64Array.from(vals);
sorted.sort();
return [
percentile(vals, COLOR_RANGE_LOW_PERCENTILE),
percentile(vals, COLOR_RANGE_HIGH_PERCENTILE),
percentile(sorted, COLOR_RANGE_LOW_PERCENTILE),
percentile(sorted, COLOR_RANGE_HIGH_PERCENTILE),
];
}, [
bounds,

View file

@ -0,0 +1,64 @@
import { useEffect, useRef } from 'react';
/**
* Shared modal accessibility behavior: locks body scroll, traps Tab focus
* inside the dialog, restores focus on unmount, and focuses the first
* focusable element (or the dialog itself) on mount.
*/
export function useModalA11y(): React.RefObject<HTMLDivElement | null> {
const dialogRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const previouslyFocused = document.activeElement as HTMLElement | null;
const dialog = dialogRef.current;
const focusableSelector =
'input:not([disabled]), button:not([disabled]), select:not([disabled]), textarea:not([disabled]), a[href], [tabindex]:not([tabindex="-1"])';
const firstFocusable = dialog?.querySelector<HTMLElement>(focusableSelector);
(firstFocusable ?? dialog)?.focus();
// Lock body scroll while preserving scroll position.
const prevOverflow = document.body.style.overflow;
const prevPaddingRight = document.body.style.paddingRight;
const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth;
document.body.style.overflow = 'hidden';
if (scrollbarWidth > 0) {
document.body.style.paddingRight = `${scrollbarWidth}px`;
}
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key !== 'Tab' || !dialog) return;
const focusables = Array.from(dialog.querySelectorAll<HTMLElement>(focusableSelector)).filter(
(el) => el.offsetParent !== null || el === document.activeElement
);
if (focusables.length === 0) {
e.preventDefault();
dialog.focus();
return;
}
const first = focusables[0];
const last = focusables[focusables.length - 1];
const active = document.activeElement as HTMLElement | null;
if (e.shiftKey) {
if (active === first || !dialog.contains(active)) {
e.preventDefault();
last.focus();
}
} else if (active === last) {
e.preventDefault();
first.focus();
}
};
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
document.body.style.overflow = prevOverflow;
document.body.style.paddingRight = prevPaddingRight;
previouslyFocused?.focus?.();
};
}, []);
return dialogRef;
}

View file

@ -12,8 +12,16 @@ export interface SavedSearch {
created: string;
}
const POLL_INTERVAL_MS = 2000;
const MAX_POLL_ATTEMPTS = 15;
// Exponential backoff: 2s, 3s, 4s, 6s, 8s, 12s, ... capped at 15s.
// Caps total wait under a minute while staying responsive for fast jobs.
const POLL_BASE_MS = 2000;
const POLL_MAX_MS = 15000;
const POLL_BACKOFF = 1.5;
const MAX_POLL_ATTEMPTS = 8;
function nextPollDelay(attempt: number): number {
return Math.min(POLL_MAX_MS, Math.round(POLL_BASE_MS * Math.pow(POLL_BACKOFF, attempt)));
}
export function useSavedSearches(userId: string | null) {
const [searches, setSearches] = useState<SavedSearch[]>([]);
@ -21,14 +29,16 @@ export function useSavedSearches(userId: string | null) {
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const pollTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const pollTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const pollAttemptsRef = useRef(0);
const pollInFlightRef = useRef(false);
const isMountedRef = useRef(true);
const userIdRef = useRef(userId);
userIdRef.current = userId;
const stopPolling = useCallback(() => {
if (pollTimerRef.current) {
clearInterval(pollTimerRef.current);
clearTimeout(pollTimerRef.current);
pollTimerRef.current = null;
}
pollAttemptsRef.current = 0;
@ -37,6 +47,15 @@ export function useSavedSearches(userId: string | null) {
// Clean up polling on unmount or userId change
useEffect(() => stopPolling, [userId, stopPolling]);
// Mark the hook as unmounted so late-arriving async work doesn't touch state
useEffect(() => {
isMountedRef.current = true;
return () => {
isMountedRef.current = false;
stopPolling();
};
}, [stopPolling]);
const fetchRecords = useCallback(async (uid: string): Promise<SavedSearch[]> => {
const records = await pb.collection('saved_searches').getFullList({
sort: '-created',
@ -57,28 +76,41 @@ export function useSavedSearches(userId: string | null) {
const startPolling = useCallback(() => {
if (pollTimerRef.current) return;
pollAttemptsRef.current = 0;
pollTimerRef.current = setInterval(async () => {
pollInFlightRef.current = false;
const scheduleNext = () => {
if (!isMountedRef.current) return;
const delay = nextPollDelay(pollAttemptsRef.current);
pollTimerRef.current = setTimeout(tick, delay);
};
const tick = async () => {
pollTimerRef.current = null;
if (pollInFlightRef.current) {
scheduleNext();
return;
}
const uid = userIdRef.current;
if (!uid) {
stopPolling();
return;
}
if (!uid) return;
pollAttemptsRef.current++;
if (pollAttemptsRef.current >= MAX_POLL_ATTEMPTS) {
stopPolling();
return;
}
if (pollAttemptsRef.current > MAX_POLL_ATTEMPTS) return;
pollInFlightRef.current = true;
try {
const mapped = await fetchRecords(uid);
if (!isMountedRef.current) return;
setSearches(mapped);
if (!mapped.some((s) => !s.screenshotUrl)) {
stopPolling();
}
if (!mapped.some((s) => !s.screenshotUrl)) return;
scheduleNext();
} catch {
// Silent — background poll errors don't surface to UI
// Silent — background poll errors don't surface to UI; keep trying.
if (isMountedRef.current) scheduleNext();
} finally {
pollInFlightRef.current = false;
}
}, POLL_INTERVAL_MS);
}, [stopPolling, fetchRecords]);
};
scheduleNext();
}, [fetchRecords]);
const fetchSearches = useCallback(async () => {
if (!userId) return;