Boring changes

This commit is contained in:
Andras Schmelczer 2026-07-12 15:10:26 +01:00
parent cfaf58dfba
commit 920119ff48
32 changed files with 912 additions and 88 deletions

View file

@ -70,7 +70,7 @@ export function useActualListings(
if (debounceRef.current) clearTimeout(debounceRef.current);
abortControllerRef.current?.abort();
};
// listings intentionally excluded it's internal state, not an input.
// listings intentionally excluded: it's internal state, not an input.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [bounds, filterParam, travelParam, shareCode]);

View file

@ -2,6 +2,7 @@ import { useState, useEffect, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import pb from '../lib/pocketbase';
import { trackEvent } from '../lib/analytics';
import { resolveAuthError, type AuthErrorAction } from '../lib/auth-errors';
export interface AuthUser {
id: string;
@ -41,6 +42,7 @@ export function useAuth() {
});
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [errorAction, setErrorAction] = useState<AuthErrorAction>(null);
// Sync with authStore changes (cross-tab, external updates)
useEffect(() => {
@ -58,13 +60,15 @@ export function useAuth() {
async (email: string, password: string) => {
setLoading(true);
setError(null);
setErrorAction(null);
try {
const result = await pb.collection('users').authWithPassword(email, password);
setUser(recordToUser(result.record));
trackEvent('Login', { method: 'email' });
} catch (err) {
const msg = err instanceof Error ? err.message : t('auth.loginFailed');
setError(msg);
const { message, action } = resolveAuthError(err, 'login', t);
setError(message);
setErrorAction(action);
throw err;
} finally {
setLoading(false);
@ -77,21 +81,41 @@ export function useAuth() {
async (email: string, password: string) => {
setLoading(true);
setError(null);
setErrorAction(null);
try {
await pb.collection('users').create({
email,
password,
passwordConfirm: password,
newsletter: true,
});
// Auto-login after registration
const result = await pb.collection('users').authWithPassword(email, password);
setUser(recordToUser(result.record));
trackEvent('Register');
} catch (err) {
const msg = err instanceof Error ? err.message : t('auth.registrationFailed');
setError(msg);
throw err;
// Step 1: create the account. A failure here means nothing was created,
// so surface a friendly, field-aware message (e.g. "email already exists").
try {
await pb.collection('users').create({
email,
password,
passwordConfirm: password,
newsletter: true,
});
} catch (err) {
const { message, action } = resolveAuthError(err, 'register', t);
setError(message);
setErrorAction(action);
throw err;
}
// Step 2: the account now EXISTS. If auto-login fails (commonly a 429
// rate limit from the two back-to-back writes) the account is NOT
// orphaned: tell the user it was created and let them log in with the
// same credentials, instead of a misleading "registration failed".
try {
const result = await pb.collection('users').authWithPassword(email, password);
setUser(recordToUser(result.record));
trackEvent('Register');
} catch (err) {
const status = (err as { status?: number } | null)?.status;
setError(
status === 429
? t('auth.accountCreatedRateLimited')
: t('auth.accountCreatedPleaseLogIn')
);
setErrorAction('switchToLogin');
throw err;
}
} finally {
setLoading(false);
}
@ -103,6 +127,7 @@ export function useAuth() {
async (provider: string) => {
setLoading(true);
setError(null);
setErrorAction(null);
try {
const result = await pb.collection('users').authWithOAuth2({
provider,
@ -111,8 +136,9 @@ export function useAuth() {
setUser(recordToUser(result.record));
trackEvent('Login', { method: provider });
} catch (err) {
const msg = err instanceof Error ? err.message : t('auth.oauthFailed');
setError(msg);
const { message, action } = resolveAuthError(err, 'oauth', t);
setError(message);
setErrorAction(action);
throw err;
} finally {
setLoading(false);
@ -131,11 +157,13 @@ export function useAuth() {
async (email: string) => {
setLoading(true);
setError(null);
setErrorAction(null);
try {
await pb.collection('users').requestPasswordReset(email);
} catch (err) {
const msg = err instanceof Error ? err.message : t('auth.passwordResetFailed');
setError(msg);
const { message, action } = resolveAuthError(err, 'reset', t);
setError(message);
setErrorAction(action);
throw err;
} finally {
setLoading(false);
@ -167,12 +195,14 @@ export function useAuth() {
const clearError = useCallback(() => {
setError(null);
setErrorAction(null);
}, []);
return {
user,
loading,
error,
errorAction,
login,
register,
loginWithOAuth,

View file

@ -45,7 +45,7 @@ describe('useClickedListings', () => {
act(() => result.current.markClicked('https://example.com/b'));
// Visible immediately — no need to await the network round-trip.
// Visible immediately. No need to await the network round-trip.
expect(result.current.clickedUrls.has('https://example.com/b')).toBe(true);
expect(mocks.create).toHaveBeenCalledWith({
user: 'user-1',

View file

@ -12,7 +12,7 @@ function currentUserId(): string | null {
/** Tracks which listings the signed-in user has opened.
*
* Reads the user from the shared PocketBase authStore so it stays self-contained no need to
* Reads the user from the shared PocketBase authStore so it stays self-contained, with no need to
* thread a user id down through the map layer tree. Returns a Set for O(1) membership checks in
* the map's colour accessor; a fresh Set instance is produced on every change so it can double
* as a deck.gl `updateTrigger` (identity-compared).
@ -27,7 +27,7 @@ export function useClickedListings() {
const userIdRef = useRef(userId);
userIdRef.current = userId;
// Mirrors the latest committed set so markClicked can decide (synchronously) whether a url is
// new without depending on the async state updater having run yet.
// new, without depending on the async state updater having run yet.
const clickedUrlsRef = useRef(clickedUrls);
clickedUrlsRef.current = clickedUrls;
@ -73,7 +73,7 @@ export function useClickedListings() {
pb.collection(CLICKED_LISTINGS_COLLECTION)
.create({ user: uid, url })
.catch(() => {
// Ignore duplicate-index conflicts and transient failures local state already
// Ignore duplicate-index conflicts and transient failures; local state already
// reflects the click, and a missed write only means it won't persist to the next visit.
});
}, []);

View file

@ -0,0 +1,103 @@
import { renderHook } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { CollisionFilterExtension } from '@deck.gl/extensions';
import type { PostcodeFeature } from '../types';
import { useDeckLayers } from './useDeckLayers';
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));
vi.mock('../lib/analytics', () => ({
trackEvent: vi.fn(),
}));
const SQUARE: [number, number][] = [
[0, 0],
[0, 0.001],
[0.001, 0.001],
[0.001, 0],
[0, 0],
];
function postcode(code: string, count: number, centroid: [number, number]): PostcodeFeature {
return {
type: 'Feature',
geometry: { type: 'Polygon', coordinates: [SQUARE] },
properties: { postcode: code, count, centroid },
};
}
function layerById(layers: readonly unknown[], id: string) {
const layer = layers.find((item) => (item as { id?: string }).id === id);
if (!layer) throw new Error(`Layer ${id} not found`);
return layer as { props: Record<string, unknown> };
}
describe('useDeckLayers postcode label decluttering', () => {
it('attaches the collision filter extension to postcode labels at z16', () => {
const { result } = renderHook(() =>
useDeckLayers({
data: [],
postcodeData: [
postcode('SW1A 1AA', 9, [-0.1, 51.5]),
postcode('SW1A 2AA', 3, [-0.1001, 51.5001]),
],
usePostcodeView: true,
zoom: 16,
pois: [],
actualListings: [],
developments: [],
viewFeature: null,
colorRange: null,
filterRange: null,
features: [],
selectedHexagonId: null,
hoveredHexagonId: null,
onHexagonClick: () => {},
onHexagonHover: () => {},
theme: 'light',
mapDataBeforeId: '',
})
);
const labels = layerById(result.current.layers, 'postcode-labels');
expect(labels.props.collisionEnabled).toBe(true);
expect(labels.props.collisionGroup).toBe('postcode-labels');
expect(labels.props.collisionTestProps).toEqual({ sizeScale: 2 });
expect(
(labels.props.extensions as unknown[]).some((ext) => ext instanceof CollisionFilterExtension)
).toBe(true);
const getPriority = labels.props.getCollisionPriority as (f: PostcodeFeature) => number;
expect(getPriority(postcode('A', 9, [0, 0]))).toBe(9);
expect(getPriority(postcode('B', 5000, [0, 0]))).toBe(1000);
});
it('omits postcode labels below z16', () => {
const { result } = renderHook(() =>
useDeckLayers({
data: [],
postcodeData: [postcode('SW1A 1AA', 9, [-0.1, 51.5])],
usePostcodeView: true,
zoom: 15,
pois: [],
actualListings: [],
developments: [],
viewFeature: null,
colorRange: null,
filterRange: null,
features: [],
selectedHexagonId: null,
hoveredHexagonId: null,
onHexagonClick: () => {},
onHexagonHover: () => {},
theme: 'light',
mapDataBeforeId: '',
})
);
const ids = result.current.layers.map((l: unknown) => (l as { id?: string }).id);
expect(ids).not.toContain('postcode-labels');
});
});

View file

@ -1,6 +1,8 @@
import { useCallback, useRef, useState, useMemo, useEffect } from 'react';
import { H3HexagonLayer } from '@deck.gl/geo-layers';
import { GeoJsonLayer, TextLayer, ScatterplotLayer } from '@deck.gl/layers';
import { CollisionFilterExtension } from '@deck.gl/extensions';
import type { CollisionFilterExtensionProps } from '@deck.gl/extensions';
import { cellToBoundary, isValidCell } from 'h3-js';
import type { PickingInfo } from '@deck.gl/core';
import type {
@ -644,7 +646,7 @@ export function useDeckLayers({
const postcodeLabelsLayer = useMemo(
() =>
new TextLayer<PostcodeFeature>({
new TextLayer<PostcodeFeature, CollisionFilterExtensionProps<PostcodeFeature>>({
id: 'postcode-labels',
data: labeledPostcodeData,
getPosition: (f) => f.properties.centroid,
@ -662,6 +664,18 @@ export function useDeckLayers({
sizeMaxPixels: 14,
billboard: false,
pickable: false,
// Declutter overlapping postcode labels in dense areas (z16+). The
// CollisionFilterExtension renders an offscreen collision map and hides
// any label whose (enlarged) footprint overlaps a higher-priority one,
// so adjacent postcodes stop smearing into an unreadable cluster.
extensions: [new CollisionFilterExtension()],
collisionEnabled: true,
collisionGroup: 'postcode-labels',
// Enlarge each label's footprint while testing for collisions so the
// labels that survive keep visible breathing room (acts like padding).
collisionTestProps: { sizeScale: 2 },
// When two labels collide, keep the postcode covering more properties.
getCollisionPriority: (f) => Math.max(-1000, Math.min(1000, f.properties.count)),
}),
[labeledPostcodeData, theme]
);

View file

@ -33,8 +33,8 @@ function radiusForDwellings(d: Development): number {
/**
* Renders development sites as blue point markers (a soft shadow, a pin, and a
* dwelling-count label at higher zoom). Sparse data the endpoint caps the
* viewport at a few thousand points so no clustering is needed, unlike the
* dwelling-count label at higher zoom). Sparse data (the endpoint caps the
* viewport at a few thousand points) so no clustering is needed, unlike the
* listings layer. Hover shows a popup; click opens the planning record.
*/
export function useDevelopmentLayers({ developments, zoom, isDark }: UseDevelopmentLayersProps) {

View file

@ -73,7 +73,7 @@ export function useDevelopments(
if (debounceRef.current) clearTimeout(debounceRef.current);
abortControllerRef.current?.abort();
};
// developments intentionally excluded it's internal state, not an input.
// developments intentionally excluded: it's internal state, not an input.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [enabled, bounds, shareCode]);

View file

@ -15,7 +15,7 @@ interface FilterCountsResponse {
* Single source of truth for the in-view property count. Returns:
* - `total`: exact count of in-bounds properties passing the active filters
* (or the full in-bounds total when there are none); null until first load.
* - `impacts`: per-filter rejection counts how many in-bounds properties
* - `impacts`: per-filter rejection counts, how many in-bounds properties
* each active filter removes.
*/
export function useFilterCounts(
@ -28,7 +28,7 @@ export function useFilterCounts(
const [impacts, setImpacts] = useState<Record<string, number>>({});
// null = not loaded yet for the current bounds (hide the counter until the
// first response). The number is the exact count of in-bounds properties
// passing the active filters — and, with no filters, the total in bounds.
// passing the active filters (and, with no filters, the total in bounds).
const [total, setTotal] = useState<number | null>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const abortRef = useRef<AbortController | null>(null);

View file

@ -58,6 +58,14 @@ import {
getTenureFilterKeyId,
normalizeTenureFilters,
} from '../lib/tenure-filter';
import {
COUNCIL_FILTER_NAME,
createCouncilFilterKey,
getCouncilFeatureName,
getCouncilFilterKeyId,
getDefaultCouncilFeatureName,
normalizeCouncilFilters,
} from '../lib/council-filter';
import {
POI_FILTER_NAMES,
createPoiFilterKey,
@ -87,12 +95,15 @@ interface UseFiltersOptions {
function normalizeFilters(filters: FeatureFilters): FeatureFilters {
return normalizePoiDistanceFilters(
normalizeTenureFilters(
normalizeQualificationFilters(
normalizeEthnicityFilters(
normalizeElectionVoteShareFilters(
normalizeCrimeSeverityFilters(
normalizeSpecificCrimeFilters(normalizeSchoolFilters(filters))
// Council folds AFTER tenure so a bare "% Social rent" is claimed by tenure.
normalizeCouncilFilters(
normalizeTenureFilters(
normalizeQualificationFilters(
normalizeEthnicityFilters(
normalizeElectionVoteShareFilters(
normalizeCrimeSeverityFilters(
normalizeSpecificCrimeFilters(normalizeSchoolFilters(filters))
)
)
)
)
@ -110,6 +121,7 @@ function getBackendFeatureName(name: string): string {
getEthnicityFeatureName(name) ??
getQualificationFeatureName(name) ??
getTenureFeatureName(name) ??
getCouncilFeatureName(name) ??
getPoiDistanceFeatureName(name) ??
name
);
@ -195,8 +207,8 @@ export function useFilters({
const originalNormalizedRef = useRef<FeatureFilters | null>(null);
if (!initialFiltersRef.current) {
// Clamp an incoming filter set (e.g. from a bookmarked or shared URL) to the
// user's remaining budget the cap minus slots already taken by travel-time
// filters so a non-paying user never starts above the cap, which the server
// user's remaining budget (the cap minus slots already taken by travel-time
// filters) so a non-paying user never starts above the cap, which the server
// would reject (400), blanking the map on first load. Unknown keys are dropped
// first WHEN the feature list is already loaded (in-app navigation); on a cold load
// features arrive after this runs, so the prune effect below re-derives from source.
@ -246,6 +258,9 @@ export function useFilters({
const tenureFilterIdRef = useRef(
getNextNumericKeyId(initialFiltersRef.current!, getTenureFilterKeyId)
);
const councilFilterIdRef = useRef(
getNextNumericKeyId(initialFiltersRef.current!, getCouncilFilterKeyId)
);
const poiDistanceFilterIdRef = useRef(
getNextNumericKeyId(initialFiltersRef.current!, getPoiDistanceFilterKeyId)
);
@ -318,6 +333,7 @@ export function useFilters({
name !== ETHNICITIES_FILTER_NAME &&
name !== QUALIFICATIONS_FILTER_NAME &&
name !== TENURE_FILTER_NAME &&
name !== COUNCIL_FILTER_NAME &&
!POI_FILTER_NAMES.includes(name as PoiFilterName) &&
!meta
) {
@ -329,7 +345,7 @@ export function useFilters({
// Re-applying an existing regular feature replaces it (no new key), so it's
// always allowed; the special/generated filters always add a new key.
// `lockAdds` (a non-paying user on a shared link) blocks every new key
// regardless of count they can adjust the shared set but not build on it.
// regardless of count: they can adjust the shared set but not build on it.
const limit = filterLimitRef.current;
if (limit != null || lockAddsRef.current) {
const current = filtersRef.current;
@ -341,6 +357,7 @@ export function useFilters({
name === ETHNICITIES_FILTER_NAME ||
name === QUALIFICATIONS_FILTER_NAME ||
name === TENURE_FILTER_NAME ||
name === COUNCIL_FILTER_NAME ||
POI_FILTER_NAMES.includes(name as PoiFilterName) ||
!(name in current);
const overLimit =
@ -479,6 +496,21 @@ export function useFilters({
],
};
}
if (name === COUNCIL_FILTER_NAME) {
const defaultCouncilFeatureName = getDefaultCouncilFeatureName(features);
const defaultCouncilFeature = defaultCouncilFeatureName
? features.find((feature) => feature.name === defaultCouncilFeatureName)
: undefined;
if (!defaultCouncilFeatureName) return prev;
return {
...prev,
[createCouncilFilterKey(defaultCouncilFeatureName, councilFilterIdRef.current++)]: [
defaultCouncilFeature?.histogram?.min ?? defaultCouncilFeature?.min ?? 0,
defaultCouncilFeature?.histogram?.max ?? defaultCouncilFeature?.max ?? 100,
],
};
}
if (POI_FILTER_NAMES.includes(name as PoiFilterName)) {
const poiFilterName = name as PoiFilterName;
const defaultPoiFeatureName = getDefaultPoiFilterFeatureName(features, poiFilterName);
@ -647,6 +679,23 @@ export function useFilters({
if (replaced) return normalizeFilters(next);
}
const councilKeyId = getCouncilFilterKeyId(name);
if (councilKeyId != null) {
let replaced = false;
const next: FeatureFilters = {};
for (const [existingName, existingValue] of Object.entries(prev)) {
if (getCouncilFilterKeyId(existingName) === councilKeyId) {
if (!replaced) {
next[name] = value;
replaced = true;
}
continue;
}
next[existingName] = existingValue;
}
if (replaced) return normalizeFilters(next);
}
const electionVoteShareKeyId = getElectionVoteShareFilterKeyId(name);
if (electionVoteShareKeyId != null) {
let replaced = false;
@ -719,7 +768,7 @@ export function useFilters({
const handleDragEnd = useCallback(() => {
if (pendingDragRef.current) {
// Click without drag no filter value was changed, just clear preview state.
// Click without drag: no filter value was changed, just clear preview state.
pendingDragRef.current = null;
setActiveFeature(null);
setDragValue(null);
@ -737,7 +786,7 @@ export function useFilters({
dragValueRef.current = null;
}, []);
/** End drag without committing to filters caller handles the commit (e.g. travel time). */
/** End drag without committing to filters; caller handles the commit (e.g. travel time). */
const handleDragEndNoCommit = useCallback((): [number, number] | null => {
if (pendingDragRef.current) {
pendingDragRef.current = null;
@ -760,7 +809,7 @@ export function useFilters({
// which the server then rejects (400), breaking the map.
let next = newFilters;
// On a shared link (lockAdds), a non-paying user may adjust the shared filters
// but not introduce new keys enforce that on bulk sets too, not just in the
// but not introduce new keys: enforce that on bulk sets too, not just in the
// per-add path, so the AI/bulk path can't be used to build a fresh set on a
// shared link. Keep only keys already present; signal if any were dropped.
if (lockAddsRef.current) {

View file

@ -59,7 +59,7 @@ interface UseHexagonSelectionOptions {
travelTimeEntries: TravelTimeEntry[];
areaStatsFields?: string[];
shareCode?: string;
/** First transit destination used to pick the best central_postcode for journey display. */
/** First transit destination, used to pick the best central_postcode for journey display. */
journeyDest?: JourneyDest | null;
}
@ -336,7 +336,7 @@ export function useHexagonSelection({
// Lazily fetch a page of individual crimes for the current selection. The
// records are an attribute of the area (filter-independent), so this carries
// no filter string only the selection identity and share code.
// no filter string, only the selection identity and share code.
const loadCrimeRecords = useCallback(
(offset: number) => {
if (!selectedHexagon) {

View file

@ -84,10 +84,19 @@ function getClusterListings(
clusterId: number,
limit: number
): ActualListing[] {
return index
.getLeaves(clusterId, limit, 0)
.map((feature) => feature.properties)
.sort(compareListingsForDisplay);
// A background refetch (every pan/zoom rebuilds the index) can leave a picked
// cluster object — or a still-selected cluster in state — holding a clusterId
// that was minted by a now-superseded index. Supercluster decodes an origin
// id/zoom from the id and throws "No cluster with the specified id." when it
// doesn't resolve against this index. The fresh clusters are already on screen,
// so degrade to an empty result instead of crashing to the error boundary.
let leaves: Supercluster.PointFeature<ActualListing>[];
try {
leaves = index.getLeaves(clusterId, limit, 0);
} catch {
return [];
}
return leaves.map((feature) => feature.properties).sort(compareListingsForDisplay);
}
function offsetLngLat(
@ -124,7 +133,7 @@ export function useListingLayers({ listings, zoom, isDark }: UseListingLayersPro
useEffect(() => {
// Each refetch returns a fresh listings array and rebuilds the cluster index, so a
// previously selected cluster's id is no longer valid (getLeaves would throw)
// previously selected cluster's id is no longer valid (getLeaves would throw), so
// always collapse the spiderfy. A locked popup, however, holds a self-contained
// snapshot of the listings captured at click time, so preserve it across background
// refetches instead of dismissing it (mirrors clearUnlockedPopup).
@ -313,6 +322,10 @@ export function useListingLayers({ listings, zoom, isDark }: UseListingLayersPro
cluster.clusterId,
LISTING_CLUSTER_POPUP_LIMIT
);
// Stale clusterId against a rebuilt index: ignore the click rather than
// opening an empty popup with a phantom count. The user's next click lands
// on a cluster from the current index.
if (clusterListings.length === 0) return;
setSelectedCluster(cluster);
setPopupInfo({
mode: 'cluster',

View file

@ -6,7 +6,7 @@ const RECENT_SEARCHES_STORAGE_KEY = 'perfect-postcode.locationSearch.recent';
const RECENT_SEARCH_LIMIT = 10;
/** Matches a full UK postcode with complete inward code (e.g. "E14 2DG", "SW1A1AA").
* Outcodes like "E14" or "SW1A" intentionally do NOT match they go through /api/places instead. */
* Outcodes like "E14" or "SW1A" intentionally do NOT match; they go through /api/places instead. */
const FULL_POSTCODE_RE = /^[A-Z]{1,2}\d[A-Z\d]?\s*\d[A-Z]{2}$/i;
function looksLikePostcode(s: string) {
@ -331,7 +331,7 @@ export function useLocationSearch(mode?: string, getViewportCenter?: () => Viewp
return;
}
lastResultsRef.current = combinedResults;
// Trust the server's unified ranking re-filtering here previously dropped valid
// Trust the server's unified ranking: re-filtering here previously dropped valid
// alias and partial-postcode matches. The optimistic pre-fetch path still filters.
setResults(combinedResults);
setOpen(true);

View file

@ -23,6 +23,7 @@ import { getElectionVoteShareFeatureName } from '../lib/election-filter';
import { getEthnicityFeatureName } from '../lib/ethnicity-filter';
import { getQualificationFeatureName } from '../lib/qualification-filter';
import { getTenureFeatureName } from '../lib/tenure-filter';
import { getCouncilFeatureName } from '../lib/council-filter';
import { getPoiDistanceFeatureName } from '../lib/poi-distance-filter';
import { POSTCODE_ZOOM_THRESHOLD } from '../lib/consts';
import { COLOR_RANGE_LOW_PERCENTILE, COLOR_RANGE_HIGH_PERCENTILE } from '../lib/consts';
@ -32,7 +33,7 @@ import pb from '../lib/pocketbase';
/** When a data request is rejected with the filter cap while we still hold a token, the
* server didn't accept our session (deleted/banned account, rotated key) and applied the
* lower anonymous cap. Surface that so the caller can re-validate auth which, if the
* lower anonymous cap. Surface that so the caller can re-validate auth, which, if the
* session is gone, drops the user, lowers the cap, and re-clamps the filters, recovering
* instead of looping on an empty map. A logged-in client only ever sends its own cap,
* so a filter-cap rejection while authed is a reliable desync signal. */
@ -72,7 +73,7 @@ interface UseMapDataOptions {
* longer grants any special access (the region lock was removed). */
shareCode?: string;
/** Called when the server rejects a data request with the filter cap while the client
* still holds a (locally-valid) token i.e. a client/server session desync. */
* still holds a (locally-valid) token, i.e. a client/server session desync. */
onSessionRejected?: () => void;
}
@ -149,6 +150,7 @@ export function useMapData({
getEthnicityFeatureName(name) ??
getQualificationFeatureName(name) ??
getTenureFeatureName(name) ??
getCouncilFeatureName(name) ??
getPoiDistanceFeatureName(name) ??
name,
[]
@ -568,7 +570,7 @@ export function useMapData({
if (vals.length === 0) return null;
// 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)`
// 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();
@ -733,7 +735,7 @@ export function useMapData({
);
// Treat the map as loading whenever the rendered hexagons don't match the
// current request — covers the brief window between a slider release and
// current request, covering the brief window between a slider release and
// the main fetch effect actually firing setLoading(true).
const isLoading = loading || (bounds != null && loadedDataKey !== dataRequestKey);

View file

@ -83,7 +83,7 @@ export function usePaneResize(
if (!draggingRef.current) return;
liveSizeRef.current = computeSize(e);
if (targetRef.current) {
// Batch DOM updates to once per animation frame on mobile, pointermove
// Batch DOM updates to once per animation frame: on mobile, pointermove
// can fire multiple times per frame, and each direct style.height write
// forces a synchronous reflow that desynchronises MapLibre and deck.gl.
if (rafRef.current == null) {

View file

@ -212,7 +212,7 @@ describe('usePoiLayers', () => {
});
expect(result.current.popupInfo?.id).toBe(supermarket.id);
// Disabling POIs empties the list the popup must not stay stuck on screen.
// Disabling POIs empties the list, so the popup must not stay stuck on screen.
rerender({ pois: [] });
expect(result.current.popupInfo).toBeNull();
});

View file

@ -69,7 +69,7 @@ export function usePoiLayers({ pois, zoom, isDark }: UsePoiLayersProps) {
const { t } = useTranslation();
const [popupInfo, setPopupInfo] = useState<PopupInfo | null>(null);
// Dismiss a lingering hover/cluster popup once the POIs behind it are gone e.g.
// Dismiss a lingering hover/cluster popup once the POIs behind it are gone, e.g.
// after the user clears the POI categories. Without this the card stays stuck on
// screen because it is only otherwise cleared on mouse-leave or the close button.
useEffect(() => {

View file

@ -104,7 +104,7 @@ export function useSavedSearches(userId: string | null) {
if (!mapped.some((s) => !s.screenshotUrl)) return;
scheduleNext();
} catch {
// Silent background poll errors don't surface to UI; keep trying.
// Silent: background poll errors don't surface to UI; keep trying.
if (isMountedRef.current) scheduleNext();
} finally {
pollInFlightRef.current = false;

View file

@ -3,6 +3,7 @@ import type { FeatureMeta, FeatureFilters } from '../types';
import { stateToParams } from '../lib/url-state';
import type { OverlayId } from '../lib/overlays';
import type { BasemapId } from '../lib/basemaps';
import type { ListingsMode } from '../lib/listings';
import type { TravelTimeEntry } from './useTravelTime';
const URL_DEBOUNCE_MS = 300;
@ -19,7 +20,8 @@ export function useUrlSync(
basemap?: BasemapId,
selectedCrimeTypes?: Set<string>,
selectedPostcode?: string,
colorOpacity?: number
colorOpacity?: number,
listingsMode?: ListingsMode
) {
const urlDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@ -40,7 +42,8 @@ export function useUrlSync(
basemap,
selectedCrimeTypes,
selectedPostcode,
colorOpacity
colorOpacity,
listingsMode
);
const search = params.toString();
const newUrl = search ? `${window.location.pathname}?${search}` : window.location.pathname;
@ -63,5 +66,6 @@ export function useUrlSync(
selectedCrimeTypes,
selectedPostcode,
colorOpacity,
listingsMode,
]);
}