This commit is contained in:
Andras Schmelczer 2026-05-31 13:17:11 +01:00
parent c995f12f8b
commit 8dc939d761
44 changed files with 3540 additions and 2159478 deletions

View file

@ -1,6 +1,6 @@
import { useEffect, useRef, useState } from 'react';
import type { ActualListing, ActualListingsResponse, Bounds } from '../types';
import { apiUrl, authHeaders, logNonAbortError } from '../lib/api';
import { apiUrl, authHeaders, isAbortError, logNonAbortError } from '../lib/api';
const DEBOUNCE_MS = 200;
@ -15,6 +15,7 @@ export function useActualListings(
{ filterParam = '', travelParam = '', shareCode = '' }: UseActualListingsOptions = {}
) {
const [listings, setListings] = useState<ActualListing[]>([]);
const [loading, setLoading] = useState(false);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const abortControllerRef = useRef<AbortController | null>(null);
const requestIdRef = useRef(0);
@ -26,10 +27,12 @@ export function useActualListings(
if (!bounds) {
abortControllerRef.current?.abort();
if (listings.length !== 0) setListings([]);
setLoading(false);
return;
}
if (debounceRef.current) clearTimeout(debounceRef.current);
setLoading(true);
debounceRef.current = setTimeout(async () => {
abortControllerRef.current?.abort();
@ -45,13 +48,20 @@ export function useActualListings(
authHeaders({ signal: abortControllerRef.current.signal })
);
if (!res.ok) {
if (requestIdRef.current === requestId) setListings([]);
if (requestIdRef.current === requestId) {
setListings([]);
setLoading(false);
}
throw new Error(`Actual listings fetch failed: HTTP ${res.status}`);
}
const json: ActualListingsResponse = await res.json();
if (requestIdRef.current !== requestId) return;
setListings(json.listings || []);
setLoading(false);
} catch (err) {
if (requestIdRef.current === requestId && !isAbortError(err)) {
setLoading(false);
}
logNonAbortError('Failed to fetch actual listings', err);
}
}, DEBOUNCE_MS);
@ -64,5 +74,5 @@ export function useActualListings(
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [bounds, filterParam, travelParam, shareCode]);
return { listings };
return { listings, loading };
}

View file

@ -8,6 +8,7 @@ export interface AuthUser {
isAdmin: boolean;
subscription: string;
newsletter: boolean;
canSeeListings: boolean;
}
function recordToUser(record: { id: string; [key: string]: unknown }): AuthUser {
@ -20,6 +21,7 @@ function recordToUser(record: { id: string; [key: string]: unknown }): AuthUser
isAdmin: typeof record.is_admin === 'boolean' ? record.is_admin : false,
subscription: typeof record.subscription === 'string' ? record.subscription : 'free',
newsletter: typeof record.newsletter === 'boolean' ? record.newsletter : false,
canSeeListings: typeof record.can_see_listings === 'boolean' ? record.can_see_listings : false,
};
}

View file

@ -1,7 +1,7 @@
import { useCallback, useRef, useState, useMemo, useEffect } from 'react';
import { H3HexagonLayer } from '@deck.gl/geo-layers';
import { GeoJsonLayer, TextLayer, ScatterplotLayer } from '@deck.gl/layers';
import { cellToBoundary } from 'h3-js';
import { cellToBoundary, isValidCell } from 'h3-js';
import type { PickingInfo } from '@deck.gl/core';
import type {
HexagonData,
@ -25,6 +25,7 @@ import { usePoiLayers } from './usePoiLayers';
import { useListingLayers } from './useListingLayers';
import { MarchingAntsExtension } from '../lib/MarchingAntsExtension';
import { PieHexExtension } from '../lib/PieHexExtension';
import { normalizeColorOpacity } from '../lib/color-opacity';
interface UseDeckLayersProps {
data: HexagonData[];
@ -47,6 +48,7 @@ interface UseDeckLayersProps {
bounds?: Bounds | null;
travelTimeEntries?: TravelTimeEntry[];
mapDataBeforeId: string;
colorOpacity?: number;
}
/** Normalize a distribution count array to [0..1] ratios, padded to 10 values. */
@ -69,6 +71,13 @@ function requireEnumPalette(
return palette;
}
type RgbaColor = [number, number, number, number];
function withColorOpacity(color: RgbaColor, opacity: number): RgbaColor {
if (opacity >= 1) return color;
return [color[0], color[1], color[2], Math.round(color[3] * opacity)];
}
export function useDeckLayers({
data,
postcodeData,
@ -90,6 +99,7 @@ export function useDeckLayers({
bounds: viewportBounds,
travelTimeEntries = [],
mapDataBeforeId,
colorOpacity = 1,
}: UseDeckLayersProps) {
const [hoverPosition, setHoverPosition] = useState<{ x: number; y: number } | null>(null);
const [hoveredPostcode, setHoveredPostcode] = useState<string | null>(null);
@ -128,10 +138,17 @@ export function useDeckLayers({
isDarkRef.current = isDark;
const densityGradientRef = useRef(densityGradient);
densityGradientRef.current = densityGradient;
const normalizedColorOpacity = normalizeColorOpacity(colorOpacity);
const colorOpacityRef = useRef(normalizedColorOpacity);
colorOpacityRef.current = normalizedColorOpacity;
const featureGradientRef = useRef(getFeatureGradient(viewFeature));
featureGradientRef.current = getFeatureGradient(viewFeature);
const selectedHexagonIdRef = useRef(selectedHexagonId);
selectedHexagonIdRef.current = selectedHexagonId;
const selectedHexagonSelectionRef = useRef<string | null>(null);
selectedHexagonSelectionRef.current = selectedPostcodeGeometry ? null : selectedHexagonId;
const selectedPostcodeIdRef = useRef<string | null>(null);
selectedPostcodeIdRef.current = selectedPostcodeGeometry ? selectedHexagonId : null;
const hoveredHexagonIdRef = useRef(hoveredHexagonId);
hoveredHexagonIdRef.current = hoveredHexagonId;
const hoveredPostcodeRef = useRef(hoveredPostcode);
@ -270,8 +287,10 @@ export function useDeckLayers({
return parts.join(';');
}, [travelTimeEntries]);
const colorTrigger = `${viewFeature}|${colorRange?.[0]}|${colorRange?.[1]}|${filterRange?.[0]}|${filterRange?.[1]}|${countRange.min}|${countRange.max}|${selectedHexagonId}|${hoveredHexagonId}|${theme}|${ttTrigger}`;
const postcodeColorTrigger = `${viewFeature}|${colorRange?.[0]}|${colorRange?.[1]}|${filterRange?.[0]}|${filterRange?.[1]}|${postcodeCountRange.min}|${postcodeCountRange.max}|${hoveredPostcode}|${theme}|${ttTrigger}`;
const selectedHexagonKey = selectedPostcodeGeometry ? '' : (selectedHexagonId ?? '');
const selectedPostcodeKey = selectedPostcodeGeometry ? (selectedHexagonId ?? '') : '';
const colorTrigger = `${viewFeature}|${colorRange?.[0]}|${colorRange?.[1]}|${filterRange?.[0]}|${filterRange?.[1]}|${countRange.min}|${countRange.max}|${selectedHexagonKey}|${hoveredHexagonId}|${theme}|${normalizedColorOpacity}|${ttTrigger}`;
const postcodeColorTrigger = `${viewFeature}|${colorRange?.[0]}|${colorRange?.[1]}|${filterRange?.[0]}|${filterRange?.[1]}|${postcodeCountRange.min}|${postcodeCountRange.max}|${selectedPostcodeKey}|${hoveredPostcode}|${theme}|${normalizedColorOpacity}|${ttTrigger}`;
// --- Layers ---
// PieHexExtension uses the canonical deck.gl v9 pattern: defaultProps with
@ -328,6 +347,7 @@ export function useDeckLayers({
if ((d.count as number) <= 0) {
return [0, 0, 0, 0] as [number, number, number, number];
}
const fill = (color: RgbaColor) => withColorOpacity(color, colorOpacityRef.current);
const dark = isDarkRef.current;
const vf = viewFeatureRef.current;
const clr = colorRangeRef.current;
@ -338,28 +358,25 @@ export function useDeckLayers({
if (vf.startsWith('tt_')) {
const ttVal = d[`avg_${vf}`];
if (ttVal == null) {
return (dark ? [80, 70, 65, 80] : [128, 128, 128, 80]) as [
number,
number,
number,
number,
];
return fill(dark ? [80, 70, 65, 80] : [128, 128, 128, 80]);
}
const ttMin = (d[`min_${vf}`] as number) ?? ttVal;
const ttMax = (d[`max_${vf}`] as number) ?? ttVal;
return getFeatureFillColor(
ttVal as number,
ttMin as number,
ttMax as number,
clr,
fr,
0,
densityGradientRef.current,
dark,
255,
0,
undefined,
featureGradientRef.current
return fill(
getFeatureFillColor(
ttVal as number,
ttMin as number,
ttMax as number,
clr,
fr,
0,
densityGradientRef.current,
dark,
255,
0,
undefined,
featureGradientRef.current
)
);
}
@ -367,19 +384,21 @@ export function useDeckLayers({
const val = d[`avg_${vf}`] ?? d[`min_${vf}`];
const minVal = d[`min_${vf}`] as number | undefined;
const maxVal = d[`max_${vf}`] as number | undefined;
return getFeatureFillColor(
val as number | null | undefined,
minVal,
maxVal,
clr,
fr,
0,
densityGradientRef.current,
dark,
255,
enumCountRef.current,
enumPaletteRef.current,
featureGradientRef.current
return fill(
getFeatureFillColor(
val as number | null | undefined,
minVal,
maxVal,
clr,
fr,
0,
densityGradientRef.current,
dark,
255,
enumCountRef.current,
enumPaletteRef.current,
featureGradientRef.current
)
);
}
}
@ -387,24 +406,29 @@ export function useDeckLayers({
const cr = countRangeRef.current;
const c = d.count as number;
const t = (c - cr.min) / (cr.max - cr.min);
return getFeatureFillColor(
null,
undefined,
undefined,
null,
null,
t,
densityGradientRef.current,
dark,
255
return fill(
getFeatureFillColor(
null,
undefined,
undefined,
null,
null,
t,
densityGradientRef.current,
dark,
255
)
);
},
getLineColor: (d) => {
if (d.h3 === selectedHexagonSelectionRef.current)
return [29, 228, 195, 255] as [number, number, number, number];
if (d.h3 === hoveredHexagonIdRef.current)
return [29, 228, 195, 200] as [number, number, number, number];
return [0, 0, 0, 0] as [number, number, number, number];
},
getLineWidth: (d) => {
if (d.h3 === selectedHexagonSelectionRef.current) return 4;
if (d.h3 === hoveredHexagonIdRef.current) return 2;
return 0;
},
@ -477,6 +501,7 @@ export function useDeckLayers({
if (d.count <= 0) {
return [0, 0, 0, 0] as [number, number, number, number];
}
const fill = (color: RgbaColor) => withColorOpacity(color, colorOpacityRef.current);
const dark = isDarkRef.current;
const vf = viewFeatureRef.current;
const clr = colorRangeRef.current;
@ -488,28 +513,25 @@ export function useDeckLayers({
if (vf.startsWith('tt_')) {
const ttVal = d[`avg_${vf}`];
if (ttVal == null) {
return (dark ? [80, 70, 65, 80] : [128, 128, 128, 80]) as [
number,
number,
number,
number,
];
return fill(dark ? [80, 70, 65, 80] : [128, 128, 128, 80]);
}
const ttMin = (d[`min_${vf}`] as number) ?? ttVal;
const ttMax = (d[`max_${vf}`] as number) ?? ttVal;
return getFeatureFillColor(
ttVal as number,
ttMin as number,
ttMax as number,
clr,
fr,
0,
densityGradientRef.current,
dark,
180,
0,
undefined,
featureGradientRef.current
return fill(
getFeatureFillColor(
ttVal as number,
ttMin as number,
ttMax as number,
clr,
fr,
0,
densityGradientRef.current,
dark,
180,
0,
undefined,
featureGradientRef.current
)
);
}
@ -518,40 +540,46 @@ export function useDeckLayers({
const val = d[`avg_${vf}`] ?? d[`min_${vf}`];
const minVal = d[`min_${vf}`] as number | undefined;
const maxVal = d[`max_${vf}`] as number | undefined;
return getFeatureFillColor(
val as number | null | undefined,
minVal,
maxVal,
clr,
fr,
0,
densityGradientRef.current,
dark,
180,
enumCountRef.current,
enumPaletteRef.current,
featureGradientRef.current
return fill(
getFeatureFillColor(
val as number | null | undefined,
minVal,
maxVal,
clr,
fr,
0,
densityGradientRef.current,
dark,
180,
enumCountRef.current,
enumPaletteRef.current,
featureGradientRef.current
)
);
}
}
const cr = postcodeCountRangeRef.current;
const c = d.count;
const t = (c - cr.min) / (cr.max - cr.min);
return getFeatureFillColor(
null,
undefined,
undefined,
null,
null,
t,
densityGradientRef.current,
dark,
180
return fill(
getFeatureFillColor(
null,
undefined,
undefined,
null,
null,
t,
densityGradientRef.current,
dark,
180
)
);
},
getLineColor: (f) => {
const pc = f.properties.postcode;
const dark = isDarkRef.current;
if (pc === selectedPostcodeIdRef.current)
return [29, 228, 195, 255] as [number, number, number, number];
if (pc === hoveredPostcodeRef.current)
return [29, 228, 195, 200] as [number, number, number, number];
if (f.properties.count <= 0) return [0, 0, 0, 0] as [number, number, number, number];
@ -564,6 +592,7 @@ export function useDeckLayers({
},
getLineWidth: (f) => {
const pc = f.properties.postcode;
if (pc === selectedPostcodeIdRef.current) return 4;
if (pc === hoveredPostcodeRef.current) return 2;
if (f.properties.count <= 0) return 0;
return 1;
@ -624,7 +653,7 @@ export function useDeckLayers({
let geometry: PostcodeGeometry | null = null;
if (selectedPostcodeGeometry) {
geometry = selectedPostcodeGeometry;
} else if (selectedHexagonId) {
} else if (selectedHexagonId && isValidCell(selectedHexagonId)) {
const boundary = cellToBoundary(selectedHexagonId, true);
geometry = { type: 'Polygon', coordinates: [boundary] };
}
@ -644,6 +673,7 @@ export function useDeckLayers({
getLineWidth: 3,
lineWidthUnits: 'pixels' as const,
pickable: false,
parameters: { depthTest: false },
marchTime,
extensions: [new MarchingAntsExtension()],
});

View file

@ -114,8 +114,13 @@ export function useListingLayers({ listings, zoom, isDark }: UseListingLayersPro
const [selectedCluster, setSelectedCluster] = useState<ListingClusterPoint | null>(null);
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) —
// 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).
setSelectedCluster(null);
setPopupInfo(null);
setPopupInfo((current) => (current?.locked ? current : null));
}, [listings]);
const clusterIndex = useMemo(() => {

View file

@ -16,7 +16,10 @@ export function useUrlSync(
travelTimeEntries?: TravelTimeEntry[],
share?: string,
selectedOverlays?: Set<OverlayId>,
basemap?: BasemapId
basemap?: BasemapId,
selectedCrimeTypes?: Set<string>,
selectedPostcode?: string,
colorOpacity?: number
) {
const urlDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@ -34,7 +37,10 @@ export function useUrlSync(
travelTimeEntries,
share,
selectedOverlays,
basemap
basemap,
selectedCrimeTypes,
selectedPostcode,
colorOpacity
);
const search = params.toString();
const newUrl = search ? `${window.location.pathname}?${search}` : window.location.pathname;
@ -54,5 +60,8 @@ export function useUrlSync(
share,
selectedOverlays,
basemap,
selectedCrimeTypes,
selectedPostcode,
colorOpacity,
]);
}