Boring changes
This commit is contained in:
parent
cfaf58dfba
commit
920119ff48
32 changed files with 912 additions and 88 deletions
|
|
@ -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]);
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
});
|
||||
}, []);
|
||||
|
|
|
|||
103
frontend/src/hooks/useDeckLayers.collision.test.ts
Normal file
103
frontend/src/hooks/useDeckLayers.collision.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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]
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 5k–10k 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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
]);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue