From fd2860070a7a0af5b7f55dd87cc7eea9166cf39c Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Mon, 22 Jun 2026 22:12:27 +0100 Subject: [PATCH] lgtm --- frontend/src/components/home/HomePage.tsx | 9 +- frontend/src/components/learn/LearnPage.tsx | 6 +- frontend/src/components/map/AreaPane.tsx | 198 +++++++++- .../src/components/map/DevelopmentPopup.tsx | 72 ++++ frontend/src/components/map/Filters.tsx | 5 + frontend/src/components/map/Map.tsx | 65 +++- frontend/src/components/map/MapPage.tsx | 9 +- .../src/components/map/NumberLine.test.ts | 83 +++++ frontend/src/components/map/NumberLine.tsx | 208 +++++++++++ .../src/components/map/OverlayTileLayers.tsx | 39 +- .../map/filters/ActiveFiltersPanel.tsx | 1 + frontend/src/hooks/useDeckLayers.ts | 16 +- frontend/src/hooks/useDevelopmentLayers.ts | 152 ++++++++ frontend/src/hooks/useDevelopments.ts | 81 +++++ frontend/src/hooks/usePoiLayers.test.ts | 41 +++ frontend/src/hooks/usePoiLayers.ts | 14 +- frontend/src/i18n/descriptions.ts | 77 ++++ frontend/src/i18n/details.ts | 96 +++++ frontend/src/i18n/locales/de.ts | 69 +++- frontend/src/i18n/locales/en.ts | 71 ++-- frontend/src/i18n/locales/fr.ts | 71 ++-- frontend/src/i18n/locales/hi.ts | 69 +++- frontend/src/i18n/locales/hu.ts | 69 +++- frontend/src/i18n/locales/zh.ts | 77 +++- frontend/src/lib/api.ts | 1 + frontend/src/lib/consts.ts | 38 ++ frontend/src/lib/map-utils.test.ts | 30 ++ frontend/src/lib/map-utils.ts | 30 ++ frontend/src/lib/overlays.ts | 9 + frontend/src/lib/qualification-filter.ts | 22 ++ frontend/src/types.ts | 45 +++ pipeline/download/census_population.py | 153 ++++++++ pipeline/download/development_sites.py | 302 +++++++++++++++ pipeline/download/education.py | 177 +++++++++ pipeline/download/tenure.py | 179 +++++++++ pipeline/download/test_development_sites.py | 165 +++++++++ pipeline/download/test_education.py | 107 ++++++ pipeline/download/test_tenure.py | 121 ++++++ pipeline/transform/crime_hotspot_tiles.py | 26 +- pipeline/transform/merge.py | 85 ++++- pipeline/transform/test_merge.py | 10 +- server-rs/src/data.rs | 6 + server-rs/src/data/area_crime_averages.rs | 47 +++ server-rs/src/data/developments.rs | 343 ++++++++++++++++++ server-rs/src/data/postcode_population.rs | 125 +++++++ server-rs/src/data/property/mod.rs | 105 ++++++ server-rs/src/features.rs | 132 +++++++ server-rs/src/main.rs | 58 +++ server-rs/src/routes.rs | 2 + server-rs/src/routes/developments.rs | 70 ++++ server-rs/src/routes/hexagon_stats.rs | 66 ++++ server-rs/src/routes/postcode_stats.rs | 14 + server-rs/src/routes/stats.rs | 142 +++++++- server-rs/src/state.rs | 21 +- server-rs/src/utils.rs | 41 +++ 55 files changed, 4084 insertions(+), 186 deletions(-) create mode 100644 frontend/src/components/map/DevelopmentPopup.tsx create mode 100644 frontend/src/components/map/NumberLine.test.ts create mode 100644 frontend/src/components/map/NumberLine.tsx create mode 100644 frontend/src/hooks/useDevelopmentLayers.ts create mode 100644 frontend/src/hooks/useDevelopments.ts create mode 100644 frontend/src/lib/qualification-filter.ts create mode 100644 pipeline/download/census_population.py create mode 100644 pipeline/download/development_sites.py create mode 100644 pipeline/download/education.py create mode 100644 pipeline/download/tenure.py create mode 100644 pipeline/download/test_development_sites.py create mode 100644 pipeline/download/test_education.py create mode 100644 pipeline/download/test_tenure.py create mode 100644 server-rs/src/data/area_crime_averages.rs create mode 100644 server-rs/src/data/developments.rs create mode 100644 server-rs/src/data/postcode_population.rs create mode 100644 server-rs/src/routes/developments.rs diff --git a/frontend/src/components/home/HomePage.tsx b/frontend/src/components/home/HomePage.tsx index 8cdaf4a..70cc34d 100644 --- a/frontend/src/components/home/HomePage.tsx +++ b/frontend/src/components/home/HomePage.tsx @@ -146,7 +146,14 @@ function ProductDemoVideo() { onPlay={() => setIsVideoPlaying(true)} onPause={() => setIsVideoPlaying(false)} onEnded={() => setIsVideoPlaying(false)} - /> + > + + {!isVideoPlaying && (
+ +
+ )} {hoverPosition && hoveredHexagonId && hoveredHexagonId !== selectedHexagonId && ( 0 ? fetchedPois : EMPTY_POIS; const overlaysZoomedIn = (mapData.currentView?.zoom ?? 0) >= POSTCODE_ZOOM_THRESHOLD; const actualListingsFilterParam = useMemo( () => buildFilterString(filters, features), diff --git a/frontend/src/components/map/NumberLine.test.ts b/frontend/src/components/map/NumberLine.test.ts new file mode 100644 index 0000000..dd104f2 --- /dev/null +++ b/frontend/src/components/map/NumberLine.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from 'vitest'; +import { computeNumberLineLayout, type NumberLinePoint } from './NumberLine'; + +const fmt = (v: number) => v.toFixed(0); + +const points: NumberLinePoint[] = [ + { kind: 'area', label: 'This area', value: 50 }, + { kind: 'national', label: 'National', value: 30 }, + { kind: 'outcode', label: 'SE3', value: 49 }, + { kind: 'sector', label: 'SE3 9', value: 51 }, +]; + +describe('computeNumberLineLayout', () => { + it('returns null when there is nothing to draw', () => { + expect(computeNumberLineLayout(points, 0, fmt)).toBeNull(); + expect(computeNumberLineLayout([], 300, fmt)).toBeNull(); + }); + + it('places ticks ordered by value', () => { + const layout = computeNumberLineLayout(points, 300, fmt)!; + expect(layout).not.toBeNull(); + // Items are sorted by tick position, which mirrors value order. + const ticks = layout.items.map((i) => i.tickX); + for (let i = 1; i < ticks.length; i++) { + expect(ticks[i]).toBeGreaterThanOrEqual(ticks[i - 1]); + } + // The smallest value (national, 30) sits left of the largest (sector, 51). + const national = layout.items.find((i) => i.kind === 'national')!; + const sector = layout.items.find((i) => i.kind === 'sector')!; + expect(national.tickX).toBeLessThan(sector.tickX); + }); + + it('maps the lowest value to the left edge and the highest to the right', () => { + const layout = computeNumberLineLayout( + [ + { kind: 'national', label: 'N', value: 30 }, + { kind: 'area', label: 'A', value: 50 }, + { kind: 'sector', label: 'S', value: 45 }, + ], + 300, + fmt + )!; + const low = layout.items.find((i) => i.kind === 'national')!; // 30 — lowest + const high = layout.items.find((i) => i.kind === 'area')!; // 50 — highest + expect(low.tickX).toBeCloseTo(layout.plotLeft, 5); + expect(high.tickX).toBeCloseTo(layout.plotRight, 5); + }); + + it('centres ticks when all values are equal', () => { + const layout = computeNumberLineLayout( + [ + { kind: 'area', label: 'A', value: 7 }, + { kind: 'national', label: 'N', value: 7 }, + ], + 300, + fmt + )!; + const centre = (layout.plotLeft + layout.plotRight) / 2; + for (const item of layout.items) { + expect(item.tickX).toBeCloseTo(centre, 5); + } + }); + + it('spreads clustered labels so their boxes never overlap', () => { + const layout = computeNumberLineLayout(points, 300, fmt)!; + for (let i = 1; i < layout.items.length; i++) { + const prev = layout.items[i - 1]; + const cur = layout.items[i]; + // Boxes [labelX ± halfWidth] must not overlap (GAP only adds more margin). + expect(cur.labelX - cur.halfWidth).toBeGreaterThanOrEqual( + prev.labelX + prev.halfWidth - 1e-6 + ); + } + }); + + it('keeps labels within the plot bounds', () => { + const layout = computeNumberLineLayout(points, 300, fmt)!; + for (const item of layout.items) { + expect(item.labelX - item.halfWidth).toBeGreaterThanOrEqual(layout.plotLeft - 1e-6); + expect(item.labelX + item.halfWidth).toBeLessThanOrEqual(layout.plotRight + 1e-6); + } + }); +}); diff --git a/frontend/src/components/map/NumberLine.tsx b/frontend/src/components/map/NumberLine.tsx new file mode 100644 index 0000000..aac6ad5 --- /dev/null +++ b/frontend/src/components/map/NumberLine.tsx @@ -0,0 +1,208 @@ +import { useEffect, useMemo, useRef, useState } from 'react'; + +export type NumberLineKind = 'area' | 'national' | 'outcode' | 'sector'; + +export interface NumberLinePoint { + kind: NumberLineKind; + /** Short label shown above the tick (e.g. "This area", "National", "SE3", "SE3 9"). */ + label: string; + value: number; +} + +interface NumberLineProps { + points: NumberLinePoint[]; + format: (value: number) => string; +} + +const HEIGHT = 54; +const PAD_X = 4; +const NAME_Y = 9; +const VALUE_Y = 19; +const LEADER_TOP = 23; +const BASE_Y = 40; +const GAP = 4; + +// Per-kind tick (stroke) and label (fill) colours. `area` is the subject and +// is emphasised; the rest are reference points. +const KIND_STYLE: Record = { + area: { tick: 'stroke-teal-600 dark:stroke-teal-400', text: 'fill-teal-700 dark:fill-teal-300' }, + national: { + tick: 'stroke-warm-400 dark:stroke-warm-500', + text: 'fill-warm-500 dark:fill-warm-300', + }, + outcode: { + tick: 'stroke-amber-500 dark:stroke-amber-400', + text: 'fill-amber-700 dark:fill-amber-400', + }, + sector: { + tick: 'stroke-indigo-500 dark:stroke-indigo-400', + text: 'fill-indigo-600 dark:fill-indigo-300', + }, +}; + +export interface NumberLineLayoutItem extends NumberLinePoint { + valueText: string; + /** x of the tick (the true value position on the scale). */ + tickX: number; + /** x of the (de-collided) label centre. */ + labelX: number; + /** half the estimated label-box width, used for collision spacing. */ + halfWidth: number; +} + +export interface NumberLineLayout { + items: NumberLineLayoutItem[]; + plotLeft: number; + plotRight: number; +} + +/** + * Pure layout: place each value's tick on a scale spanning the lowest→highest + * value (not anchored at 0, to maximise separation) and spread the labels so + * their boxes never overlap (nested area/sector/outcode values tend to cluster), + * clamped within the plot bounds while preserving order. Returns null when there + * is nothing to draw. Exported for testing. + */ +export function computeNumberLineLayout( + points: NumberLinePoint[], + width: number, + format: (value: number) => string +): NumberLineLayout | null { + if (width <= 0 || points.length === 0) return null; + + const values = points.map((p) => p.value); + const lo = Math.min(...values); + const hi = Math.max(...values); + const span = hi - lo; + const plotLeft = PAD_X; + const plotRight = width - PAD_X; + const plotW = Math.max(1, plotRight - plotLeft); + // Scale spans the lowest→highest value (not anchored at 0) to maximise the + // separation between the clustered ticks. When all values are equal, centre them. + const scaleX = (value: number) => + span > 0 ? plotLeft + ((value - lo) / span) * plotW : plotLeft + plotW / 2; + + const items: NumberLineLayoutItem[] = points.map((point) => { + const valueText = format(point.value); + // Rough text-width estimate at the 9px font (≈5px/char), padded a little. + const chars = Math.max(point.label.length, valueText.length); + const halfWidth = Math.max(9, (chars * 5) / 2 + 2); + const tickX = scaleX(point.value); + return { ...point, valueText, tickX, halfWidth, labelX: tickX }; + }); + + // Spread labels left-to-right so adjacent boxes never overlap, then clamp to + // the plot bounds (right pass, then left pass) preserving order. + items.sort((a, b) => a.tickX - b.tickX); + for (let i = 1; i < items.length; i++) { + const minX = items[i - 1].labelX + items[i - 1].halfWidth + GAP + items[i].halfWidth; + if (items[i].labelX < minX) items[i].labelX = minX; + } + const last = items.length - 1; + if (last >= 0) { + items[last].labelX = Math.min(items[last].labelX, plotRight - items[last].halfWidth); + for (let i = last - 1; i >= 0; i--) { + const maxX = items[i + 1].labelX - items[i + 1].halfWidth - GAP - items[i].halfWidth; + if (items[i].labelX > maxX) items[i].labelX = maxX; + } + items[0].labelX = Math.max(items[0].labelX, plotLeft + items[0].halfWidth); + for (let i = 1; i < items.length; i++) { + const minX = items[i - 1].labelX + items[i - 1].halfWidth + GAP + items[i].halfWidth; + if (items[i].labelX < minX) items[i].labelX = minX; + } + } + + return { items, plotLeft, plotRight }; +} + +/** + * A compact horizontal number line that places one tick per value on a scale + * spanning the lowest→highest value, so the selection can be read against its + * reference points (national / outcode / sector) at a glance. Labels are spread + * to avoid overlap + * — common since the nested area/sector/outcode values cluster — and connected + * back to their tick with a thin leader line. + */ +export default function NumberLine({ points, format }: NumberLineProps) { + const containerRef = useRef(null); + const [width, setWidth] = useState(0); + + useEffect(() => { + const el = containerRef.current; + if (!el) return; + const observer = new ResizeObserver((entries) => { + const w = entries[0].contentRect.width; + if (w > 0) setWidth(w); + }); + observer.observe(el); + return () => observer.disconnect(); + }, []); + + const laidOut = useMemo( + () => computeNumberLineLayout(points, width, format), + [points, width, format] + ); + + return ( +
+ {laidOut && ( + + + {laidOut.items.map((item) => { + const style = KIND_STYLE[item.kind]; + const emphasized = item.kind === 'area'; + const halfTick = emphasized ? 7 : 5; + return ( + + + + + {item.label} + + + {item.valueText} + + {`${item.label}: ${item.valueText}`} + + ); + })} + + )} +
+ ); +} diff --git a/frontend/src/components/map/OverlayTileLayers.tsx b/frontend/src/components/map/OverlayTileLayers.tsx index 8ca2ff3..673b953 100644 --- a/frontend/src/components/map/OverlayTileLayers.tsx +++ b/frontend/src/components/map/OverlayTileLayers.tsx @@ -79,8 +79,43 @@ export function OverlayTileLayers({ 10, 1, ], - 'heatmap-intensity': ['interpolate', ['linear'], ['zoom'], 15, 0.8, 18, 2.2], - 'heatmap-radius': ['interpolate', ['linear'], ['zoom'], 15, 18, 18, 30], + // police.uk snaps incidents to a sparse, fixed set of anonymised + // "map point" anchors (tens-to-hundreds of metres apart), so a + // pixel-fixed blur fragments into isolated dots as you zoom in + // (the ground a pixel covers halves each zoom level). Grow the + // radius ~geometrically with zoom to hold a roughly constant + // ~140m ground footprint, so neighbouring anchors' kernels keep + // overlapping into a connected surface — kernel-density smoothing + // is the honest form of "interpolating between points" for this + // data; we deliberately do NOT triangulate/IDW, which would + // invent crime values across parks/water and over-claim a + // precision the anonymised snap-points don't have. Cap past z16 so + // the blur doesn't saturate the whole screen at street zoom (where + // an honestly-connected crime surface isn't achievable anyway). + // Intensity is lowered to match: wider overlapping kernels pile up + // density, so the old isolated-dot boost would now go all-red. + 'heatmap-intensity': [ + 'interpolate', + ['linear'], + ['zoom'], + 14, + 0.6, + 16, + 0.9, + 18, + 1.2, + ], + 'heatmap-radius': [ + 'interpolate', + ['exponential', 2], + ['zoom'], + 14, + 24, + 16, + 94, + 18, + 120, + ], 'heatmap-opacity': 0.72, 'heatmap-color': [ 'interpolate', diff --git a/frontend/src/components/map/filters/ActiveFiltersPanel.tsx b/frontend/src/components/map/filters/ActiveFiltersPanel.tsx index 3556b0d..ea38d66 100644 --- a/frontend/src/components/map/filters/ActiveFiltersPanel.tsx +++ b/frontend/src/components/map/filters/ActiveFiltersPanel.tsx @@ -170,6 +170,7 @@ export function ActiveFiltersPanel({