lgtm
Some checks failed
Build and publish Docker image / build-and-push (push) Successful in 18m12s
CI / Check (push) Failing after 23m38s

This commit is contained in:
Andras Schmelczer 2026-06-22 22:12:27 +01:00
parent f7e0814a38
commit fd2860070a
55 changed files with 4084 additions and 186 deletions

View file

@ -146,7 +146,14 @@ function ProductDemoVideo() {
onPlay={() => setIsVideoPlaying(true)}
onPause={() => setIsVideoPlaying(false)}
onEnded={() => setIsVideoPlaying(false)}
/>
>
<track
kind="captions"
srcLang={(i18n.language ?? 'en').split('-')[0]}
label="Captions"
src={`/video/${productDemoSlug}.vtt`}
/>
</video>
{!isVideoPlaying && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center bg-navy-950/15 transition-colors">
<button

View file

@ -44,7 +44,7 @@ const SOCIAL_VIDEOS: { slug: string; titleKey: string; descKey: string }[] = [
descKey: 'learnPage.video07Desc',
},
{
slug: 'ad-08-cheap-insurance',
slug: 'ad-08-value',
titleKey: 'learnPage.video08Title',
descKey: 'learnPage.video08Desc',
},
@ -261,7 +261,9 @@ function SocialVideoCard({
onPlay={() => setIsPlaying(true)}
onPause={() => setIsPlaying(false)}
onEnded={() => setIsPlaying(false)}
/>
>
<track kind="captions" srcLang="en" label="Captions" src={`/video/${slug}.vtt`} />
</video>
{!isPlaying && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center bg-navy-950/15 transition-colors">
<button

View file

@ -1,4 +1,12 @@
import { useMemo, useState, type MutableRefObject, type ReactNode } from 'react';
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type MutableRefObject,
type ReactNode,
} from 'react';
import { useTranslation } from 'react-i18next';
import { ts } from '../../i18n/server';
import type {
@ -37,6 +45,7 @@ import StackedBarChart from './StackedBarChart';
import StackedEnumChart from './StackedEnumChart';
import PriceHistoryChart from './PriceHistoryChart';
import CrimeYearChart from './CrimeYearChart';
import NumberLine, { type NumberLinePoint } from './NumberLine';
import ExternalSearchLinks from './ExternalSearchLinks';
import { InfoIcon, TransitIcon } from '../ui/icons';
import { CollapsibleGroupHeader } from '../ui/CollapsibleGroupHeader';
@ -239,6 +248,7 @@ export default function AreaPane({
}: AreaPaneProps) {
const { t } = useTranslation();
const propertyCount = stats?.count;
const population = stats?.population;
const activeFilterCount = Object.keys(filters).length + (travelTimeEntries?.length ?? 0);
const filtersActive = activeFilterCount > 0;
const filteredStatsEmpty = filtersActive && statsUseFilters && stats?.count === 0;
@ -284,6 +294,65 @@ export default function AreaPane({
suspendSave: scrollSaveDisabled ?? (loading && stats == null),
});
// When the user expands a group, scroll the bottom of the newly opened content
// into view so the whole group is revealed without manual scrolling. The group
// header stays pinned at the top via its sticky positioning.
const scrollContainerRef = useRef<HTMLDivElement | null>(null);
const setScrollNode = useCallback(
(node: HTMLDivElement | null) => {
scrollContainerRef.current = node;
scrollRef(node);
},
[scrollRef]
);
const groupRefs = useRef<Map<string, HTMLDivElement>>(new Map());
// A fresh object each toggle so re-expanding the same group still re-triggers.
const [groupToReveal, setGroupToReveal] = useState<{ name: string } | null>(null);
const handleToggleGroup = useCallback(
(name: string) => {
const willExpand = !isGroupExpanded(name);
onToggleGroup(name);
setGroupToReveal(willExpand ? { name } : null);
},
[isGroupExpanded, onToggleGroup]
);
useEffect(() => {
if (!groupToReveal) return;
const el = groupRefs.current.get(groupToReveal.name);
const container = scrollContainerRef.current;
if (!el || !container) return;
// Scroll down just enough to bring the bottom of the opened group into view.
// For groups taller than the pane this scrolls past the header, but it stays
// visible via its sticky positioning. Never scroll up (group already in view).
const alignBottom = () => {
const delta = el.getBoundingClientRect().bottom - container.getBoundingClientRect().bottom;
if (delta <= 1) return;
container.scrollTo({ top: container.scrollTop + delta });
};
alignBottom();
// The group's charts and async data (e.g. nearby stations) can grow its
// height after this first pass, so keep the bottom pinned as it settles.
// Stop once the user scrolls or after a short grace period so we never fight
// a deliberate scroll.
let timer = 0;
const observer = new ResizeObserver(alignBottom);
const stop = () => {
observer.disconnect();
container.removeEventListener('wheel', stop);
container.removeEventListener('touchmove', stop);
window.clearTimeout(timer);
};
observer.observe(el);
container.addEventListener('wheel', stop, { passive: true });
container.addEventListener('touchmove', stop, { passive: true });
timer = window.setTimeout(stop, 1500);
return stop;
}, [groupToReveal]);
const numericByName = useMemo(() => {
if (!stats) return new Map();
return new Map(stats.numeric_features.map((feature) => [feature.name, feature]));
@ -306,6 +375,18 @@ export default function AreaPane({
return map;
}, [stats]);
// Per-crime-type outcode/sector averages, keyed by both the bare crime type
// and the " (avg/yr)" feature name (same convention as crimeByYearByFeatureName)
// so renderers can look them up by the feature name they already hold.
const crimeAreaAvgByName = useMemo(() => {
const map = new Map<string, NonNullable<HexagonStatsResponse['crime_area_averages']>[number]>();
for (const entry of stats?.crime_area_averages ?? []) {
map.set(entry.name, entry);
map.set(`${entry.name} (avg/yr)`, entry);
}
return map;
}, [stats]);
const globalFeatureByName = useMemo(
() => new Map(globalFeatures.map((f) => [f.name, f])),
[globalFeatures]
@ -359,7 +440,7 @@ export default function AreaPane({
<>
<div className="relative flex h-full flex-col">
<IndeterminateProgressBar show={loading && stats != null} />
<div ref={scrollRef} onScroll={onScroll} className="flex-1 overflow-y-auto">
<div ref={setScrollNode} onScroll={onScroll} className="flex-1 overflow-y-auto">
<div className="border-b border-warm-200 bg-white dark:border-navy-700 dark:bg-navy-950">
<div className="space-y-3 p-3">
<div className="flex items-start justify-between gap-3">
@ -380,12 +461,24 @@ export default function AreaPane({
})}
</p>
</div>
<div className="shrink-0 text-right">
<div className="text-lg font-semibold tabular-nums leading-none text-navy-950 dark:text-warm-50">
{propertyCount == null ? '...' : propertyCount.toLocaleString()}
</div>
<div className="mt-0.5 text-xs font-medium text-warm-500 dark:text-warm-400">
{t('common.propertiesPlural')}
<div className="flex shrink-0 items-start gap-4 text-right">
{population != null && (
<div title={t('areaPane.residentsTooltip')}>
<div className="text-lg font-semibold tabular-nums leading-none text-navy-950 dark:text-warm-50">
{population.toLocaleString()}
</div>
<div className="mt-0.5 text-xs font-medium text-warm-500 dark:text-warm-400">
{t('areaPane.residents')}
</div>
</div>
)}
<div>
<div className="text-lg font-semibold tabular-nums leading-none text-navy-950 dark:text-warm-50">
{propertyCount == null ? '...' : propertyCount.toLocaleString()}
</div>
<div className="mt-0.5 text-xs font-medium text-warm-500 dark:text-warm-400">
{t('common.propertiesPlural')}
</div>
</div>
</div>
</div>
@ -522,11 +615,17 @@ export default function AreaPane({
);
return (
<div key={group.name}>
<div
key={group.name}
ref={(el) => {
if (el) groupRefs.current.set(group.name, el);
else groupRefs.current.delete(group.name);
}}
>
<CollapsibleGroupHeader
name={group.name}
expanded={expanded}
onToggle={() => onToggleGroup(group.name)}
onToggle={() => handleToggleGroup(group.name)}
className="area-pane-group-header sticky top-0 z-10 bg-white px-3 pb-1.5 pt-4 text-[11px] font-bold uppercase tracking-wide text-warm-500 hover:bg-warm-50 dark:bg-navy-950 dark:text-warm-400 dark:hover:bg-navy-900"
/>
{expanded && (
@ -571,6 +670,47 @@ export default function AreaPane({
const globalMean = featureMeta?.histogram
? calculateHistogramMean(featureMeta.histogram)
: undefined;
const crimeAreaAvg = infoFeatureName
? crimeAreaAvgByName.get(infoFeatureName)
: undefined;
// For crime, prefer the exact national mean so it shares
// one estimator with the outcode/sector/selection values.
const nationalAvg = crimeAreaAvg?.national ?? globalMean;
// Crime metrics get a number line comparing this area to
// its sector / outcode / nation instead of a flat list.
const numberLinePoints: NumberLinePoint[] = crimeAreaAvg
? (
[
{
kind: 'area',
label: t('areaPane.thisArea'),
value: displayValue,
},
nationalAvg != null
? {
kind: 'national',
label: t('areaPane.national'),
value: nationalAvg,
}
: null,
crimeAreaAvg.outcode != null
? {
kind: 'outcode',
label: stats?.crime_outcode ?? t('areaPane.outcodeAvg'),
value: crimeAreaAvg.outcode,
}
: null,
crimeAreaAvg.sector != null
? {
kind: 'sector',
label: stats?.crime_sector ?? t('areaPane.sectorAvg'),
value: crimeAreaAvg.sector,
}
: null,
] as (NumberLinePoint | null)[]
).filter((p): p is NumberLinePoint => p !== null)
: [];
if (total === 0) return null;
@ -601,9 +741,12 @@ export default function AreaPane({
{formatValue(displayValue)}
{chart.unit ? ` ${chart.unit}` : ''}
</span>
{globalMean != null && (
{/* Crime shows its national/outcode/sector
comparison on the number line below; other
stacked metrics keep the inline national avg. */}
{!crimeAreaAvg && nationalAvg != null && (
<div className="text-[10px] text-warm-400 dark:text-warm-500 whitespace-nowrap">
{t('areaPane.nationalAvg')}: {formatValue(globalMean)}
{t('areaPane.nationalAvg')}: {formatValue(nationalAvg)}
</div>
)}
</div>
@ -617,6 +760,11 @@ export default function AreaPane({
: STACKED_SEGMENT_COLORS
}
/>
{numberLinePoints.length >= 2 && (
<div className="mt-2">
<NumberLine points={numberLinePoints} format={formatValue} />
</div>
)}
{crimeSeries && crimeSeries.points.length > 1 && (
<div className="mt-2">
<CrimeYearChart
@ -653,6 +801,26 @@ export default function AreaPane({
? calculateHistogramMean(globalHistogram)
: undefined;
const crimeSeries = crimeByYearByFeatureName.get(feature.name);
const crimeAreaAvg = crimeAreaAvgByName.get(feature.name);
// National avg is shown for every metric here as a
// tooltip; for crime metrics the outcode and sector
// averages join it on their own lines, and the exact
// national mean is preferred over the histogram one.
const nationalAvg = crimeAreaAvg?.national ?? globalMean;
const valueTitle =
[
nationalAvg != null
? `${t('areaPane.nationalAvg')}: ${formatValue(nationalAvg)}`
: null,
crimeAreaAvg?.outcode != null
? `${t('areaPane.outcodeAvg')}: ${formatValue(crimeAreaAvg.outcode)}`
: null,
crimeAreaAvg?.sector != null
? `${t('areaPane.sectorAvg')}: ${formatValue(crimeAreaAvg.sector)}`
: null,
]
.filter(Boolean)
.join('\n') || undefined;
return (
<MetricRow
@ -712,11 +880,7 @@ export default function AreaPane({
)
}
value={formatValue(numericStats.mean, feature)}
valueTitle={
globalMean != null
? `${t('areaPane.nationalAvg')}: ${formatValue(globalMean)}`
: undefined
}
valueTitle={valueTitle}
/>
);
}

View file

@ -0,0 +1,72 @@
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import type { Development } from '../../types';
/** Turn a raw server status like "full-planning-permission" into "Full planning permission". */
function prettifyStatus(value: string): string {
const cleaned = value.replace(/[-_]+/g, ' ').trim();
if (!cleaned) return value;
return cleaned.charAt(0).toUpperCase() + cleaned.slice(1);
}
export const DevelopmentPopupContent = memo(function DevelopmentPopupContent({
development,
}: {
development: Development;
}) {
const { t } = useTranslation();
const min = development.min_dwellings ?? null;
const max = development.max_dwellings ?? null;
let dwellings: string | null = null;
if (min != null && max != null) {
dwellings =
min === max
? t('newDevelopments.homesExact', { count: max })
: t('newDevelopments.homesRange', { min, max });
} else if (max != null) {
dwellings = t('newDevelopments.homesUpTo', { count: max });
} else if (min != null) {
dwellings = t('newDevelopments.homesExact', { count: min });
}
const sourceLabel =
development.source === 'homes-england'
? t('newDevelopments.sourceHomesEngland')
: t('newDevelopments.sourceBrownfield');
return (
<div className="max-w-[260px] px-3 py-2">
<div className="text-sm font-bold text-blue-700 dark:text-blue-400">
{development.name || t('newDevelopments.title')}
</div>
{dwellings && (
<div className="mt-0.5 text-xs font-medium text-warm-700 dark:text-warm-200">
{dwellings}
</div>
)}
{development.planning_status && (
<div className="mt-0.5 text-xs text-warm-500 dark:text-warm-400">
{t('newDevelopments.planningStatus')}: {prettifyStatus(development.planning_status)}
</div>
)}
{development.local_authority && (
<div className="mt-0.5 text-[11px] text-warm-500 dark:text-warm-400">
{t('newDevelopments.localAuthority')}: {development.local_authority}
</div>
)}
<div className="mt-1 text-[11px] text-warm-400 dark:text-warm-500">{sourceLabel}</div>
{development.url && (
<a
href={development.url}
target="_blank"
rel="noopener noreferrer"
className="mt-1.5 block text-[11px] font-medium text-blue-600 dark:text-blue-400"
>
{t('newDevelopments.viewRecord')}
</a>
)}
</div>
);
});

View file

@ -37,6 +37,7 @@ import {
isEthnicityFeatureName,
isEthnicityFilterName,
} from '../../lib/ethnicity-filter';
import { isQualificationFeatureName } from '../../lib/qualification-filter';
import {
SCHOOL_FILTER_NAME,
getDefaultSchoolFeatureName,
@ -347,6 +348,10 @@ export default memo(function Filters({
maybeInsertPoiFilter(getPoiFilterName(feature.name));
continue;
}
// Qualification breakdown is display-only (shown as the stacked
// "Qualifications" composition in the area pane), so keep its seven
// component features out of the filter browser.
if (isQualificationFeatureName(feature.name)) continue;
if (!enabledFeatures.has(feature.name)) result.push(feature);
}

View file

@ -24,13 +24,24 @@ import {
getMapStyle,
getMapDataBeforeId,
getMapCenterForTargetScreenPoint,
selectSpacedItems,
type PlacedItem,
} from '../../lib/map-utils';
import { MAP_MIN_ZOOM, MAP_BOUNDS, POI_AUTO_CARD_ZOOM_THRESHOLD } from '../../lib/consts';
import {
MAP_MIN_ZOOM,
MAP_BOUNDS,
POI_AUTO_CARD_ZOOM_THRESHOLD,
MAX_AUTO_POI_CARDS,
AUTO_POI_CARD_MIN_DX,
AUTO_POI_CARD_MIN_DY,
POSTCODE_ZOOM_THRESHOLD,
} from '../../lib/consts';
import type { SearchedLocation } from './LocationSearch';
import { LogoIcon } from '../ui/icons/LogoIcon';
import { CloseIcon } from '../ui/icons/CloseIcon';
import type { FeatureFilters } from '../../types';
import { useDeckLayers } from '../../hooks/useDeckLayers';
import { useDevelopments } from '../../hooks/useDevelopments';
import { useMapCardLayout } from '../../hooks/useMapCardLayout';
import type { TravelTimeEntry } from '../../hooks/useTravelTime';
import { type OverlayId } from '../../lib/overlays';
@ -41,6 +52,7 @@ import { OverlayTileLayers } from './OverlayTileLayers';
import { MapTopCards } from './MapTopCards';
import { PoiPopupCardContent } from './PoiPopupCard';
import { ListingClusterPopupContent, ListingPopupSingleContent } from './ListingPopups';
import { DevelopmentPopupContent } from './DevelopmentPopup';
import { HoverCardOverlay } from './HoverCardOverlay';
interface MapProps {
@ -425,6 +437,14 @@ export default memo(function Map({
return center ? { lat: center.lat, lng: center.lng } : null;
}, []);
// Only fetch/show developments once zoomed in, matching the tile overlays
// (noise/crime/trees/borders) which all gate on POSTCODE_ZOOM_THRESHOLD.
const developmentsEnabled =
activeOverlays.has('new-developments') && viewState.zoom >= POSTCODE_ZOOM_THRESHOLD;
const { developments } = useDevelopments(viewportBounds ?? null, {
enabled: developmentsEnabled,
});
const {
layers,
popupInfo,
@ -432,6 +452,8 @@ export default memo(function Map({
visiblePois,
listingPopup,
clearListingPopup,
developmentPopup,
clearDevelopmentPopup,
hoverPosition,
countRange,
postcodeCountRange,
@ -444,6 +466,7 @@ export default memo(function Map({
zoom: viewState.zoom,
pois,
actualListings,
developments,
viewFeature,
colorRange,
filterRange,
@ -468,7 +491,8 @@ export default memo(function Map({
return [];
}
return visiblePois.flatMap((poi) => {
const candidates: PlacedItem<POI>[] = [];
for (const poi of visiblePois) {
const point = map.project([poi.lng, poi.lat]);
if (
!Number.isFinite(point.x) ||
@ -478,10 +502,18 @@ export default memo(function Map({
point.y < 0 ||
point.y > dimensions.height
) {
return [];
continue;
}
return [{ poi, x: point.x, y: point.y }];
});
candidates.push({ item: poi, x: point.x, y: point.y });
}
// Cull overlapping cards so a dense area with many categories doesn't render a
// wall of hundreds of cards over the map.
return selectSpacedItems(
candidates,
AUTO_POI_CARD_MIN_DX,
AUTO_POI_CARD_MIN_DY,
MAX_AUTO_POI_CARDS
);
// viewState isn't read directly but drives map.project — recompute when the camera moves.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [showAutoPoiCards, mapReady, visiblePois, dimensions, viewState]);
@ -599,7 +631,7 @@ export default memo(function Map({
theme={theme}
/>
)}
{autoPoiCards.map(({ poi, x, y }) => (
{autoPoiCards.map(({ item: poi, x, y }) => (
<div
key={poi.id}
className="pointer-events-none absolute bg-white dark:bg-warm-800 rounded-lg shadow-lg text-sm dark:text-white"
@ -673,6 +705,27 @@ export default memo(function Map({
)}
</div>
)}
{developmentPopup && (
<div
className="pointer-events-auto absolute max-w-[280px] rounded-lg bg-white text-sm shadow-lg dark:bg-warm-800 dark:text-white"
style={{
left: developmentPopup.x,
top: developmentPopup.y - 12,
transform: 'translate(-50%, -100%)',
zIndex: 30,
}}
onMouseLeave={clearDevelopmentPopup}
>
<button
type="button"
className="pointer-events-auto absolute -top-2 -right-2 w-5 h-5 flex items-center justify-center rounded-full bg-warm-200 dark:bg-warm-700 text-warm-500 dark:text-warm-400 hover:text-warm-700 dark:hover:text-warm-300 shadow-sm"
onClick={clearDevelopmentPopup}
>
<CloseIcon className="w-3 h-3" />
</button>
<DevelopmentPopupContent development={developmentPopup.development} />
</div>
)}
{hoverPosition && hoveredHexagonId && hoveredHexagonId !== selectedHexagonId && (
<HoverCardOverlay
x={hoverPosition.x}

View file

@ -1,7 +1,7 @@
import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Trans, useTranslation } from 'react-i18next';
import type { ActualListing, PostcodeGeometry } from '../../types';
import type { ActualListing, POI, PostcodeGeometry } from '../../types';
import type { SearchedLocation } from './LocationSearch';
import { useMapData } from '../../hooks/useMapData';
import { usePOIData } from '../../hooks/usePOIData';
@ -88,6 +88,7 @@ export type { ExportState } from './map-page/types';
declare const __DEV__: boolean;
const EMPTY_ACTUAL_LISTINGS: ActualListing[] = [];
const EMPTY_POIS: POI[] = [];
export default function MapPage({
features,
@ -562,7 +563,11 @@ export default function MapPage({
if (filtersUnlimited) setDemoPromptOpen(false);
}, [filtersUnlimited]);
const pois = usePOIData(mapData.bounds, selectedPOICategories);
const fetchedPois = usePOIData(mapData.bounds, selectedPOICategories);
// Disabling POIs (clearing every category) must remove all POI cards/markers
// immediately, not on the next fetch tick — gate on the selection itself so a
// stale fetch result can never keep cards on screen.
const pois: POI[] = selectedPOICategories.size > 0 ? fetchedPois : EMPTY_POIS;
const overlaysZoomedIn = (mapData.currentView?.zoom ?? 0) >= POSTCODE_ZOOM_THRESHOLD;
const actualListingsFilterParam = useMemo(
() => buildFilterString(filters, features),

View file

@ -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);
}
});
});

View file

@ -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<NumberLineKind, { tick: string; text: string }> = {
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 lowesthighest
* 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 lowesthighest 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<HTMLDivElement>(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 (
<div ref={containerRef} className="w-full">
{laidOut && (
<svg width={width} height={HEIGHT} className="block">
<line
x1={laidOut.plotLeft}
y1={BASE_Y}
x2={laidOut.plotRight}
y2={BASE_Y}
strokeWidth={1}
className="stroke-warm-300 dark:stroke-warm-600"
/>
{laidOut.items.map((item) => {
const style = KIND_STYLE[item.kind];
const emphasized = item.kind === 'area';
const halfTick = emphasized ? 7 : 5;
return (
<g key={item.kind}>
<line
x1={item.labelX}
y1={LEADER_TOP}
x2={item.tickX}
y2={BASE_Y - halfTick}
strokeWidth={0.75}
className="stroke-warm-300 dark:stroke-warm-600"
/>
<line
x1={item.tickX}
y1={BASE_Y - halfTick}
x2={item.tickX}
y2={BASE_Y + halfTick}
strokeWidth={emphasized ? 2.5 : 1.5}
strokeLinecap="round"
className={style.tick}
/>
<text
x={item.labelX}
y={NAME_Y}
textAnchor="middle"
fontSize={9}
fontWeight={emphasized ? 700 : 500}
className={style.text}
>
{item.label}
</text>
<text
x={item.labelX}
y={VALUE_Y}
textAnchor="middle"
fontSize={9}
className={style.text}
>
{item.valueText}
</text>
<title>{`${item.label}: ${item.valueText}`}</title>
</g>
);
})}
</svg>
)}
</div>
);
}

View file

@ -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',

View file

@ -170,6 +170,7 @@ export function ActiveFiltersPanel({
<div className="px-3 pb-2 space-y-2">
<button
onClick={onShowPhilosophy}
data-video-hide="ai-cta"
className="w-full px-3 py-1.5 rounded-lg border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 hover:bg-warm-50 dark:hover:bg-warm-700 text-teal-600 dark:text-teal-400 font-medium text-sm flex items-center justify-center gap-2"
>
<LightbulbIcon />

View file

@ -12,6 +12,7 @@ import type {
FeatureMeta,
Bounds,
ActualListing,
Development,
} from '../types';
import {
DENSITY_GRADIENT,
@ -23,6 +24,7 @@ import { getFeatureFillColor } from '../lib/map-utils';
import type { TravelTimeEntry } from './useTravelTime';
import { usePoiLayers } from './usePoiLayers';
import { useListingLayers } from './useListingLayers';
import { useDevelopmentLayers } from './useDevelopmentLayers';
import { MarchingAntsExtension } from '../lib/MarchingAntsExtension';
import { PieHexExtension } from '../lib/PieHexExtension';
import { normalizeColorOpacity } from '../lib/color-opacity';
@ -34,6 +36,7 @@ interface UseDeckLayersProps {
zoom: number;
pois: POI[];
actualListings: ActualListing[];
developments: Development[];
viewFeature: string | null;
colorRange: [number, number] | null;
filterRange: [number, number] | null;
@ -85,6 +88,7 @@ export function useDeckLayers({
zoom,
pois,
actualListings,
developments,
viewFeature,
colorRange,
filterRange,
@ -126,6 +130,11 @@ export function useDeckLayers({
zoom,
isDark,
});
const { developmentLayers, developmentPopup, clearDevelopmentPopup } = useDevelopmentLayers({
developments,
zoom,
isDark,
});
// --- Refs for deck.gl accessors ---
const viewFeatureRef = useRef(viewFeature);
@ -709,6 +718,7 @@ export function useDeckLayers({
if (marchingAntsLayer) baseLayers.push(marchingAntsLayer);
if (currentLocationLayer) baseLayers.push(currentLocationLayer);
if (listingLayers.length > 0) baseLayers.push(...listingLayers);
if (developmentLayers.length > 0) baseLayers.push(...developmentLayers);
return baseLayers;
}, [
usePostcodeView,
@ -720,6 +730,7 @@ export function useDeckLayers({
marchingAntsLayer,
currentLocationLayer,
listingLayers,
developmentLayers,
]);
const handleMouseLeave = useCallback(() => {
@ -727,8 +738,9 @@ export function useDeckLayers({
setHoveredPostcode(null);
clearPopupInfo();
clearListingPopup();
clearDevelopmentPopup();
onHexagonHoverRef.current(null);
}, [clearPopupInfo, clearListingPopup]);
}, [clearPopupInfo, clearListingPopup, clearDevelopmentPopup]);
return {
layers,
@ -737,6 +749,8 @@ export function useDeckLayers({
clearPopupInfo,
listingPopup,
clearListingPopup,
developmentPopup,
clearDevelopmentPopup,
hoverPosition,
countRange,
postcodeCountRange,

View file

@ -0,0 +1,152 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { Layer, PickingInfo } from '@deck.gl/core';
import { ScatterplotLayer, TextLayer } from '@deck.gl/layers';
import type { Development } from '../types';
import { trackEvent } from '../lib/analytics';
const COUNT_LABEL_MIN_ZOOM = 14;
export interface DevelopmentPopupInfo {
x: number;
y: number;
development: Development;
}
interface UseDevelopmentLayersProps {
developments: Development[];
zoom: number;
isDark: boolean;
}
function dwellingCount(d: Development): number | null {
return d.max_dwellings ?? d.min_dwellings ?? null;
}
// Pixel radius scales with the number of homes so larger schemes read as larger
// dots. sqrt compresses the range (a 5,000-home site shouldn't dwarf a 20-home
// one) and the result is clamped to a sane band; sites with no count get the floor.
function radiusForDwellings(d: Development): number {
const count = dwellingCount(d) ?? 0;
return Math.min(22, 4 + Math.sqrt(Math.max(count, 0)) * 0.8);
}
/**
* 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
* listings layer. Hover shows a popup; click opens the planning record.
*/
export function useDevelopmentLayers({ developments, zoom, isDark }: UseDevelopmentLayersProps) {
const [popupInfo, setPopupInfo] = useState<DevelopmentPopupInfo | null>(null);
// A fresh fetch returns a new array; the previously hovered object is gone.
useEffect(() => {
setPopupInfo(null);
}, [developments]);
const points = useMemo(
() => developments.filter((d) => Number.isFinite(d.lat) && Number.isFinite(d.lon)),
[developments]
);
const handleHover = useCallback((info: PickingInfo<Development>) => {
if (info.object && info.x !== undefined && info.y !== undefined) {
setPopupInfo({ x: info.x, y: info.y, development: info.object });
} else {
setPopupInfo(null);
}
}, []);
const handleClick = useCallback((info: PickingInfo<Development>) => {
const url = info.object?.url;
if (!url) return;
trackEvent('Development Site Click', { url });
window.open(url, '_blank', 'noopener,noreferrer');
}, []);
const handleHoverRef = useRef(handleHover);
handleHoverRef.current = handleHover;
const stableHover = useCallback(
(info: PickingInfo<Development>) => handleHoverRef.current(info),
[]
);
const handleClickRef = useRef(handleClick);
handleClickRef.current = handleClick;
const stableClick = useCallback(
(info: PickingInfo<Development>) => handleClickRef.current(info),
[]
);
const shadowLayer = useMemo(
() =>
new ScatterplotLayer<Development>({
id: 'development-shadow',
data: points,
getPosition: (d) => [d.lon, d.lat],
getRadius: (d) => radiusForDwellings(d) + 1.5,
radiusUnits: 'pixels',
getFillColor: isDark ? [0, 0, 0, 80] : [0, 0, 0, 40],
pickable: false,
}),
[points, isDark]
);
const pinLayer = useMemo(
() =>
new ScatterplotLayer<Development>({
id: 'development-pin',
data: points,
getPosition: (d) => [d.lon, d.lat],
getRadius: radiusForDwellings,
radiusUnits: 'pixels',
getFillColor: [37, 99, 235, 235],
getLineColor: [255, 255, 255, 255],
getLineWidth: 1.5,
lineWidthUnits: 'pixels',
stroked: true,
pickable: true,
autoHighlight: true,
highlightColor: [29, 228, 195, 220],
onHover: stableHover,
onClick: stableClick,
}),
[points, stableHover, stableClick]
);
const countLabelLayer = useMemo(() => {
if (zoom < COUNT_LABEL_MIN_ZOOM) return null;
const labeled = points.filter((d) => (dwellingCount(d) ?? 0) > 0);
return new TextLayer<Development>({
id: 'development-count',
data: labeled,
getPosition: (d) => [d.lon, d.lat],
getText: (d) => String(dwellingCount(d) ?? ''),
getSize: 11,
getPixelOffset: (d) => [0, -(radiusForDwellings(d) + 6)],
getColor: isDark ? [191, 219, 254, 240] : [30, 58, 138, 240],
fontFamily: 'Inter, system-ui, sans-serif',
fontWeight: 700,
getTextAnchor: 'middle',
getAlignmentBaseline: 'bottom',
outlineWidth: 3,
outlineColor: isDark ? [10, 10, 10, 220] : [255, 255, 255, 230],
fontSettings: { sdf: true },
sizeUnits: 'pixels',
sizeMinPixels: 9,
sizeMaxPixels: 13,
pickable: false,
});
}, [points, zoom, isDark]);
const developmentLayers = useMemo(() => {
const layers: Layer[] = [shadowLayer, pinLayer];
if (countLabelLayer) layers.push(countLabelLayer);
return layers;
}, [shadowLayer, pinLayer, countLabelLayer]);
const clearDevelopmentPopup = useCallback(() => setPopupInfo(null), []);
return { developmentLayers, developmentPopup: popupInfo, clearDevelopmentPopup };
}

View file

@ -0,0 +1,81 @@
import { useEffect, useRef, useState } from 'react';
import type { Bounds, Development, DevelopmentsResponse } from '../types';
import { apiUrl, authHeaders, isAbortError, logNonAbortError } from '../lib/api';
const DEBOUNCE_MS = 200;
interface UseDevelopmentsOptions {
/** Only fetch when the "new developments" overlay is toggled on. */
enabled?: boolean;
shareCode?: string;
}
/**
* Fetches planned/pipeline development sites (brownfield register + Homes
* England) within the current viewport. Mirrors useActualListings, but gated by
* an `enabled` flag so nothing is fetched until the overlay is switched on.
*/
export function useDevelopments(
bounds: Bounds | null,
{ enabled = false, shareCode = '' }: UseDevelopmentsOptions = {}
) {
const [developments, setDevelopments] = useState<Development[]>([]);
const [loading, setLoading] = useState(false);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const abortControllerRef = useRef<AbortController | null>(null);
const requestIdRef = useRef(0);
useEffect(() => {
requestIdRef.current += 1;
const requestId = requestIdRef.current;
if (!enabled || !bounds) {
abortControllerRef.current?.abort();
if (developments.length !== 0) setDevelopments([]);
setLoading(false);
return;
}
if (debounceRef.current) clearTimeout(debounceRef.current);
setLoading(true);
debounceRef.current = setTimeout(async () => {
abortControllerRef.current?.abort();
abortControllerRef.current = new AbortController();
try {
const boundsStr = `${bounds.south},${bounds.west},${bounds.north},${bounds.east}`;
const params = new URLSearchParams({ bounds: boundsStr });
if (shareCode) params.set('share', shareCode);
const res = await fetch(
apiUrl('developments', params),
authHeaders({ signal: abortControllerRef.current.signal })
);
if (!res.ok) {
if (requestIdRef.current === requestId) {
setDevelopments([]);
setLoading(false);
}
throw new Error(`Developments fetch failed: HTTP ${res.status}`);
}
const json: DevelopmentsResponse = await res.json();
if (requestIdRef.current !== requestId) return;
setDevelopments(json.developments || []);
setLoading(false);
} catch (err) {
if (requestIdRef.current === requestId && !isAbortError(err)) {
setLoading(false);
}
logNonAbortError('Failed to fetch developments', err);
}
}, DEBOUNCE_MS);
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
abortControllerRef.current?.abort();
};
// developments intentionally excluded — it's internal state, not an input.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [enabled, bounds, shareCode]);
return { developments, loading };
}

View file

@ -186,6 +186,47 @@ describe('usePoiLayers', () => {
expect(result.current.popupInfo).toBeNull();
});
it('dismisses a stuck hover popup once its POI is gone', () => {
const { result, rerender } = renderHook(
({ pois }) => usePoiLayers({ pois, zoom: 15, isDark: false }),
{ initialProps: { pois: [supermarket, busStop] } }
);
const backgroundLayer = layerById(result.current.poiLayers, 'poi-background');
act(() => {
(backgroundLayer.props.onHover as (info: unknown) => void)({
object: supermarket,
x: 10,
y: 20,
});
});
expect(result.current.popupInfo?.id).toBe(supermarket.id);
// Disabling POIs empties the list — the popup must not stay stuck on screen.
rerender({ pois: [] });
expect(result.current.popupInfo).toBeNull();
});
it('dismisses the hover popup when only its specific POI is deselected', () => {
const { result, rerender } = renderHook(
({ pois }) => usePoiLayers({ pois, zoom: 15, isDark: false }),
{ initialProps: { pois: [supermarket, busStop] } }
);
const backgroundLayer = layerById(result.current.poiLayers, 'poi-background');
act(() => {
(backgroundLayer.props.onHover as (info: unknown) => void)({
object: supermarket,
x: 10,
y: 20,
});
});
expect(result.current.popupInfo?.id).toBe(supermarket.id);
// busStop remains but the hovered supermarket is gone -> popup clears.
rerender({ pois: [busStop] });
expect(result.current.popupInfo).toBeNull();
});
it('creates cluster hover popup state from clustered POIs', () => {
const clusteredPois = Array.from(
{ length: 4 },

View file

@ -1,4 +1,4 @@
import { useCallback, useMemo, useRef, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { PickingInfo } from '@deck.gl/core';
import { IconLayer, ScatterplotLayer, TextLayer } from '@deck.gl/layers';
import Supercluster from 'supercluster';
@ -67,6 +67,18 @@ function getPoiIconSize(poi: POI): number {
export function usePoiLayers({ pois, zoom, isDark }: UsePoiLayersProps) {
const [popupInfo, setPopupInfo] = useState<PopupInfo | null>(null);
// 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(() => {
setPopupInfo((current) => {
if (!current) return current;
if (pois.length === 0) return null;
if (current.isCluster) return current;
return pois.some((poi) => poi.id === current.id) ? current : null;
});
}, [pois]);
const handlePoiHover = useCallback((info: PickingInfo<POI>) => {
if (info.object && info.x !== undefined && info.y !== undefined) {
setPopupInfo({

View file

@ -77,6 +77,25 @@ const descriptions: Record<string, Record<string, string>> = {
'Public order (avg/yr)': 'Moyenne annuelle des troubles à lordre public dans le secteur',
'Other crime (avg/yr)': 'Moyenne annuelle des autres infractions dans le secteur',
'Median age': 'Âge médian de la population locale',
'% No qualifications': 'Part des résidents (16+) sans aucun diplôme',
'% Some GCSEs':
'Part des résidents (16+) dont le diplôme le plus élevé équivaut à 1 à 4 GCSE environ (niveau 1)',
'% Good GCSEs':
'Part des résidents (16+) dont le diplôme le plus élevé est 5 GCSE ou plus (niveau 2)',
'% Apprenticeship':
'Part des résidents (16+) dont le diplôme le plus élevé est un apprentissage',
'% A-levels':
'Part des résidents (16+) dont le diplôme le plus élevé est le A-levels (niveau 3)',
'% Degree or higher':
'Part des résidents (16+) ayant un diplôme de niveau universitaire ou supérieur',
'% Other qualifications':
"Part des résidents (16+) ayant d'autres diplômes, y compris professionnels ou obtenus à l'étranger",
'% Owner occupied':
'Part des ménages propriétaires de leur logement, intégralement ou avec un crédit',
'% Social rent':
"Part des ménages locataires auprès d'une collectivité locale ou d'un bailleur social",
'% Private rent':
'Part des ménages locataires dans le privé ou logés à titre gratuit',
'% White': 'Part de la population sidentifiant comme blanche',
'% South Asian': 'Part de la population sidentifiant comme sud-asiatique',
'% Black': 'Part de la population sidentifiant comme noire',
@ -170,6 +189,23 @@ const descriptions: Record<string, Record<string, string>> = {
'Jährlicher Durchschnitt der Störungen der öffentlichen Ordnung in der Gegend',
'Other crime (avg/yr)': 'Jährlicher Durchschnitt sonstiger Straftaten in der Gegend',
'Median age': 'Medianalter der lokalen Bevölkerung',
'% No qualifications': 'Anteil der Einwohner (16+) ohne formalen Abschluss',
'% Some GCSEs':
'Anteil der Einwohner (16+), deren höchster Abschluss etwa 14 GCSEs entspricht (Level 1)',
'% Good GCSEs':
'Anteil der Einwohner (16+), deren höchster Abschluss 5+ GCSEs entspricht (Level 2)',
'% Apprenticeship': 'Anteil der Einwohner (16+), deren höchster Abschluss eine Ausbildung ist',
'% A-levels': 'Anteil der Einwohner (16+), deren höchster Abschluss A-levels ist (Level 3)',
'% Degree or higher':
'Anteil der Einwohner (16+) mit einem Abschluss auf Hochschulniveau oder höher',
'% Other qualifications':
'Anteil der Einwohner (16+) mit sonstigen Abschlüssen, einschließlich beruflicher oder im Ausland erworbener',
'% Owner occupied':
'Anteil der Haushalte, die ihre Wohnung besitzen, schuldenfrei oder mit Hypothek',
'% Social rent':
'Anteil der Haushalte, die von einer Kommune oder Wohnungsbaugesellschaft mieten',
'% Private rent':
'Anteil der Haushalte, die privat mieten oder mietfrei wohnen',
'% White': 'Anteil der Personen, die sich als weiß identifizieren',
'% South Asian': 'Anteil der Personen, die sich als südasiatisch identifizieren',
'% Black': 'Anteil der Personen, die sich als schwarz identifizieren',
@ -245,6 +281,16 @@ const descriptions: Record<string, Record<string, string>> = {
'Public order (avg/yr)': '该地区年均扰乱公共秩序数',
'Other crime (avg/yr)': '该地区年均其他犯罪数',
'Median age': '当地人口的中位年龄',
'% No qualifications': '无任何学历的居民16岁以上占比',
'% Some GCSEs': '最高学历约为 14 门 GCSELevel 1的居民16岁以上占比',
'% Good GCSEs': '最高学历为 5 门及以上 GCSELevel 2的居民16岁以上占比',
'% Apprenticeship': '最高学历为学徒制的居民16岁以上占比',
'% A-levels': '最高学历为 A-levelsLevel 3的居民16岁以上占比',
'% Degree or higher': '拥有学位或更高学历的居民16岁以上占比',
'% Other qualifications': '拥有其他学历包括职业资格或境外取得的居民16岁以上占比',
'% Owner occupied': '拥有自住房(全款或按揭)的家庭比例',
'% Social rent': '向地方政府或住房协会租房的家庭比例',
'% Private rent': '私人租房或免租居住的家庭比例',
'% White': '白人人口比例',
'% South Asian': '南亚裔人口比例',
'% Black': '黑人人口比例',
@ -324,6 +370,21 @@ const descriptions: Record<string, Record<string, string>> = {
'Public order (avg/yr)': 'क्षेत्र में सार्वजनिक व्यवस्था अपराधों का सालाना औसत',
'Other crime (avg/yr)': 'क्षेत्र में अन्य अपराधों का सालाना औसत',
'Median age': 'स्थानीय आबादी की मध्य आयु',
'% No qualifications': 'बिना किसी औपचारिक योग्यता वाले निवासियों (16+) का हिस्सा',
'% Some GCSEs':
'ऐसे निवासियों (16+) का हिस्सा जिनकी सर्वोच्च योग्यता लगभग 14 GCSE (Level 1) है',
'% Good GCSEs': 'ऐसे निवासियों (16+) का हिस्सा जिनकी सर्वोच्च योग्यता 5+ GCSE (Level 2) है',
'% Apprenticeship': 'ऐसे निवासियों (16+) का हिस्सा जिनकी सर्वोच्च योग्यता अप्रेंटिसशिप है',
'% A-levels': 'ऐसे निवासियों (16+) का हिस्सा जिनकी सर्वोच्च योग्यता A-levels (Level 3) है',
'% Degree or higher': 'डिग्री स्तर या उससे ऊंची योग्यता वाले निवासियों (16+) का हिस्सा',
'% Other qualifications':
'अन्य योग्यताओं वाले निवासियों (16+) का हिस्सा, जिनमें व्यावसायिक या विदेश में प्राप्त योग्यताएं शामिल हैं',
'% Owner occupied':
'अपने घर के स्वामी परिवारों का हिस्सा, चाहे पूर्ण रूप से या बंधक के साथ',
'% Social rent':
'काउंसिल या हाउसिंग एसोसिएशन से किराए पर रहने वाले परिवारों का हिस्सा',
'% Private rent':
'निजी रूप से किराए पर या बिना किराए के रहने वाले परिवारों का हिस्सा',
'% White': 'श्वेत के रूप में पहचान करने वाली आबादी का प्रतिशत',
'% South Asian': 'दक्षिण एशियाई के रूप में पहचान करने वाली आबादी का प्रतिशत',
'% Black': 'अश्वेत के रूप में पहचान करने वाली आबादी का प्रतिशत',
@ -409,6 +470,22 @@ const descriptions: Record<string, Record<string, string>> = {
'Public order (avg/yr)': 'Közrend elleni bűncselekmények éves átlaga a környéken',
'Other crime (avg/yr)': 'Egyéb bűncselekmények éves átlaga a környéken',
'Median age': 'A helyi lakosság medián életkora',
'% No qualifications': 'A végzettség nélküli lakosok (16+) aránya',
'% Some GCSEs':
'Azon lakosok (16+) aránya, akiknek a legmagasabb végzettsége kb. 14 GCSE (Level 1)',
'% Good GCSEs':
'Azon lakosok (16+) aránya, akiknek a legmagasabb végzettsége 5+ GCSE (Level 2)',
'% Apprenticeship': 'Azon lakosok (16+) aránya, akiknek a legmagasabb végzettsége tanoncképzés',
'% A-levels': 'Azon lakosok (16+) aránya, akiknek a legmagasabb végzettsége A-levels (Level 3)',
'% Degree or higher': 'A diploma szintű vagy magasabb végzettségű lakosok (16+) aránya',
'% Other qualifications':
'Az egyéb végzettségű lakosok (16+) aránya, ideértve a szakmai vagy külföldön szerzett képesítéseket is',
'% Owner occupied':
'Azon háztartások aránya, amelyek saját tulajdonú lakásban élnek, tehermentesen vagy jelzáloggal',
'% Social rent':
'Azon háztartások aránya, amelyek önkormányzattól vagy lakásszövetkezettől bérelnek',
'% Private rent':
'Azon háztartások aránya, amelyek magánbérleményben élnek vagy lakbér nélkül laknak',
'% White': 'A fehérként azonosított lakosság aránya',
'% South Asian': 'A dél-ázsiaiként azonosított lakosság aránya',
'% Black': 'A feketeként azonosított lakosság aránya',

View file

@ -95,6 +95,26 @@ export const details: Record<string, Record<string, string>> = {
"Nombre moyen annuel d'infractions « other crime » dans un rayon de 50 m du code postal, daprès les données de criminalité street-level de police.uk. Catégorie résiduelle pour les infractions non classées ailleurs.",
'Median age':
"Provient du Census 2021 (TS007A). Âge médian des résidents habituels dans le LSOA, calculé par interpolation linéaire à partir des effectifs par tranche d'âge de cinq ans. Les zones à population plus jeune ont tendance à être urbaines, universitaires ou à accueillir davantage de familles ; les médianes plus élevées sont typiques des zones rurales et côtières.",
'% No qualifications':
"D'après le recensement de 2021 (TS067). Pourcentage des résidents habituels âgés de 16 ans et plus dans le quartier qui ne possèdent aucun diplôme officiel.",
'% Some GCSEs':
"D'après le recensement de 2021 (TS067). Le diplôme le plus élevé correspond à environ 1 à 4 GCSE aux notes 94 (A*C), ou à des qualifications de niveau d'entrée ou fondamental.",
'% Good GCSEs':
"D'après le recensement de 2021 (TS067). Le diplôme le plus élevé correspond à environ 5 GCSE ou plus aux notes 94 (A*C), à un apprentissage intermédiaire ou équivalent.",
'% Apprenticeship':
"D'après le recensement de 2021 (TS067). Le diplôme le plus élevé est un apprentissage.",
'% A-levels':
"D'après le recensement de 2021 (TS067). Le diplôme le plus élevé est le A-levels, AS-levels, T-levels, un apprentissage avancé ou équivalent — généralement étudié après 16 ans et avant un diplôme universitaire.",
'% Degree or higher':
"D'après le recensement de 2021 (TS067). Le diplôme le plus élevé est de niveau universitaire ou supérieur — licence, master ou doctorat, foundation degree, HNC/HND, NVQ 45 ou qualification professionnelle supérieure. Le recensement ne distingue pas les diplômes de premier cycle de ceux de troisième cycle.",
'% Other qualifications':
"D'après le recensement de 2021 (TS067). Le diplôme le plus élevé est classé « autre » — qualifications professionnelles ou techniques non rattachées à un niveau britannique, et diplômes obtenus hors du Royaume-Uni.",
'% Owner occupied':
"D'après le recensement de 2021 (TS054). Pourcentage des ménages du quartier (LSOA) qui possèdent leur logement sans crédit, le possèdent avec un emprunt ou un crédit, ou le détiennent en propriété partagée.",
'% Social rent':
"D'après le recensement de 2021 (TS054). Pourcentage des ménages du quartier (LSOA) locataires auprès d'une collectivité ou d'une autorité locale, ou d'un bailleur social ou autre bailleur à vocation sociale.",
'% Private rent':
"D'après le recensement de 2021 (TS054). Pourcentage des ménages du quartier (LSOA) locataires auprès d'un propriétaire privé ou d'une agence de location, plus la faible part logée à titre gratuit.",
'% White':
"Provient du Census 2021. Pourcentage de la population du quartier (LSOA) s'identifiant comme Blanc (anglais, gallois, écossais, nord-irlandais, britannique, irlandais, Gitan ou Voyageur irlandais, Rom, ou tout autre origine blanche).",
'% South Asian':
@ -235,6 +255,25 @@ export const details: Record<string, Record<string, string>> = {
'Durchschnittliche Anzahl sonstiger Straftaten pro Jahr im LSOA, aus police.uk-Kriminalitätsdaten auf Straßenebene. Eine Sammelkategorie für Straftaten, die nicht anderweitig eingestuft sind.',
'Median age':
'Aus dem Census 2021 (TS007A). Medianalter der ortsansässigen Bevölkerung im LSOA, berechnet durch lineare Interpolation aus Fünfjahres-Altersband-Zählungen. Gebiete mit jüngerer Bevölkerung sind tendenziell städtisch, Universitätsstädte oder haben mehr Familien; höhere Mediane sind typisch für ländliche und Küstengebiete.',
'% No qualifications':
'Aus dem Census 2021 (TS067). Prozentsatz der gewöhnlichen Einwohner ab 16 Jahren im Viertel, die keinen formalen Bildungsabschluss haben.',
'% Some GCSEs':
'Aus dem Census 2021 (TS067). Der höchste Abschluss entspricht etwa 1 bis 4 GCSEs mit den Noten 94 (A*C) oder Abschlüssen auf Einstiegs- bzw. Grundniveau.',
'% Good GCSEs':
'Aus dem Census 2021 (TS067). Der höchste Abschluss entspricht etwa 5 oder mehr GCSEs mit den Noten 94 (A*C), einer mittleren Ausbildung oder Gleichwertigem.',
'% Apprenticeship': 'Aus dem Census 2021 (TS067). Der höchste Abschluss ist eine Ausbildung.',
'% A-levels':
'Aus dem Census 2021 (TS067). Der höchste Abschluss sind A-levels, AS-levels, T-levels, eine weiterführende Ausbildung oder Gleichwertiges — meist nach dem 16. Lebensjahr und vor einem Studienabschluss erworben.',
'% Degree or higher':
'Aus dem Census 2021 (TS067). Der höchste Abschluss liegt auf Hochschulniveau oder darüber — Bachelor, Master oder Doktortitel, Foundation Degree, HNC/HND, NVQ 45 oder höhere berufliche Qualifikation. Der Census unterscheidet nicht zwischen Bachelor- und Masterabschlüssen.',
'% Other qualifications':
'Aus dem Census 2021 (TS067). Der höchste Abschluss wird als „sonstiger“ eingestuft — berufliche oder fachliche Qualifikationen, die keinem britischen Niveau zugeordnet sind, sowie außerhalb des Vereinigten Königreichs erworbene Abschlüsse.',
'% Owner occupied':
'Aus dem Census 2021 (TS054). Prozentsatz der Haushalte in der Nachbarschaft (LSOA), die ihre Wohnung schuldenfrei besitzen, sie mit Hypothek oder Darlehen besitzen oder sie über gemeinschaftliches Eigentum (Shared Ownership) halten.',
'% Social rent':
'Aus dem Census 2021 (TS054). Prozentsatz der Haushalte in der Nachbarschaft (LSOA), die von einer Kommune oder Gemeindeverwaltung oder von einer Wohnungsbaugesellschaft oder einem anderen sozialen Vermieter mieten.',
'% Private rent':
'Aus dem Census 2021 (TS054). Prozentsatz der Haushalte in der Nachbarschaft (LSOA), die von einem privaten Vermieter oder einer Vermittlungsagentur mieten, zuzüglich des kleinen Anteils, der mietfrei wohnt.',
'% White':
'Aus dem Census 2021. Prozentsatz der Bevölkerung der Nachbarschaft (LSOA), die sich als Weiß identifiziert (Englisch, Walisisch, Schottisch, Nordirisch, Britisch, Irisch, Sinti und Roma, Roma oder sonstiger weißer Hintergrund).',
'% South Asian':
@ -373,6 +412,25 @@ export const details: Record<string, Record<string, string>> = {
'LSOA内每年其他犯罪的平均数量来自police.uk街道级犯罪数据。此类别涵盖未在其他分类中列出的犯罪行为。',
'Median age':
'来自2021年CensusTS007A。通过对五岁年龄段人口数进行线性插值计算得出的LSOA常住居民年龄中位数。年轻人口集中的地区往往是城市、大学城或家庭聚居地年龄中位数较高的地区多见于农村和沿海地区。',
'% No qualifications':
'数据来自 2021 年 CensusTS067。指该社区 16 岁及以上常住居民中没有任何正式学历者所占的百分比。',
'% Some GCSEs':
'数据来自 2021 年 CensusTS067。最高学历约为 1 至 4 门成绩为 94A*C的 GCSE或入门级/基础级别的资格。',
'% Good GCSEs':
'数据来自 2021 年 CensusTS067。最高学历约为 5 门或以上成绩为 94A*C的 GCSE、中级学徒制或同等资格。',
'% Apprenticeship': '数据来自 2021 年 CensusTS067。最高学历为学徒制。',
'% A-levels':
'数据来自 2021 年 CensusTS067。最高学历为 A-levels、AS-levels、T-levels、高级学徒制或同等资格——通常在 16 岁之后、获得学位之前修读。',
'% Degree or higher':
'数据来自 2021 年 CensusTS067。最高学历为学位及以上——学士、硕士或博士、预科学位、HNC/HND、NVQ 45或更高级别的职业资格。该 Census 不区分本科与研究生学位。',
'% Other qualifications':
'数据来自 2021 年 CensusTS067。最高学历被归为“其他”——未对应到英国级别的职业或专业资格以及在英国境外取得的学历。',
'% Owner occupied':
'数据来自 2021 年 CensusTS054。本地社区LSOA中全款拥有自住房、以按揭或贷款拥有自住房或通过共享产权持有住房的家庭百分比。',
'% Social rent':
'数据来自 2021 年 CensusTS054。本地社区LSOA中向地方议会或地方政府或向住房协会及其他社会房东租房的家庭百分比。',
'% Private rent':
'数据来自 2021 年 CensusTS054。本地社区LSOA中向私人房东或租赁中介租房的家庭百分比外加少量免租居住的家庭。',
'% White':
'来自2021年Census。本地社区LSOA人口中认同为白人英格兰人、威尔士人、苏格兰人、北爱尔兰人、英国人、爱尔兰人、吉普赛人或爱尔兰旅行者、罗姆人或其他白人背景的百分比。',
'% South Asian':
@ -508,6 +566,25 @@ export const details: Record<string, Record<string, string>> = {
'LSOA में प्रति वर्ष अन्य आपराधिक अपराधों की औसत संख्या, police.uk के सड़क-स्तर अपराध डेटा से. यह उन अपराधों के लिए सामान्य श्रेणी है जिन्हें कहीं और वर्गीकृत नहीं किया गया है.',
'Median age':
'Census 2021 (TS007A) से. LSOA में सामान्य निवासियों की मध्य आयु, पांच-वर्षीय आयु समूहों की गणना से रैखिक इंटरपोलेशन द्वारा निकाली गई. युवा आबादी वाले क्षेत्र आमतौर पर शहरी, विश्वविद्यालय नगर या अधिक परिवारों वाले होते हैं; अधिक मध्य आयु वाले क्षेत्र आमतौर पर ग्रामीण और तटीय इलाकों में मिलते हैं.',
'% No qualifications':
'2021 की Census (TS067) से। पड़ोस में 16 वर्ष और उससे अधिक उम्र के उन सामान्य निवासियों का प्रतिशत जिनके पास कोई औपचारिक योग्यता नहीं है।',
'% Some GCSEs':
'2021 की Census (TS067) से। सर्वोच्च योग्यता लगभग 1 से 4 GCSE है जिनके ग्रेड 94 (A*C) हैं, या एंट्री-लेवल/फाउंडेशन स्तर की योग्यताएं हैं।',
'% Good GCSEs':
'2021 की Census (TS067) से। सर्वोच्च योग्यता लगभग 5 या अधिक GCSE है जिनके ग्रेड 94 (A*C) हैं, एक इंटरमीडिएट अप्रेंटिसशिप, या समकक्ष।',
'% Apprenticeship': '2021 की Census (TS067) से। सर्वोच्च योग्यता एक अप्रेंटिसशिप है।',
'% A-levels':
'2021 की Census (TS067) से। सर्वोच्च योग्यता A-levels, AS-levels, T-levels, एक एडवांस्ड अप्रेंटिसशिप, या समकक्ष है — आमतौर पर 16 वर्ष की उम्र के बाद और डिग्री से पहले पढ़ी जाती है।',
'% Degree or higher':
'2021 की Census (TS067) से। सर्वोच्च योग्यता डिग्री स्तर या उससे ऊपर है — बैचलर, मास्टर या PhD, फाउंडेशन डिग्री, HNC/HND, NVQ 45, या उच्च व्यावसायिक योग्यता। Census स्नातक और स्नातकोत्तर डिग्री में अंतर नहीं करती।',
'% Other qualifications':
"2021 की Census (TS067) से। सर्वोच्च योग्यता 'अन्य' श्रेणी में आती है — ऐसी व्यावसायिक या पेशेवर योग्यताएं जो किसी UK स्तर से मेल नहीं खातीं, और UK के बाहर प्राप्त योग्यताएं।",
'% Owner occupied':
'2021 की Census (TS054) से। पड़ोस (LSOA) में उन परिवारों का प्रतिशत जो अपना घर पूर्ण रूप से अपने पास रखते हैं, बंधक या ऋण के साथ रखते हैं, या साझा स्वामित्व के माध्यम से रखते हैं।',
'% Social rent':
'2021 की Census (TS054) से। पड़ोस (LSOA) में उन परिवारों का प्रतिशत जो स्थानीय काउंसिल या स्थानीय प्राधिकरण से, या किसी हाउसिंग एसोसिएशन या अन्य सामाजिक मकान-मालिक से किराए पर रहते हैं।',
'% Private rent':
'2021 की Census (TS054) से। पड़ोस (LSOA) में उन परिवारों का प्रतिशत जो किसी निजी मकान-मालिक या किराया एजेंसी से किराए पर रहते हैं, साथ ही बिना किराए के रहने वाला छोटा हिस्सा।',
'% White':
'Census 2021 से. स्थानीय पड़ोस (LSOA) की आबादी का प्रतिशत जो खुद को श्वेत (अंग्रेज़, वेल्श, स्कॉटिश, उत्तरी आयरिश, ब्रिटिश, आयरिश, जिप्सी या आयरिश ट्रैवलर, रोमा या किसी अन्य श्वेत पृष्ठभूमि) के रूप में पहचानता है.',
'% South Asian':
@ -648,6 +725,25 @@ export const details: Record<string, Record<string, string>> = {
'Az egyéb bűncselekmények átlagos éves száma az LSOA-ban, a police.uk utcai szintű bűnügyi adataiból. Gyűjtőkategória azoknak a bűncselekményeknek, amelyek máshol nem kerülnek besorolásra.',
'Median age':
'A 2021-es Census alapján (TS007A). Az LSOA szokásos lakóinak medián életkora, ötéves korcsoport-számlálásokból lineáris interpolációval számítva. A fiatalabb népességű területek jellemzően városiak, egyetemi városok vagy több családot vonzanak; az idősebb medián értékek jellemzően vidéki és tengerparti területekre jellemzők.',
'% No qualifications':
'A 2021-es Census (TS067) alapján. A városrész 16 éves és idősebb állandó lakosainak aránya, akik nem rendelkeznek semmilyen formális végzettséggel.',
'% Some GCSEs':
'A 2021-es Census (TS067) alapján. A legmagasabb végzettség körülbelül 14 GCSE 94 (A*C) osztályzattal, vagy belépő szintű/alapszintű képesítés.',
'% Good GCSEs':
'A 2021-es Census (TS067) alapján. A legmagasabb végzettség körülbelül 5 vagy több GCSE 94 (A*C) osztályzattal, középszintű tanoncképzés vagy ezzel egyenértékű.',
'% Apprenticeship': 'A 2021-es Census (TS067) alapján. A legmagasabb végzettség tanoncképzés.',
'% A-levels':
'A 2021-es Census (TS067) alapján. A legmagasabb végzettség A-levels, AS-levels, T-levels, emelt szintű tanoncképzés vagy ezzel egyenértékű — jellemzően 16 éves kor után és a diploma előtt szerezhető meg.',
'% Degree or higher':
'A 2021-es Census (TS067) alapján. A legmagasabb végzettség diploma szintű vagy afölötti — alapdiploma, mesterdiploma vagy PhD, foundation degree, HNC/HND, NVQ 45, vagy magasabb szakmai képesítés. A Census nem különbözteti meg az alap- és mesterszintű diplomákat.',
'% Other qualifications':
'A 2021-es Census (TS067) alapján. A legmagasabb végzettség „egyéb“ besorolású — olyan szakmai vagy hivatásbeli képesítések, amelyek nem feleltethetők meg egy brit szintnek, valamint az Egyesült Királyságon kívül szerzett képesítések.',
'% Owner occupied':
'A 2021-es Census (TS054) alapján. A környéken (LSOA) azon háztartások százaléka, amelyek tehermentesen birtokolják lakásukat, jelzáloggal vagy kölcsönnel birtokolják, vagy résztulajdon (shared ownership) keretében tartják.',
'% Social rent':
'A 2021-es Census (TS054) alapján. A környéken (LSOA) azon háztartások százaléka, amelyek helyi önkormányzattól vagy hatóságtól, illetve lakásszövetkezettől vagy más szociális bérbeadótól bérelnek.',
'% Private rent':
'A 2021-es Census (TS054) alapján. A környéken (LSOA) azon háztartások százaléka, amelyek magán bérbeadótól vagy ingatlanügynökségtől bérelnek, kiegészülve a lakbér nélkül lakók kis arányával.',
'% White':
'A 2021-es Census alapján. A helyi környéken (LSOA) fehérként (angol, walesi, skót, észak-ír, brit, ír, cigány vagy ír vándor, roma, vagy bármely más fehér háttér) azonosított népesség százaléka.',
'% South Asian':

View file

@ -1,6 +1,17 @@
import type { Translations } from './en';
const de: Translations = {
newDevelopments: {
title: 'Bauvorhaben',
homesUpTo: 'Bis zu {{count}} Wohnungen',
homesRange: '{{min}}{{max}} Wohnungen',
homesExact: '{{count}} Wohnungen',
planningStatus: 'Planungsstatus',
sourceBrownfield: 'Brachflächenregister',
sourceHomesEngland: 'Homes-England-Fläche',
localAuthority: 'Kommunalbehörde',
viewRecord: 'Planungsunterlagen ansehen ↗',
},
// ── Common ──────────────────────────────────────────
common: {
save: 'Speichern',
@ -787,10 +798,10 @@ const de: Translations = {
describeIdealArea: 'Beschreibe, wo du wohnen möchtest',
aiSearch: 'KI-Suche',
describeHint: 'beschreibe, wonach du suchst',
placeholder: 'z. B. 2-bed unter £525k, 45 Min. zur Arbeit, ruhig...',
example1: '2-bed unter £525k, 45 Min. zur Arbeit',
example2: 'Familienfreundliche Gebiete nahe guter Schulen unter £650k',
example3: 'Mehr Platz mit vernünftigem Pendelweg',
placeholder: 'z. B. gleiche Schulen, günstigere Postleitzahl, unter £500k…',
example1: 'Gleiche Schulen, günstigere Postleitzahl',
example2: 'Bestes Preis-Leistungs-Verhältnis in 30 Minuten zur Arbeit',
example3: 'Unterbewertete Gegenden nahe guter Grundschulen',
analysing: 'Anfrage wird ausgewertet...',
searchingDestinations: 'Ziele werden gesucht...',
generatingFilters: 'Filter werden generiert...',
@ -888,7 +899,13 @@ const de: Translations = {
walk: 'Zu Fuß',
cycle: 'Fahrrad',
nationalAvg: 'England-Schnitt',
outcodeAvg: 'Outcode-Schnitt',
sectorAvg: 'Sektor-Schnitt',
thisArea: 'Dieses Gebiet',
national: 'England',
crimeDataEnds: 'Polizeidaten für dieses Gebiet enden {{year}}',
residents: 'Einwohner',
residentsTooltip: 'Wohnbevölkerung (ONS-Zensus 2021)',
},
// ── Street View ────────────────────────────────────
@ -1132,30 +1149,30 @@ const de: Translations = {
videosTitle: 'Social-Media-Videos',
videosIntro:
'Kurze Clips aus unseren Social-Media-Kanälen jeder zeigt eine einzelne Suche in Aktion, von ruhigen Straßen über Schuleinzugsgebiete bis zu Pendelzeiten.',
video01Title: 'Ein Satz, jede Postleitzahl',
video01Title: 'Finde den günstigeren Zwilling',
video01Desc:
'Gib deinen kompletten Wohnungswunsch in normaler Sprache ein und sieh zu, wie jede passende Postleitzahl in England aufleuchtet.',
video02Title: 'Die 20-Minuten-Karte',
'Gib deinen kompletten Wohnungswunsch in einem schlichten Satz ein und sieh zu, wie sich jede passende Postleitzahl in England nach Preis-Leistung sortiert und der günstigere Zwilling auftaucht, den niemand hochgeboten hat.',
video02Title: 'Die 20-Minuten-Pendelkarte',
video02Desc:
'Färbe die Karte nach Pendelzeit und sieh genau, was dir 20 Minuten ins Zentrum von London wirklich übrig lassen.',
video03Title: 'Jede Postleitzahl hat eine Akte',
'Färbe London nach Pendelzeit ins Zentrum, beschränke auf eine 20-minütige Fahrt und sieh, wie sich identische Wege in die Namen, die jeder kennt, und ruhigere Postleitzahlen aufteilen, die niemand hochgeboten hat.',
video03Title: 'Die Beweisakte zur Postleitzahl',
video03Desc:
'Tippe auf eine Postleitzahl und lies ihre Akte verkaufte Preise, Schulen, Kriminalität und Street View an einem Ort.',
video04Title: 'Ein Foto kann man nicht hören',
'Tippe auf eine Postleitzahl und ein Panel öffnet sich mit ihren verkauften Preisen, Schul-Einzugsgebieten, Kriminalität und Street View so erkennst du, ob du für Substanz zahlst oder nur für einen Ruf.',
video04Title: 'Die übersehene ruhige Straße',
video04Desc:
'Anzeigenfotos sind stumm. Filtere nach Lärmpegel und finde die wirklich ruhigen Straßen unter 55 Dezibel.',
video05Title: 'Die Schulweg-Karte',
'Berühmte Postleitzahlen preisen ihren Ruf ein, doch ein Anzeigenfoto schweigt zum Lärm. Filtere nach Dezibel und finde die wirklich ruhige Straße eine weiter, die niemand hochgeboten hat.',
video05Title: 'Das Einzugsgebiet ohne den Aufschlag',
video05Desc:
'Gute Grundschul-Einzugsgebiete, wenig Kriminalität und ein Budget der Familienwunsch, über eine ganze Stadt kartiert.',
video06Title: 'Der Waitrose-Test',
'Eine Familie in Leeds sucht eine gute Grundschule, wenig Kriminalität und ein Budget unter £350k und findet die still günstigeren Straßen, die genau dasselbe Einzugsgebiet teilen.',
video06Title: 'Der Waitrose-Effekt, eingepreist',
video06Desc:
'Zu Fuß zu einem Supermarkt, einer U-Bahn-Station und einem Park filtere nach dem Leben, nicht nur nach dem Grundriss.',
video07Title: 'Auch Mieter bekommen eine Karte',
'Zu Fuß zu einem Waitrose, einer U-Bahn-Station und einem Park ist ein eingepreister Aufschlag finde die nahen Postleitzahlen mit denselben Annehmlichkeiten für weniger pro Quadratmeter.',
video07Title: 'Eine Preis-Leistungs-Karte für Mieter',
video07Desc:
'Miete im Budget, kurzer Arbeitsweg und eine ruhige Straße Mietportale zeigen Wohnungen, das hier zeigt dir Gegenden.',
video08Title: '9,99 £ gegen einen verlorenen Samstag',
'Auch namhafte Postleitzahlen kosten mehr Miete. Lege deine Miete, deinen Arbeitsweg und eine ruhige Straße fest und sieh, welche Londoner Postleitzahlen wirklich ins Budget passen.',
video08Title: 'Hör auf, für einen Namen zu viel zu zahlen',
video08Desc:
'Eine schlechte Besichtigung kostet eine Zugfahrt und ein halbes Wochenende. Sieh vorher, wo du nicht hinmusst.',
'Lege Budget, Arbeitsweg, Kriminalität und Schulen fest, und die London-Karte füllt sich mit unterbewerteten Postleitzahlen, die eine Straße neben den berühmten Namen liegen.',
source: 'Quelle:',
optOut: 'Widerspruch gegen öffentliche Offenlegung',
attribution: 'Quellenangaben',
@ -1581,6 +1598,16 @@ const de: Translations = {
// ─ Feature names (Neighbours) ─
'Median age': 'Medianalter',
'% No qualifications': '% Ohne Abschluss',
'% Some GCSEs': '% Einige GCSEs',
'% Good GCSEs': '% Gute GCSEs',
'% Apprenticeship': '% Ausbildung',
'% A-levels': '% A-levels',
'% Degree or higher': '% Hochschulabschluss oder höher',
'% Other qualifications': '% Sonstige Abschlüsse',
'% Owner occupied': '% Wohneigentum',
'% Social rent': '% Sozialwohnung',
'% Private rent': '% Privatmiete',
'% White': '% weiß',
'% South Asian': '% südasiatisch',
'% Black': '% schwarz',
@ -1627,6 +1654,8 @@ const de: Translations = {
'Minor crime': 'Leichte Straftaten',
'Ethnic composition': 'Ethnische Zusammensetzung',
'Political vote share': 'Politischer Stimmenanteil',
Qualifications: 'Bildungsabschlüsse',
Tenure: 'Eigentumsverhältnis',
'Anti-social': 'Antisoziales Verhalten',
Vehicle: 'Fahrzeug',
Burglary: 'Einbruch',

View file

@ -1,4 +1,15 @@
const en = {
newDevelopments: {
title: 'Development site',
homesUpTo: 'Up to {{count}} homes',
homesRange: '{{min}}{{max}} homes',
homesExact: '{{count}} homes',
planningStatus: 'Planning status',
sourceBrownfield: 'Brownfield land register',
sourceHomesEngland: 'Homes England land',
localAuthority: 'Local authority',
viewRecord: 'View planning record ↗',
},
// ── Common ──────────────────────────────────────────
common: {
save: 'Save',
@ -773,10 +784,10 @@ const en = {
describeIdealArea: 'Describe where you want to live',
aiSearch: 'AI Search',
describeHint: 'describe what youre looking for',
placeholder: 'e.g. 2-bed under £525k, 45 mins to work, quiet...',
example1: '2-bed under £525k, 45 mins to work',
example2: 'Family areas near good schools under £650k',
example3: 'More space with a sane commute',
placeholder: 'e.g. same schools, cheaper postcode, under £500k…',
example1: 'Same schools, cheaper postcode',
example2: 'Best value within 30 minutes of work',
example3: 'Underpriced areas near good primaries',
analysing: 'Analysing your query...',
searchingDestinations: 'Searching for destinations...',
generatingFilters: 'Generating filters...',
@ -872,7 +883,13 @@ const en = {
walk: 'Walk',
cycle: 'Cycle',
nationalAvg: 'National avg',
outcodeAvg: 'Outcode avg',
sectorAvg: 'Sector avg',
thisArea: 'This area',
national: 'National',
crimeDataEnds: 'Police data for this area ends {{year}}',
residents: 'Residents',
residentsTooltip: 'Usual residents (ONS Census 2021)',
},
// ── Street View ────────────────────────────────────
@ -1114,30 +1131,30 @@ const en = {
videosTitle: 'Social media videos',
videosIntro:
'Short clips from our social channels — each one shows a single search in action, from quiet streets to school catchments and commute times.',
video01Title: 'One sentence, every postcode',
video01Title: 'Find the cheaper twin',
video01Desc:
'Type your whole house brief in plain English and watch every matching postcode in England light up.',
video02Title: 'The 20-minute map',
'Type your whole house brief in one plain sentence and watch every matching postcode in England sort by value, surfacing the cheaper twin nobody bid up.',
video02Title: 'The 20-minute commute map',
video02Desc:
'Colour the map by commute time and see exactly what a 20-minute journey to central London actually leaves you.',
video03Title: 'Every postcode has a file',
'Colour London by commute to the centre, prune to a 20-minute ride, and watch identical journeys split into the names everyone knows and quieter postcodes nobody bid up.',
video03Title: 'The postcode evidence file',
video03Desc:
'Tap any postcode to read its file — sold prices, schools, crime and Street View, all in one place.',
video04Title: 'You cant hear a photo',
'Tap any postcode and a panel opens with its sold prices, school catchments, crime and Street View — so you can tell whether you are paying for value or just a reputation.',
video04Title: 'The overlooked quiet street',
video04Desc:
'Listing photos are silent. Filter by noise level to find the genuinely quiet streets under 55 decibels.',
video05Title: 'The school-run map',
'Famous postcodes price in their reputation, but a listing photo stays silent on noise. Filter by decibels to find the genuinely quiet street one over that nobody bid up.',
video05Title: 'The catchment without the premium',
video05Desc:
'Good primary catchments, low crime and a budget — the family brief, mapped across a whole city.',
video06Title: 'The Waitrose test',
'A Leeds family search for a good primary, low crime and a budget under £350k surfaces the quietly cheaper streets that share the very same catchment.',
video06Title: 'The Waitrose effect, priced in',
video06Desc:
'Walking distance to a Waitrose, a tube station and a park — filter for the life, not just the floor plan.',
video07Title: 'Renters get a map too',
'Walking distance to a Waitrose, a tube station and a park is a priced-in premium — find the nearby postcodes with the same amenities for less per square metre.',
video07Title: 'A value map for renters',
video07Desc:
'Rent under budget, a short commute and a quiet street — letting sites show flats, this shows you areas.',
video08Title: '£9.99 vs a wasted Saturday',
'Big-name postcodes cost more to rent, too. Set your rent, commute and a quiet street, and see which London postcodes actually fit the money.',
video08Title: 'Stop overpaying for a name',
video08Desc:
'A bad viewing costs a train ticket and half a weekend. See where not to go before you book one.',
'Set a budget, commute, crime and schools, and the London map fills with underpriced postcodes sitting one street over from the famous names.',
source: 'Source:',
optOut: 'Opt out of public disclosure',
attribution: 'Attribution',
@ -1452,7 +1469,7 @@ const en = {
travelDestination: '{{count}} travel time destination',
travelDestinations: '{{count}} travel time destinations',
propertiesMatch: '{{count}} properties match',
setFilters: 'Set {{count}} filter(s): {{list}}',
setFilters: 'Set {{count}} filters: {{list}}',
noFiltersSet: 'No filters set',
toDestination: '{{mode}} to {{label}} {{bounds}}',
lessThanMin: '< {{max}} min',
@ -1555,6 +1572,16 @@ const en = {
// ─ Feature names (Neighbours) ─
'Median age': 'Median age',
'% No qualifications': '% No qualifications',
'% Some GCSEs': '% Some GCSEs',
'% Good GCSEs': '% Good GCSEs',
'% Apprenticeship': '% Apprenticeship',
'% A-levels': '% A-levels',
'% Degree or higher': '% Degree or higher',
'% Other qualifications': '% Other qualifications',
'% Owner occupied': '% Owner occupied',
'% Social rent': '% Social rent',
'% Private rent': '% Private rent',
'% White': '% White',
'% South Asian': '% South Asian',
'% Black': '% Black',
@ -1601,6 +1628,8 @@ const en = {
'Minor crime': 'Minor crime',
'Ethnic composition': 'Ethnic composition',
'Political vote share': 'Political vote share',
Qualifications: 'Qualifications',
Tenure: 'Tenure',
'Anti-social': 'Anti-social',
Vehicle: 'Vehicle',
Burglary: 'Burglary',

View file

@ -1,6 +1,17 @@
import { Translations } from './en';
const fr: Translations = {
newDevelopments: {
title: 'Site de construction',
homesUpTo: "Jusqu'à {{count}} logements",
homesRange: '{{min}}{{max}} logements',
homesExact: '{{count}} logements',
planningStatus: "Statut d'urbanisme",
sourceBrownfield: 'Registre des friches',
sourceHomesEngland: 'Terrain Homes England',
localAuthority: 'Autorité locale',
viewRecord: "Voir le dossier d'urbanisme ↗",
},
// ── Common ──────────────────────────────────────────
common: {
save: 'Enregistrer',
@ -801,10 +812,10 @@ const fr: Translations = {
describeIdealArea: 'Décrivez où vous voulez vivre',
aiSearch: 'Recherche IA',
describeHint: 'décrivez ce que vous recherchez',
placeholder: 'ex. 2 chambres à moins de £525k, 45 min du travail, calme...',
example1: '2 chambres à moins de £525k, 45 min du travail',
example2: 'Quartiers familiaux près de bonnes écoles à moins de £650k',
example3: 'Plus despace avec un trajet raisonnable',
placeholder: 'ex. mêmes écoles, code postal moins cher, sous £500k…',
example1: 'Mêmes écoles, code postal moins cher',
example2: 'Meilleur rapport qualité-prix à 30 minutes du travail',
example3: 'Zones sous-cotées près de bonnes écoles primaires',
analysing: 'Analyse de votre requête...',
searchingDestinations: 'Recherche de destinations...',
generatingFilters: 'Génération des filtres...',
@ -901,7 +912,13 @@ const fr: Translations = {
walk: 'Marche',
cycle: 'Vélo',
nationalAvg: 'Moyenne nationale',
outcodeAvg: 'Moyenne outcode',
sectorAvg: 'Moyenne secteur',
thisArea: 'Cette zone',
national: 'National',
crimeDataEnds: "Les données de police pour cette zone s'arrêtent en {{year}}",
residents: 'Habitants',
residentsTooltip: 'Résidents habituels (recensement ONS 2021)',
},
// ── Street View ────────────────────────────────────
@ -1146,30 +1163,30 @@ const fr: Translations = {
videosTitle: 'Vidéos pour les réseaux sociaux',
videosIntro:
'De courtes vidéos de nos réseaux sociaux — chacune montre une recherche en action, des rues calmes aux secteurs scolaires en passant par les temps de trajet.',
video01Title: 'Une phrase, chaque code postal',
video01Title: 'Trouvez le jumeau moins cher',
video01Desc:
'Décrivez tout votre projet immobilier en langage courant et voyez sallumer chaque code postal correspondant en Angleterre.',
video02Title: 'La carte des 20 minutes',
'Décrivez tout votre projet immobilier en une phrase simple et voyez chaque code postal correspondant en Angleterre se classer par rapport qualité-prix, révélant le jumeau moins cher que personne na fait monter aux enchères.',
video02Title: 'La carte des trajets de 20 minutes',
video02Desc:
'Colorez la carte selon le temps de trajet et voyez exactement ce que 20 minutes du centre de Londres vous laissent vraiment.',
video03Title: 'Chaque code postal a sa fiche',
'Colorez Londres selon le temps de trajet vers le centre, limitez à 20 minutes, et voyez des trajets identiques se diviser entre les noms que tout le monde connaît et les codes postaux plus calmes que personne na fait monter.',
video03Title: 'Le dossier de preuves du code postal',
video03Desc:
'Touchez un code postal pour lire sa fiche — prix de vente, écoles, criminalité et Street View, au même endroit.',
video04Title: 'Une photo, ça ne sentend pas',
'Touchez un code postal et un panneau souvre avec ses prix de vente, ses secteurs scolaires, la criminalité et Street View — pour savoir si vous payez pour de la valeur ou seulement pour une réputation.',
video04Title: 'La rue calme quon néglige',
video04Desc:
'Les photos dannonces sont muettes. Filtrez par niveau de bruit pour trouver les rues vraiment calmes, sous 55 décibels.',
video05Title: 'La carte du trajet décole',
'Les codes postaux célèbres intègrent leur réputation au prix, mais une photo dannonce reste muette sur le bruit. Filtrez par décibels pour trouver la rue vraiment calme juste à côté que personne na fait monter.',
video05Title: 'Le secteur scolaire sans le surcoût',
video05Desc:
'Bons secteurs décole primaire, faible criminalité et un budget — le projet des familles, cartographié sur toute une ville.',
video06Title: 'Le test Waitrose',
'Une famille de Leeds cherche une bonne école primaire, peu de criminalité et un budget sous £350k, et fait apparaître les rues discrètement moins chères qui partagent exactement le même secteur.',
video06Title: 'Leffet Waitrose, déjà dans le prix',
video06Desc:
'À pied dun supermarché, dune station de métro et dun parc — filtrez selon votre vie, pas seulement selon le plan.',
video07Title: 'Les locataires aussi ont une carte',
'Être à pied dun Waitrose, dune station de métro et dun parc est un surcoût déjà intégré — trouvez les codes postaux voisins offrant les mêmes commodités pour moins cher au mètre carré.',
video07Title: 'Une carte de la valeur pour les locataires',
video07Desc:
'Loyer dans le budget, trajet court et rue calme — les sites de location montrent des logements, ceci vous montre des quartiers.',
video08Title: '9,99 £ contre un samedi gâché',
'Les codes postaux réputés coûtent aussi plus cher à louer. Indiquez votre loyer, votre trajet et une rue calme, et voyez quels codes postaux de Londres tiennent vraiment dans le budget.',
video08Title: 'Arrêtez de surpayer pour un nom',
video08Desc:
'Une mauvaise visite coûte un billet de train et la moitié dun week-end. Voyez où ne pas aller avant den réserver une.',
'Définissez un budget, un trajet, la criminalité et les écoles, et la carte de Londres se remplit de codes postaux sous-cotés situés à une rue des noms célèbres.',
source: 'Source :',
optOut: 'Refus de la publication publique',
attribution: 'Attribution',
@ -1494,7 +1511,7 @@ const fr: Translations = {
travelDestination: '{{count}} destination de trajet',
travelDestinations: '{{count}} destinations de trajet',
propertiesMatch: '{{count}} biens correspondent',
setFilters: 'Définir {{count}} filtre(s) : {{list}}',
setFilters: 'Définir {{count}} filtres : {{list}}',
noFiltersSet: 'Aucun filtre défini',
toDestination: '{{mode}} vers {{label}} {{bounds}}',
lessThanMin: '< {{max}} min',
@ -1598,6 +1615,16 @@ const fr: Translations = {
// ─ Feature names (Neighbours) ─
'Median age': 'Âge médian',
'% No qualifications': '% Sans diplôme',
'% Some GCSEs': '% Quelques GCSE',
'% Good GCSEs': '% Bons GCSE',
'% Apprenticeship': '% Apprentissage',
'% A-levels': '% A-levels',
'% Degree or higher': '% Diplôme universitaire ou plus',
'% Other qualifications': '% Autres diplômes',
'% Owner occupied': '% Propriétaires occupants',
'% Social rent': '% Logements sociaux',
'% Private rent': '% Location privée',
'% White': '% Blancs',
'% South Asian': '% Sud-Asiatiques',
'% Black': '% Noirs',
@ -1644,6 +1671,8 @@ const fr: Translations = {
'Minor crime': 'Infractions mineures',
'Ethnic composition': 'Composition ethnique',
'Political vote share': 'Répartition des voix',
Qualifications: 'Diplômes',
Tenure: 'Statut doccupation',
'Anti-social': 'Antisocial',
Vehicle: 'Véhicule',
Burglary: 'Cambriolage',

View file

@ -1,6 +1,17 @@
import type { Translations } from './en';
const hi: Translations = {
newDevelopments: {
title: 'विकास स्थल',
homesUpTo: '{{count}} तक घर',
homesRange: '{{min}}{{max}} घर',
homesExact: '{{count}} घर',
planningStatus: 'योजना स्थिति',
sourceBrownfield: 'ब्राउनफ़ील्ड रजिस्टर',
sourceHomesEngland: 'Homes England भूमि',
localAuthority: 'स्थानीय प्राधिकरण',
viewRecord: 'योजना रिकॉर्ड देखें ↗',
},
common: {
save: 'सहेजें',
update: 'अपडेट करें',
@ -764,10 +775,10 @@ const hi: Translations = {
describeIdealArea: 'बताएं आप कहां रहना चाहते हैं',
aiSearch: 'AI खोज',
describeHint: 'बताएं आप क्या खोज रहे हैं',
placeholder: 'जैसे 2 बेडरूम £525,000 से कम, काम तक 45 मिनट, शांत...',
example1: '2 बेडरूम £525,000 से कम, काम तक 45 मिनट',
example2: '£650,000 से कम अच्छे स्कूलों के पास परिवारों वाले क्षेत्र',
example3: 'समझदारी वाले आवागमन के साथ ज्यादा जगह',
placeholder: 'जैसे वही स्कूल, सस्ता पोस्टकोड, £500,000 से कम…',
example1: 'वही स्कूल, सस्ता पोस्टकोड',
example2: 'काम से 30 मिनट के भीतर सबसे अच्छा मूल्य',
example3: 'अच्छे प्राइमरी स्कूलों के पास कम-कीमत वाले इलाके',
analysing: 'आपकी क्वेरी का विश्लेषण हो रहा है...',
searchingDestinations: 'गंतव्य खोजे जा रहे हैं...',
generatingFilters: 'फ़िल्टर बनाए जा रहे हैं...',
@ -860,7 +871,13 @@ const hi: Translations = {
walk: 'पैदल',
cycle: 'साइकिल',
nationalAvg: 'राष्ट्रीय औसत',
outcodeAvg: 'आउटकोड औसत',
sectorAvg: 'सेक्टर औसत',
thisArea: 'यह क्षेत्र',
national: 'राष्ट्रीय',
crimeDataEnds: 'इस क्षेत्र के लिए पुलिस डेटा {{year}} में समाप्त होता है',
residents: 'निवासी',
residentsTooltip: 'सामान्य निवासी (ONS जनगणना 2021)',
},
streetView: {
@ -1093,30 +1110,30 @@ const hi: Translations = {
videosTitle: 'सोशल मीडिया वीडियो',
videosIntro:
'हमारे सोशल चैनलों की छोटी क्लिप्स — हर एक एक खोज को क्रिया में दिखाती है, शांत गलियों से लेकर स्कूल कैचमेंट और सफर के समय तक.',
video01Title: 'एक वाक्य, हर पोस्टकोड',
video01Title: 'सस्ता जुड़वाँ खोजें',
video01Desc:
'अपनी पूरी घर की ज़रूरत सामान्य भाषा में लिखें और इंग्लैंड का हर मेल खाता पोस्टकोड जगमगाते देखें.',
video02Title: '20 मिनट का नक्शा',
'अपनी पूरी घर की ज़रूरत एक सरल वाक्य में लिखें और देखें कि इंग्लैंड का हर मेल खाता पोस्टकोड मूल्य के अनुसार छँटता है, उस सस्ते जुड़वाँ को सामने लाता है जिसकी किसी ने बोली नहीं बढ़ाई.',
video02Title: '20 मिनट का आवागमन नक्शा',
video02Desc:
'नक्शे को सफर के समय के अनुसार रंगें और देखें कि सेंट्रल लंदन से 20 मिनट वास्तव में आपको क्या देते हैं.',
video03Title: 'हर पोस्टकोड की एक फ़ाइल है',
'लंदन को केंद्र तक आवागमन के अनुसार रंगें, 20 मिनट की सवारी तक सीमित करें, और देखें कि एक जैसे सफर कैसे बँट जाते हैं — मशहूर नामों और उन शांत पोस्टकोडों में जिनकी किसी ने बोली नहीं बढ़ाई.',
video03Title: 'पोस्टकोड की सबूत-फ़ाइल',
video03Desc:
'किसी भी पोस्टकोड पर टैप करके उसकी फ़ाइल पढ़ें — बिक्री मूल्य, स्कूल, अपराध और स्ट्रीट व्यू, सब एक जगह.',
video04Title: 'फ़ोटो सुनी नहीं जा सकती',
'किसी भी पोस्टकोड पर टैप करें और एक पैनल खुलता है जिसमें उसके बिक्री मूल्य, स्कूल कैचमेंट, अपराध और स्ट्रीट व्यू होते हैं — ताकि आप जान सकें कि आप मूल्य के लिए चुका रहे हैं या सिर्फ़ एक नाम के लिए.',
video04Title: 'अनदेखी की गई शांत गली',
video04Desc:
'विज्ञापन की तस्वीरें खामोश होती हैं. शोर के स्तर से छानकर 55 डेसिबल से नीचे की सचमुच शांत गलियाँ खोजें.',
video05Title: 'स्कूल-रन नक्शा',
'मशहूर पोस्टकोड अपनी प्रतिष्ठा को कीमत में जोड़ लेते हैं, पर विज्ञापन की तस्वीर शोर पर खामोश रहती है. डेसिबल से छानकर ठीक बगल की सचमुच शांत गली खोजें जिसकी किसी ने बोली नहीं बढ़ाई.',
video05Title: 'बिना प्रीमियम वाला कैचमेंट',
video05Desc:
'अच्छे प्राइमरी कैचमेंट, कम अपराध और एक बजट — परिवार की ज़रूरत, पूरे शहर पर मैप की गई.',
video06Title: 'वेट्रोज़ टेस्ट',
'लीड्स का एक परिवार अच्छे प्राइमरी, कम अपराध और £350,000 से कम बजट की खोज करता है, और उन चुपचाप सस्ती गलियों को सामने लाता है जो ठीक वही कैचमेंट साझा करती हैं.',
video06Title: 'वेट्रोज़ असर, कीमत में शामिल',
video06Desc:
'सुपरमार्केट, ट्यूब स्टेशन और पार्क से पैदल दूरी — सिर्फ़ फ़्लोर प्लान नहीं, अपनी ज़िंदगी के हिसाब से छानें.',
video07Title: 'किराएदारों के लिए भी नक्शा',
'किसी वेट्रोज़, ट्यूब स्टेशन और पार्क से पैदल दूरी एक कीमत-में-शामिल प्रीमियम है — पास के वे पोस्टकोड खोजें जिनमें वही सुविधाएँ प्रति वर्ग मीटर कम दाम पर मिलती हैं.',
video07Title: 'किराएदारों के लिए मूल्य नक्शा',
video07Desc:
'बजट में किराया, छोटा सफर और शांत गली — किराये की साइटें फ़्लैट दिखाती हैं, यह आपको इलाके दिखाता है.',
video08Title: '£9.99 बनाम एक बर्बाद शनिवार',
'मशहूर पोस्टकोड किराये पर भी महँगे पड़ते हैं. अपना किराया, आवागमन और एक शांत गली तय करें, और देखें कि लंदन के कौन-से पोस्टकोड वाकई बजट में बैठते हैं.',
video08Title: 'नाम के लिए ज़्यादा चुकाना बंद करें',
video08Desc:
'एक ख़राब विज़िट एक ट्रेन टिकट और आधा सप्ताहांत ले जाती है. बुकिंग से पहले देखें कि कहाँ नहीं जाना है.',
'बजट, आवागमन, अपराध और स्कूल तय करें, और लंदन का नक्शा उन कम-कीमत वाले पोस्टकोडों से भर जाता है जो मशहूर नामों से बस एक गली दूर बैठे हैं.',
source: 'स्रोत:',
optOut: 'सार्वजनिक प्रकटीकरण से बाहर निकलें',
attribution: 'श्रेय',
@ -1501,6 +1518,16 @@ const hi: Translations = {
'Public order (avg/yr)': 'सार्वजनिक व्यवस्था अपराध (घनत्व)',
'Other crime (avg/yr)': 'अन्य अपराध (घनत्व)',
'Median age': 'मध्य आयु',
'% No qualifications': '% कोई योग्यता नहीं',
'% Some GCSEs': '% कुछ GCSE',
'% Good GCSEs': '% अच्छे GCSE',
'% Apprenticeship': '% अप्रेंटिसशिप',
'% A-levels': '% A-levels',
'% Degree or higher': '% डिग्री या उससे ऊपर',
'% Other qualifications': '% अन्य योग्यताएं',
'% Owner occupied': '% स्वामित्व वाले घर',
'% Social rent': '% सामाजिक किराया',
'% Private rent': '% निजी किराया',
'% White': '% श्वेत',
'% South Asian': '% दक्षिण एशियाई',
'% Black': '% अश्वेत',
@ -1538,6 +1565,8 @@ const hi: Translations = {
'Minor crime': 'मामूली अपराध',
'Ethnic composition': 'जातीय संरचना',
'Political vote share': 'राजनीतिक मत हिस्सेदारी',
Qualifications: 'योग्यताएं',
Tenure: 'आवास स्वामित्व',
'Anti-social': 'असामाजिक',
Vehicle: 'वाहन',
Burglary: 'सेंधमारी',

View file

@ -1,6 +1,17 @@
import type { Translations } from './en';
const hu: Translations = {
newDevelopments: {
title: 'Fejlesztési terület',
homesUpTo: 'Akár {{count}} lakás',
homesRange: '{{min}}{{max}} lakás',
homesExact: '{{count}} lakás',
planningStatus: 'Tervezési állapot',
sourceBrownfield: 'Barnamezős nyilvántartás',
sourceHomesEngland: 'Homes England terület',
localAuthority: 'Helyi önkormányzat',
viewRecord: 'Tervezési dokumentum megtekintése ↗',
},
// ── Common ──────────────────────────────────────────
common: {
save: 'Mentés',
@ -790,10 +801,10 @@ const hu: Translations = {
describeIdealArea: 'Írd le, hol szeretnél élni',
aiSearch: 'AI-keresés',
describeHint: 'Írd le, mit keresel',
placeholder: 'pl. 2 hálószoba £525k alatt, 45 perc munkába, csendes...',
example1: '2 hálószoba £525k alatt, 45 perc munkába',
example2: 'Családbarát területek jó iskolák közelében £650k alatt',
example3: 'Több hely és ésszerű ingázás',
placeholder: 'pl. ugyanazok az iskolák, olcsóbb irányítószám, £500k alatt…',
example1: 'Ugyanazok az iskolák, olcsóbb irányítószám',
example2: 'A legjobb ár-érték a munkahelytől 30 percen belül',
example3: 'Alulárazott területek jó általános iskolák közelében',
analysing: 'Lekérdezés elemzése...',
searchingDestinations: 'Úticélok keresése...',
generatingFilters: 'Szűrők létrehozása...',
@ -889,7 +900,13 @@ const hu: Translations = {
walk: 'Gyalog',
cycle: 'Kerékpár',
nationalAvg: 'Országos átlag',
outcodeAvg: 'Outcode átlag',
sectorAvg: 'Szektor átlag',
thisArea: 'Ez a terület',
national: 'Országos',
crimeDataEnds: 'A körzet rendőrségi adatai {{year}}-ig érhetők el',
residents: 'Lakosok',
residentsTooltip: 'Állandó lakosok (ONS 2021-es népszámlálás)',
},
// ── Street View ────────────────────────────────────
@ -1130,30 +1147,30 @@ const hu: Translations = {
videosTitle: 'Közösségimédia-videók',
videosIntro:
'Rövid klipek a közösségi csatornáinkról mindegyik egy-egy keresést mutat be működés közben, a csendes utcáktól az iskolai körzeteken át az utazási időkig.',
video01Title: 'Egy mondat, minden irányítószám',
video01Title: 'Találd meg az olcsóbb ikertestvért',
video01Desc:
'Írd le a teljes lakáskeresési igényedet hétköznapi nyelven, és nézd, ahogy Anglia minden illeszkedő irányítószáma felgyúl.',
video02Title: 'A 20 perces térkép',
'Írd le a teljes lakáskeresési igényedet egyetlen egyszerű mondatban, és nézd, ahogy Anglia minden illeszkedő irányítószáma ár-érték szerint rendeződik, felszínre hozva az olcsóbb ikertestvért, amit senki sem vert fel.',
video02Title: 'A 20 perces ingázási térkép',
video02Desc:
'Színezd a térképet utazási idő szerint, és lásd pontosan, mit hagy neked valójában 20 perc London központjától.',
video03Title: 'Minden irányítószámnak van aktája',
'Színezd Londont a központba tartó ingázás szerint, szűkítsd 20 perces útra, és nézd, ahogy az azonos utak kettéválnak a mindenki által ismert nevekre és a csendesebb irányítószámokra, amelyeket senki sem vert fel.',
video03Title: 'Az irányítószám bizonyítékaktája',
video03Desc:
'Koppints bármelyik irányítószámra, és olvasd el az aktáját eladási árak, iskolák, bűnözés és Street View egy helyen.',
video04Title: 'Egy fotót nem lehet meghallani',
'Koppints bármelyik irányítószámra, és megnyílik egy panel az eladási áraival, iskolai körzeteivel, bűnözéssel és Street View-val így megállapíthatod, valódi értékért fizetsz-e, vagy csak egy hírnévért.',
video04Title: 'A figyelmen kívül hagyott csendes utca',
video04Desc:
'A hirdetésfotók némák. Szűrj zajszint szerint, és találd meg a valóban csendes, 55 decibel alatti utcákat.',
video05Title: 'Az iskolába vezető út térképe',
'A híres irányítószámak beárazzák a hírnevüket, de egy hirdetésfotó néma a zajról. Szűrj decibel szerint, és találd meg az igazán csendes utcát egy sarokkal arrébb, amit senki sem vert fel.',
video05Title: 'A körzet felár nélkül',
video05Desc:
'Jó általános iskolai körzetek, alacsony bűnözés és egy költségvetés a családi igény, egy egész városra térképezve.',
video06Title: 'A Waitrose-teszt',
'Egy leedsi család jó általános iskolát, alacsony bűnözést és £350k alatti költségvetést keres, és felszínre hozza a csendben olcsóbb utcákat, amelyek ugyanazt a körzetet osztják.',
video06Title: 'A Waitrose-hatás, beárazva',
video06Desc:
'Sétatávolságra egy szupermarkettől, egy metrómegállótól és egy parktól az életedre szűrj, ne csak az alaprajzra.',
video07Title: 'A bérlőknek is jár térkép',
'Sétatávolságra egy Waitrose-tól, egy metrómegállótól és egy parktól ez egy beárazott felár; találd meg a közeli irányítószámokat ugyanazokkal a szolgáltatásokkal, négyzetméterenként olcsóbban.',
video07Title: 'Ár-érték térkép bérlőknek',
video07Desc:
'Költségvetésbe férő bérleti díj, rövid ingázás és csendes utca a bérleti oldalak lakásokat mutatnak, ez környékeket.',
video08Title: '9,99 £ egy elpazarolt szombat ellen',
'A neves irányítószámok bérelni is drágábbak. Add meg a bérleti díjat, az ingázást és egy csendes utcát, és lásd, mely londoni irányítószámok férnek tényleg a pénztárcádba.',
video08Title: 'Ne fizess túl egy névért',
video08Desc:
'Egy rossz megtekintés egy vonatjegybe és egy fél hétvégébe kerül. Még foglalás előtt lásd, hova ne menj.',
'Állíts be költségvetést, ingázást, bűnözést és iskolákat, és a londoni térkép megtelik alulárazott irányítószámokkal, amelyek egy utcányira fekszenek a híres nevektől.',
source: 'Forrás:',
optOut: 'Nyilvános közzététel visszautasítása',
attribution: 'Forrásmegnevezés',
@ -1577,6 +1594,16 @@ const hu: Translations = {
// ─ Feature names (Neighbours) ─
'Median age': 'Medián életkor',
'% No qualifications': '% Végzettség nélkül',
'% Some GCSEs': '% Néhány GCSE',
'% Good GCSEs': '% Jó GCSE',
'% Apprenticeship': '% Tanoncképzés',
'% A-levels': '% A-levels',
'% Degree or higher': '% Diploma vagy magasabb',
'% Other qualifications': '% Egyéb végzettség',
'% Owner occupied': '% Saját tulajdonú lakás',
'% Social rent': '% Szociális bérlakás',
'% Private rent': '% Magánbérlemény',
'% White': '% fehér',
'% South Asian': '% dél-ázsiai',
'% Black': '% fekete',
@ -1623,6 +1650,8 @@ const hu: Translations = {
'Minor crime': 'Kisebb bűncselekmény',
'Ethnic composition': 'Etnikai összetétel',
'Political vote share': 'Szavazati megoszlás',
Qualifications: 'Végzettség',
Tenure: 'Lakhatási jogviszony',
'Anti-social': 'Közösségellenes',
Vehicle: 'Jármű',
Burglary: 'Betörés',

View file

@ -1,6 +1,17 @@
import type { Translations } from './en';
const zh: Translations = {
newDevelopments: {
title: '开发地块',
homesUpTo: '最多 {{count}} 套住宅',
homesRange: '{{min}}{{max}} 套住宅',
homesExact: '{{count}} 套住宅',
planningStatus: '规划状态',
sourceBrownfield: '棕地登记册',
sourceHomesEngland: 'Homes England 用地',
localAuthority: '地方政府',
viewRecord: '查看规划记录 ↗',
},
// ── Common ──────────────────────────────────────────
common: {
save: '保存',
@ -735,10 +746,10 @@ const zh: Translations = {
describeIdealArea: '描述您想住在哪里',
aiSearch: 'AI 搜索',
describeHint: '描述您要找的区域',
placeholder: '例如:两居室£525,000 以下,到公司 45 分钟,安静...',
example1: '两居室£525,000 以下,到公司 45 分钟',
example2: '靠近好学校、低于 £650,000 的家庭友好区域',
example3: '空间更大,通勤别太累',
placeholder: '例如:同样的学校更便宜的邮编£500,000 以下…',
example1: '同样的学校,更便宜的邮编',
example2: '通勤 30 分钟内性价比最高',
example3: '优质小学附近被低估的区域',
analysing: '正在分析您的需求...',
searchingDestinations: '正在搜索目的地...',
generatingFilters: '正在生成筛选条件...',
@ -831,7 +842,13 @@ const zh: Translations = {
walk: '步行',
cycle: '骑行',
nationalAvg: '全国平均',
outcodeAvg: '邮区平均',
sectorAvg: '分区平均',
thisArea: '本区域',
national: '全国',
crimeDataEnds: '该地区的警方数据截至{{year}}年',
residents: '居民',
residentsTooltip: '常住居民ONS 2021 年人口普查)',
},
// ── Street View ────────────────────────────────────
@ -1066,22 +1083,30 @@ const zh: Translations = {
videosTitle: '社交媒体视频',
videosIntro:
'来自我们社交平台的短片——每一个都展示一次实际搜索,从安静街道到学校学区,再到通勤时间。',
video01Title: '一句话,每个邮编',
video01Desc: '用日常语言写下你的全部购房需求,看着英格兰每个符合条件的邮编亮起来。',
video02Title: '20 分钟地图',
video02Desc: '按通勤时间为地图着色,看清从伦敦市中心 20 分钟究竟能到达哪里。',
video03Title: '每个邮编都有一份档案',
video03Desc: '点按任意邮编即可查看其档案——成交价、学校、犯罪和街景,尽在一处。',
video04Title: '照片听不见声音',
video04Desc: '房源照片是无声的。按噪音水平筛选,找到真正安静、低于 55 分贝的街道。',
video05Title: '上学路线地图',
video05Desc: '优质小学学区、低犯罪率和预算——把家庭需求映射到整座城市。',
video06Title: 'Waitrose 测试',
video06Desc: '步行可达超市、地铁站和公园——按你的生活方式筛选,而不只是户型图。',
video07Title: '租房者也有地图',
video07Desc: '预算内的租金、短通勤和安静街道——租房网站展示房源,这里为你展示区域。',
video08Title: '£9.99 对比浪费的周六',
video08Desc: '一次糟糕的看房要花一张车票和半个周末。在预约之前就看清哪里不该去。',
video01Title: '找到更便宜的孪生邮编',
video01Desc:
'用一句平实的话写下你的全部购房需求,看着英格兰每个符合条件的邮编按性价比排序,浮现出那个无人抬价、更便宜的孪生邮编。',
video02Title: '20 分钟通勤地图',
video02Desc:
'按到市中心的通勤时间为伦敦着色,收窄到 20 分钟车程,看着相同的通勤路程被分成人人皆知的名字,和那些无人抬价、更安静的邮编。',
video03Title: '邮编证据档案',
video03Desc:
'点按任意邮编,便会打开一个面板,列出它的成交价、学校学区、犯罪和街景——让你看清自己付的是真正的价值,还是仅仅一个名声。',
video04Title: '被忽视的安静街道',
video04Desc:
'知名邮编把名声计入了价格,但房源照片对噪音却只字不提。按分贝筛选,找到隔壁那条真正安静、无人抬价的街道。',
video05Title: '没有溢价的学区',
video05Desc:
'利兹的一家人寻找优质小学、低犯罪率以及 £350,000 以下的预算,便浮现出那些悄然更便宜、却共享同一学区的街道。',
video06Title: 'Waitrose 效应,已计入价格',
video06Desc:
'步行可达 Waitrose、地铁站和公园是一种已计入价格的溢价——找到附近拥有同样配套、每平方米却更便宜的邮编。',
video07Title: '为租房者准备的价值地图',
video07Desc:
'知名邮编租起来也更贵。设定你的租金、通勤和一条安静街道,看看伦敦哪些邮编真正符合预算。',
video08Title: '别再为一个名字多花钱',
video08Desc:
'设定预算、通勤、犯罪和学校,伦敦地图便会布满被低估的邮编,它们就坐落在知名地段隔壁一条街。',
source: '来源:',
optOut: '选择不公开',
attribution: '数据引用声明',
@ -1488,6 +1513,16 @@ const zh: Translations = {
// ─ Feature names (Neighbours) ─
'Median age': '中位年龄',
'% No qualifications': '% 无学历',
'% Some GCSEs': '% 部分 GCSE',
'% Good GCSEs': '% 良好 GCSE',
'% Apprenticeship': '% 学徒制',
'% A-levels': '% A-levels',
'% Degree or higher': '% 学位及以上',
'% Other qualifications': '% 其他学历',
'% Owner occupied': '% 自有住房',
'% Social rent': '% 社会租赁',
'% Private rent': '% 私人租赁',
'% White': '% 白人',
'% South Asian': '% 南亚裔',
'% Black': '% 黑人',
@ -1534,6 +1569,8 @@ const zh: Translations = {
'Minor crime': '轻微犯罪',
'Ethnic composition': '族裔组成',
'Political vote share': '政党得票率',
Qualifications: '学历',
Tenure: '住房产权',
'Anti-social': '反社会',
Vehicle: '车辆',
Burglary: '入室盗窃',

View file

@ -63,6 +63,7 @@ const DEMO_GATED_ENDPOINTS = new Set([
'hexagon-properties',
'journey',
'actual-listings',
'developments',
'export',
]);

View file

@ -227,6 +227,14 @@ export const POI_CLUSTER_MAX_ZOOM = 15;
/** Zoom level at which individual POI cards are shown without hovering */
export const POI_AUTO_CARD_ZOOM_THRESHOLD = POI_CLUSTER_MAX_ZOOM + 1;
/** Hard cap on auto POI cards rendered at once. With every category enabled a
* dense area can yield hundreds of overlapping cards an unreadable wall so we
* show a spaced subset and rely on the map markers (+ hover) for the rest. */
export const MAX_AUTO_POI_CARDS = 40;
/** Minimum screen-space gap (px) between two auto POI cards before one is culled. */
export const AUTO_POI_CARD_MIN_DX = 130;
export const AUTO_POI_CARD_MIN_DY = 34;
/**
* Groups whose features should be collapsed into stacked bar charts.
* Keyed by feature group name. Each entry defines one stacked chart.
@ -290,6 +298,19 @@ export const STACKED_GROUPS: Record<
'% Other',
],
},
{
label: 'Qualifications',
unit: '%',
components: [
'% No qualifications',
'% Some GCSEs',
'% Good GCSEs',
'% Apprenticeship',
'% A-levels',
'% Degree or higher',
'% Other qualifications',
],
},
{
label: 'Political vote share',
unit: '%',
@ -302,6 +323,11 @@ export const STACKED_GROUPS: Record<
'% Other parties',
],
},
{
label: 'Tenure',
unit: '%',
components: ['% Owner occupied', '% Social rent', '% Private rent'],
},
],
};
@ -470,6 +496,18 @@ export const STACKED_SEGMENT_COLORS: Record<string, string> = {
'% Black': '#8b5cf6',
'% Mixed': '#14b8a6',
'% Other': '#6b7280',
// Qualifications: low-attainment (red) → high-attainment (green) ramp.
'% No qualifications': '#ef4444',
'% Some GCSEs': '#f97316',
'% Good GCSEs': '#eab308',
'% Apprenticeship': '#14b8a6',
'% A-levels': '#3b82f6',
'% Degree or higher': '#22c55e',
'% Other qualifications': '#6b7280',
// Tenure (Census 2021 TS054): owner-occupied (green) → social rent (amber) → private rent (blue).
'% Owner occupied': '#22c55e',
'% Social rent': '#f59e0b',
'% Private rent': '#3b82f6',
'Anti-social': '#14b8a6',
Vehicle: '#3b82f6',
Burglary: '#eab308',

View file

@ -21,9 +21,39 @@ import {
getFeatureFillColor,
getMapCenterForTargetScreenPoint,
getPoiIconUrl,
selectSpacedItems,
zoomToResolution,
} from './map-utils';
describe('selectSpacedItems', () => {
const mk = (id: string, x: number, y: number) => ({ item: id, x, y });
it('keeps every item when none overlap', () => {
const items = [mk('a', 0, 0), mk('b', 200, 0), mk('c', 0, 200)];
const kept = selectSpacedItems(items, 130, 34, 40);
expect(kept.map((k) => k.item)).toEqual(['a', 'b', 'c']);
});
it('culls items that fall within the spacing of an already-kept item', () => {
// b and c sit on top of a; d is clear.
const items = [mk('a', 100, 100), mk('b', 120, 110), mk('c', 150, 100), mk('d', 400, 400)];
const kept = selectSpacedItems(items, 130, 34, 40);
expect(kept.map((k) => k.item)).toEqual(['a', 'd']);
});
it('treats dx and dy independently (far enough on either axis is kept)', () => {
const items = [mk('a', 0, 0), mk('b', 10, 40)]; // dx<130 but dy>=34
const kept = selectSpacedItems(items, 130, 34, 40);
expect(kept.map((k) => k.item)).toEqual(['a', 'b']);
});
it('stops at the max cap', () => {
const items = Array.from({ length: 100 }, (_, i) => mk(String(i), i * 500, 0));
const kept = selectSpacedItems(items, 130, 34, 40);
expect(kept).toHaveLength(40);
});
});
describe('map utilities', () => {
it('maps zoom levels to H3 resolutions at configured thresholds', () => {
expect(zoomToResolution(6.9)).toBe(5);

View file

@ -13,6 +13,36 @@ import {
MAP_MIN_ZOOM,
type GradientStop,
} from './consts';
/** A candidate overlay anchored at screen-space (x, y). */
export interface PlacedItem<T> {
item: T;
x: number;
y: number;
}
/**
* Greedily keep a spaced-out subset of screen-space items so dense areas don't
* render a wall of overlapping cards. Walks `candidates` in order, keeping one
* only when it clears every already-kept item by `minDx`/`minDy`, and stops at
* `max`. Order-dependent by design pass candidates in priority order.
*/
export function selectSpacedItems<T>(
candidates: PlacedItem<T>[],
minDx: number,
minDy: number,
max: number
): PlacedItem<T>[] {
const kept: PlacedItem<T>[] = [];
for (const candidate of candidates) {
if (kept.length >= max) break;
const overlaps = kept.some(
(k) => Math.abs(k.x - candidate.x) < minDx && Math.abs(k.y - candidate.y) < minDy
);
if (!overlaps) kept.push(candidate);
}
return kept;
}
const ROAD_OPACITY = 0.4;
const TILE_SIZE = 512;
const MAX_MERCATOR_LATITUDE = 85;

View file

@ -3,6 +3,7 @@ export const OVERLAY_IDS = [
'crime-hotspots',
'trees-outside-woodlands',
'property-borders',
'new-developments',
] as const;
export type OverlayId = (typeof OVERLAY_IDS)[number];
@ -47,6 +48,13 @@ export const OVERLAYS: OverlayDefinition[] = [
detail:
'HM Land Registry INSPIRE Index Polygons — the position and indicative extent of freehold registered property in England & Wales, drawn as outlines at street level. These are "general boundaries" for guidance only, not the precise legal boundary of a property, and they exclude leasehold-only interests and unregistered land (roughly 8590% of freehold land is covered). This information is subject to Crown copyright and database rights 2026 and is reproduced with the permission of HM Land Registry. The polygons (including the associated geometry, namely x, y co-ordinates) are subject to Crown copyright and database rights 2026 Ordnance Survey AC0000851063. Licensed under the Open Government Licence v3.0.',
},
{
id: 'new-developments',
label: 'New developments',
description: 'Planned new-home sites (brownfield register + Homes England)',
detail:
'A forward-looking pipeline of new housing. Blue markers mark sites on the statutory MHCLG Brownfield Land registers — each carrying an estimated net-dwelling capacity and planning-permission status — together with Homes England Land Hub disposal sites. These show where new homes are planned, often years before they appear in EPC or sale records. Dwelling figures are capacity estimates, not commitments, and a site on a register is an opportunity rather than a guarantee of construction. Licensed under the Open Government Licence v3.0.',
},
];
/** Overlays shown on a fresh visit, before any `overlay` URL params apply. */
@ -73,4 +81,5 @@ export const OVERLAY_MIN_ZOOM: Record<OverlayId, number> = {
noise: 12,
'property-borders': 12,
'trees-outside-woodlands': 12,
'new-developments': 12,
};

View file

@ -0,0 +1,22 @@
/**
* The Census 2021 qualification-breakdown features (TS067). These render as a
* single stacked "Qualifications" composition in the area pane (see
* STACKED_GROUPS["Neighbours"] in consts.ts) and are display-only: they are
* hidden from the filter browser rather than offered as seven individual
* sliders, so the breakdown reads as a ratio without cluttering the filter list.
*/
export const QUALIFICATION_FEATURE_NAMES = [
'% No qualifications',
'% Some GCSEs',
'% Good GCSEs',
'% Apprenticeship',
'% A-levels',
'% Degree or higher',
'% Other qualifications',
] as const;
const QUALIFICATION_FEATURE_NAME_SET = new Set<string>(QUALIFICATION_FEATURE_NAMES);
export function isQualificationFeatureName(name: string): boolean {
return QUALIFICATION_FEATURE_NAME_SET.has(name);
}

View file

@ -165,6 +165,27 @@ export interface ActualListingsResponse {
total: number;
}
export interface Development {
lat: number;
lon: number;
source: 'brownfield' | 'homes-england';
name?: string | null;
min_dwellings?: number | null;
max_dwellings?: number | null;
planning_status?: string | null;
permission_type?: string | null;
permission_date?: string | null;
hectares?: number | null;
local_authority?: string | null;
url?: string | null;
}
export interface DevelopmentsResponse {
developments: Development[];
total: number;
truncated: boolean;
}
export interface POICategoryGroup {
name: string;
categories: string[];
@ -294,6 +315,18 @@ export interface CrimeYearStats {
points: CrimeYearPoint[];
}
export interface CrimeAreaAverage {
/** Crime type without the " (avg/yr)" suffix (e.g. "Burglary"). */
name: string;
/** Exact national mean (avg/yr). Preferred over the histogram-bin national
* average for crime so all reference numbers share one estimator. */
national?: number;
/** Mean headline rate (avg/yr) across the selection's outcode. */
outcode?: number;
/** Mean headline rate (avg/yr) across the selection's postcode sector. */
sector?: number;
}
export interface FilterExclusion {
name: string;
kind: 'numeric' | 'enum' | 'poi' | 'travel';
@ -319,6 +352,18 @@ export interface HexagonStatsResponse {
* since mid-2019) and its crime figures are captioned as stale.
*/
crime_latest_year?: number;
/** Outward code (e.g. "E14") of the selection's central postcode, when
* outcode crime averages are available for it. */
crime_outcode?: string;
/** Postcode sector (e.g. "E14 2") of the selection's central postcode, when
* sector crime averages are available for it. */
crime_sector?: string;
/** Per-crime-type average rates across the central postcode's outcode and
* sector, shown alongside the national average for each crime metric. */
crime_area_averages?: CrimeAreaAverage[];
central_postcode?: string;
/** Total usual residents (ONS Census 2021) across the postcodes in this
* selection. Display-only; independent of active filters. */
population?: number;
filter_exclusions?: FilterExclusion[];
}

View file

@ -0,0 +1,153 @@
"""Download Census 2021 usual-resident counts per unit postcode.
ONS Census 2021 publishes a *direct* headcount of usual residents for every
unit postcode in England and Wales (table P001, disaggregated by sex) as a bulk
CSV on the NOMIS webservice. We sum the two sex rows per postcode to get the
total usual-resident population and emit one row per unit postcode.
This is a display-only side table (shown in the right-hand area pane); it is NOT
merged into the property/postcode feature frames and is never a filterable
attribute. The Rust server loads the parquet directly via --population-path.
Source: NOMIS bulk product "Postcode resident and household estimates, England
and Wales: Census 2021" (table P001), as at census day 21 March 2021.
https://www.nomisweb.co.uk/sources/census_2021_pc
License: Open Government Licence v3.0
Caveats (Census 2021 disclosure control): counts carry targeted record swapping
and cell-key perturbation, and only postcodes with at least one usual resident
are present, so vacant/non-residential postcodes are absent from the output.
"""
import argparse
import tempfile
import time
from pathlib import Path
import polars as pl
from pipeline.utils import download
# P001 = "Number of usual residents by postcode by sex", split alphabetically by
# postcode into four CSVs (each: Postcode, Sex Code, Sex Label, Count). The
# per-file minimums are ~95% of the published record counts and let us retry
# until a download is complete (see _fetch_csv).
BASE = "https://www.nomisweb.co.uk/output/census/2021"
P001_FILES = [
("pcd_p001_a_d.csv", 720_000),
("pcd_p001_e_l.csv", 540_000),
("pcd_p001_m_r.csv", 630_000),
("pcd_p001_s_z.csv", 750_000),
]
# P001 covers England & Wales (~1.37M postcodes). A materially smaller result
# means a split file was truncated or silently 404'd.
MIN_EXPECTED_POSTCODES = 1_000_000
def _canonical_postcode(col: pl.Expr) -> pl.Expr:
"""Canonical spaced unit postcode (e.g. "AL1 1AG"), matching the Rust
server's `normalize_postcode`: uppercase, strip non-alphanumerics, then
insert a single space before the final three (inward-code) characters."""
cleaned = col.cast(pl.String).str.to_uppercase().str.replace_all(r"[^A-Z0-9]", "")
return (
cleaned.str.slice(0, cleaned.str.len_chars() - 3)
+ pl.lit(" ")
+ cleaned.str.slice(-3)
)
def _fetch_csv(
url: str, dest: Path, min_rows: int, *, max_attempts: int = 20
) -> pl.DataFrame:
"""Download a CSV, defending against silent truncation.
The NOMIS bulk files are served with chunked transfer encoding and no
Content-Length, so a dropped connection ends the stream without raising
yielding a short file. We retry until the parsed row count reaches the
file's known approximate size, keeping the largest parse seen.
"""
best: pl.DataFrame | None = None
for attempt in range(1, max_attempts + 1):
try:
download(url, dest)
df = pl.read_csv(dest)
except Exception as exc: # noqa: BLE001 — retry any transport/parse error
print(f" {url} attempt {attempt}: error {exc}")
time.sleep(3)
continue
rows = df.height
if best is None or rows > best.height:
best = df
complete = rows >= min_rows
print(
f" {url} attempt {attempt}: {rows} rows "
f"({'complete' if complete else f'< {min_rows}, retrying'})"
)
if complete:
return df
time.sleep(2)
raise RuntimeError(
f"Failed to fully download {url} after {max_attempts} attempts "
f"(best {best.height if best is not None else 0} rows, need >= {min_rows})"
)
def download_and_convert(output_path: Path) -> None:
frames: list[pl.DataFrame] = []
with tempfile.TemporaryDirectory() as tmp:
for name, min_rows in P001_FILES:
url = f"{BASE}/{name}"
dest = Path(tmp) / name
print(f"Downloading {url} ...")
frames.append(_fetch_csv(url, dest, min_rows))
df = pl.concat(frames)
print(f"Total P001 rows (postcode x sex): {df.height}")
if "Count" not in df.columns or "Postcode" not in df.columns:
raise ValueError(
f"Unexpected P001 columns {df.columns}; expected 'Postcode' and 'Count'."
)
result = (
df.with_columns(_canonical_postcode(pl.col("Postcode")).alias("postcode"))
.filter(pl.col("postcode").str.len_chars() >= 5)
.group_by("postcode")
.agg(pl.col("Count").cast(pl.Int64).sum().alias("population"))
.filter(pl.col("population") > 0)
.with_columns(pl.col("population").cast(pl.UInt32))
.sort("postcode")
)
print(f"Unit postcodes with population: {result.height}")
if result.height < MIN_EXPECTED_POSTCODES:
raise ValueError(
f"Only {result.height} postcodes (expected >= {MIN_EXPECTED_POSTCODES}); "
"a NOMIS P001 split file was likely truncated or unavailable."
)
total = result["population"].sum()
print(f"Total usual residents (England & Wales): {total:,}")
print(
f"Per-postcode population range: {result['population'].min()} - "
f"{result['population'].max()}"
)
output_path.parent.mkdir(parents=True, exist_ok=True)
result.write_parquet(output_path, compression="zstd")
print(f"Saved to {output_path}")
def main() -> None:
parser = argparse.ArgumentParser(
description="Download Census 2021 usual-resident counts per unit postcode"
)
parser.add_argument(
"--output", type=Path, required=True, help="Output parquet file path"
)
args = parser.parse_args()
download_and_convert(args.output)
if __name__ == "__main__":
main()

View file

@ -0,0 +1,302 @@
"""Download planned/pipeline development sites for the "new developments" layer.
This is the forward-looking "where new homes are coming" signal that complements
the EPC new-build + Land Registry "first sale" delivery signals already in the
pipeline. Two national, Open Government Licence v3.0 sources are merged into a
single coordinate-keyed parquet that the Rust server serves as a map layer:
- MHCLG Brownfield Land register (statutory LPA brownfield registers, normalised)
https://www.planning.data.gov.uk/dataset/brownfield-land
- Homes England Land Hub (public land being disposed for development)
https://www.gov.uk/government/publications/homes-england-land-hub
Output schema (one row per site):
lat, lon, source, name, min_dwellings, max_dwellings, planning_status,
permission_type, permission_date, hectares, local_authority, url
"""
import argparse
import math
import re
import tempfile
from pathlib import Path
import httpx
import polars as pl
from shapely.geometry import shape
from pipeline.local_temp import local_tmp_dir
from pipeline.utils.download import download
BROWNFIELD_URL = "https://files.planning.data.gov.uk/dataset/brownfield-land.parquet"
HOMES_ENGLAND_URL = (
"https://services-eu1.arcgis.com/yo0w4PgP4XL49bfF/arcgis/rest/services/"
"Homes_England_Land_Hub_Sites/FeatureServer/0/query"
)
# Output column order; doubles as the polars schema so empty inputs still produce
# a well-typed frame. The Rust loader (data/developments.rs) reads these names.
SCHEMA: dict[str, pl.DataType] = {
"lat": pl.Float64,
"lon": pl.Float64,
"source": pl.String,
"name": pl.String,
"min_dwellings": pl.Int32,
"max_dwellings": pl.Int32,
"planning_status": pl.String,
"permission_type": pl.String,
"permission_date": pl.String,
"hectares": pl.Float64,
"local_authority": pl.String,
"url": pl.String,
}
_COORD = r"-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?"
_POINT_RE = re.compile(rf"POINT\s*\(\s*({_COORD})\s+({_COORD})\s*\)", re.IGNORECASE)
# 1 acre = 0.404686 hectares. The Homes England feed reports area in acres.
_ACRES_TO_HECTARES = 0.404686
# Great Britain bounding box, used to reject mis-ordered or projected coordinates
# (e.g. a WKT written "lat lon", or stray British National Grid eastings).
_LON_MIN, _LON_MAX = -9.0, 2.1
_LAT_MIN, _LAT_MAX = 49.0, 61.1
def _first(row: dict, *keys: str):
"""First present, non-None value among ``keys`` (case-sensitive)."""
for key in keys:
if key in row and row[key] is not None:
return row[key]
return None
def _clean_str(value) -> str | None:
if value is None:
return None
text = str(value).strip()
return text or None
def _to_int(value) -> int | None:
if value is None:
return None
try:
number = float(value)
except (TypeError, ValueError):
return None
if not math.isfinite(number): # NaN or +/-inf (int(inf) raises OverflowError)
return None
return int(number)
def _to_float(value) -> float | None:
if value is None:
return None
try:
number = float(value)
except (TypeError, ValueError):
return None
if not math.isfinite(number): # NaN or +/-inf
return None
return number
def _valid_lonlat(lon: float, lat: float) -> bool:
return _LON_MIN <= lon <= _LON_MAX and _LAT_MIN <= lat <= _LAT_MAX
def _point_lonlat(row: dict) -> tuple[float, float] | None:
"""Extract (lon, lat) from a brownfield row's WKT point or lat/lon columns."""
wkt = _clean_str(_first(row, "point"))
if wkt:
match = _POINT_RE.match(wkt)
if match:
lon, lat = float(match.group(1)), float(match.group(2))
if _valid_lonlat(lon, lat):
return lon, lat
lon = _to_float(_first(row, "longitude", "long", "lng"))
lat = _to_float(_first(row, "latitude", "lat"))
if lon is not None and lat is not None and _valid_lonlat(lon, lat):
return lon, lat
return None
def _geometry_centroid(geom) -> tuple[float, float] | None:
"""Centroid (lon, lat) of a GeoJSON geometry already in WGS84."""
if not geom:
return None
try:
centroid = shape(geom).centroid
except (ValueError, TypeError, AttributeError):
return None
if centroid.is_empty:
return None
lon, lat = float(centroid.x), float(centroid.y)
return (lon, lat) if _valid_lonlat(lon, lat) else None
def _has_dwellings(min_d: int | None, max_d: int | None) -> bool:
return (min_d is not None and min_d > 0) or (max_d is not None and max_d > 0)
def _brownfield_url(entity) -> str | None:
"""Canonical per-site page on the Planning Data platform.
The register's own ``site-plan-url`` is unreliable — many LPAs point every
row at a single generic register landing page so we link to the stable,
always-per-site entity page instead.
"""
entity_id = _to_int(entity)
if entity_id is None:
return None
return f"https://www.planning.data.gov.uk/entity/{entity_id}"
def _is_residential(use: str | None, capacity: int | None) -> bool:
"""Keep Homes England sites that will deliver homes."""
if capacity is not None and capacity > 0:
return True
if use is None:
return False
lowered = use.lower()
return any(token in lowered for token in ("resid", "housing", "mixed", "dwelling"))
def _frame_from_rows(rows: list[dict]) -> pl.DataFrame:
return pl.DataFrame(rows, schema=SCHEMA, orient="row")
def brownfield_to_frame(raw: pl.DataFrame) -> pl.DataFrame:
"""Normalise the brownfield-land register to the unified development schema.
Drops sites that have left the register (a non-empty ``end-date`` means the
site was built out or withdrawn) and sites with no residential capacity, so
the layer shows pipeline housing rather than every parcel ever registered.
"""
rows: list[dict] = []
for row in raw.iter_rows(named=True):
lonlat = _point_lonlat(row)
if lonlat is None:
continue
if _clean_str(_first(row, "end-date", "end_date")):
continue
min_d = _to_int(_first(row, "minimum-net-dwellings", "minimum_net_dwellings"))
max_d = _to_int(_first(row, "maximum-net-dwellings", "maximum_net_dwellings"))
if not _has_dwellings(min_d, max_d):
continue
lon, lat = lonlat
rows.append(
{
"lat": lat,
"lon": lon,
"source": "brownfield",
"name": _clean_str(_first(row, "site-address", "name")),
"min_dwellings": min_d,
"max_dwellings": max_d,
"planning_status": _clean_str(
_first(row, "planning-permission-status")
),
"permission_type": _clean_str(_first(row, "planning-permission-type")),
"permission_date": _clean_str(_first(row, "planning-permission-date")),
"hectares": _to_float(_first(row, "hectares")),
"local_authority": _clean_str(_first(row, "organisation")),
"url": _brownfield_url(_first(row, "entity")),
}
)
return _frame_from_rows(rows)
def homes_england_to_frame(features: list[dict]) -> pl.DataFrame:
"""Normalise Homes England Land Hub GeoJSON features to the unified schema."""
rows: list[dict] = []
for feature in features:
props = feature.get("properties") or {}
lonlat = _geometry_centroid(feature.get("geometry"))
if lonlat is None:
continue
capacity = _to_int(
_first(props, "Housing_Capacity", "HousingCapacity", "Units", "Homes")
)
use = _clean_str(_first(props, "Proposed_Use"))
if not _is_residential(use, capacity):
continue
lon, lat = lonlat
# The feed reports area in acres (`Gross_Area__Acres_`); convert to hectares
# so the column is consistent with the brownfield register.
acres = _to_float(_first(props, "Gross_Area__Acres_"))
hectares = round(acres * _ACRES_TO_HECTARES, 4) if acres is not None else None
rows.append(
{
"lat": lat,
"lon": lon,
"source": "homes-england",
"name": _clean_str(_first(props, "Parcel_Name", "Site_Reference")),
"min_dwellings": None,
"max_dwellings": capacity,
"planning_status": _clean_str(_first(props, "Planning_Status")),
"permission_type": None,
"permission_date": None,
"hectares": hectares,
"local_authority": _clean_str(_first(props, "Local_Authority")),
# The Land Hub exposes no stable per-site page; leave the link unset
# rather than pointing at a generic landing page.
"url": None,
}
)
return _frame_from_rows(rows)
def _fetch_homes_england() -> list[dict]:
params = {
"where": "1=1",
"outFields": "*",
"outSR": "4326",
"f": "geojson",
"resultRecordCount": "2000",
}
response = httpx.get(
HOMES_ENGLAND_URL, params=params, follow_redirects=True, timeout=120
)
response.raise_for_status()
payload = response.json()
if payload.get("exceededTransferLimit"):
print(
" WARNING: Homes England query hit the transfer limit; "
"only the first page of sites was fetched."
)
return payload.get("features", []) or []
def main() -> None:
parser = argparse.ArgumentParser(
description="Download brownfield + Homes England development sites"
)
parser.add_argument(
"--output", type=Path, required=True, help="Output parquet file path"
)
args = parser.parse_args()
with tempfile.TemporaryDirectory(dir=local_tmp_dir()) as cache_dir:
brownfield_path = Path(cache_dir) / "brownfield-land.parquet"
print("Downloading MHCLG brownfield land register...")
download(BROWNFIELD_URL, brownfield_path)
brownfield = brownfield_to_frame(pl.read_parquet(brownfield_path))
print(f" Brownfield: {brownfield.height} residential sites")
print("Downloading Homes England Land Hub...")
homes_england = homes_england_to_frame(_fetch_homes_england())
print(f" Homes England: {homes_england.height} residential sites")
combined = pl.concat([brownfield, homes_england]).filter(
pl.col("lat").is_not_null() & pl.col("lon").is_not_null()
)
args.output.parent.mkdir(parents=True, exist_ok=True)
combined.write_parquet(args.output, compression="zstd")
size_kb = args.output.stat().st_size / 1024
print(f"Saved {combined.height} development sites to {args.output} ({size_kb:.0f} KB)")
if __name__ == "__main__":
main()

View file

@ -0,0 +1,177 @@
"""Download Census 2021 highest level of qualification (TS067) by LSOA.
Downloads the 8-category "highest level of qualification" breakdown (TS067,
classification C2021_HIQUAL_8) from the NOMIS API at LSOA 2021 granularity and
emits one row per LSOA with the percentage of usual residents aged 16+ in each
qualification band. The bands sum to 100%, so downstream they render as a
composition/ratio (like the ethnicity and political-vote-share stacked bars)
rather than a single headline number.
We give the ONS bands colloquial labels (the census's "Level 1/2/3/4+" jargon
means little to a homebuyer): No qualifications / Some GCSEs / Good GCSEs /
Apprenticeship / A-levels / Degree or higher / Other qualifications. NOTE the
census does NOT split undergraduate from postgraduate "Level 4 and above" is a
single bucket ("Degree or higher").
The join key downstream (merge.py) is `lsoa21`, the same key used for ethnicity,
median age, and IoD.
Source: NOMIS (ONS Census 2021 TS067 dataset, NM_2084_1)
License: Open Government Licence v3.0
"""
import argparse
from pathlib import Path
import polars as pl
from pipeline.utils import ENGLAND_LSOA_COUNT_2021, download_nomis_csv
pl.Config.set_tbl_cols(-1)
# NOMIS API: Census 2021 TS067 (highest level of qualification) by LSOA 2021
# (TYPE151). c2021_hiqual_8=1..7 selects the 7 substantive bands (excluding
# 0 = Total, which we re-derive by summing). measures=20100 selects the count.
BASE_URL = (
"https://www.nomisweb.co.uk/api/v01/dataset/NM_2084_1.data.csv"
"?date=latest"
"&geography=TYPE151"
"&c2021_hiqual_8=1,2,3,4,5,6,7"
"&measures=20100"
"&select=GEOGRAPHY_CODE,C2021_HIQUAL_8_NAME,OBS_VALUE"
)
# Map each canonical NOMIS C2021_HIQUAL_8 band to our colloquial output bucket.
# 1:1 mapping (no folding) — keyed on the exact NOMIS label so a relabelled or
# missing band fails loudly in validation instead of silently dropping people.
BAND_MAP = {
"No qualifications": "No qualifications",
"Level 1 and entry level qualifications": "Some GCSEs",
"Level 2 qualifications": "Good GCSEs",
"Apprenticeship": "Apprenticeship",
"Level 3 qualifications": "A-levels",
"Level 4 qualifications or above": "Degree or higher",
"Other qualifications": "Other qualifications",
}
# Output buckets in ascending-attainment order, so the stacked composition reads
# left-to-right from least to most qualified.
OUTPUT_BUCKETS = [
"No qualifications",
"Some GCSEs",
"Good GCSEs",
"Apprenticeship",
"A-levels",
"Degree or higher",
"Other qualifications",
]
assert set(BAND_MAP.values()) == set(OUTPUT_BUCKETS), (
"BAND_MAP values must be exactly the OUTPUT_BUCKETS"
)
def _qualification_percentages(df: pl.DataFrame) -> pl.DataFrame:
"""Fold the 7 NOMIS bands into percentage buckets per LSOA (summing to 100).
`df` is the long-format NOMIS download with columns GEOGRAPHY_CODE,
C2021_HIQUAL_8_NAME (the band label) and OBS_VALUE (a count). A missing,
extra, or relabelled band would silently change the denominator (the sum over
bands) so we validate the band set against BAND_MAP first and fail otherwise.
Returns one row per LSOA with `lsoa21` and `% <bucket>` for each bucket.
"""
found = set(df["C2021_HIQUAL_8_NAME"].unique().to_list())
expected = set(BAND_MAP)
if found != expected:
missing = sorted(expected - found)
unexpected = sorted(found - expected)
raise ValueError(
"Census qualification bands do not match the expected NOMIS "
"TS067 C2021_HIQUAL_8 set.\n"
f" expected {len(expected)} bands, found {len(found)}\n"
f" missing: {missing}\n"
f" unexpected: {unexpected}\n"
"Refusing to compute percentages against an unrecognised breakdown."
)
grouped = (
df.with_columns(
pl.col("C2021_HIQUAL_8_NAME").replace_strict(BAND_MAP).alias("bucket"),
pl.col("OBS_VALUE").cast(pl.Float64, strict=False).alias("_count"),
)
.group_by("GEOGRAPHY_CODE", "bucket")
.agg(pl.col("_count").sum())
)
wide = grouped.pivot(on="bucket", index="GEOGRAPHY_CODE", values="_count").rename(
{"GEOGRAPHY_CODE": "lsoa21"}
)
# A bucket with no people in an LSOA is absent from the long rows, so the
# pivot leaves a null; treat it as 0 before normalising.
wide = wide.with_columns(pl.col(OUTPUT_BUCKETS).fill_null(0.0))
# Normalize so each row sums to exactly 100%, then round with the
# largest-remainder method to preserve the sum (independent rounding of 7
# values can drift +/-0.3).
row_total = sum(pl.col(c) for c in OUTPUT_BUCKETS)
wide = wide.with_columns(
[(pl.col(c) / row_total * 100.0).alias(c) for c in OUTPUT_BUCKETS]
)
wide = wide.with_columns([pl.col(c).round(1).alias(c) for c in OUTPUT_BUCKETS])
rounded_sum = sum(pl.col(c) for c in OUTPUT_BUCKETS)
residual = (100.0 - rounded_sum).round(1)
largest_col = pl.concat_list(OUTPUT_BUCKETS).list.arg_max()
wide = wide.with_columns(
[
pl.when(largest_col == i)
.then(pl.col(c) + residual)
.otherwise(pl.col(c))
.alias(c)
for i, c in enumerate(OUTPUT_BUCKETS)
]
)
rename_map = {col: f"% {col}" for col in OUTPUT_BUCKETS}
return wide.rename(rename_map).select(
"lsoa21", *[f"% {c}" for c in OUTPUT_BUCKETS]
)
def download_and_convert(output_path: Path) -> None:
print("Downloading Census 2021 highest qualification (TS067) by LSOA from NOMIS...")
df = download_nomis_csv(BASE_URL)
print(f"Total rows: {df.height}")
# Filter to England only (E-prefixed LSOA codes); the merge joins on the
# English postcode universe and the LSOA coverage check is England-wide.
df = df.filter(pl.col("GEOGRAPHY_CODE").str.starts_with("E"))
wide = _qualification_percentages(df)
print(f"England LSOAs: {wide.height}")
if wide.height != ENGLAND_LSOA_COUNT_2021:
raise ValueError(
f"Expected {ENGLAND_LSOA_COUNT_2021} England LSOAs, "
f"got {wide.height}: truncated NOMIS download?"
)
print(f"Columns: {wide.columns}")
deg = wide["% Degree or higher"]
print(f"% Degree or higher: {deg.min()}% - {deg.max()}% (mean {deg.mean():.1f}%)")
output_path.parent.mkdir(parents=True, exist_ok=True)
wide.write_parquet(output_path, compression="zstd")
print(f"Saved to {output_path}")
def main() -> None:
parser = argparse.ArgumentParser(
description="Download Census 2021 highest level of qualification (TS067) by LSOA"
)
parser.add_argument(
"--output", type=Path, required=True, help="Output parquet file path"
)
args = parser.parse_args()
download_and_convert(args.output)
if __name__ == "__main__":
main()

179
pipeline/download/tenure.py Normal file
View file

@ -0,0 +1,179 @@
"""Download Census 2021 household tenure (TS054) by LSOA.
Downloads the household-tenure breakdown (TS054, classification C2021_TENURE_9)
from the NOMIS API at LSOA 2021 granularity and folds the 8 detailed leaf
categories into our 3 output buckets Owner occupied / Social rent /
Private rent emitting one row per LSOA with the percentage of households in
each. The three buckets sum to 100%, so downstream they render as a
composition/ratio (like the ethnicity and qualifications stacked bars) AND each
percentage is independently filterable.
The 3-bucket grouping follows ONS's standard tenure summary:
* Owner occupied = Owns outright + Owns with a mortgage/loan + Shared ownership
* Social rent = Rents from council/LA + Other social rented
* Private rent = Private landlord/letting agency + Other private + Lives rent free
(Shared ownership is part-owned and rolls into owner-occupied; "lives rent free"
rolls into private rent, mirroring ONS's "Private rented or lives rent free".)
NOTE this table counts HOUSEHOLDS (not usual residents). The join key downstream
(merge.py) is `lsoa21`, the same key used for ethnicity, qualifications,
median age, and IoD.
Source: NOMIS (ONS Census 2021 TS054 dataset, NM_2072_1)
License: Open Government Licence v3.0
"""
import argparse
from pathlib import Path
import polars as pl
from pipeline.utils import ENGLAND_LSOA_COUNT_2021, download_nomis_csv
pl.Config.set_tbl_cols(-1)
# NOMIS API: Census 2021 TS054 (household tenure) by LSOA 2021 (TYPE151).
# c2021_tenure_9=1..8 selects the 8 substantive leaf categories (excluding the
# aggregates 0/1001-1004/9996/9997, whose components we sum ourselves).
# measures=20100 selects the count.
BASE_URL = (
"https://www.nomisweb.co.uk/api/v01/dataset/NM_2072_1.data.csv"
"?date=latest"
"&geography=TYPE151"
"&c2021_tenure_9=1,2,3,4,5,6,7,8"
"&measures=20100"
"&select=GEOGRAPHY_CODE,C2021_TENURE_9_NAME,OBS_VALUE"
)
# Map each canonical NOMIS C2021_TENURE_9 leaf to our 3 output buckets. Keyed on
# the exact NOMIS label so a relabelled or missing leaf fails loudly in
# validation instead of silently dropping households from the denominator.
TENURE_MAP = {
"Owned: Owns outright": "Owner occupied",
"Owned: Owns with a mortgage or loan": "Owner occupied",
"Shared ownership: Shared ownership": "Owner occupied",
"Social rented: Rents from council or Local Authority": "Social rent",
"Social rented: Other social rented": "Social rent",
"Private rented: Private landlord or letting agency": "Private rent",
"Private rented: Other private rented": "Private rent",
"Lives rent free": "Private rent",
}
# Output buckets in a fixed order (own -> social rent -> private rent), so the
# stacked composition reads left-to-right and the largest-remainder rounding
# below is deterministic regardless of pivot column ordering.
OUTPUT_BUCKETS = ["Owner occupied", "Social rent", "Private rent"]
assert set(TENURE_MAP.values()) == set(OUTPUT_BUCKETS), (
"TENURE_MAP values must be exactly the OUTPUT_BUCKETS"
)
def _tenure_percentages(df: pl.DataFrame) -> pl.DataFrame:
"""Fold the 8 NOMIS tenure leaves into 3-bucket percentages per LSOA.
`df` is the long-format NOMIS download with columns GEOGRAPHY_CODE,
C2021_TENURE_9_NAME (the leaf label) and OBS_VALUE (a household count). A
missing, extra, or relabelled leaf would silently change the denominator (the
sum over leaves) so we validate the leaf set against TENURE_MAP first and fail
otherwise. Returns one row per LSOA with `lsoa21` and `% <bucket>` for each
bucket.
"""
found = set(df["C2021_TENURE_9_NAME"].unique().to_list())
expected = set(TENURE_MAP)
if found != expected:
missing = sorted(expected - found)
unexpected = sorted(found - expected)
raise ValueError(
"Census tenure categories do not match the expected NOMIS "
"TS054 C2021_TENURE_9 leaf set.\n"
f" expected {len(expected)} categories, found {len(found)}\n"
f" missing: {missing}\n"
f" unexpected: {unexpected}\n"
"Refusing to compute percentages against an unrecognised breakdown."
)
# Map each leaf to its output bucket and sum counts per (LSOA, bucket).
# Summing counts (not rounded percentages) keeps the denominator exact.
grouped = (
df.with_columns(
pl.col("C2021_TENURE_9_NAME").replace_strict(TENURE_MAP).alias("bucket"),
pl.col("OBS_VALUE").cast(pl.Float64, strict=False).alias("_count"),
)
.group_by("GEOGRAPHY_CODE", "bucket")
.agg(pl.col("_count").sum())
)
wide = grouped.pivot(on="bucket", index="GEOGRAPHY_CODE", values="_count").rename(
{"GEOGRAPHY_CODE": "lsoa21"}
)
# A bucket with no households in an LSOA is absent from the long rows, so the
# pivot leaves a null; treat it as 0 before normalising.
wide = wide.with_columns(pl.col(OUTPUT_BUCKETS).fill_null(0.0))
# Normalize so each row sums to exactly 100%, then round with the
# largest-remainder method to preserve the sum (independent rounding of 3
# values can drift +/-0.1).
row_total = sum(pl.col(c) for c in OUTPUT_BUCKETS)
wide = wide.with_columns(
[(pl.col(c) / row_total * 100.0).alias(c) for c in OUTPUT_BUCKETS]
)
wide = wide.with_columns([pl.col(c).round(1).alias(c) for c in OUTPUT_BUCKETS])
rounded_sum = sum(pl.col(c) for c in OUTPUT_BUCKETS)
residual = (100.0 - rounded_sum).round(1)
largest_col = pl.concat_list(OUTPUT_BUCKETS).list.arg_max()
wide = wide.with_columns(
[
pl.when(largest_col == i)
.then(pl.col(c) + residual)
.otherwise(pl.col(c))
.alias(c)
for i, c in enumerate(OUTPUT_BUCKETS)
]
)
rename_map = {col: f"% {col}" for col in OUTPUT_BUCKETS}
return wide.rename(rename_map).select(
"lsoa21", *[f"% {c}" for c in OUTPUT_BUCKETS]
)
def download_and_convert(output_path: Path) -> None:
print("Downloading Census 2021 household tenure (TS054) by LSOA from NOMIS...")
df = download_nomis_csv(BASE_URL)
print(f"Total rows: {df.height}")
# Filter to England only (E-prefixed LSOA codes); the merge joins on the
# English postcode universe and the LSOA coverage check is England-wide.
df = df.filter(pl.col("GEOGRAPHY_CODE").str.starts_with("E"))
wide = _tenure_percentages(df)
print(f"England LSOAs: {wide.height}")
if wide.height != ENGLAND_LSOA_COUNT_2021:
raise ValueError(
f"Expected {ENGLAND_LSOA_COUNT_2021} England LSOAs, "
f"got {wide.height}: truncated NOMIS download?"
)
print(f"Columns: {wide.columns}")
for bucket in OUTPUT_BUCKETS:
col = wide[f"% {bucket}"]
print(f"% {bucket}: {col.min()}% - {col.max()}% (mean {col.mean():.1f}%)")
output_path.parent.mkdir(parents=True, exist_ok=True)
wide.write_parquet(output_path, compression="zstd")
print(f"Saved to {output_path}")
def main() -> None:
parser = argparse.ArgumentParser(
description="Download Census 2021 household tenure (TS054) by LSOA"
)
parser.add_argument(
"--output", type=Path, required=True, help="Output parquet file path"
)
args = parser.parse_args()
download_and_convert(args.output)
if __name__ == "__main__":
main()

View file

@ -0,0 +1,165 @@
import polars as pl
from pipeline.download.development_sites import (
SCHEMA,
_has_dwellings,
_is_residential,
_point_lonlat,
_valid_lonlat,
brownfield_to_frame,
homes_england_to_frame,
)
def test_point_lonlat_parses_wkt():
assert _point_lonlat({"point": "POINT(-0.1278 51.5074)"}) == (-0.1278, 51.5074)
# Tolerates extra spacing and uppercase.
assert _point_lonlat({"point": "POINT ( -1.5 53.8 )"}) == (-1.5, 53.8)
def test_point_lonlat_falls_back_to_columns():
assert _point_lonlat({"longitude": "-2.0", "latitude": "52.4"}) == (-2.0, 52.4)
def test_point_lonlat_rejects_out_of_range():
# A WKT written "lat lon" would place lat (51.5) into the lon slot -> rejected.
assert _point_lonlat({"point": "POINT(51.5 -0.12)"}) is None
assert _point_lonlat({"point": "garbage"}) is None
assert _point_lonlat({}) is None
def test_valid_lonlat_bounds():
assert _valid_lonlat(-0.1, 51.5)
assert not _valid_lonlat(2.5, 51.5) # lon east of GB
assert not _valid_lonlat(-0.1, 40.0) # lat south of GB
def test_has_dwellings():
assert _has_dwellings(0, 5)
assert _has_dwellings(3, None)
assert not _has_dwellings(None, None)
assert not _has_dwellings(0, 0)
def test_is_residential():
assert _is_residential("Residential", None)
assert _is_residential("Mixed Use", None)
assert _is_residential(None, 12)
assert not _is_residential("Employment", None)
assert not _is_residential(None, None)
assert not _is_residential("Industrial", 0)
def test_brownfield_to_frame_normalises_and_filters():
raw = pl.DataFrame(
{
"point": [
"POINT(-0.1 51.5)", # kept
"POINT(-1.2 52.6)", # dropped: no dwellings
"POINT(-2.0 53.0)", # dropped: end-date set (built out)
"POINT(-3.0 54.0)", # kept
],
"site-address": ["10 High St", "Old Yard", "Mill Site", "Dock Road"],
"minimum-net-dwellings": ["5", "0", "10", None],
"maximum-net-dwellings": ["15", "0", "10", "40"],
"planning-permission-status": [
"permissioned",
"not-permissioned",
"permissioned",
"pending-decision",
],
"planning-permission-type": ["full", "", "outline", None],
"planning-permission-date": ["2023-01-01", None, "2022-05-01", None],
"hectares": ["0.5", "0.2", "1.0", "2.0"],
"end-date": [None, None, "2021-06-01", ""],
"entity": [1700000, 1700001, 1700002, 1700003],
"organisation": [
"London Borough of Lewisham",
"Org B",
"Org C",
"London Borough of Newham",
],
}
)
frame = brownfield_to_frame(raw)
assert frame.columns == list(SCHEMA.keys())
assert frame.schema["min_dwellings"] == pl.Int32
assert frame.schema["lat"] == pl.Float64
# Only the first and last rows survive (dwellings present, not built out).
assert frame.height == 2
assert frame["source"].to_list() == ["brownfield", "brownfield"]
first = frame.row(0, named=True)
assert first["lat"] == 51.5
assert first["lon"] == -0.1
assert first["min_dwellings"] == 5
assert first["max_dwellings"] == 15
assert first["name"] == "10 High St"
assert first["local_authority"] == "London Borough of Lewisham"
# Per-site Planning Data entity page, not the unreliable site-plan-url.
assert first["url"] == "https://www.planning.data.gov.uk/entity/1700000"
last = frame.row(1, named=True)
assert last["min_dwellings"] is None
assert last["max_dwellings"] == 40
assert last["url"] == "https://www.planning.data.gov.uk/entity/1700003"
def test_brownfield_empty_input_yields_typed_empty_frame():
raw = pl.DataFrame(
{
"point": [],
"minimum-net-dwellings": [],
"maximum-net-dwellings": [],
"end-date": [],
}
)
frame = brownfield_to_frame(raw)
assert frame.height == 0
assert frame.columns == list(SCHEMA.keys())
def test_homes_england_to_frame_uses_centroid_and_capacity():
features = [
{
"geometry": {
"type": "Polygon",
"coordinates": [[[-0.2, 51.4], [0.0, 51.4], [0.0, 51.6], [-0.2, 51.6], [-0.2, 51.4]]],
},
# Real Land Hub field names (Housing_Capacity arrives as a float string).
"properties": {
"Parcel_Name": "South West Of Old Park Mound, Telford, TF3 4AW",
"Proposed_Use": "Residential",
"Housing_Capacity": "67.0",
"Planning_Status": "Detailed Application Submitted",
"Local_Authority": "Telford and Wrekin",
"Gross_Area__Acres_": "2.8602",
"Site_Reference": "1494",
},
},
{
# Non-residential, no capacity -> dropped.
"geometry": {
"type": "Polygon",
"coordinates": [[[-1.0, 52.0], [-0.9, 52.0], [-0.9, 52.1], [-1.0, 52.1], [-1.0, 52.0]]],
},
"properties": {"Proposed_Use": "Employment"},
},
]
frame = homes_england_to_frame(features)
assert frame.height == 1
row = frame.row(0, named=True)
assert row["source"] == "homes-england"
# Real name, not the bare Site_Reference fallback.
assert row["name"] == "South West Of Old Park Mound, Telford, TF3 4AW"
assert row["max_dwellings"] == 67
assert row["min_dwellings"] is None
assert row["local_authority"] == "Telford and Wrekin"
# Acres converted to hectares: 2.8602 * 0.404686 ≈ 1.1575.
assert row["hectares"] == 1.1575
# No stable per-site URL on the Land Hub.
assert row["url"] is None
# Centroid of the unit-ish box.
assert abs(row["lon"] - (-0.1)) < 1e-6
assert abs(row["lat"] - 51.5) < 1e-6
assert frame.columns == list(SCHEMA.keys())

View file

@ -0,0 +1,107 @@
import polars as pl
import pytest
from pipeline.download.education import (
BAND_MAP,
OUTPUT_BUCKETS,
_qualification_percentages,
)
def _long_rows(geo: str, counts: dict[str, int]) -> list[dict]:
"""Build NOMIS-shaped long rows for one LSOA from {band_label: count}.
NOMIS emits a 0-count row when an LSOA has none in a band, so bands not
given default to 0 to mirror that.
"""
return [
{
"GEOGRAPHY_CODE": geo,
"C2021_HIQUAL_8_NAME": label,
"OBS_VALUE": counts.get(label, 0),
}
for label in BAND_MAP
]
def test_qualification_percentages_keyed_by_lsoa_with_seven_buckets():
df = pl.DataFrame(
_long_rows(
"E01000001",
{
"No qualifications": 10,
"Level 2 qualifications": 20,
"Level 3 qualifications": 20,
"Level 4 qualifications or above": 50,
},
)
)
result = _qualification_percentages(df)
assert result.columns[0] == "lsoa21"
assert set(result.columns) == {"lsoa21", *(f"% {b}" for b in OUTPUT_BUCKETS)}
row = result.filter(pl.col("lsoa21") == "E01000001").to_dicts()[0]
assert row["% Degree or higher"] == 50.0
assert row["% No qualifications"] == 10.0
assert row["% Good GCSEs"] == 20.0
assert row["% A-levels"] == 20.0
# Percentages always sum to exactly 100 (largest-remainder rounding).
assert round(sum(row[f"% {b}"] for b in OUTPUT_BUCKETS), 1) == 100.0
def test_qualification_colloquial_band_mapping():
"""ONS jargon bands fold into the colloquial buckets, not 'Level N' names."""
df = pl.DataFrame(
_long_rows(
"E01000002",
{
"Level 1 and entry level qualifications": 40,
"Apprenticeship": 60,
},
)
)
row = _qualification_percentages(df).to_dicts()[0]
assert row["% Some GCSEs"] == 40.0
assert row["% Apprenticeship"] == 60.0
# No raw ONS "Level N" column names leak through.
assert not any("Level" in c for c in row)
def test_qualification_percentages_independent_per_lsoa():
df = pl.concat(
[
pl.DataFrame(_long_rows("E01000010", {"Level 4 qualifications or above": 100})),
pl.DataFrame(_long_rows("E01000011", {"No qualifications": 100})),
]
)
result = _qualification_percentages(df).sort("lsoa21")
assert result["% Degree or higher"].to_list() == [100.0, 0.0]
assert result["% No qualifications"].to_list() == [0.0, 100.0]
def test_qualification_percentages_rejects_unexpected_band():
rows = _long_rows("E01000003", {"Level 4 qualifications or above": 10})
rows.append(
{
"GEOGRAPHY_CODE": "E01000003",
"C2021_HIQUAL_8_NAME": "Level 5 brand new census band",
"OBS_VALUE": 5,
}
)
with pytest.raises(ValueError, match="do not match the expected"):
_qualification_percentages(pl.DataFrame(rows))
def test_qualification_percentages_rejects_missing_band():
rows = [
r
for r in _long_rows("E01000004", {"Level 4 qualifications or above": 10})
if r["C2021_HIQUAL_8_NAME"] != "Apprenticeship"
]
with pytest.raises(ValueError, match="missing"):
_qualification_percentages(pl.DataFrame(rows))

View file

@ -0,0 +1,121 @@
import polars as pl
import pytest
from pipeline.download.tenure import OUTPUT_BUCKETS, TENURE_MAP, _tenure_percentages
def _long_rows(geo: str, counts: dict[str, int]) -> list[dict]:
"""Build NOMIS-shaped long rows for one LSOA from {leaf_label: count}.
Every one of the 8 leaf categories must be present in the download (NOMIS
emits a 0-count row when an LSOA has none), so categories not given default
to 0 to mirror that.
"""
return [
{
"GEOGRAPHY_CODE": geo,
"C2021_TENURE_9_NAME": label,
"OBS_VALUE": counts.get(label, 0),
}
for label in TENURE_MAP
]
def test_tenure_percentages_keyed_by_lsoa_with_three_buckets():
df = pl.DataFrame(
_long_rows(
"E01000001",
{
"Owned: Owns outright": 40,
"Owned: Owns with a mortgage or loan": 15,
"Shared ownership: Shared ownership": 5,
"Social rented: Rents from council or Local Authority": 15,
"Social rented: Other social rented": 5,
"Private rented: Private landlord or letting agency": 18,
"Private rented: Other private rented": 1,
"Lives rent free": 1,
},
)
)
result = _tenure_percentages(df)
assert result.columns[0] == "lsoa21"
assert set(result.columns) == {"lsoa21", *(f"% {b}" for b in OUTPUT_BUCKETS)}
row = result.filter(pl.col("lsoa21") == "E01000001").to_dicts()[0]
# Owner occupied = outright + mortgage + shared ownership = 60.
assert row["% Owner occupied"] == 60.0
# Social rent = council + other social = 20.
assert row["% Social rent"] == 20.0
# Private rent = private landlord + other private + lives rent free = 20.
assert row["% Private rent"] == 20.0
# Percentages always sum to exactly 100 (largest-remainder rounding).
assert round(sum(row[f"% {b}"] for b in OUTPUT_BUCKETS), 1) == 100.0
def test_tenure_shared_ownership_rolls_into_owner_occupied():
"""Shared ownership is part-owned, so it counts as owner-occupied, not rent."""
df = pl.DataFrame(_long_rows("E01000002", {"Shared ownership: Shared ownership": 100}))
row = _tenure_percentages(df).to_dicts()[0]
assert row["% Owner occupied"] == 100.0
assert row["% Social rent"] == 0.0
assert row["% Private rent"] == 0.0
def test_tenure_lives_rent_free_rolls_into_private_rent():
"""'Lives rent free' folds into private rent (ONS 'private rented or rent free')."""
df = pl.DataFrame(_long_rows("E01000003", {"Lives rent free": 100}))
row = _tenure_percentages(df).to_dicts()[0]
assert row["% Private rent"] == 100.0
assert row["% Owner occupied"] == 0.0
assert row["% Social rent"] == 0.0
def test_tenure_percentages_independent_per_lsoa():
"""Two LSOAs get independent profiles — the LSOA granularity is the point."""
df = pl.concat(
[
pl.DataFrame(_long_rows("E01000010", {"Owned: Owns outright": 100})),
pl.DataFrame(
_long_rows(
"E01000011",
{"Social rented: Rents from council or Local Authority": 100},
)
),
]
)
result = _tenure_percentages(df).sort("lsoa21")
assert result["% Owner occupied"].to_list() == [100.0, 0.0]
assert result["% Social rent"].to_list() == [0.0, 100.0]
def test_tenure_percentages_rejects_unexpected_category():
rows = _long_rows("E01000004", {"Owned: Owns outright": 10})
rows.append(
{
"GEOGRAPHY_CODE": "E01000004",
"C2021_TENURE_9_NAME": "A Brand New Census Tenure Category",
"OBS_VALUE": 5,
}
)
with pytest.raises(ValueError, match="do not match the expected"):
_tenure_percentages(pl.DataFrame(rows))
def test_tenure_percentages_rejects_missing_category():
# Drop one leaf entirely: its households would vanish from the denominator.
rows = [
r
for r in _long_rows("E01000005", {"Owned: Owns outright": 10})
if r["C2021_TENURE_9_NAME"] != "Lives rent free"
]
with pytest.raises(ValueError, match="missing"):
_tenure_percentages(pl.DataFrame(rows))

View file

@ -53,12 +53,13 @@ def _write_geojsonseq(csvs: list[Path], output_path: Path) -> tuple[int, int]:
to a shared "map point" anchor, so many incidents land on the exact same
coordinate. Collapsing them into one feature carrying ``count`` (the number
of incidents) keeps the per-crime-type and per-month filters intact while
turning each hotspot into a single high-weight point. That matters because
tippecanoe's ``--drop-densest-as-needed`` thins *feature density*, not
weight: with one feature per row the busiest streets were silently deleted;
with one weighted feature per anchor those hotspots survive and the dropped
detail is only redundant duplicate points. The heatmap reads ``count`` as
its weight.
turning each hotspot into a single high-weight point. That matters for the
heatmap weight: each anchor becomes one high-weight point, and tippecanoe's
``--cluster-densest-as-needed`` (with ``--accumulate-attribute=count:sum``)
merges any still-too-dense low-zoom features by *summing* their counts
rather than dropping them, so the total heat weight is conserved across zoom
levels and the surface no longer jumps at tile-zoom boundaries. The heatmap
reads ``count`` as its weight.
"""
grouped = (
pl.scan_csv(
@ -144,7 +145,18 @@ def build_crime_hotspot_tiles(
str(min_zoom),
"--maximum-zoom",
str(max_zoom),
"--drop-densest-as-needed",
# Merge (don't delete) the densest features at low zoom and sum
# their incident counts into the surviving point, so total heat
# weight is conserved across zoom levels. With --drop-densest the
# z14 tile lost hotspots that z15 kept, so the heatmap visibly
# collapsed into smaller spots when crossing the z14<->z15 tile
# boundary. Clustering is spatial only (it can merge different
# crime_types into one representative point), so per-type
# filtering is slightly approximate in the densest z14 tiles;
# the all-types surface and every zoom >= 15 stay accurate.
"--cluster-densest-as-needed",
"--accumulate-attribute=count:sum",
"--accumulate-attribute=weight:sum",
"--extend-zooms-if-still-dropping",
"--temporary-directory",
tmp,

View file

@ -112,6 +112,19 @@ _AREA_COLUMNS = [
"Outstanding secondary school catchments",
# Demographics
"Median age",
# Education (Census 2021 TS067, % of residents 16+ by highest qualification)
"% No qualifications",
"% Some GCSEs",
"% Good GCSEs",
"% Apprenticeship",
"% A-levels",
"% Degree or higher",
"% Other qualifications",
# Tenure (Census 2021 TS054, % of households by tenure) — unlike ethnicity &
# education these three percentages are user-filterable, not display-only.
"% Owner occupied",
"% Social rent",
"% Private rent",
# Politics
"Voter turnout (%)",
"% Labour",
@ -725,28 +738,31 @@ def _tree_density_by_postcode(tree_density_postcodes_path: Path) -> pl.LazyFrame
)
def _validate_lsoa_source_coverage(iod_path: Path, ethnicity_path: Path) -> None:
"""Fail if ethnicity (now LSOA-keyed) misses any IoD LSOA.
def _validate_lsoa_source_coverage(
iod_path: Path, lsoa_sources: dict[str, Path]
) -> None:
"""Fail if any LSOA-keyed side table misses an IoD LSOA.
Ethnicity is sourced from Census 2021 TS021 at LSOA, then joined on `lsoa21`
like median age and IoD. The IoD table defines the LSOA universe every
postcode resolves into, so a missing LSOA would silently null the ethnicity
columns for those postcodes; require full coverage instead.
Ethnicity (TS021) and education (TS067) are sourced from Census 2021 at LSOA,
then joined on `lsoa21` like median age and IoD. The IoD table defines the
LSOA universe every postcode resolves into, so a missing LSOA would silently
null those columns for the affected postcodes; require full coverage instead.
`lsoa_sources` maps a human label (used in the error message) to a parquet
that must carry an `lsoa21` column.
"""
iod_lsoas = pl.read_parquet(iod_path, columns=["LSOA code (2021)"]).rename(
{"LSOA code (2021)": "lsoa21"}
)
ethnicity_lsoas = pl.read_parquet(ethnicity_path, columns=["lsoa21"])
missing_ethnicity = iod_lsoas.join(ethnicity_lsoas, on="lsoa21", how="anti").sort(
"lsoa21"
)
if missing_ethnicity.height > 0:
raise ValueError(
"Ethnicity data is missing LSOA coverage: "
f"{missing_ethnicity.height} LSOAs, e.g. "
f"{missing_ethnicity.head(10).to_dicts()}"
)
for label, source_path in lsoa_sources.items():
source_lsoas = pl.read_parquet(source_path, columns=["lsoa21"])
missing = iod_lsoas.join(source_lsoas, on="lsoa21", how="anti").sort("lsoa21")
if missing.height > 0:
raise ValueError(
f"{label} data is missing LSOA coverage: "
f"{missing.height} LSOAs, e.g. "
f"{missing.head(10).to_dicts()}"
)
def _validate_lad_source_coverage(iod_path: Path, rental_prices_path: Path) -> None:
@ -883,6 +899,8 @@ def _join_area_side_tables(
*,
iod: pl.LazyFrame,
ethnicity: pl.LazyFrame,
education: pl.LazyFrame,
tenure: pl.LazyFrame,
crime: pl.LazyFrame,
median_age: pl.LazyFrame,
election: pl.LazyFrame,
@ -898,6 +916,12 @@ def _join_area_side_tables(
# `lsoa21` key as median age and IoD — a ~100x granularity gain over the old
# Local-Authority broadcast, with no change to the 6-bucket output schema.
base = base.join(ethnicity, on="lsoa21", how="left")
# Education (Census 2021 TS067 "highest level of qualification") is sourced at
# LSOA and joined on the same `lsoa21` key as ethnicity, IoD, and median age.
base = base.join(education, on="lsoa21", how="left")
# Tenure (Census 2021 TS054 "tenure of household") is sourced at LSOA and
# joined on the same `lsoa21` key as ethnicity, education, IoD, and median age.
base = base.join(tenure, on="lsoa21", how="left")
# Crime is counted spatially per postcode (incidents within 50m of the
# postcode boundary), so it joins on postcode rather than LSOA. crime_spatial
@ -2323,6 +2347,8 @@ def _build(
iod_path: Path,
poi_proximity_path: Path,
ethnicity_path: Path,
education_path: Path,
tenure_path: Path,
crime_path: Path,
noise_path: Path,
school_catchments_path: Path,
@ -2350,7 +2376,14 @@ def _build(
"""
if mode == "listings" and actual_listings_path is None:
raise ValueError("listings mode requires actual_listings_path")
_validate_lsoa_source_coverage(iod_path, ethnicity_path)
_validate_lsoa_source_coverage(
iod_path,
{
"Ethnicity": ethnicity_path,
"Education": education_path,
"Tenure": tenure_path,
},
)
_validate_lad_source_coverage(iod_path, rental_prices_path)
wide = pl.scan_parquet(epc_pp_path).filter(
@ -2424,6 +2457,8 @@ def _build(
*(_less_deprived_percentile_expr(c) for c in _IOD_PERCENTILE_COLUMNS)
)
ethnicity = pl.scan_parquet(ethnicity_path)
education = pl.scan_parquet(education_path)
tenure = pl.scan_parquet(tenure_path)
crime = pl.scan_parquet(crime_path)
median_age = pl.scan_parquet(median_age_path)
election = pl.scan_parquet(election_results_path)
@ -2475,6 +2510,8 @@ def _build(
area_side_tables = {
"iod": iod,
"ethnicity": ethnicity,
"education": education,
"tenure": tenure,
"crime": crime,
"median_age": median_age,
"election": election,
@ -2617,6 +2654,18 @@ def main():
required=True,
help="Census 2021 ethnic group (TS021) by LSOA parquet file",
)
parser.add_argument(
"--education",
type=Path,
required=True,
help="Census 2021 highest qualification (TS067) by LSOA parquet file",
)
parser.add_argument(
"--tenure",
type=Path,
required=True,
help="Census 2021 household tenure (TS054) by LSOA parquet file",
)
parser.add_argument(
"--crime",
type=Path,
@ -2734,6 +2783,8 @@ def main():
iod_path=args.iod,
poi_proximity_path=args.poi_proximity,
ethnicity_path=args.ethnicity,
education_path=args.education,
tenure_path=args.tenure,
crime_path=args.crime,
noise_path=args.noise,
school_catchments_path=args.school_catchments,

View file

@ -300,6 +300,8 @@ def test_join_area_side_tables_does_not_fan_out_on_unique_keys() -> None:
base,
iod=pl.LazyFrame({"LSOA code (2021)": ["E01000001", "E01000002"]}),
ethnicity=pl.LazyFrame({"lsoa21": ["E01000001", "E01000002"]}),
education=pl.LazyFrame({"lsoa21": ["E01000001", "E01000002"]}),
tenure=pl.LazyFrame({"lsoa21": ["E01000001", "E01000002"]}),
crime=crime,
median_age=pl.LazyFrame({"lsoa21": ["E01000001", "E01000002"]}),
election=pl.LazyFrame({"pcon": ["E14000001", "E14000002"]}),
@ -358,6 +360,8 @@ def test_join_area_side_tables_normalizes_broadband_postcode_key() -> None:
base,
iod=pl.LazyFrame({"LSOA code (2021)": ["E01000001", "E01000002"]}),
ethnicity=pl.LazyFrame({"lsoa21": ["E01000001", "E01000002"]}),
education=pl.LazyFrame({"lsoa21": ["E01000001", "E01000002"]}),
tenure=pl.LazyFrame({"lsoa21": ["E01000001", "E01000002"]}),
crime=crime,
median_age=pl.LazyFrame({"lsoa21": ["E01000001", "E01000002"]}),
election=pl.LazyFrame({"pcon": ["E14000001", "E14000002"]}),
@ -586,7 +590,7 @@ def test_validate_lsoa_source_coverage_allows_full_ethnicity_coverage(
ethnicity_path
)
_validate_lsoa_source_coverage(iod_path, ethnicity_path)
_validate_lsoa_source_coverage(iod_path, {"Ethnicity": ethnicity_path})
def test_validate_lsoa_source_coverage_rejects_missing_lsoa(tmp_path) -> None:
@ -598,7 +602,7 @@ def test_validate_lsoa_source_coverage_rejects_missing_lsoa(tmp_path) -> None:
pl.DataFrame({"lsoa21": ["E01000001"]}).write_parquet(ethnicity_path)
with pytest.raises(ValueError, match="Ethnicity data is missing LSOA coverage"):
_validate_lsoa_source_coverage(iod_path, ethnicity_path)
_validate_lsoa_source_coverage(iod_path, {"Ethnicity": ethnicity_path})
def test_tree_density_by_postcode_aliases_radius_percentile(tmp_path) -> None:
@ -1339,6 +1343,8 @@ def test_join_area_side_tables_preserves_missing_crime_as_null() -> None:
base,
iod=pl.LazyFrame({"LSOA code (2021)": ["E01000001", "E01000002"]}),
ethnicity=pl.LazyFrame({"lsoa21": ["E01000001", "E01000002"]}),
education=pl.LazyFrame({"lsoa21": ["E01000001", "E01000002"]}),
tenure=pl.LazyFrame({"lsoa21": ["E01000001", "E01000002"]}),
crime=crime,
median_age=pl.LazyFrame({"lsoa21": ["E01000001", "E01000002"]}),
election=pl.LazyFrame({"pcon": ["E14000001", "E14000002"]}),

View file

@ -1,7 +1,10 @@
mod actual_listings;
pub mod area_crime_averages;
pub mod crime_by_year;
mod developments;
mod places;
mod poi;
pub mod postcode_population;
mod postcodes;
mod property;
pub mod spill;
@ -60,11 +63,14 @@ where
}
pub use actual_listings::{ActualListing, ActualListingData};
pub use area_crime_averages::AreaCrimeAverages;
pub use crime_by_year::CrimeByYearData;
pub use developments::{DevelopmentData, DevelopmentSite};
pub use places::{
compute_trigrams, normalize_search_text, place_alias_tokens, trigram_similarity, PlaceData,
};
pub use poi::{resolve_poi_category_filter, POICategoryGroup, POIData, SchoolMetadata};
pub use postcode_population::PostcodePopulation;
pub use postcodes::{OutcodeData, PostcodeData};
pub use property::{
precompute_h3, FeatureStats, Histogram, HistoricalPrice, PostcodePoiMetrics, PropertyData,

View file

@ -0,0 +1,47 @@
//! Precomputed per-outcode and per-postcode-sector average crime rates.
//!
//! The right pane shows each crime metric's national average (the global
//! feature-histogram mean). To let users see how an area compares with its
//! immediate surroundings, we also precompute the mean headline crime rate
//! (`"X (avg/yr)"`) across every property in the selection's outcode (e.g.
//! `"E14"`) and postcode sector (e.g. `"E14 2"`).
//!
//! Crime figures are constant within a postcode (the pipeline merges them on
//! the postcode key), so each postcode's value is read once — from its first
//! row — and property-weighted by the postcode's row count. That keeps these
//! averages on the same property-weighted basis as the national average, so the
//! four numbers (this area / sector / outcode / nation) are directly comparable.
use rustc_hash::FxHashMap;
/// Crime-feature name suffix that marks an annualised headline-rate column
/// (e.g. `"Burglary (avg/yr)"`). Stripped to derive the bare type name.
pub const AVG_YR_SUFFIX: &str = " (avg/yr)";
pub struct AreaCrimeAverages {
/// Bare crime-type names (suffix stripped, e.g. `"Burglary"`), index-aligned
/// with the per-area mean vectors. Matches `CrimeYearStats.name`.
pub crime_types: Vec<String>,
/// National mean headline rate per crime type (index-aligned with
/// `crime_types`). An EXACT property-weighted mean over every postcode, so it
/// shares a basis with `by_outcode`/`by_sector` and the per-selection mean —
/// unlike the histogram-bin national average, which is biased upward for the
/// right-skewed crime densities. `NaN` where no postcode has data.
pub national: Vec<f32>,
/// Outcode (e.g. `"E14"`) → mean headline rate per crime type. `NaN` where
/// the outcode has no data for that type.
pub by_outcode: FxHashMap<String, Vec<f32>>,
/// Postcode sector (e.g. `"E14 2"`) → mean headline rate per crime type.
pub by_sector: FxHashMap<String, Vec<f32>>,
}
impl AreaCrimeAverages {
pub fn empty() -> Self {
Self {
crime_types: Vec::new(),
national: Vec::new(),
by_outcode: FxHashMap::default(),
by_sector: FxHashMap::default(),
}
}
}

View file

@ -0,0 +1,343 @@
use std::path::Path;
use anyhow::{Context, Result};
use polars::lazy::frame::LazyFrame;
use polars::prelude::*;
use serde::Serialize;
use tracing::info;
use crate::utils::GridIndex;
const GRID_CELL_SIZE: f32 = 0.01;
/// A single planned/pipeline development site (one brownfield-register entry or
/// one Homes England land-disposal site). These are *sites*, not properties:
/// they carry a coordinate, an estimate of the number of new dwellings, and the
/// planning status — the forward-looking "where new homes are coming" signal.
#[derive(Serialize, Clone)]
pub struct DevelopmentSite {
pub lat: f32,
pub lon: f32,
/// Data source: "brownfield" (MHCLG Brownfield Land register) or
/// "homes-england" (Homes England Land Hub).
pub source: String,
pub name: Option<String>,
pub min_dwellings: Option<i32>,
pub max_dwellings: Option<i32>,
pub planning_status: Option<String>,
pub permission_type: Option<String>,
pub permission_date: Option<String>,
pub hectares: Option<f32>,
pub local_authority: Option<String>,
pub url: Option<String>,
}
/// Columnar in-memory store of development sites with a spatial grid index for
/// viewport queries. Small enough (~40k rows nationally) to keep on the heap; no
/// quantization or spill needed.
pub struct DevelopmentData {
pub lat: Vec<f32>,
pub lon: Vec<f32>,
source: Vec<String>,
name: Vec<Option<String>>,
min_dwellings: Vec<Option<i32>>,
max_dwellings: Vec<Option<i32>>,
planning_status: Vec<Option<String>>,
permission_type: Vec<Option<String>>,
permission_date: Vec<Option<String>>,
hectares: Vec<Option<f32>>,
local_authority: Vec<Option<String>>,
url: Vec<Option<String>>,
grid: GridIndex,
}
impl DevelopmentData {
/// Empty store, used only by tests (the `--developments` parquet is required
/// in production).
#[cfg(test)]
pub fn empty() -> Self {
Self {
lat: Vec::new(),
lon: Vec::new(),
source: Vec::new(),
name: Vec::new(),
min_dwellings: Vec::new(),
max_dwellings: Vec::new(),
planning_status: Vec::new(),
permission_type: Vec::new(),
permission_date: Vec::new(),
hectares: Vec::new(),
local_authority: Vec::new(),
url: Vec::new(),
grid: GridIndex::build(&[], &[], GRID_CELL_SIZE),
}
}
pub fn load(parquet_path: &Path) -> Result<Self> {
super::run_polars_io(|| Self::load_inner(parquet_path))
}
fn load_inner(parquet_path: &Path) -> Result<Self> {
info!("Loading development sites from {:?}", parquet_path);
let pl_path = PlRefPath::try_from_path(parquet_path)
.context("Failed to normalize development sites parquet path")?;
let df = LazyFrame::scan_parquet(pl_path, Default::default())
.context("Failed to scan development sites parquet")?
.collect()
.context("Failed to read development sites parquet")?;
let lat = extract_f32(&df, "lat")?;
let lon = extract_f32(&df, "lon")?;
let source = extract_str(&df, "source")?;
let name = extract_opt_str(&df, "name")?;
let min_dwellings = extract_opt_i32(&df, "min_dwellings")?;
let max_dwellings = extract_opt_i32(&df, "max_dwellings")?;
let planning_status = extract_opt_str(&df, "planning_status")?;
let permission_type = extract_opt_str(&df, "permission_type")?;
let permission_date = extract_opt_str(&df, "permission_date")?;
let hectares = extract_opt_f32(&df, "hectares")?;
let local_authority = extract_opt_str(&df, "local_authority")?;
let url = extract_opt_str(&df, "url")?;
let grid = GridIndex::build(&lat, &lon, GRID_CELL_SIZE);
info!(rows = lat.len(), "Development sites loaded");
Ok(Self {
lat,
lon,
source,
name,
min_dwellings,
max_dwellings,
planning_status,
permission_type,
permission_date,
hectares,
local_authority,
url,
grid,
})
}
fn site_at(&self, row: usize) -> DevelopmentSite {
DevelopmentSite {
lat: self.lat[row],
lon: self.lon[row],
source: self.source[row].clone(),
name: self.name[row].clone(),
min_dwellings: self.min_dwellings[row],
max_dwellings: self.max_dwellings[row],
planning_status: self.planning_status[row].clone(),
permission_type: self.permission_type[row].clone(),
permission_date: self.permission_date[row].clone(),
hectares: self.hectares[row],
local_authority: self.local_authority[row].clone(),
url: self.url[row].clone(),
}
}
/// Return every site whose coordinate falls within the bounds, sorted by the
/// largest dwelling estimate first (so the biggest schemes survive the cap),
/// capped at `limit`. The bool is true when the result was truncated.
pub fn query_bounds(
&self,
south: f64,
west: f64,
north: f64,
east: f64,
limit: usize,
) -> (Vec<DevelopmentSite>, usize, bool) {
let mut rows: Vec<usize> = self
.grid
.query(south, west, north, east)
.into_iter()
.filter_map(|row_idx| {
let row = row_idx as usize;
row_within_bounds(self.lat[row], self.lon[row], south, west, north, east)
.then_some(row)
})
.collect();
let total = rows.len();
// Biggest schemes first: rank on the upper dwelling estimate, falling back
// to the lower estimate, so a viewport that exceeds the cap still surfaces
// the most significant developments.
rows.sort_by(|&a, &b| {
let key = |row: usize| {
self.max_dwellings[row]
.or(self.min_dwellings[row])
.unwrap_or(0)
};
key(b).cmp(&key(a))
});
let truncated = total > limit;
let sites = rows
.into_iter()
.take(limit)
.map(|row| self.site_at(row))
.collect();
(sites, total, truncated)
}
}
fn row_within_bounds(lat: f32, lon: f32, south: f64, west: f64, north: f64, east: f64) -> bool {
let lat = lat as f64;
let lon = lon as f64;
lat >= south && lat <= north && lon >= west && lon <= east
}
fn extract_f32(df: &DataFrame, name: &str) -> Result<Vec<f32>> {
let cast = df
.column(name)
.with_context(|| format!("Missing column '{name}'"))?
.cast(&DataType::Float32)
.with_context(|| format!("Failed to cast '{name}' to Float32"))?;
let column = cast
.f32()
.with_context(|| format!("Column '{name}' is not Float32"))?;
column
.into_iter()
.enumerate()
.map(|(row, value)| value.with_context(|| format!("Column '{name}' has null at row {row}")))
.collect()
}
fn extract_str(df: &DataFrame, name: &str) -> Result<Vec<String>> {
let column = df
.column(name)
.with_context(|| format!("Missing column '{name}'"))?;
let strings = column
.str()
.with_context(|| format!("Column '{name}' is not a string column"))?;
strings
.into_iter()
.enumerate()
.map(|(row, value)| {
value
.map(ToString::to_string)
.with_context(|| format!("Column '{name}' has null at row {row}"))
})
.collect()
}
fn extract_opt_str(df: &DataFrame, name: &str) -> Result<Vec<Option<String>>> {
let column = df
.column(name)
.with_context(|| format!("Missing column '{name}'"))?;
let strings = column
.str()
.with_context(|| format!("Column '{name}' is not a string column"))?;
Ok(strings
.into_iter()
.map(|value| value.and_then(|text| (!text.trim().is_empty()).then(|| text.to_string())))
.collect())
}
fn extract_opt_i32(df: &DataFrame, name: &str) -> Result<Vec<Option<i32>>> {
let cast = df
.column(name)
.with_context(|| format!("Missing column '{name}'"))?
.cast(&DataType::Int32)
.with_context(|| format!("Failed to cast '{name}' to Int32"))?;
let column = cast
.i32()
.with_context(|| format!("Column '{name}' is not Int32"))?;
Ok(column.into_iter().collect())
}
fn extract_opt_f32(df: &DataFrame, name: &str) -> Result<Vec<Option<f32>>> {
let cast = df
.column(name)
.with_context(|| format!("Missing column '{name}'"))?
.cast(&DataType::Float32)
.with_context(|| format!("Failed to cast '{name}' to Float32"))?;
let column = cast
.f32()
.with_context(|| format!("Column '{name}' is not Float32"))?;
Ok(column
.into_iter()
.map(|value| value.filter(|v| v.is_finite()))
.collect())
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
fn build(points: &[(f32, f32, Option<i32>)]) -> DevelopmentData {
let lat: Vec<f32> = points.iter().map(|p| p.0).collect();
let lon: Vec<f32> = points.iter().map(|p| p.1).collect();
let grid = GridIndex::build(&lat, &lon, GRID_CELL_SIZE);
DevelopmentData {
source: points.iter().map(|_| "brownfield".to_string()).collect(),
name: points.iter().map(|_| None).collect(),
min_dwellings: points.iter().map(|_| None).collect(),
max_dwellings: points.iter().map(|p| p.2).collect(),
planning_status: points.iter().map(|_| None).collect(),
permission_type: points.iter().map(|_| None).collect(),
permission_date: points.iter().map(|_| None).collect(),
hectares: points.iter().map(|_| None).collect(),
local_authority: points.iter().map(|_| None).collect(),
url: points.iter().map(|_| None).collect(),
lat,
lon,
grid,
}
}
#[test]
fn query_returns_only_in_bounds_sites() {
let data = build(&[
(51.50, -0.10, Some(5)), // inside
(51.55, -0.05, Some(50)), // inside
(52.50, -1.00, Some(99)), // outside
]);
let (sites, total, truncated) = data.query_bounds(51.4, -0.2, 51.6, 0.0, 100);
assert_eq!(total, 2);
assert!(!truncated);
// Sorted by max_dwellings desc: the 50-dwelling scheme comes first.
assert_eq!(sites[0].max_dwellings, Some(50));
assert_eq!(sites[1].max_dwellings, Some(5));
}
#[test]
fn query_caps_and_flags_truncation_keeping_biggest() {
let data = build(&[
(51.50, -0.10, Some(1)),
(51.51, -0.11, Some(900)),
(51.52, -0.12, Some(10)),
]);
let (sites, total, truncated) = data.query_bounds(51.4, -0.2, 51.6, 0.0, 1);
assert_eq!(total, 3);
assert!(truncated);
assert_eq!(sites.len(), 1);
assert_eq!(sites[0].max_dwellings, Some(900));
}
#[test]
fn empty_store_is_empty() {
let data = DevelopmentData::empty();
assert!(data.lat.is_empty());
let (sites, total, truncated) = data.query_bounds(51.4, -0.2, 51.6, 0.0, 100);
assert!(sites.is_empty());
assert_eq!(total, 0);
assert!(!truncated);
}
#[test]
fn loads_sample_parquet_when_available() {
let path = PathBuf::from("../property-data/development_sites.parquet");
if !path.exists() {
eprintln!("sample development_sites.parquet not present; skipping");
return;
}
let data = DevelopmentData::load_inner(&path).expect("developments load");
assert!(!data.lat.is_empty());
assert_eq!(data.lat.len(), data.lon.len());
assert_eq!(data.lat.len(), data.source.len());
}
}

View file

@ -0,0 +1,125 @@
//! Per-unit-postcode usual-resident headcounts (ONS Census 2021, table P001),
//! loaded from a side parquet and shown in the right pane. This is display-only
//! area data: it is never a filterable attribute and never enters the feature
//! matrix, mirroring the crime-by-year side table.
use std::path::Path;
use anyhow::{bail, Context};
use polars::prelude::PlRefPath;
use polars::prelude::*;
use rustc_hash::FxHashMap;
use tracing::info;
use crate::utils::normalize_postcode;
use super::run_polars_io;
pub struct PostcodePopulation {
/// Canonical spaced postcode (e.g. "AL1 1AG") → usual residents (Census 2021).
by_postcode: FxHashMap<String, u32>,
}
impl PostcodePopulation {
/// Empty table — used in tests and when no --population-path is supplied.
pub fn empty() -> Self {
Self {
by_postcode: FxHashMap::default(),
}
}
pub fn load(path: &Path) -> anyhow::Result<Self> {
run_polars_io(|| Self::load_inner(path))
}
fn load_inner(path: &Path) -> anyhow::Result<Self> {
info!("Loading postcode population from {}", path.display());
let pl_path = PlRefPath::try_from_path(path).with_context(|| {
format!(
"Failed to normalize population parquet path {}",
path.display()
)
})?;
let df = LazyFrame::scan_parquet(pl_path, Default::default())
.with_context(|| format!("Failed to scan population parquet at {}", path.display()))?
.collect()
.with_context(|| format!("Failed to read population parquet at {}", path.display()))?;
let postcode_col = df
.column("postcode")
.context("population parquet missing 'postcode' column")?
.str()
.context("'postcode' column is not a string")?;
// Accept whatever integer width the parquet writer used.
let population_cast = df
.column("population")
.context("population parquet missing 'population' column")?
.cast(&DataType::Int64)
.context("'population' column is not an integer")?;
let population_col = population_cast
.i64()
.context("failed to read 'population' as i64")?;
let mut by_postcode: FxHashMap<String, u32> = FxHashMap::default();
by_postcode.reserve(df.height());
for (postcode, population) in postcode_col.into_iter().zip(population_col.into_iter()) {
let (Some(postcode), Some(population)) = (postcode, population) else {
continue;
};
let trimmed = postcode.trim();
if trimmed.is_empty() || population <= 0 {
continue;
}
// Normalize to the exact canonical form the routes look up with, so
// a stray double-space or lowercase in the source can't miss.
by_postcode.insert(
normalize_postcode(trimmed),
population.min(u32::MAX as i64) as u32,
);
}
if by_postcode.is_empty() {
bail!("population parquet at {} produced no rows", path.display());
}
info!(
postcodes = by_postcode.len(),
"Postcode population loaded"
);
Ok(Self { by_postcode })
}
/// Usual-resident count for a single canonical (spaced, upper-case) postcode.
pub fn for_postcode(&self, postcode: &str) -> Option<u32> {
self.by_postcode.get(postcode).copied()
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Integration smoke test against the real Census 2021 parquet. Skips when
/// the data file is absent (CI without a data build) so it never blocks.
#[test]
fn loads_real_census_parquet_if_present() {
let path = std::path::Path::new("../property-data/population_by_postcode.parquet");
if !path.exists() {
eprintln!("skipping: {} not present", path.display());
return;
}
let pop = PostcodePopulation::load(path).expect("population parquet should load");
// Covers the whole of England & Wales (~1.37M unit postcodes).
assert!(pop.by_postcode.len() > 1_000_000);
// A residential postcode has a positive headcount...
assert!(pop.for_postcode("AL1 1AG").is_some_and(|n| n > 0));
// ...reachable from non-canonical input via normalize-on-load.
assert_eq!(
pop.for_postcode(&normalize_postcode("al1 1ag")),
pop.for_postcode("AL1 1AG"),
);
// Postcodes with zero usual residents are absent from P001.
assert_eq!(pop.for_postcode("EC1A 1BB"), None);
}
}

View file

@ -26,7 +26,9 @@ use rustc_hash::FxHashMap;
use serde::Serialize;
use crate::consts::NAN_U16;
use crate::data::area_crime_averages::{AreaCrimeAverages, AVG_YR_SUFFIX};
use crate::data::spill::SpillVec;
use crate::utils::{postcode_outcode, postcode_sector};
#[derive(Serialize, Clone)]
pub struct RenovationEvent {
@ -224,6 +226,109 @@ impl PropertyData {
num_numeric: self.num_numeric,
}
}
/// Precompute mean headline crime rates nationally and per outcode / postcode
/// sector.
///
/// Crime values are identical for every property in a postcode (the pipeline
/// merges them on the postcode key), so each postcode is sampled once from
/// its first row and property-weighted by its row count. All three scopes use
/// the same exact property-weighted estimator over the same row universe as
/// the per-selection mean, so the four numbers shown in a crime row (this
/// selection / sector / outcode / nation) are directly comparable — without
/// the upward bias of the histogram-bin national average.
pub fn compute_area_crime_averages(&self) -> AreaCrimeAverages {
// Crime headline columns are exactly the " (avg/yr)" features.
let crime_indices: Vec<usize> = self
.feature_names
.iter()
.enumerate()
.filter(|(_, name)| name.ends_with(AVG_YR_SUFFIX))
.map(|(idx, _)| idx)
.collect();
if crime_indices.is_empty() {
return AreaCrimeAverages::empty();
}
let crime_types: Vec<String> = crime_indices
.iter()
.map(|&idx| {
self.feature_names[idx]
.strip_suffix(AVG_YR_SUFFIX)
.unwrap_or(&self.feature_names[idx])
.to_string()
})
.collect();
let n = crime_indices.len();
// (weighted value sum, weight) accumulators per crime type.
let mut nat_sums = vec![0.0f64; n];
let mut nat_weights = vec![0u64; n];
let mut out_acc: FxHashMap<String, (Vec<f64>, Vec<u64>)> = FxHashMap::default();
let mut sec_acc: FxHashMap<String, (Vec<f64>, Vec<u64>)> = FxHashMap::default();
for (key, rows) in &self.postcode_row_index {
let Some(&first) = rows.first() else { continue };
let count = rows.len() as u64;
let postcode = self.postcode_interner.resolve(key);
let outcode = postcode_outcode(postcode);
let sector = postcode_sector(postcode);
for (j, &fi) in crime_indices.iter().enumerate() {
// A NaN value is "no crime data for this postcode" — skip it so
// it dilutes neither the sum nor the weight (a genuine gap, not
// a zero), exactly as the global histogram excludes it.
let value = self.get_feature(first as usize, fi);
if !value.is_finite() {
continue;
}
let weighted = value as f64 * count as f64;
// National counts every postcode (the population the global mean
// is built over); outcode/sector only when the postcode parses.
nat_sums[j] += weighted;
nat_weights[j] += count;
if let Some(outcode) = outcode {
let acc = out_acc
.entry(outcode.to_string())
.or_insert_with(|| (vec![0.0; n], vec![0; n]));
acc.0[j] += weighted;
acc.1[j] += count;
}
if let Some(sector) = sector {
let acc = sec_acc
.entry(sector.to_string())
.or_insert_with(|| (vec![0.0; n], vec![0; n]));
acc.0[j] += weighted;
acc.1[j] += count;
}
}
}
let means_of = |sums: &[f64], weights: &[u64]| -> Vec<f32> {
sums.iter()
.zip(weights.iter())
.map(|(&sum, &weight)| {
if weight == 0 {
f32::NAN
} else {
(sum / weight as f64) as f32
}
})
.collect()
};
let finalize =
|acc: FxHashMap<String, (Vec<f64>, Vec<u64>)>| -> FxHashMap<String, Vec<f32>> {
acc.into_iter()
.map(|(area, (sums, weights))| (area, means_of(&sums, &weights)))
.collect()
};
AreaCrimeAverages {
crime_types,
national: means_of(&nat_sums, &nat_weights),
by_outcode: finalize(out_acc),
by_sector: finalize(sec_acc),
}
}
}
#[cfg(test)]

View file

@ -733,6 +733,138 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
raw: false,
absolute: false,
}),
// Education: Census 2021 TS067 highest-qualification breakdown. The
// seven bands sum to 100% per neighbourhood (LSOA) and render as a
// stacked composition (see STACKED_GROUPS["Neighbours"] in the
// frontend), like the ethnicity and vote-share bars. Colloquial
// labels stand in for the ONS "Level 1/2/3/4+" jargon.
Feature::Numeric(FeatureConfig {
name: "% No qualifications",
bounds: Bounds::Fixed { min: 0.0, max: 100.0 },
step: 1.0,
description: "Share of residents (16+) with no qualifications",
detail: "From the 2021 Census (TS067). Percentage of usual residents aged 16 and over in the neighbourhood (LSOA) who hold no formal qualifications.",
source: "census-2021",
prefix: "",
suffix: "%",
raw: false,
absolute: false,
}),
Feature::Numeric(FeatureConfig {
name: "% Some GCSEs",
bounds: Bounds::Fixed { min: 0.0, max: 100.0 },
step: 1.0,
description: "Share of residents (16+) whose highest qualification is roughly 1-4 GCSEs (Level 1)",
detail: "From the 2021 Census (TS067). Highest qualification is around 1 to 4 GCSEs at grades 9-4 (A*-C), or entry-level/foundation qualifications. The ONS calls this 'Level 1 and entry level'.",
source: "census-2021",
prefix: "",
suffix: "%",
raw: false,
absolute: false,
}),
Feature::Numeric(FeatureConfig {
name: "% Good GCSEs",
bounds: Bounds::Fixed { min: 0.0, max: 100.0 },
step: 1.0,
description: "Share of residents (16+) whose highest qualification is 5+ GCSEs (Level 2)",
detail: "From the 2021 Census (TS067). Highest qualification is roughly 5 or more GCSEs at grades 9-4 (A*-C), an intermediate apprenticeship, or equivalent. The ONS calls this 'Level 2'.",
source: "census-2021",
prefix: "",
suffix: "%",
raw: false,
absolute: false,
}),
Feature::Numeric(FeatureConfig {
name: "% Apprenticeship",
bounds: Bounds::Fixed { min: 0.0, max: 100.0 },
step: 1.0,
description: "Share of residents (16+) whose highest qualification is an apprenticeship",
detail: "From the 2021 Census (TS067). Highest qualification is an apprenticeship.",
source: "census-2021",
prefix: "",
suffix: "%",
raw: false,
absolute: false,
}),
Feature::Numeric(FeatureConfig {
name: "% A-levels",
bounds: Bounds::Fixed { min: 0.0, max: 100.0 },
step: 1.0,
description: "Share of residents (16+) whose highest qualification is A-levels (Level 3)",
detail: "From the 2021 Census (TS067). Highest qualification is A-levels, AS-levels, T-levels, an advanced apprenticeship, or equivalent — typically studied after 16 and before a degree. The ONS calls this 'Level 3'.",
source: "census-2021",
prefix: "",
suffix: "%",
raw: false,
absolute: false,
}),
Feature::Numeric(FeatureConfig {
name: "% Degree or higher",
bounds: Bounds::Fixed { min: 0.0, max: 100.0 },
step: 1.0,
description: "Share of residents (16+) with a degree-level or higher qualification",
detail: "From the 2021 Census (TS067). Highest qualification is degree level or above — a Bachelor's, Master's or PhD, foundation degree, HNC/HND, NVQ 4-5, or higher professional qualification. The census does not separate undergraduate from postgraduate degrees. The ONS calls this 'Level 4 or above'.",
source: "census-2021",
prefix: "",
suffix: "%",
raw: false,
absolute: false,
}),
Feature::Numeric(FeatureConfig {
name: "% Other qualifications",
bounds: Bounds::Fixed { min: 0.0, max: 100.0 },
step: 1.0,
description: "Share of residents (16+) with other qualifications, including vocational or overseas ones",
detail: "From the 2021 Census (TS067). Highest qualification is classed as 'other' — vocational or professional qualifications not mapped to a UK level, and qualifications gained outside the UK.",
source: "census-2021",
prefix: "",
suffix: "%",
raw: false,
absolute: false,
}),
// Tenure: Census 2021 TS054 household-tenure breakdown. The three
// shares sum to ~100% per neighbourhood (LSOA) and render as a
// stacked composition (see STACKED_GROUPS["Neighbours"] in the
// frontend), like the ethnicity, qualifications and vote-share bars.
// Unlike those, the three shares are ALSO offered as individual
// filters (they are not added to the display-only skip-list in
// Filters.tsx), so users can target e.g. owner-occupier-heavy areas.
Feature::Numeric(FeatureConfig {
name: "% Owner occupied",
bounds: Bounds::Fixed { min: 0.0, max: 100.0 },
step: 1.0,
description: "Share of households that own their home, outright or with a mortgage",
detail: "From the 2021 Census (TS054). Percentage of households in the neighbourhood (LSOA) that own their home outright, own it with a mortgage or loan, or hold it through shared ownership.",
source: "census-2021",
prefix: "",
suffix: "%",
raw: false,
absolute: false,
}),
Feature::Numeric(FeatureConfig {
name: "% Social rent",
bounds: Bounds::Fixed { min: 0.0, max: 100.0 },
step: 1.0,
description: "Share of households renting from a council or housing association",
detail: "From the 2021 Census (TS054). Percentage of households in the neighbourhood (LSOA) renting from a local council or local authority, or from a housing association or other social landlord.",
source: "census-2021",
prefix: "",
suffix: "%",
raw: false,
absolute: false,
}),
Feature::Numeric(FeatureConfig {
name: "% Private rent",
bounds: Bounds::Fixed { min: 0.0, max: 100.0 },
step: 1.0,
description: "Share of households renting privately or living rent-free",
detail: "From the 2021 Census (TS054). Percentage of households in the neighbourhood (LSOA) renting from a private landlord or letting agency, plus the small share living rent-free.",
source: "census-2021",
prefix: "",
suffix: "%",
raw: false,
absolute: false,
}),
Feature::Numeric(FeatureConfig {
name: "% White",
bounds: Bounds::Fixed {

View file

@ -325,10 +325,21 @@ struct Cli {
#[arg(long, env = "ACTUAL_LISTINGS_PATH")]
actual_listings_path: PathBuf,
/// Path to a parquet of planned/pipeline development sites (MHCLG brownfield
/// register + Homes England Land Hub) for the "new developments" layer.
#[arg(long, env = "DEVELOPMENTS_PATH")]
developments_path: PathBuf,
/// Path to the per-LSOA per-year crime parquet (display-only side table for the right pane).
#[arg(long, env = "CRIME_BY_YEAR_PATH")]
crime_by_year_path: PathBuf,
/// Path to the per-unit-postcode population parquet (ONS Census 2021 usual
/// residents; display-only side table for the right pane). Optional: when
/// absent or missing, the area pane simply omits the population figure.
#[arg(long, env = "POPULATION_PATH")]
population_path: Option<PathBuf>,
/// Google Maps API key for Street View metadata lookups
#[arg(long, env = "GOOGLE_MAPS_API_KEY")]
google_maps_api_key: String,
@ -692,6 +703,18 @@ async fn main() -> anyhow::Result<()> {
Arc::new(listings)
};
let developments = {
let path = &cli.developments_path;
if !path.exists() {
bail!("Development sites parquet not found: {}", path.display());
}
info!("Loading development sites from {}", path.display());
let data = data::DevelopmentData::load(path)?;
trim_allocator("development sites load");
info!(rows = data.lat.len(), "Development sites loaded");
Arc::new(data)
};
let crime_by_year = {
let path = &cli.crime_by_year_path;
if !path.exists() {
@ -702,6 +725,34 @@ async fn main() -> anyhow::Result<()> {
Arc::new(data)
};
let population = match &cli.population_path {
Some(path) if path.exists() => {
let data = data::PostcodePopulation::load(path)?;
trim_allocator("postcode population load");
Arc::new(data)
}
Some(path) => {
tracing::warn!(
"Population parquet not found at {}; area pane will omit population",
path.display()
);
Arc::new(data::PostcodePopulation::empty())
}
None => Arc::new(data::PostcodePopulation::empty()),
};
let area_crime_averages = {
let data = property_data.compute_area_crime_averages();
info!(
outcodes = data.by_outcode.len(),
sectors = data.by_sector.len(),
crime_types = data.crime_types.len(),
"Per-outcode/sector crime averages computed"
);
trim_allocator("area crime averages");
Arc::new(data)
};
let app_state = AppState {
data: property_data,
grid,
@ -728,7 +779,10 @@ async fn main() -> anyhow::Result<()> {
gemini_model: cli.gemini_model,
travel_time_store,
actual_listings,
developments,
crime_by_year,
population,
area_crime_averages,
token_cache,
superuser_token_cache,
share_cache,
@ -801,6 +855,10 @@ async fn main() -> anyhow::Result<()> {
"/api/actual-listings",
get(routes::get_actual_listings).layer(ConcurrencyLimitLayer::new(20)),
)
.route(
"/api/developments",
get(routes::get_developments).layer(ConcurrencyLimitLayer::new(20)),
)
.route(
"/api/poi-categories",
get(routes::get_poi_categories).layer(ConcurrencyLimitLayer::new(20)),

View file

@ -1,6 +1,7 @@
mod actual_listings;
mod ai_filters;
mod checkout;
mod developments;
mod export;
mod features;
mod filter_counts;
@ -34,6 +35,7 @@ pub(crate) mod travel_time;
pub use actual_listings::get_actual_listings;
pub use ai_filters::{build_system_prompt, post_ai_filters};
pub use checkout::post_checkout;
pub use developments::get_developments;
pub use export::get_export;
pub use features::{build_features_response, get_features, FeatureInfo, FeaturesResponse};
pub use filter_counts::get_filter_counts;

View file

@ -0,0 +1,70 @@
use std::sync::Arc;
use axum::extract::{Query, State};
use axum::response::{IntoResponse, Json, Response};
use axum::Extension;
use serde::{Deserialize, Serialize};
use tracing::info;
use crate::auth::OptionalUser;
use crate::data::DevelopmentSite;
use crate::licensing::{check_license_bounds, resolve_share_code};
use crate::parsing::require_bounds;
use crate::state::SharedState;
/// Hard cap on sites returned per viewport. Biggest schemes (by dwelling count)
/// are kept when a dense viewport exceeds this; `truncated` flags the clamp.
const DEVELOPMENTS_LIMIT: usize = 4000;
#[derive(Deserialize)]
pub struct DevelopmentsParams {
bounds: Option<String>,
/// Share-link code; grants bbox-scoped access for unlicensed users.
share: Option<String>,
}
#[derive(Serialize)]
pub struct DevelopmentsResponse {
pub developments: Vec<DevelopmentSite>,
pub total: usize,
pub truncated: bool,
}
/// Forward-looking "where new homes are coming" layer: planned/pipeline
/// development sites (MHCLG Brownfield Land register + Homes England Land Hub),
/// served as points within a viewport. Public OGL data, but still gated by the
/// normal demo/licence bounds check so unlicensed users only see their free zone.
pub async fn get_developments(
State(shared): State<Arc<SharedState>>,
Extension(user): Extension<OptionalUser>,
Extension(geo): Extension<crate::demo_zone::DemoZone>,
Query(params): Query<DevelopmentsParams>,
) -> Result<Json<DevelopmentsResponse>, Response> {
let state = shared.load_state();
let (south, west, north, east) =
require_bounds(params.bounds).map_err(IntoResponse::into_response)?;
let share_bounds = resolve_share_code(&state, params.share.as_deref()).await;
check_license_bounds(
&user.0,
(south, west, north, east),
geo.free_zone,
share_bounds,
)?;
let developments = state.developments.clone();
let (sites, total, truncated) =
developments.query_bounds(south, west, north, east, DEVELOPMENTS_LIMIT);
info!(
results = sites.len(),
total, truncated, "GET /api/developments"
);
Ok(Json(DevelopmentsResponse {
developments: sites,
total,
truncated,
}))
}

View file

@ -80,6 +80,24 @@ pub struct CrimeYearStats {
pub points: Vec<CrimeYearPoint>,
}
/// Average headline crime rate (avg/yr) for one crime type across the
/// selection's outcode and postcode sector. Comparable to the national average
/// shown per metric in the right pane.
#[derive(Serialize)]
pub struct CrimeAreaAverage {
/// Crime type, bare (e.g. "Burglary"). Matches `CrimeYearStats.name`.
pub name: String,
/// Exact national mean (avg/yr) — the frontend prefers this over the
/// histogram-bin national average for crime so all four numbers in the row
/// share one estimator.
#[serde(skip_serializing_if = "Option::is_none")]
pub national: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub outcode: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub sector: Option<f32>,
}
#[derive(Serialize)]
pub struct FilterExclusion {
pub name: String,
@ -135,8 +153,25 @@ pub struct HexagonStatsResponse {
/// the client captions the data as stale.
#[serde(skip_serializing_if = "Option::is_none")]
pub crime_latest_year: Option<i32>,
/// Outward code (e.g. "E14") of the selection's central postcode, present
/// only when outcode crime averages are available for it.
#[serde(skip_serializing_if = "Option::is_none")]
pub crime_outcode: Option<String>,
/// Postcode sector (e.g. "E14 2") of the selection's central postcode,
/// present only when sector crime averages are available for it.
#[serde(skip_serializing_if = "Option::is_none")]
pub crime_sector: Option<String>,
/// Per-crime-type average rates across the central postcode's outcode and
/// sector, shown alongside the national average for each crime metric.
#[serde(skip_serializing_if = "Vec::is_empty")]
pub crime_area_averages: Vec<CrimeAreaAverage>,
#[serde(skip_serializing_if = "Option::is_none")]
pub central_postcode: Option<String>,
/// Total usual residents (ONS Census 2021) living across the distinct
/// postcodes in this selection. Display-only and independent of active
/// filters; absent when no population data covers the selection.
#[serde(skip_serializing_if = "Option::is_none")]
pub population: Option<u32>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub filter_exclusions: Vec<FilterExclusion>,
}
@ -657,6 +692,33 @@ pub async fn get_hexagon_stats(
stats::crime_latest_available_year(&state.crime_by_year)
};
let (crime_outcode, crime_sector, crime_area_averages) = stats::area_crime_averages_for(
central_postcode.as_deref(),
&state.area_crime_averages,
fields_specified,
&field_set,
);
// Sum usual residents across the distinct postcodes covered by the
// hexagon. Computed over `area_rows` (all properties in the cell), not
// the filter-matching subset, so toggling filters never changes it —
// population is an attribute of the area, like the council-house count.
let population = {
let mut seen: HashSet<&str> = HashSet::new();
let mut total: u64 = 0;
let mut found = false;
for &row in &area_rows {
let pc = state.data.postcode(row);
if seen.insert(pc) {
if let Some(p) = state.population.for_postcode(pc) {
total += p as u64;
found = true;
}
}
}
found.then(|| total.min(u32::MAX as u64) as u32)
};
Ok(HexagonStatsResponse {
count: total_count,
numeric_features,
@ -664,7 +726,11 @@ pub async fn get_hexagon_stats(
price_history,
crime_by_year,
crime_latest_year,
crime_outcode,
crime_sector,
crime_area_averages,
central_postcode,
population,
filter_exclusions,
})
})

View file

@ -217,6 +217,16 @@ pub async fn get_postcode_stats(
stats::crime_latest_available_year(&state.crime_by_year)
};
let (crime_outcode, crime_sector, crime_area_averages) = stats::area_crime_averages_for(
Some(postcode_str.as_str()),
&state.area_crime_averages,
fields_specified,
&field_set,
);
// Usual residents (Census 2021) for this postcode. Display-only.
let population = state.population.for_postcode(&postcode_str);
Ok(HexagonStatsResponse {
count: total_count,
numeric_features,
@ -224,7 +234,11 @@ pub async fn get_postcode_stats(
price_history,
crime_by_year,
crime_latest_year,
crime_outcode,
crime_sector,
crime_area_averages,
central_postcode: None,
population,
filter_exclusions,
})
})

View file

@ -5,12 +5,14 @@ use rustc_hash::FxHashMap;
use tracing::error;
use crate::consts::PRICE_HISTORY_POINTS_LIMIT;
use crate::data::area_crime_averages::AreaCrimeAverages;
use crate::data::crime_by_year::CrimeByYearData;
use crate::data::{FeatureStats, PostcodePoiMetrics, PropertyData};
use crate::utils::{postcode_outcode, postcode_sector};
use super::hexagon_stats::{
CrimeYearPoint, CrimeYearStats, EnumFeatureStats, HistogramStats, NumericFeatureStats,
PricePoint,
CrimeAreaAverage, CrimeYearPoint, CrimeYearStats, EnumFeatureStats, HistogramStats,
NumericFeatureStats, PricePoint,
};
/// Extract price history (year, price) pairs from matching rows, downsampled if needed.
@ -401,6 +403,76 @@ pub fn crime_latest_available_year(crime_by_year: &CrimeByYearData) -> Option<i3
crime_by_year.years_by_type.iter().flatten().copied().max()
}
/// Per-crime-type national/outcode/sector averages for a selection, keyed off
/// its central postcode. Returns `(outcode, sector, per_type_averages)` where
/// the outcode/sector strings are present only when matching averages exist.
///
/// Honours the same `fields` filtering as [`compute_crime_by_year`]: when fields
/// are specified, only the requested crime types are emitted, and a type is
/// omitted only when none of its national/outcode/sector averages are known.
pub fn area_crime_averages_for(
central_postcode: Option<&str>,
averages: &AreaCrimeAverages,
fields_specified: bool,
field_set: &HashSet<String>,
) -> (Option<String>, Option<String>, Vec<CrimeAreaAverage>) {
let none = || (None, None, Vec::new());
let Some(postcode) = central_postcode else {
return none();
};
if averages.crime_types.is_empty() {
return none();
}
let outcode = postcode_outcode(postcode);
let sector = postcode_sector(postcode);
let outcode_means = outcode.and_then(|code| averages.by_outcode.get(code));
let sector_means = sector.and_then(|code| averages.by_sector.get(code));
// National is always available, so we still emit it (to override the
// histogram national average for crime) even when this postcode's outcode
// and sector both lack precomputed data.
let finite_at = |means: Option<&Vec<f32>>, idx: usize| -> Option<f32> {
means
.and_then(|m| m.get(idx).copied())
.filter(|v| v.is_finite())
};
let mut out = Vec::new();
for (idx, name) in averages.crime_types.iter().enumerate() {
// Crime types are bare here ("Burglary"); requested fields may carry the
// " (avg/yr)" suffix — accept either form, like compute_crime_by_year.
if fields_specified {
let with_suffix = format!("{name} (avg/yr)");
if !field_set.contains(name.as_str()) && !field_set.contains(with_suffix.as_str()) {
continue;
}
}
let national_val = finite_at(Some(&averages.national), idx);
let outcode_val = finite_at(outcode_means, idx);
let sector_val = finite_at(sector_means, idx);
if national_val.is_none() && outcode_val.is_none() && sector_val.is_none() {
continue;
}
out.push(CrimeAreaAverage {
name: name.clone(),
national: national_val,
outcode: outcode_val,
sector: sector_val,
});
}
(
outcode
.filter(|_| outcode_means.is_some())
.map(str::to_string),
sector
.filter(|_| sector_means.is_some())
.map(str::to_string),
out,
)
}
pub fn compute_poi_feature_stats(
matching_rows: &[usize],
poi_metrics: &PostcodePoiMetrics,
@ -516,4 +588,70 @@ mod tests {
let data = enum_data(&[0, 1]);
assert!(compute_enum_feature_counts(&[0, 1], &data, 1).is_none());
}
fn sample_averages() -> AreaCrimeAverages {
let mut by_outcode = rustc_hash::FxHashMap::default();
by_outcode.insert("E14".to_string(), vec![10.0, f32::NAN]);
let mut by_sector = rustc_hash::FxHashMap::default();
by_sector.insert("E14 2".to_string(), vec![5.0, 7.0]);
AreaCrimeAverages {
crime_types: vec!["Burglary".to_string(), "Robbery".to_string()],
national: vec![8.0, 6.0],
by_outcode,
by_sector,
}
}
#[test]
fn area_crime_averages_populate_national_outcode_sector() {
let avgs = sample_averages();
let (outcode, sector, out) =
area_crime_averages_for(Some("E14 2DG"), &avgs, false, &HashSet::new());
assert_eq!(outcode.as_deref(), Some("E14"));
assert_eq!(sector.as_deref(), Some("E14 2"));
assert_eq!(out.len(), 2);
let burglary = out.iter().find(|c| c.name == "Burglary").unwrap();
assert_eq!(burglary.national, Some(8.0));
assert_eq!(burglary.outcode, Some(10.0));
assert_eq!(burglary.sector, Some(5.0));
let robbery = out.iter().find(|c| c.name == "Robbery").unwrap();
assert_eq!(robbery.national, Some(6.0));
// The outcode value was NaN — dropped to None; the sector value is finite.
assert_eq!(robbery.outcode, None);
assert_eq!(robbery.sector, Some(7.0));
}
#[test]
fn area_crime_averages_respect_fields_filter() {
let avgs = sample_averages();
// The suffixed feature-name form is accepted, like compute_crime_by_year.
let fields: HashSet<String> = ["Burglary (avg/yr)".to_string()].into_iter().collect();
let (_, _, out) = area_crime_averages_for(Some("E14 2DG"), &avgs, true, &fields);
assert_eq!(out.len(), 1);
assert_eq!(out[0].name, "Burglary");
}
#[test]
fn area_crime_averages_unknown_area_still_emits_national() {
let avgs = sample_averages();
let (outcode, sector, out) =
area_crime_averages_for(Some("ZZ9 9ZZ"), &avgs, false, &HashSet::new());
// The area is absent from both maps: no codes, but national still applies.
assert!(outcode.is_none());
assert!(sector.is_none());
assert_eq!(out.len(), 2);
assert!(out
.iter()
.all(|c| c.outcode.is_none() && c.sector.is_none()));
assert_eq!(out[0].national, Some(8.0));
}
#[test]
fn area_crime_averages_none_without_postcode() {
let avgs = sample_averages();
let (outcode, sector, out) = area_crime_averages_for(None, &avgs, false, &HashSet::new());
assert!(outcode.is_none() && sector.is_none() && out.is_empty());
}
}

View file

@ -6,8 +6,9 @@ use rustc_hash::FxHashMap;
use crate::auth::TokenCache;
use crate::bugsink::FrontendConfig as BugsinkFrontendConfig;
use crate::data::{
ActualListingData, CrimeByYearData, OutcodeData, POICategoryGroup, POIData, PlaceData,
PostcodeData, PropertyData, TravelTimeStore,
ActualListingData, AreaCrimeAverages, CrimeByYearData, DevelopmentData, OutcodeData,
POICategoryGroup, POIData, PlaceData, PostcodeData, PostcodePopulation, PropertyData,
TravelTimeStore,
};
use crate::licensing::ShareBoundsCache;
use crate::pocketbase::SuperuserTokenCache;
@ -46,8 +47,17 @@ pub struct AppState {
pub travel_time_store: Arc<TravelTimeStore>,
/// Real-world listings (e.g. Rightmove / Zoopla data) loaded from ACTUAL_LISTINGS_PATH.
pub actual_listings: Arc<ActualListingData>,
/// Planned/pipeline development sites (brownfield register + Homes England)
/// loaded from the required DEVELOPMENTS_PATH.
pub developments: Arc<DevelopmentData>,
/// Per-LSOA per-year crime counts used by the right pane to plot trends.
pub crime_by_year: Arc<CrimeByYearData>,
/// Per-unit-postcode usual-resident headcounts (Census 2021), shown in the
/// right pane. Display-only — never filterable. Empty when no data is loaded.
pub population: Arc<PostcodePopulation>,
/// Precomputed per-outcode and per-postcode-sector average crime rates,
/// shown in the right pane alongside the national average for each metric.
pub area_crime_averages: Arc<AreaCrimeAverages>,
/// Token validation cache (60s TTL)
pub token_cache: Arc<TokenCache>,
/// Cached PocketBase superuser token (10min TTL) to avoid rate-limiting
@ -99,8 +109,8 @@ impl AppState {
use std::time::Duration;
use crate::data::{
ActualListingData, CrimeByYearData, OutcodeData, POIData, PlaceData, PostcodeData,
PropertyData, TravelTimeStore,
ActualListingData, AreaCrimeAverages, CrimeByYearData, DevelopmentData, OutcodeData,
POIData, PlaceData, PostcodeData, PostcodePopulation, PropertyData, TravelTimeStore,
};
use crate::utils::InternedColumn;
@ -161,12 +171,15 @@ impl AppState {
poi_filter_feature_data: Vec::new(),
grid: GridIndex::build(&[], &[], 0.01),
}),
developments: Arc::new(DevelopmentData::empty()),
crime_by_year: Arc::new(CrimeByYearData {
crime_types: Vec::new(),
years_by_type: Vec::new(),
series_by_postcode: FxHashMap::default(),
covered_years_by_postcode: FxHashMap::default(),
}),
population: Arc::new(PostcodePopulation::empty()),
area_crime_averages: Arc::new(AreaCrimeAverages::empty()),
token_cache: Arc::new(TokenCache::new()),
superuser_token_cache: Arc::new(SuperuserTokenCache::new()),
share_cache: Arc::new(ShareBoundsCache::new()),

View file

@ -20,3 +20,44 @@ pub fn normalize_postcode(raw: &str) -> String {
upper
}
}
/// Outward code (outcode) of a normalized postcode: the part before the space.
/// e.g. "E14 2DG" → Some("E14"). Returns `None` when the input has no space
/// (already-short or malformed — `normalize_postcode` only inserts a space for
/// inputs of length ≥ 5).
pub fn postcode_outcode(postcode: &str) -> Option<&str> {
postcode.split_once(' ').map(|(outward, _)| outward)
}
/// Postcode sector of a normalized postcode: the outward code, the space, and
/// the first character of the inward code. e.g. "E14 2DG" → Some("E14 2").
/// Returns `None` when there is no space or no inward code.
pub fn postcode_sector(postcode: &str) -> Option<&str> {
let space = postcode.find(' ')?;
let first = postcode[space + 1..].chars().next()?;
Some(&postcode[..space + 1 + first.len_utf8()])
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn outcode_and_sector_split() {
assert_eq!(postcode_outcode("E14 2DG"), Some("E14"));
assert_eq!(postcode_sector("E14 2DG"), Some("E14 2"));
assert_eq!(postcode_outcode("EC1A 1BB"), Some("EC1A"));
assert_eq!(postcode_sector("EC1A 1BB"), Some("EC1A 1"));
// Single-letter area, single-digit district.
assert_eq!(postcode_outcode("M1 1AE"), Some("M1"));
assert_eq!(postcode_sector("M1 1AE"), Some("M1 1"));
}
#[test]
fn outcode_and_sector_reject_malformed() {
assert_eq!(postcode_outcode("E14"), None);
assert_eq!(postcode_sector("E14"), None);
assert_eq!(postcode_outcode(""), None);
assert_eq!(postcode_sector("E14 "), None);
}
}