Fun changes
Some checks failed
CI / Python (lint + test) (push) Failing after 3m38s
CI / Rust (lint + test) (push) Failing after 3m32s
CI / Frontend (lint + typecheck) (push) Failing after 4m12s
Build and publish Docker image / build-and-push (push) Failing after 4m48s

This commit is contained in:
Andras Schmelczer 2026-04-04 22:59:44 +01:00
parent cd778dd088
commit 349a6c1d53
60 changed files with 1260 additions and 2600 deletions

View file

@ -53,7 +53,6 @@ function buildSummary(
const parts: string[] = [];
for (const [name, value] of Object.entries(filters)) {
if (name === 'Listing status') continue;
if (Array.isArray(value) && value.length === 2 && typeof value[0] === 'number') {
parts.push(ts(name));
} else if (Array.isArray(value)) {

View file

@ -1,6 +1,6 @@
import { useCallback, useRef, useState, useMemo, useEffect } from 'react';
import { H3HexagonLayer } from '@deck.gl/geo-layers';
import { GeoJsonLayer, IconLayer, TextLayer, ScatterplotLayer, PolygonLayer } from '@deck.gl/layers';
import { GeoJsonLayer, IconLayer, TextLayer, ScatterplotLayer } from '@deck.gl/layers';
import { cellToBoundary } from 'h3-js';
import Supercluster from 'supercluster';
import type { PickingInfo } from '@deck.gl/core';
@ -22,6 +22,7 @@ import {
MINOR_POI_ZOOM_THRESHOLD,
POI_CLUSTER_RADIUS,
POI_CLUSTER_MAX_ZOOM,
getEnumPaletteForFeature,
} from '../lib/consts';
import { emojiToTwemojiUrl, getFeatureFillColor } from '../lib/map-utils';
import type { TravelTimeEntry } from './useTravelTime';
@ -146,6 +147,12 @@ export function useDeckLayers({
? colorFeatureMeta.values.length
: 0;
// Per-feature color palette (uses overrides when defined)
const enumPaletteRef = useRef(
getEnumPaletteForFeature(viewFeature, colorFeatureMeta?.values)
);
enumPaletteRef.current = getEnumPaletteForFeature(viewFeature, colorFeatureMeta?.values);
const countRange = useMemo(() => {
if (data.length === 0) return { min: 0, max: 1, total: 0 };
let min = Infinity;
@ -306,85 +313,42 @@ export function useDeckLayers({
const postcodeColorTrigger = `${viewFeature}|${colorRange?.[0]}|${colorRange?.[1]}|${filterRange?.[0]}|${filterRange?.[1]}|${postcodeCountRange.min}|${postcodeCountRange.max}|${hoveredPostcode}|${theme}|${ttTrigger}`;
// --- Layers ---
// For enum features, we bypass H3HexagonLayer and use PolygonLayer directly.
// H3HexagonLayer has double CompositeLayer nesting (H3 → PolygonLayer → SolidPolygonLayer)
// which prevents custom binary attributes from reaching the fill sublayer.
// PolygonLayer has only one level of nesting, so _subLayerProps.fill works reliably.
// PieHexExtension uses the canonical deck.gl v9 pattern: defaultProps with
// type:'accessor' + stepMode:'dynamic'. LayerExtension.getSubLayerProps()
// wraps accessors via getSubLayerAccessor() which unwraps __source.object,
// letting accessor functions work through CompositeLayer sublayer chains.
const hexLayer = useMemo(() => {
const isEnum = enumCountRef.current > 0;
const distKey = viewFeatureRef.current ? `dist_${viewFeatureRef.current}` : '';
if (isEnum) {
const distKey = viewFeatureRef.current ? `dist_${viewFeatureRef.current}` : '';
const n = data.length;
// Pre-compute hex boundaries and binary attribute buffers
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const polyData: any[] = new Array(n);
const centers = new Float32Array(n * 2);
const r0 = new Float32Array(n * 4);
const r1 = new Float32Array(n * 4);
const r2 = new Float32Array(n * 2);
for (let i = 0; i < n; i++) {
const d = data[i];
polyData[i] = { ...d, polygon: cellToBoundary(d.h3, true) };
centers[i * 2] = d.lon as number;
centers[i * 2 + 1] = d.lat as number;
const r = distToRatios(d[distKey]);
r0[i * 4] = r[0];
r0[i * 4 + 1] = r[1];
r0[i * 4 + 2] = r[2];
r0[i * 4 + 3] = r[3];
r1[i * 4] = r[4];
r1[i * 4 + 1] = r[5];
r1[i * 4 + 2] = r[6];
r1[i * 4 + 3] = r[7];
r2[i * 2] = r[8];
r2[i * 2 + 1] = r[9];
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return new (PolygonLayer as any)({
id: 'h3-hexagons',
data: polyData,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
getPolygon: (d: any) => d.polygon,
getFillColor: [200, 200, 200],
// eslint-disable-next-line @typescript-eslint/no-explicit-any
getLineColor: (d: any) => {
if (d.h3 === hoveredHexagonIdRef.current)
return [29, 228, 195, 200];
return [0, 0, 0, 0];
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
getLineWidth: (d: any) => {
if (d.h3 === hoveredHexagonIdRef.current) return 2;
return 0;
},
lineWidthUnits: 'pixels',
updateTriggers: {
getLineColor: [colorTrigger],
getLineWidth: [colorTrigger],
},
extensions: [new PieHexExtension()],
_subLayerProps: {
fill: {
instancePieCenter: { value: centers, size: 2 },
instanceRatios0: { value: r0, size: 4 },
instanceRatios1: { value: r1, size: 4 },
instanceRatios2: { value: r2, size: 2 },
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const pieProps: any = isEnum
? {
extensions: [new PieHexExtension(enumPaletteRef.current)],
getCenter: (d: HexagonData) => [d.lon, d.lat],
getRatios0: (d: HexagonData) => {
const r = distToRatios(d[distKey]);
return [r[0], r[1], r[2], r[3]];
},
},
extruded: false,
pickable: true,
onClick: handleHexagonClick,
onHover: handleHexagonHover,
beforeId: 'landuse_park',
});
}
getRatios1: (d: HexagonData) => {
const r = distToRatios(d[distKey]);
return [r[4], r[5], r[6], r[7]];
},
getRatios2: (d: HexagonData) => {
const r = distToRatios(d[distKey]);
return [r[8], r[9]];
},
updateTriggers: {
getCenter: [colorTrigger, data],
getRatios0: [colorTrigger, data],
getRatios1: [colorTrigger, data],
getRatios2: [colorTrigger, data],
},
}
: {};
// Non-enum: use H3HexagonLayer as normal
return new H3HexagonLayer<HexagonData>({
id: 'h3-hexagons',
id: isEnum ? 'h3-hexagons-pie' : 'h3-hexagons',
data,
getHexagon: (d) => d.h3,
getFillColor: (d) => {
@ -434,7 +398,8 @@ export function useDeckLayers({
densityGradientRef.current,
dark,
255,
enumCountRef.current
enumCountRef.current,
enumPaletteRef.current
);
}
}
@ -468,6 +433,7 @@ export function useDeckLayers({
getFillColor: [colorTrigger],
getLineColor: [colorTrigger],
getLineWidth: [colorTrigger],
...(pieProps.updateTriggers || {}),
},
extruded: false,
pickable: true,
@ -475,59 +441,12 @@ export function useDeckLayers({
highPrecision: true,
onClick: handleHexagonClick,
onHover: handleHexagonHover,
// @ts-expect-error beforeId is a MapboxOverlay interleave prop, not typed in LayerProps
beforeId: 'landuse_park',
...pieProps,
});
}, [data, colorTrigger, handleHexagonClick, handleHexagonHover]);
const postcodeLayer = useMemo(() => {
const isEnum = enumCountRef.current > 0;
const distKey = viewFeatureRef.current ? `dist_${viewFeatureRef.current}` : '';
// Same binary buffer approach as hexagons, routed via _subLayerProps.
// GeoJsonLayer → 'polygons-fill' (PolygonLayer) → 'fill' (SolidPolygonLayer)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let pieProps: any = {};
if (isEnum) {
const n = postcodeData.length;
const centers = new Float32Array(n * 2);
const r0 = new Float32Array(n * 4);
const r1 = new Float32Array(n * 4);
const r2 = new Float32Array(n * 2);
for (let i = 0; i < n; i++) {
const centroid = postcodeData[i].properties.centroid as [number, number];
centers[i * 2] = centroid[0];
centers[i * 2 + 1] = centroid[1];
const r = distToRatios(postcodeData[i].properties[distKey]);
r0[i * 4] = r[0];
r0[i * 4 + 1] = r[1];
r0[i * 4 + 2] = r[2];
r0[i * 4 + 3] = r[3];
r1[i * 4] = r[4];
r1[i * 4 + 1] = r[5];
r1[i * 4 + 2] = r[6];
r1[i * 4 + 3] = r[7];
r2[i * 2] = r[8];
r2[i * 2 + 1] = r[9];
}
const fillAttrs = {
instancePieCenter: { value: centers, size: 2 },
instanceRatios0: { value: r0, size: 4 },
instanceRatios1: { value: r1, size: 4 },
instanceRatios2: { value: r2, size: 2 },
};
pieProps = {
extensions: [new PieHexExtension()],
_subLayerProps: {
'polygons-fill': {
_subLayerProps: {
fill: fillAttrs,
},
},
},
};
}
return new GeoJsonLayer<PostcodeProperties>({
id: 'postcode-polygons',
data: postcodeData as PostcodeFeature[],
@ -581,7 +500,8 @@ export function useDeckLayers({
densityGradientRef.current,
dark,
180,
enumCountRef.current
enumCountRef.current,
enumPaletteRef.current
);
}
}
@ -627,8 +547,8 @@ export function useDeckLayers({
pickable: true,
onClick: handlePostcodeClick,
onHover: handlePostcodeHoverCallback,
// @ts-expect-error beforeId is a MapboxOverlay interleave prop, not typed in LayerProps
beforeId: 'landuse_park',
...pieProps,
});
}, [postcodeData, postcodeColorTrigger, handlePostcodeClick, handlePostcodeHoverCallback]);

View file

@ -71,7 +71,14 @@ export function useFilters({ initialFilters, features }: UseFiltersOptions) {
}, [handleUndo]);
const handleFilterChange = useCallback((name: string, value: [number, number] | string[]) => {
setFilters((prev) => ({ ...prev, [name]: value }));
setFilters((prev) => {
if (Array.isArray(value) && value.length === 0) {
const next = { ...prev };
delete next[name];
return next;
}
return { ...prev, [name]: value };
});
}, []);
const handleRemoveFilter = useCallback((name: string) => {

View file

@ -18,6 +18,7 @@ export function usePaneResize(
const targetRef = useRef<HTMLElement | null>(null);
const containerOffsetRef = useRef(0);
const containerSizeRef = useRef(0);
const rafRef = useRef<number | null>(null);
const isVertical = side === 'top' || side === 'bottom';
const styleProp = isVertical ? 'height' : 'width';
@ -80,12 +81,21 @@ export function usePaneResize(
const handlePointerMove = useCallback(
(e: React.PointerEvent) => {
if (!draggingRef.current) return;
const newSize = computeSize(e);
liveSizeRef.current = newSize;
liveSizeRef.current = computeSize(e);
if (targetRef.current) {
targetRef.current.style[styleProp] = `${newSize}px`;
// 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) {
rafRef.current = requestAnimationFrame(() => {
rafRef.current = null;
if (targetRef.current) {
targetRef.current.style[styleProp] = `${liveSizeRef.current}px`;
}
});
}
} else {
setSize(newSize);
setSize(liveSizeRef.current);
}
},
[computeSize, styleProp]
@ -93,8 +103,16 @@ export function usePaneResize(
const handlePointerUp = useCallback(() => {
draggingRef.current = false;
if (rafRef.current != null) {
cancelAnimationFrame(rafRef.current);
rafRef.current = null;
}
// Apply final size synchronously so the commit is immediate
if (targetRef.current) {
targetRef.current.style[styleProp] = `${liveSizeRef.current}px`;
}
setSize(liveSizeRef.current);
}, []);
}, [styleProp]);
return [
size,

View file

@ -12,11 +12,7 @@ export interface SavedPropertyData {
energyRating?: string;
price?: number;
estimatedPrice?: number;
askingPrice?: number;
askingRent?: number;
bedrooms?: number;
floorArea?: number;
listingUrl?: string;
}
export interface SavedProperty {
@ -84,11 +80,7 @@ export function useSavedProperties(userId: string | null) {
energyRating: property.current_energy_rating,
price: getNum(property, 'Last known price'),
estimatedPrice: getNum(property, 'Estimated current price'),
askingPrice: getNum(property, 'Asking price'),
askingRent: getNum(property, 'Asking rent (monthly)'),
bedrooms: getNum(property, 'Bedrooms'),
floorArea: getNum(property, 'Total floor area (sqm)'),
listingUrl: property.listing_url || undefined,
};
await pb.collection('saved_properties').create({