good
This commit is contained in:
parent
c995f12f8b
commit
8dc939d761
44 changed files with 3540 additions and 2159478 deletions
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()],
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
]);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue