lgtm
This commit is contained in:
parent
982e0cc89c
commit
cfaf58dfba
44 changed files with 793 additions and 201 deletions
14
frontend/public/video/twin-beckenham-croydon.vtt
Normal file
14
frontend/public/video/twin-beckenham-croydon.vtt
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
WEBVTT
|
||||
|
||||
00:00:00.203 --> 00:00:06.283
|
||||
Beckenham and Croydon sit side by side: same trains, same school catchment.
|
||||
|
||||
00:00:06.683 --> 00:00:10.523
|
||||
Rank them by what each pound of floor space actually buys.
|
||||
|
||||
00:00:11.823 --> 00:00:16.943
|
||||
One postcode over, the same home quietly costs about a third less.
|
||||
|
||||
00:00:18.093 --> 00:00:22.893
|
||||
Beckenham's cheaper twin is on this map. Find yours, free.
|
||||
|
||||
14
frontend/public/video/twin-ha7-2-vs-ha3-0.vtt
Normal file
14
frontend/public/video/twin-ha7-2-vs-ha3-0.vtt
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
WEBVTT
|
||||
|
||||
00:00:00.202 --> 00:00:05.882
|
||||
Stanmore and Kenton sit right next door, with the same schools and transport links.
|
||||
|
||||
00:00:06.282 --> 00:00:10.842
|
||||
Rank every postcode by what each pound of floor space actually buys.
|
||||
|
||||
00:00:12.142 --> 00:00:17.422
|
||||
One postcode over, the same home quietly costs about a sixth less.
|
||||
|
||||
00:00:18.572 --> 00:00:22.652
|
||||
Stanmore's cheaper twin is on this map. Find yours, free.
|
||||
|
||||
14
frontend/public/video/twin-l16-7-vs-l14-6.vtt
Normal file
14
frontend/public/video/twin-l16-7-vs-l14-6.vtt
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
WEBVTT
|
||||
|
||||
00:00:00.203 --> 00:00:06.843
|
||||
Childwall and Broadgreen sit right next door, with the same schools and transport links.
|
||||
|
||||
00:00:07.243 --> 00:00:11.803
|
||||
Rank every postcode by what each pound of floor space actually buys.
|
||||
|
||||
00:00:13.103 --> 00:00:18.223
|
||||
One postcode over, the same home quietly costs about a third less.
|
||||
|
||||
00:00:19.373 --> 00:00:24.493
|
||||
Childwall's cheaper twin is on this map. Find yours, free.
|
||||
|
||||
|
|
@ -16,7 +16,7 @@
|
|||
// present in en.ts > server so they can be translated.
|
||||
//
|
||||
// The script parses the TypeScript source with the compiler API and walks the
|
||||
// AST — no runtime import, no transpilation, no temp files. Run it with:
|
||||
// AST: no runtime import, no transpilation, no temp files. Run it with:
|
||||
// node frontend/scripts/check-translations.mjs
|
||||
|
||||
import { readFileSync, readdirSync } from 'fs';
|
||||
|
|
@ -106,7 +106,7 @@ function parseFile(path) {
|
|||
}
|
||||
|
||||
// Recursively turn a TS literal expression into a plain JS value.
|
||||
// Returns undefined for nodes we don't understand — callers must check.
|
||||
// Returns undefined for nodes we don't understand. Callers must check.
|
||||
function literalToJs(node) {
|
||||
if (!node) return undefined;
|
||||
if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) return node.text;
|
||||
|
|
@ -217,7 +217,7 @@ function readServerFeatureNames() {
|
|||
// Names of the Enum/Numeric feature *configs* (each needs a description + detail
|
||||
// translation). We take the FIRST `name:` field after every Feature::Enum( /
|
||||
// Feature::Numeric( opening. This deliberately skips macro-generated configs
|
||||
// whose name is a `concat!(...)` expression (the crime rates — handled via
|
||||
// whose name is a `concat!(...)` expression (the crime rates, handled via
|
||||
// deriveLegacyCrimeKeys instead) and stops the lazy match from running past such
|
||||
// a config into an unrelated FeatureGroup `name:` (which previously made the
|
||||
// group name "Properties" look like a required feature).
|
||||
|
|
@ -302,7 +302,7 @@ function checkLeafConsistency(path, enValue, trValue, lang) {
|
|||
const got = tokenMultiset(trValue, re);
|
||||
if (!multisetsEqual(want, got)) {
|
||||
fail(
|
||||
`[${lang}] ${path}: ${label} mismatch — en=${JSON.stringify(want)} ` +
|
||||
`[${lang}] ${path}: ${label} mismatch, en=${JSON.stringify(want)} ` +
|
||||
`${lang}=${JSON.stringify(got)}`
|
||||
);
|
||||
}
|
||||
|
|
@ -425,7 +425,7 @@ function checkRecordCoverage(file, varName, supportedCodes, serverKeys, required
|
|||
// Every key here must also be a translatable feature name in en.ts > server,
|
||||
// or a legacy crime key that maps onto the per-window server keys (see
|
||||
// deriveLegacyCrimeKeys / legacyCrimeFeatureKey). Otherwise the description is
|
||||
// unreachable — ts() looks up server.${name}.
|
||||
// unreachable: ts() looks up server.${name}.
|
||||
for (const key of union) {
|
||||
if (!serverKeys.has(key) && !legacyCrimeKeys.has(key)) {
|
||||
fail(`${file}: key "${key}" has no matching entry in en.ts > server`);
|
||||
|
|
@ -532,7 +532,7 @@ function main() {
|
|||
console.error(`\n${errors.length} translation error(s).`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`i18n OK — ${supportedCodes.length} languages, ${warnings.length} warning(s).`);
|
||||
console.log(`i18n OK: ${supportedCodes.length} languages, ${warnings.length} warning(s).`);
|
||||
}
|
||||
|
||||
main();
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ const ROUTES = [
|
|||
output: 'property-price-map/index.html',
|
||||
title: 'Property price map for England - Compare postcodes before viewing',
|
||||
description:
|
||||
'Compare sold prices, estimated current value, price per square metre and local context across English postcodes before searching listings.',
|
||||
'Compare sold prices, estimated current value, price per square metre and local context across English postcodes to find the underpriced ones.',
|
||||
},
|
||||
{
|
||||
path: '/postcode-property-search',
|
||||
|
|
@ -127,7 +127,7 @@ const FAQ_SCHEMA_ITEMS = [
|
|||
{
|
||||
question: 'Where should I look once the obvious areas are too expensive?',
|
||||
answer:
|
||||
'Set your budget, property type, floor area, commute, schools, crime, noise, broadband, parks, and other must-haves. The map removes postcodes that fail those tests, so overlooked areas can surface before you start searching listings.',
|
||||
'Set your budget, property type, floor area, commute, schools, crime, noise, broadband, parks, and other must-haves. The map ranks every postcode in England on those measures, so overlooked, underpriced areas (the cheaper twins of the names everyone knows) rise to the top.',
|
||||
},
|
||||
{
|
||||
question: 'What should I do when my search returns too many or too few areas?',
|
||||
|
|
@ -145,9 +145,9 @@ const FAQ_SCHEMA_ITEMS = [
|
|||
'The estimate starts with the last HM Land Registry sale price, adjusts it to current-market terms using repeat-sales modelling and fallback models, then blends that result with a nearest-neighbour estimate from nearby, recently sold, same-type homes.',
|
||||
},
|
||||
{
|
||||
question: 'Does Perfect Postcode replace Rightmove, Zoopla and OnTheMarket?',
|
||||
question: 'How is Perfect Postcode different from Rightmove, Zoopla and OnTheMarket?',
|
||||
answer:
|
||||
'No. Perfect Postcode helps you choose the right postcode from area data; the listing portals are still where you check live availability, photos, agent contact, viewings and alerts.',
|
||||
'Listing portals show you individual homes that are for sale right now. Perfect Postcode is where you decide which postcode to buy in, ranking every postcode in England on 200+ data fields the portals never compare, from £/sqm to crime, schools and commute.',
|
||||
},
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -640,6 +640,7 @@ export default function App() {
|
|||
initialCrimeTypes={urlState.crimeTypes}
|
||||
initialBasemap={urlState.basemap}
|
||||
initialColorOpacity={urlState.colorOpacity}
|
||||
initialListingsMode={urlState.listings}
|
||||
initialTab={urlState.tab}
|
||||
initialLoading={initialLoading}
|
||||
theme={theme}
|
||||
|
|
@ -754,6 +755,7 @@ export default function App() {
|
|||
initialCrimeTypes={mapUrlState.crimeTypes}
|
||||
initialBasemap={mapUrlState.basemap}
|
||||
initialColorOpacity={mapUrlState.colorOpacity}
|
||||
initialListingsMode={mapUrlState.listings}
|
||||
initialTab={mapUrlState.tab}
|
||||
initialLoading={initialLoading}
|
||||
theme={theme}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,24 @@
|
|||
import { render, cleanup } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { i18nReady } from '../../i18n';
|
||||
import SeoLandingPage from './SeoLandingPage';
|
||||
|
||||
// analytics pulls in @plausible-analytics/tracker, which vitest cannot resolve.
|
||||
vi.mock('../../lib/analytics', () => ({ trackEvent: vi.fn() }));
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe('SeoLandingPage structured data', () => {
|
||||
it('does not render a BreadcrumbList JSON-LD (prerender owns it)', async () => {
|
||||
await i18nReady;
|
||||
const { container } = render(
|
||||
<SeoLandingPage pageKey="property-price-map" onOpenDashboard={() => {}} />
|
||||
);
|
||||
const scripts = Array.from(
|
||||
container.querySelectorAll('script[type="application/ld+json"]')
|
||||
).map((s) => s.textContent ?? '');
|
||||
expect(scripts.some((t) => t.includes('"BreadcrumbList"'))).toBe(false);
|
||||
// FAQPage JSON-LD is still expected
|
||||
expect(scripts.some((t) => t.includes('"FAQPage"'))).toBe(true);
|
||||
});
|
||||
});
|
||||
87
frontend/src/components/map/ListingPane.tsx
Normal file
87
frontend/src/components/map/ListingPane.tsx
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { LISTINGS_MODES, type ListingsMode } from '../../lib/listings';
|
||||
import { PillToggle } from '../ui/PillToggle';
|
||||
import { SpinnerIcon } from '../ui/icons/SpinnerIcon';
|
||||
import { CloseIcon } from '../ui/icons';
|
||||
|
||||
interface ListingPaneProps {
|
||||
mode: ListingsMode;
|
||||
onModeChange: (mode: ListingsMode) => void;
|
||||
/** Number of listings currently shown for the active mode. */
|
||||
count: number;
|
||||
loading: boolean;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
export default function ListingPane({
|
||||
mode,
|
||||
onModeChange,
|
||||
count,
|
||||
loading,
|
||||
onClose,
|
||||
}: ListingPaneProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const modeLabel = (value: ListingsMode): string => {
|
||||
switch (value) {
|
||||
case 'all':
|
||||
return t('common.all');
|
||||
case 'new':
|
||||
return t('map.actualListings.modes.new');
|
||||
case 'non-new':
|
||||
return t('map.actualListings.modes.nonNew');
|
||||
case 'none':
|
||||
return t('common.none');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-col overflow-hidden bg-white shadow-lg dark:bg-warm-900">
|
||||
<div className="flex-shrink-0 px-3 pt-3 pb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-semibold uppercase tracking-wide text-warm-500 dark:text-warm-400">
|
||||
{t('map.actualListings.label')}
|
||||
</span>
|
||||
{mode !== 'none' && (
|
||||
<span className="inline-flex items-center gap-1 text-xs text-warm-400 dark:text-warm-500">
|
||||
{loading && <SpinnerIcon className="h-3 w-3 animate-spin" />}
|
||||
{count}
|
||||
</span>
|
||||
)}
|
||||
{onClose && (
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="ml-auto p-0.5 text-warm-400 hover:text-warm-700 dark:hover:text-warm-300"
|
||||
title={t('common.close')}
|
||||
aria-label={t('common.close')}
|
||||
>
|
||||
<CloseIcon className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 overflow-y-auto overscroll-contain border-t border-warm-200 px-3 py-3 dark:border-warm-700">
|
||||
<div
|
||||
className="flex flex-wrap gap-1.5"
|
||||
role="group"
|
||||
aria-label={t('map.actualListings.label')}
|
||||
>
|
||||
{LISTINGS_MODES.map((value) => (
|
||||
<PillToggle
|
||||
key={value}
|
||||
label={modeLabel(value)}
|
||||
active={mode === value}
|
||||
onClick={() => onModeChange(value)}
|
||||
size="sm"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<p className="mt-2 text-[11px] leading-snug text-warm-400 dark:text-warm-500">
|
||||
{t('map.actualListings.modes.hint')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -39,6 +39,7 @@ import type { BasemapId } from '../../lib/basemaps';
|
|||
import { useLicense } from '../../hooks/useLicense';
|
||||
import { stateToParams } from '../../lib/url-state';
|
||||
import { DEFAULT_COLOR_OPACITY, normalizeColorOpacity } from '../../lib/color-opacity';
|
||||
import { DEFAULT_LISTINGS_MODE, filterListingsByMode, type ListingsMode } from '../../lib/listings';
|
||||
import { groupFeaturesByCategory } from '../../lib/features';
|
||||
import {
|
||||
getActiveAmenityFilterFeatureNames,
|
||||
|
|
@ -47,6 +48,7 @@ import {
|
|||
import {
|
||||
AreaPane,
|
||||
Filters,
|
||||
ListingPane,
|
||||
OverlayPane,
|
||||
POIPane,
|
||||
PropertiesPane,
|
||||
|
|
@ -91,6 +93,7 @@ export default function MapPage({
|
|||
initialCrimeTypes,
|
||||
initialBasemap = 'standard',
|
||||
initialColorOpacity = DEFAULT_COLOR_OPACITY,
|
||||
initialListingsMode = DEFAULT_LISTINGS_MODE,
|
||||
initialTab,
|
||||
initialLoading,
|
||||
theme,
|
||||
|
|
@ -134,13 +137,14 @@ export default function MapPage({
|
|||
);
|
||||
const [leftPaneWidth, leftPaneHandlers] = usePaneResize(384, 200, 0.45, 'left');
|
||||
const [rightPaneWidth, rightPaneHandlers] = usePaneResize(384, 200, 0.45, 'right');
|
||||
// The POI and overlay panes are mutually exclusive, so a single state tracks
|
||||
// which one (if any) is open.
|
||||
const [openMapPane, setOpenMapPane] = useState<'poi' | 'overlay' | null>(null);
|
||||
// The POI, overlay and listings panes are mutually exclusive, so a single state
|
||||
// tracks which one (if any) is open.
|
||||
const [openMapPane, setOpenMapPane] = useState<'poi' | 'overlay' | 'listings' | null>(null);
|
||||
const poiPaneOpen = openMapPane === 'poi';
|
||||
const overlayPaneOpen = openMapPane === 'overlay';
|
||||
const listingsPaneOpen = openMapPane === 'listings';
|
||||
const [currentLocation, setCurrentLocation] = useState<{ lat: number; lng: number } | null>(null);
|
||||
const [listingsToggleEnabled, setListingsToggleEnabled] = useState(true);
|
||||
const [listingsMode, setListingsMode] = useState<ListingsMode>(initialListingsMode);
|
||||
const [pendingInitialPostcode, setPendingInitialPostcode] = useState<string | null>(
|
||||
initialPostcode ?? null
|
||||
);
|
||||
|
|
@ -151,7 +155,7 @@ export default function MapPage({
|
|||
const isLoggedIn = !!user;
|
||||
// Screenshot/OG renders are non-interactive previews of a (possibly shared) search.
|
||||
// They go through the server's internal-request exemption, so they must render the
|
||||
// full filter set rather than the capped demo view — otherwise a 4–5-filter shared
|
||||
// full filter set rather than the capped demo view. Otherwise a 4–5-filter shared
|
||||
// search is silently trimmed to the anonymous cap (3) in the generated preview image.
|
||||
const filtersUnlimited =
|
||||
screenshotMode === true ||
|
||||
|
|
@ -162,7 +166,7 @@ export default function MapPage({
|
|||
const filterLimit = filtersUnlimited ? null : effectiveFilterCap;
|
||||
// On a shared link, anonymous visitors may view and adjust the shared filters but
|
||||
// not add new ones. Keyed off login state (not paid status) so the "create a free
|
||||
// account to build your own" upsell isn't a dead-end — once registered, the user can
|
||||
// account to build your own" upsell isn't a dead-end: once registered, the user can
|
||||
// add filters up to their normal cap right there on the shared search. The
|
||||
// `!filtersUnlimited` term also excludes the non-interactive screenshot/OG render
|
||||
// (treated as unlimited above), which must show the shared filters in full.
|
||||
|
|
@ -192,7 +196,7 @@ export default function MapPage({
|
|||
}, []);
|
||||
|
||||
// Pop the upgrade modal once when the shared/bookmarked search carried more filters
|
||||
// than the viewer's tier allows — so trimmed filters are surfaced, not silently
|
||||
// than the viewer's tier allows, so trimmed filters are surfaced, not silently
|
||||
// dropped. Fires when the signal first turns true (which may be after features finish
|
||||
// loading), and only once so dismissing it sticks.
|
||||
const overCapPopupShownRef = useRef(false);
|
||||
|
|
@ -308,7 +312,7 @@ export default function MapPage({
|
|||
// On a shared link, non-paying users may adjust the shared filters but not build
|
||||
// a new set. The AI box is a "build me a search" tool (and it also drives
|
||||
// travel-time entries, which bypass the per-add lock), so block it outright here
|
||||
// — mirroring handleAddTravelTimeEntry — and surface the upsell instead.
|
||||
// (mirroring handleAddTravelTimeEntry) and surface the upsell instead.
|
||||
if (lockFilterAdds) {
|
||||
handleFilterLimitReached();
|
||||
return;
|
||||
|
|
@ -362,7 +366,7 @@ export default function MapPage({
|
|||
}))
|
||||
);
|
||||
|
||||
// Move the camera to where the matches actually are — flying to the
|
||||
// Move the camera to where the matches actually are: flying to the
|
||||
// travel-time anchor often lands on a viewport with zero matches.
|
||||
if (result.matchBounds) {
|
||||
const target = boundsToCenterZoom(result.matchBounds);
|
||||
|
|
@ -410,7 +414,7 @@ export default function MapPage({
|
|||
);
|
||||
|
||||
// Travel-time entries share the non-paying user's filter cap, so block adding one
|
||||
// when the combined feature + travel count is already at the limit — and block it
|
||||
// when the combined feature + travel count is already at the limit, and block it
|
||||
// outright on a shared link, where non-paying users can't add filters at all.
|
||||
const handleAddTravelTimeEntry = useCallback(
|
||||
(mode: TransportMode) => {
|
||||
|
|
@ -584,7 +588,7 @@ export default function MapPage({
|
|||
|
||||
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
|
||||
// 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;
|
||||
|
|
@ -597,7 +601,7 @@ export default function MapPage({
|
|||
// default). Only users with the flag get the toggle button and the fetch;
|
||||
// the backing API independently rejects anyone else with 403.
|
||||
const canSeeListings = user?.canSeeListings ?? false;
|
||||
const actualListingsEnabled = canSeeListings && listingsToggleEnabled;
|
||||
const actualListingsEnabled = canSeeListings && listingsMode !== 'none';
|
||||
const { listings: actualListings, loading: actualListingsLoading } = useActualListings(
|
||||
actualListingsEnabled ? mapData.visibleBounds : null,
|
||||
{
|
||||
|
|
@ -606,10 +610,28 @@ export default function MapPage({
|
|||
shareCode,
|
||||
}
|
||||
);
|
||||
const visibleActualListings = actualListingsEnabled ? actualListings : EMPTY_ACTUAL_LISTINGS;
|
||||
const handleToggleActualListings = useCallback(() => {
|
||||
if (!canSeeListings) return;
|
||||
setListingsToggleEnabled((enabled) => !enabled);
|
||||
// The fetch returns every listing in view; the new/non-new split is decided
|
||||
// client-side from each listing's Rightmove channel URL.
|
||||
const visibleActualListings = useMemo(
|
||||
() =>
|
||||
actualListingsEnabled
|
||||
? filterListingsByMode(actualListings, listingsMode)
|
||||
: EMPTY_ACTUAL_LISTINGS,
|
||||
[actualListingsEnabled, actualListings, listingsMode]
|
||||
);
|
||||
const handleListingsModeChange = useCallback(
|
||||
(mode: ListingsMode) => {
|
||||
if (!canSeeListings) return;
|
||||
setListingsMode(mode);
|
||||
},
|
||||
[canSeeListings]
|
||||
);
|
||||
// Losing listings access (e.g. logging out) hides the button that dismisses the
|
||||
// pane, so close the pane here to avoid leaving an orphaned card on screen.
|
||||
useEffect(() => {
|
||||
if (!canSeeListings) {
|
||||
setOpenMapPane((pane) => (pane === 'listings' ? null : pane));
|
||||
}
|
||||
}, [canSeeListings]);
|
||||
const selectedPostcodeParam =
|
||||
selectedHexagon?.type === 'postcode'
|
||||
|
|
@ -628,7 +650,8 @@ export default function MapPage({
|
|||
basemap,
|
||||
crimeTypes,
|
||||
selectedPostcodeParam,
|
||||
colorOpacity
|
||||
colorOpacity,
|
||||
listingsMode
|
||||
);
|
||||
|
||||
useInitialMapPageView(mapData, initialViewState, initialTab, setRightPaneTab);
|
||||
|
|
@ -701,7 +724,8 @@ export default function MapPage({
|
|||
basemap,
|
||||
crimeTypes,
|
||||
selectedPostcodeParam,
|
||||
colorOpacity
|
||||
colorOpacity,
|
||||
listingsMode
|
||||
).toString(),
|
||||
[
|
||||
activeOverlays,
|
||||
|
|
@ -711,6 +735,7 @@ export default function MapPage({
|
|||
entries,
|
||||
features,
|
||||
filters,
|
||||
listingsMode,
|
||||
rightPaneTab,
|
||||
selectedPOICategories,
|
||||
selectedPostcodeParam,
|
||||
|
|
@ -756,7 +781,7 @@ export default function MapPage({
|
|||
|
||||
// Clear the filter-cap upsell once it's no longer relevant: the user became
|
||||
// licensed/admin, or dropped back under the cap by removing filters. On a shared
|
||||
// link adds are blocked regardless of count, so don't auto-clear there — the user
|
||||
// link adds are blocked regardless of count, so don't auto-clear there; the user
|
||||
// dismisses it themselves. Prevents a stale flag from resurfacing spuriously.
|
||||
useEffect(() => {
|
||||
if (
|
||||
|
|
@ -768,8 +793,8 @@ export default function MapPage({
|
|||
}, [filtersUnlimited, lockFilterAdds, effectiveFilterCap, filters, entries]);
|
||||
|
||||
// When the cap tightens (logout / session invalidated / license lapse), useFilters
|
||||
// re-clamps the feature filters, but travel-time entries — which count toward the same
|
||||
// server cap — live here. Trim them to the cap too so the combined request can't exceed
|
||||
// re-clamps the feature filters, but travel-time entries, which count toward the same
|
||||
// server cap, live here. Trim them to the cap too so the combined request can't exceed
|
||||
// it and blank the map. Read via ref so this only fires on a cap change, not on every
|
||||
// travel edit (those are already gated by handleAddTravelTimeEntry).
|
||||
const entriesRef = useRef(entries);
|
||||
|
|
@ -796,6 +821,12 @@ export default function MapPage({
|
|||
const handleCloseOverlayPane = useCallback(() => {
|
||||
setOpenMapPane((pane) => (pane === 'overlay' ? null : pane));
|
||||
}, []);
|
||||
const handleToggleListingsPane = useCallback(() => {
|
||||
setOpenMapPane((pane) => (pane === 'listings' ? null : 'listings'));
|
||||
}, []);
|
||||
const handleCloseListingsPane = useCallback(() => {
|
||||
setOpenMapPane((pane) => (pane === 'listings' ? null : pane));
|
||||
}, []);
|
||||
const handleAreaTabClick = useCallback(() => {
|
||||
setRightPaneTab('area');
|
||||
}, [setRightPaneTab]);
|
||||
|
|
@ -910,6 +941,27 @@ export default function MapPage({
|
|||
[activeOverlays, basemap, colorOpacity, crimeTypes, handleCloseOverlayPane, overlaysZoomedIn]
|
||||
);
|
||||
|
||||
const listingsPane = useMemo(
|
||||
() => (
|
||||
<Suspense fallback={<PaneFallback />}>
|
||||
<ListingPane
|
||||
mode={listingsMode}
|
||||
onModeChange={handleListingsModeChange}
|
||||
count={visibleActualListings.length}
|
||||
loading={actualListingsLoading}
|
||||
onClose={handleCloseListingsPane}
|
||||
/>
|
||||
</Suspense>
|
||||
),
|
||||
[
|
||||
listingsMode,
|
||||
handleListingsModeChange,
|
||||
visibleActualListings.length,
|
||||
actualListingsLoading,
|
||||
handleCloseListingsPane,
|
||||
]
|
||||
);
|
||||
|
||||
const filtersPane = useMemo(
|
||||
() => (
|
||||
<Suspense fallback={<PaneFallback />}>
|
||||
|
|
@ -1155,7 +1207,9 @@ export default function MapPage({
|
|||
actualListings={visibleActualListings}
|
||||
actualListingsEnabled={actualListingsEnabled}
|
||||
actualListingsLoading={actualListingsLoading}
|
||||
onToggleActualListings={canSeeListings ? handleToggleActualListings : undefined}
|
||||
onToggleListingsPane={canSeeListings ? handleToggleListingsPane : undefined}
|
||||
listingsPaneOpen={listingsPaneOpen}
|
||||
listingsPane={listingsPane}
|
||||
travelTimeEntries={entries}
|
||||
bottomScreenInset={mobileBottomSheetHeight}
|
||||
onBottomSheetCoveredHeightChange={setMobileBottomSheetHeight}
|
||||
|
|
@ -1216,7 +1270,9 @@ export default function MapPage({
|
|||
actualListings={visibleActualListings}
|
||||
actualListingsEnabled={actualListingsEnabled}
|
||||
actualListingsLoading={actualListingsLoading}
|
||||
onToggleActualListings={canSeeListings ? handleToggleActualListings : undefined}
|
||||
onToggleListingsPane={canSeeListings ? handleToggleListingsPane : undefined}
|
||||
listingsPaneOpen={listingsPaneOpen}
|
||||
listingsPane={listingsPane}
|
||||
travelTimeEntries={entries}
|
||||
densityLabel={densityLabel}
|
||||
totalCount={filterCounts.total ?? undefined}
|
||||
|
|
|
|||
|
|
@ -85,7 +85,7 @@ export function OverlayTileLayers({
|
|||
// (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
|
||||
// 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
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ function getPoiGroupColor(group: string): [number, number, number] {
|
|||
return color;
|
||||
}
|
||||
|
||||
/** Best-effort web URL from a free-text website field — GIAS stores some with
|
||||
/** Best-effort web URL from a free-text website field. GIAS stores some with
|
||||
* "http://", some without, and some as bare hostnames. */
|
||||
function normalizeSchoolWebsiteUrl(raw: string): string | null {
|
||||
const trimmed = raw.trim();
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ function summarize(property: Property): string[] {
|
|||
case 'tenure':
|
||||
return `tenure:${event.status}:${event.year}`;
|
||||
case 'sale':
|
||||
return `sale:${event.year}`;
|
||||
return `sale:${event.year}${event.isNew ? ':new' : ''}`;
|
||||
case 'reno':
|
||||
return `reno:${event.event}:${event.year}`;
|
||||
case 'built':
|
||||
|
|
@ -42,9 +42,9 @@ describe('buildTimelineEvents', () => {
|
|||
summarize(
|
||||
makeProperty({
|
||||
historical_prices: [
|
||||
{ year: 2015, month: 6, price: 200_000 },
|
||||
{ year: 2015, month: 6, price: 200_000, is_new: false },
|
||||
// Most recent sale is the card headline, so it is dropped here.
|
||||
{ year: 2024, month: 1, price: 300_000 },
|
||||
{ year: 2024, month: 1, price: 300_000, is_new: false },
|
||||
],
|
||||
tenure_history: [{ year: 2020, status: 'Rented (private)' }],
|
||||
})
|
||||
|
|
@ -55,4 +55,19 @@ describe('buildTimelineEvents', () => {
|
|||
it('emits nothing when there is no tenure history', () => {
|
||||
expect(summarize(makeProperty({}))).toEqual([]);
|
||||
});
|
||||
|
||||
it('flags a past sale that was a new-build transfer', () => {
|
||||
expect(
|
||||
summarize(
|
||||
makeProperty({
|
||||
historical_prices: [
|
||||
{ year: 2016, month: 3, price: 250_000, is_new: true },
|
||||
{ year: 2019, month: 9, price: 280_000, is_new: false },
|
||||
// Most recent sale is the card headline, so it is dropped here.
|
||||
{ year: 2024, month: 1, price: 350_000, is_new: false },
|
||||
],
|
||||
})
|
||||
)
|
||||
).toEqual(['sale:2019', 'sale:2016:new']);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -94,7 +94,7 @@ export function TravelTimeCard({
|
|||
const displayRange = isActive && dragValue ? dragValue : (timeRange ?? [sliderMin, sliderMax]);
|
||||
|
||||
// Synthetic feature so the time labels reuse the shared SliderLabels (matching
|
||||
// every other filter card) — editable, thumb-following, with the minute unit.
|
||||
// every other filter card): editable, thumb-following, with the minute unit.
|
||||
const travelFeature: FeatureMeta = {
|
||||
name: 'travelTime',
|
||||
type: 'numeric',
|
||||
|
|
@ -149,7 +149,7 @@ export function TravelTimeCard({
|
|||
portal={destinationDropdownPortal}
|
||||
/>
|
||||
|
||||
{/* Transit-only toggles — shown when destination is set */}
|
||||
{/* Transit-only toggles, shown when destination is set */}
|
||||
{slug && mode === 'transit' && (
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-1.5">
|
||||
<div className="flex items-center gap-0.5">
|
||||
|
|
@ -236,7 +236,7 @@ export function TravelTimeCard({
|
|||
</InfoPopup>
|
||||
)}
|
||||
|
||||
{/* Time range slider — only show when we have data */}
|
||||
{/* Time range slider: only show when we have data */}
|
||||
{slug && (
|
||||
<div>
|
||||
<span className="text-[10px] font-medium text-warm-500 dark:text-warm-400 uppercase tracking-wide">
|
||||
|
|
|
|||
|
|
@ -29,6 +29,11 @@ import {
|
|||
getTenureFeatureName,
|
||||
isTenureFilterName,
|
||||
} from '../../../lib/tenure-filter';
|
||||
import {
|
||||
COUNCIL_VARIANT_CONFIG,
|
||||
getCouncilFeatureName,
|
||||
isCouncilFilterName,
|
||||
} from '../../../lib/council-filter';
|
||||
import { getSchoolBackendFeatureName, isSchoolFilterName } from '../../../lib/school-filter';
|
||||
import {
|
||||
getPoiDistanceFeatureName,
|
||||
|
|
@ -289,6 +294,31 @@ export function ActiveFilterList({
|
|||
);
|
||||
}
|
||||
|
||||
if (isCouncilFilterName(feature.name)) {
|
||||
const councilBackendName = getCouncilFeatureName(feature.name);
|
||||
return (
|
||||
<VariantFilterCard
|
||||
key={feature.name}
|
||||
config={COUNCIL_VARIANT_CONFIG}
|
||||
features={features}
|
||||
variantFeature={feature}
|
||||
filters={filters}
|
||||
activeFeature={activeFeature}
|
||||
dragValue={dragValue}
|
||||
pinnedFeature={pinnedFeature}
|
||||
filterImpact={councilBackendName ? filterImpacts?.[councilBackendName] : undefined}
|
||||
percentileScale={councilBackendName ? percentileScales.get(councilBackendName) : undefined}
|
||||
onFilterChange={onFilterChange}
|
||||
onDragStart={onDragStart}
|
||||
onDragChange={onDragChange}
|
||||
onDragEnd={onDragEnd}
|
||||
onTogglePin={onTogglePin}
|
||||
onShowInfo={onShowInfo}
|
||||
onRemove={() => onRemoveFilter(feature.name)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (isElectionVoteShareFilterName(feature.name)) {
|
||||
const electionVoteShareBackendName = getElectionVoteShareFeatureName(feature.name);
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import {
|
|||
isQualificationFilterName,
|
||||
} from '../../../lib/qualification-filter';
|
||||
import { TENURE_FILTER_NAME, isTenureFilterName } from '../../../lib/tenure-filter';
|
||||
import { COUNCIL_FILTER_NAME, isCouncilFilterName } from '../../../lib/council-filter';
|
||||
import { SCHOOL_FILTER_NAME, isSchoolFilterName } from '../../../lib/school-filter';
|
||||
import {
|
||||
POI_DISTANCE_FILTER_NAME,
|
||||
|
|
@ -45,6 +46,7 @@ interface AddFilterPanelProps {
|
|||
defaultEthnicityFeatureName: string | null;
|
||||
defaultQualificationFeatureName: string | null;
|
||||
defaultTenureFeatureName: string | null;
|
||||
defaultCouncilFeatureName: string | null;
|
||||
defaultPoiFilterFeatureNames: Record<PoiFilterName, string | null>;
|
||||
openInfoFeature?: string | null;
|
||||
travelTimeEntries: TravelTimeEntry[];
|
||||
|
|
@ -73,6 +75,7 @@ export function AddFilterPanel({
|
|||
defaultEthnicityFeatureName,
|
||||
defaultQualificationFeatureName,
|
||||
defaultTenureFeatureName,
|
||||
defaultCouncilFeatureName,
|
||||
defaultPoiFilterFeatureNames,
|
||||
openInfoFeature,
|
||||
travelTimeEntries,
|
||||
|
|
@ -101,11 +104,13 @@ export function AddFilterPanel({
|
|||
? QUALIFICATIONS_FILTER_NAME
|
||||
: pinnedFeature && isTenureFilterName(pinnedFeature)
|
||||
? TENURE_FILTER_NAME
|
||||
: pinnedFeature && isPoiDistanceFilterName(pinnedFeature)
|
||||
? (getPoiFilterName(pinnedFeature) ?? POI_DISTANCE_FILTER_NAME)
|
||||
: pinnedFeature && isCrimeSeverityFilterName(pinnedFeature)
|
||||
? (getCrimeSeverityFilterName(pinnedFeature) ?? pinnedFeature)
|
||||
: pinnedFeature;
|
||||
: pinnedFeature && isCouncilFilterName(pinnedFeature)
|
||||
? COUNCIL_FILTER_NAME
|
||||
: pinnedFeature && isPoiDistanceFilterName(pinnedFeature)
|
||||
? (getPoiFilterName(pinnedFeature) ?? POI_DISTANCE_FILTER_NAME)
|
||||
: pinnedFeature && isCrimeSeverityFilterName(pinnedFeature)
|
||||
? (getCrimeSeverityFilterName(pinnedFeature) ?? pinnedFeature)
|
||||
: pinnedFeature;
|
||||
|
||||
const handleTogglePin = (name: string) => {
|
||||
if (name === SCHOOL_FILTER_NAME) {
|
||||
|
|
@ -132,6 +137,10 @@ export function AddFilterPanel({
|
|||
if (defaultTenureFeatureName) onTogglePin(defaultTenureFeatureName);
|
||||
return;
|
||||
}
|
||||
if (name === COUNCIL_FILTER_NAME) {
|
||||
if (defaultCouncilFeatureName) onTogglePin(defaultCouncilFeatureName);
|
||||
return;
|
||||
}
|
||||
if (CRIME_SEVERITY_FILTER_NAMES.includes(name as CrimeSeverityFilterName)) {
|
||||
const defaultSeverityFeatureName =
|
||||
defaultCrimeSeverityFeatureNames[name as CrimeSeverityFilterName];
|
||||
|
|
|
|||
|
|
@ -57,7 +57,9 @@ interface MobileMapPageProps {
|
|||
actualListings: ActualListing[];
|
||||
actualListingsEnabled: boolean;
|
||||
actualListingsLoading: boolean;
|
||||
onToggleActualListings?: () => void;
|
||||
onToggleListingsPane?: () => void;
|
||||
listingsPaneOpen: boolean;
|
||||
listingsPane: ReactNode;
|
||||
travelTimeEntries: TravelTimeEntry[];
|
||||
bottomScreenInset: number;
|
||||
onBottomSheetCoveredHeightChange: (height: number) => void;
|
||||
|
|
@ -110,7 +112,9 @@ export function MobileMapPage({
|
|||
actualListings,
|
||||
actualListingsEnabled,
|
||||
actualListingsLoading,
|
||||
onToggleActualListings,
|
||||
onToggleListingsPane,
|
||||
listingsPaneOpen,
|
||||
listingsPane,
|
||||
travelTimeEntries,
|
||||
bottomScreenInset,
|
||||
onBottomSheetCoveredHeightChange,
|
||||
|
|
@ -145,7 +149,7 @@ export function MobileMapPage({
|
|||
// Each button is p-2 + a 1.25rem icon (36px); they sit in a `gap-2` (8px) row.
|
||||
const ACTION_BUTTON_WIDTH = 36;
|
||||
const ACTION_BUTTON_GAP = 8;
|
||||
const actionButtonCount = (onToggleActualListings ? 1 : 0) + 2;
|
||||
const actionButtonCount = (onToggleListingsPane ? 1 : 0) + 2;
|
||||
const topCardsRightInset =
|
||||
actionButtonCount * ACTION_BUTTON_WIDTH + actionButtonCount * ACTION_BUTTON_GAP; // inter-button gaps + breathing room before the search
|
||||
|
||||
|
|
@ -199,19 +203,15 @@ export function MobileMapPage({
|
|||
</div>
|
||||
|
||||
<div className="absolute right-3 top-3 z-20 flex max-w-[calc(100%_-_1.5rem)] flex-row flex-wrap justify-end gap-2">
|
||||
{onToggleActualListings && (
|
||||
{onToggleListingsPane && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleActualListings}
|
||||
onClick={onToggleListingsPane}
|
||||
className={`rounded-lg bg-white p-2 shadow-lg dark:bg-warm-800 ${actualListingsEnabled ? 'text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300' : 'text-warm-500 hover:text-red-600 dark:text-warm-400 dark:hover:text-red-400'}`}
|
||||
aria-pressed={actualListingsEnabled}
|
||||
aria-expanded={listingsPaneOpen}
|
||||
aria-busy={actualListingsLoading}
|
||||
aria-label={
|
||||
actualListingsEnabled ? t('map.actualListings.hide') : t('map.actualListings.show')
|
||||
}
|
||||
title={
|
||||
actualListingsEnabled ? t('map.actualListings.hide') : t('map.actualListings.show')
|
||||
}
|
||||
aria-label={t('map.actualListings.label')}
|
||||
title={t('map.actualListings.label')}
|
||||
>
|
||||
{actualListingsLoading ? (
|
||||
<SpinnerIcon className="h-5 w-5 animate-spin" />
|
||||
|
|
@ -236,6 +236,12 @@ export function MobileMapPage({
|
|||
</button>
|
||||
</div>
|
||||
|
||||
{listingsPaneOpen && (
|
||||
<div className="absolute top-24 right-3 left-3 z-20 flex max-h-[60dvh] min-h-0 flex-col overflow-hidden rounded-lg border border-warm-200 bg-white shadow-xl dark:border-warm-700 dark:bg-warm-900">
|
||||
{listingsPane}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{overlayPaneOpen && (
|
||||
<div className="absolute top-24 right-3 left-3 z-20 flex max-h-[60dvh] min-h-0 flex-col overflow-hidden rounded-lg border border-warm-200 bg-white shadow-xl dark:border-warm-700 dark:bg-warm-900">
|
||||
{overlayPane}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import type {
|
|||
import type { TravelTimeInitial } from '../../../hooks/useTravelTime';
|
||||
import type { OverlayId } from '../../../lib/overlays';
|
||||
import type { BasemapId } from '../../../lib/basemaps';
|
||||
import type { ListingsMode } from '../../../lib/listings';
|
||||
import type { Page } from '../../ui/Header';
|
||||
import type { PointerEvent } from 'react';
|
||||
|
||||
|
|
@ -31,6 +32,7 @@ export interface MapPageProps {
|
|||
initialCrimeTypes?: Set<string>;
|
||||
initialBasemap?: BasemapId;
|
||||
initialColorOpacity?: number;
|
||||
initialListingsMode?: ListingsMode;
|
||||
initialTab: 'properties' | 'area';
|
||||
initialLoading: boolean;
|
||||
theme: 'light' | 'dark';
|
||||
|
|
|
|||
|
|
@ -150,8 +150,8 @@ export function useExportController({
|
|||
const params = new URLSearchParams();
|
||||
if (isListMode) {
|
||||
params.set('postcodes', postcodeList.join(','));
|
||||
// Pass the active filters so the export keeps its two sheets — "Selected"
|
||||
// (the filtered feature columns) and "All Data" — even in list mode. The
|
||||
// Pass the active filters so the export keeps its two sheets, "Selected"
|
||||
// (the filtered feature columns) and "All Data", even in list mode. The
|
||||
// server uses them only to pick columns; the supplied postcodes still
|
||||
// drive which rows appear.
|
||||
const filterStr = buildFilterString(filters, features);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { CheckIcon } from '../ui/icons/CheckIcon';
|
||||
import { ChevronIcon } from '../ui/icons/ChevronIcon';
|
||||
import { SpinnerIcon } from '../ui/icons/SpinnerIcon';
|
||||
import type { AuthUser } from '../../hooks/useAuth';
|
||||
import { useLicense } from '../../hooks/useLicense';
|
||||
|
|
@ -9,8 +10,7 @@ import { trackEvent } from '../../lib/analytics';
|
|||
import { apiUrl } from '../../lib/api';
|
||||
import HexCanvas from '../home/HexCanvas';
|
||||
import { useIsDarkTheme } from '../../hooks/useIsDarkTheme';
|
||||
|
||||
// Feature list keys — resolved inside the component via t()
|
||||
import Footer from '../ui/Footer';
|
||||
|
||||
interface PricingTier {
|
||||
up_to: number | null;
|
||||
|
|
@ -24,6 +24,8 @@ interface PricingData {
|
|||
tiers: PricingTier[];
|
||||
}
|
||||
|
||||
// Feature list keys, resolved inside the component via t()
|
||||
|
||||
function formatPricePence(pence: number): string {
|
||||
return `\u00A3${pence / 100}`;
|
||||
}
|
||||
|
|
@ -45,26 +47,61 @@ export default function PricingPage({
|
|||
const [pricing, setPricing] = useState<PricingData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [scrolledLeft, setScrolledLeft] = useState(false);
|
||||
const [scrolledRight, setScrolledRight] = useState(false);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const activeCardRef = useRef<HTMLDivElement>(null);
|
||||
const retryTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const loadPricingRef = useRef<(attempt?: number) => void>(() => {});
|
||||
|
||||
const onScroll = useCallback(() => {
|
||||
if (scrollRef.current) setScrolledLeft(scrollRef.current.scrollLeft > 0);
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
setScrolledLeft(el.scrollLeft > 0);
|
||||
setScrolledRight(el.scrollLeft + el.clientWidth < el.scrollWidth - 1);
|
||||
}, []);
|
||||
|
||||
const scrollByAmount = useCallback((dir: 1 | -1) => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
el.scrollBy({ left: dir * el.clientWidth * 0.85, behavior: 'smooth' });
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
trackEvent('Pricing View');
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetch(apiUrl('pricing'))
|
||||
.then((res) => {
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return res.json();
|
||||
})
|
||||
.then(setPricing)
|
||||
.catch((err) => logNonAbortError('Failed to load pricing', err))
|
||||
.finally(() => setLoading(false));
|
||||
const loadPricing = useCallback(async (attempt = 0): Promise<void> => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch(apiUrl('pricing'));
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data: PricingData = await res.json();
|
||||
setPricing(data);
|
||||
setLoading(false);
|
||||
} catch (err) {
|
||||
logNonAbortError('Failed to load pricing', err);
|
||||
// Auto-retry transient failures with exponential backoff (3 retries: 1s/2s/4s).
|
||||
if (attempt < 3) {
|
||||
const delay = 1000 * 2 ** attempt;
|
||||
retryTimerRef.current = setTimeout(() => loadPricingRef.current(attempt + 1), delay);
|
||||
return; // keep the spinner up while we back off
|
||||
}
|
||||
// Retries exhausted: show the failed-to-load state.
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
loadPricingRef.current = loadPricing;
|
||||
|
||||
useEffect(() => {
|
||||
void loadPricing();
|
||||
return () => {
|
||||
if (retryTimerRef.current) clearTimeout(retryTimerRef.current);
|
||||
};
|
||||
}, [loadPricing]);
|
||||
|
||||
const handleRetry = useCallback(() => {
|
||||
void loadPricing();
|
||||
}, [loadPricing]);
|
||||
|
||||
const isLicensed = user?.subscription === 'licensed' || user?.isAdmin;
|
||||
const isFree = pricing?.current_price_pence === 0;
|
||||
|
|
@ -98,8 +135,17 @@ export default function PricingPage({
|
|||
containerRect.left -
|
||||
(container.clientWidth - card.offsetWidth) / 2;
|
||||
container.scrollLeft = Math.max(0, scrollLeft);
|
||||
setScrolledLeft(container.scrollLeft > 0);
|
||||
}, [pricing, currentTierIndex]);
|
||||
onScroll();
|
||||
}, [pricing, currentTierIndex, onScroll]);
|
||||
|
||||
// Keep scroll affordances (chevrons + fades) accurate on mount and resize so
|
||||
// the off-screen fifth tier is always discoverable.
|
||||
useEffect(() => {
|
||||
if (!pricing) return;
|
||||
onScroll();
|
||||
window.addEventListener('resize', onScroll);
|
||||
return () => window.removeEventListener('resize', onScroll);
|
||||
}, [pricing, onScroll]);
|
||||
|
||||
if (pricing && pricing.tiers.length === 0) {
|
||||
throw new Error('Pricing data did not include any tiers');
|
||||
|
|
@ -159,7 +205,7 @@ export default function PricingPage({
|
|||
</div>
|
||||
|
||||
<div className="relative z-10 max-w-5xl mx-auto px-6 pb-8 md:pb-16">
|
||||
{/* Tier cards — full viewport width carousel */}
|
||||
{/* Tier cards: full viewport width carousel */}
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-16">
|
||||
<SpinnerIcon className="w-8 h-8 animate-spin text-teal-400" />
|
||||
|
|
@ -174,15 +220,37 @@ export default function PricingPage({
|
|||
}}
|
||||
>
|
||||
{scrolledLeft && (
|
||||
<div
|
||||
className="pointer-events-none absolute inset-y-0 left-0 w-12 z-10 backdrop-blur-sm"
|
||||
style={{ maskImage: 'linear-gradient(to right, black, transparent)' }}
|
||||
/>
|
||||
<>
|
||||
<div
|
||||
className="pointer-events-none absolute inset-y-0 left-0 w-12 z-10 backdrop-blur-sm"
|
||||
style={{ maskImage: 'linear-gradient(to right, black, transparent)' }}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => scrollByAmount(-1)}
|
||||
aria-label={t('pricingPage.scrollPrev')}
|
||||
className="absolute left-2 top-1/2 z-20 -translate-y-1/2 flex h-10 w-10 items-center justify-center rounded-full border border-warm-300 bg-white/90 text-navy-950 shadow-md hover:bg-white dark:border-warm-700 dark:bg-warm-800/90 dark:text-warm-100"
|
||||
>
|
||||
<ChevronIcon direction="left" className="w-5 h-5" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{scrolledRight && (
|
||||
<>
|
||||
<div
|
||||
className="pointer-events-none absolute inset-y-0 right-0 w-12 z-10 backdrop-blur-sm"
|
||||
style={{ maskImage: 'linear-gradient(to left, black, transparent)' }}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => scrollByAmount(1)}
|
||||
aria-label={t('pricingPage.scrollNext')}
|
||||
className="absolute right-2 top-1/2 z-20 -translate-y-1/2 flex h-10 w-10 items-center justify-center rounded-full border border-warm-300 bg-white/90 text-navy-950 shadow-md hover:bg-white dark:border-warm-700 dark:bg-warm-800/90 dark:text-warm-100"
|
||||
>
|
||||
<ChevronIcon direction="right" className="w-5 h-5" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<div
|
||||
className="pointer-events-none absolute inset-y-0 right-0 w-12 z-10 backdrop-blur-sm"
|
||||
style={{ maskImage: 'linear-gradient(to left, black, transparent)' }}
|
||||
/>
|
||||
<div
|
||||
ref={scrollRef}
|
||||
onScroll={onScroll}
|
||||
|
|
@ -338,9 +406,21 @@ export default function PricingPage({
|
|||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-center text-warm-400 py-16">{t('pricingPage.failedToLoad')}</p>
|
||||
<div className="flex flex-col items-center gap-4 py-16 text-center">
|
||||
<p className="text-warm-500 dark:text-warm-400">{t('pricingPage.failedToLoad')}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRetry}
|
||||
className="px-5 py-2.5 rounded-lg border border-[#d27a11] bg-[#f09a22] text-navy-950 font-semibold hover:bg-[#df8614] transition-colors shadow-lg shadow-[#7a3905]/25"
|
||||
>
|
||||
{t('common.retry')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="relative z-10">
|
||||
<Footer />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { CloseIcon } from './icons/CloseIcon';
|
|||
import { GoogleIcon } from './icons/GoogleIcon';
|
||||
import { trackEvent } from '../../lib/analytics';
|
||||
import { useModalA11y } from '../../hooks/useModalA11y';
|
||||
import type { AuthErrorAction } from '../../lib/auth-errors';
|
||||
|
||||
type View = 'login' | 'register' | 'forgot';
|
||||
|
||||
|
|
@ -17,6 +18,7 @@ export default function AuthModal({
|
|||
onForgotPassword,
|
||||
loading,
|
||||
error,
|
||||
errorAction,
|
||||
onClearError,
|
||||
initialTab = 'login',
|
||||
valuePropKey,
|
||||
|
|
@ -29,6 +31,7 @@ export default function AuthModal({
|
|||
onForgotPassword: (email: string) => Promise<void>;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
errorAction?: AuthErrorAction;
|
||||
onClearError: () => void;
|
||||
initialTab?: 'login' | 'register';
|
||||
/** Translation key for the value-prop line, e.g. a context-specific nudge. Falls
|
||||
|
|
@ -261,7 +264,20 @@ export default function AuthModal({
|
|||
<p className="text-sm text-teal-700 dark:text-teal-400">{t('auth.resetSent')}</p>
|
||||
)}
|
||||
|
||||
{error && <p className="text-sm text-red-600 dark:text-red-300">{error}</p>}
|
||||
{error && (
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-sm text-red-600 dark:text-red-300">{error}</p>
|
||||
{errorAction === 'switchToLogin' && view !== 'login' && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => switchView('login')}
|
||||
className="text-sm font-medium text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300"
|
||||
>
|
||||
{t('auth.logInInstead')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!(view === 'forgot' && resetSent) && (
|
||||
<button
|
||||
|
|
|
|||
|
|
@ -246,8 +246,10 @@ export default function Header({
|
|||
</span>
|
||||
</a>
|
||||
|
||||
{/* Desktop nav */}
|
||||
{!useSidebarNav && (
|
||||
{/* Desktop nav: hidden while the saved-search "is being updated" banner
|
||||
is shown so the centered pointer-events-auto banner can't overlap (and
|
||||
block clicks on) the Invite Friends / Learn / Pricing links at ~1366px. */}
|
||||
{!useSidebarNav && !showEditingBar && (
|
||||
<nav className="top-menu flex items-center">
|
||||
<a
|
||||
href={PAGE_PATHS.dashboard}
|
||||
|
|
@ -287,7 +289,7 @@ export default function Header({
|
|||
|
||||
{/* Right side */}
|
||||
<div className="ml-auto flex shrink-0 items-center gap-2">
|
||||
{/* Desktop-only dashboard actions — shown to everyone; a logged-out click
|
||||
{/* Desktop-only dashboard actions: shown to everyone; a logged-out click
|
||||
opens the register prompt instead of acting. */}
|
||||
{!useSidebarNav && activePage === 'dashboard' && (
|
||||
<>
|
||||
|
|
@ -393,7 +395,7 @@ export default function Header({
|
|||
{/* Language selector (desktop) */}
|
||||
{!useSidebarNav && <LanguageDropdown />}
|
||||
|
||||
{/* Theme toggle (desktop, logged-out only — logged-in users use UserMenu) */}
|
||||
{/* Theme toggle (desktop, logged-out only; logged-in users use UserMenu) */}
|
||||
{!useSidebarNav && !user && (
|
||||
<button
|
||||
onClick={onToggleTheme}
|
||||
|
|
|
|||
40
frontend/src/components/ui/UserMenu.test.tsx
Normal file
40
frontend/src/components/ui/UserMenu.test.tsx
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import { cleanup, render, screen } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('react-i18next', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('react-i18next')>()),
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
import UserMenu from './UserMenu';
|
||||
import type { AuthUser } from '../../hooks/useAuth';
|
||||
|
||||
const user: AuthUser = {
|
||||
id: 'u1',
|
||||
email: 'casey@example.com',
|
||||
isAdmin: false,
|
||||
subscription: 'free',
|
||||
newsletter: false,
|
||||
canSeeListings: false,
|
||||
};
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe('UserMenu avatar trigger (a11y)', () => {
|
||||
it('has an accessible name, opens a menu, and is type=button', () => {
|
||||
render(
|
||||
<UserMenu
|
||||
user={user}
|
||||
theme="light"
|
||||
onToggleTheme={() => {}}
|
||||
onLogout={() => {}}
|
||||
onNavigate={() => {}}
|
||||
/>
|
||||
);
|
||||
// t() is mocked to echo the key, so the accessible name is the key.
|
||||
const trigger = screen.getByRole('button', { name: 'userMenu.accountMenu' });
|
||||
expect(trigger.getAttribute('type')).toBe('button');
|
||||
expect(trigger.getAttribute('aria-haspopup')).toBe('menu');
|
||||
expect(trigger.getAttribute('aria-expanded')).toBe('false');
|
||||
});
|
||||
});
|
||||
|
|
@ -40,7 +40,11 @@ export default function UserMenu({
|
|||
return (
|
||||
<div className="relative" ref={menuRef}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((prev) => !prev)}
|
||||
aria-label={t('userMenu.accountMenu')}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
className="flex cursor-pointer items-center justify-center w-8 h-8 rounded-full bg-teal-600 text-white text-sm font-medium hover:bg-teal-700"
|
||||
title={user.email}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -1171,8 +1171,6 @@ const de: Translations = {
|
|||
},
|
||||
|
||||
pricingPage: {
|
||||
cachedNotice:
|
||||
'Live-Preise konnten nicht aktualisiert werden; diese Angaben sind möglicherweise veraltet.',
|
||||
scrollPrev: 'Vorherige Tarife anzeigen',
|
||||
scrollNext: 'Weitere Tarife anzeigen',
|
||||
title: 'Mit einem besseren Suchgebiet kaufen',
|
||||
|
|
|
|||
|
|
@ -1156,7 +1156,6 @@ const en = {
|
|||
},
|
||||
|
||||
pricingPage: {
|
||||
cachedNotice: 'We couldn’t refresh live pricing, so these figures may be out of date.',
|
||||
scrollPrev: 'Show previous plans',
|
||||
scrollNext: 'Show more plans',
|
||||
title: 'Buy with a better search area',
|
||||
|
|
|
|||
|
|
@ -1182,8 +1182,6 @@ const fr: Translations = {
|
|||
},
|
||||
|
||||
pricingPage: {
|
||||
cachedNotice:
|
||||
'Impossible d’actualiser les tarifs en direct ; ces chiffres peuvent être obsolètes.',
|
||||
scrollPrev: 'Afficher les offres précédentes',
|
||||
scrollNext: 'Afficher plus d’offres',
|
||||
title: 'Acheter avec un meilleur secteur de recherche',
|
||||
|
|
|
|||
|
|
@ -1131,7 +1131,6 @@ const hi: Translations = {
|
|||
},
|
||||
|
||||
pricingPage: {
|
||||
cachedNotice: 'हम लाइव मूल्य ताज़ा नहीं कर सके, इसलिए ये आँकड़े पुराने हो सकते हैं।',
|
||||
scrollPrev: 'पिछली योजनाएँ दिखाएँ',
|
||||
scrollNext: 'और योजनाएँ दिखाएँ',
|
||||
title: 'बेहतर खोज क्षेत्र के साथ खरीदें',
|
||||
|
|
|
|||
|
|
@ -1170,7 +1170,6 @@ const hu: Translations = {
|
|||
},
|
||||
|
||||
pricingPage: {
|
||||
cachedNotice: 'Nem sikerült frissíteni az élő árakat, így ezek az adatok elavultak lehetnek.',
|
||||
scrollPrev: 'Előző csomagok megjelenítése',
|
||||
scrollNext: 'További csomagok megjelenítése',
|
||||
title: 'Vásárolj jobb keresési területből kiindulva',
|
||||
|
|
|
|||
|
|
@ -1101,7 +1101,6 @@ const zh: Translations = {
|
|||
},
|
||||
|
||||
pricingPage: {
|
||||
cachedNotice: '无法刷新实时价格,以下数据可能已过时。',
|
||||
scrollPrev: '显示上一组方案',
|
||||
scrollNext: '显示更多方案',
|
||||
title: '用更靠谱的搜索范围去买房',
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#fafaf9" media="(prefers-color-scheme: light)" />
|
||||
<meta name="theme-color" content="#0a0e1a" media="(prefers-color-scheme: dark)" />
|
||||
<meta name="referrer" content="no-referrer" />
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ export const QUALIFICATIONS_FILTER_KEY_PREFIX = `${QUALIFICATIONS_FILTER_NAME}:`
|
|||
* 100% per neighbourhood and render as a single stacked "Qualifications"
|
||||
* composition in the area pane (see STACKED_GROUPS["Neighbours"] in consts.ts).
|
||||
* In the filter browser they fold into one "Qualifications" filter whose
|
||||
* dropdown selects a band — including "% Degree or higher" — rather than seven
|
||||
* dropdown selects a band (including "% Degree or higher") rather than seven
|
||||
* separate sliders.
|
||||
*/
|
||||
export const QUALIFICATION_FEATURE_NAMES = [
|
||||
|
|
|
|||
|
|
@ -77,9 +77,9 @@ export const SEO_LANDING_PAGES: Record<SeoLandingKey, SeoLandingContent> = {
|
|||
title: 'Compare property prices across every postcode in England',
|
||||
metaTitle: 'Property price map for England - Compare postcodes before viewing',
|
||||
metaDescription:
|
||||
'Compare sold prices, estimated current value, price per square metre and local context across English postcodes before searching listings.',
|
||||
'Compare sold prices, estimated current value, price per square metre and local context across English postcodes to find the underpriced ones.',
|
||||
intro:
|
||||
'Perfect Postcode maps sold prices, estimated current value, price per square metre, property type, floor area, tenure, and local context so buyers can find realistic search areas before opening listing portals.',
|
||||
'Perfect Postcode maps sold prices, estimated current value, price per square metre, property type, floor area, tenure, and local context so buyers can rank postcodes by value and find the underpriced areas the market has overlooked.',
|
||||
points: [
|
||||
'Screen historical sale prices and current-value estimates by postcode.',
|
||||
'Compare value with commute, schools, broadband, crime, noise, and amenities.',
|
||||
|
|
@ -714,7 +714,7 @@ export const SEO_CONTENT_PAGES: Record<SeoContentKey, SeoContentPage> = {
|
|||
sections: [
|
||||
{
|
||||
title: 'Your account stays separate from the public site',
|
||||
body: 'The guides, methodology, and support pages are public. Everything tied to your account — your dashboard, saved searches, and invitations — is kept out of public view and out of search engines.',
|
||||
body: 'The guides, methodology, and support pages are public. Everything tied to your account (your dashboard, saved searches, and invitations) is kept out of public view and out of search engines.',
|
||||
},
|
||||
{
|
||||
title: 'Saved searches belong to your account',
|
||||
|
|
|
|||
|
|
@ -8,8 +8,8 @@ export const TENURE_FILTER_KEY_PREFIX = `${TENURE_FILTER_NAME}:`;
|
|||
* The Census 2021 housing tenure categories (TS054). They sum to 100% per
|
||||
* neighbourhood and render as a single stacked "Tenure" composition in the area
|
||||
* pane (see STACKED_GROUPS["Neighbours"] in consts.ts). In the filter browser
|
||||
* they fold into one "Tenure" filter whose dropdown selects a category —
|
||||
* owner-occupied, social rent or private rent — rather than three separate
|
||||
* they fold into one "Tenure" filter whose dropdown selects a category
|
||||
* (owner-occupied, social rent or private rent) rather than three separate
|
||||
* sliders.
|
||||
*/
|
||||
export const TENURE_FEATURE_NAMES = [
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import { createElectionVoteShareFilterKey } from './election-filter';
|
|||
import { createEthnicityFilterKey } from './ethnicity-filter';
|
||||
import { createQualificationFilterKey } from './qualification-filter';
|
||||
import { createTenureFilterKey } from './tenure-filter';
|
||||
import { createCouncilFilterKey } from './council-filter';
|
||||
import {
|
||||
POI_COUNT_2KM_FILTER_NAME,
|
||||
TRANSPORT_DISTANCE_FILTER_NAME,
|
||||
|
|
@ -285,6 +286,52 @@ describe('url-state', () => {
|
|||
expect(parseUrlState().colorOpacity).toBe(0.1);
|
||||
});
|
||||
|
||||
it('round-trips a non-default listings mode and defaults to "all"', () => {
|
||||
// Default mode emits no param, so a fresh reload restores the default.
|
||||
const defaultParams = stateToParams(
|
||||
null,
|
||||
{},
|
||||
[],
|
||||
new Set(),
|
||||
'area',
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
'all'
|
||||
);
|
||||
expect(defaultParams.has('listings')).toBe(false);
|
||||
|
||||
const params = stateToParams(
|
||||
null,
|
||||
{},
|
||||
[],
|
||||
new Set(),
|
||||
'area',
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
'new'
|
||||
);
|
||||
expect(params.get('listings')).toBe('new');
|
||||
|
||||
window.history.replaceState({}, '', `/?${params.toString()}`);
|
||||
expect(parseUrlState().listings).toBe('new');
|
||||
|
||||
// A bare URL falls back to the default; an unknown value is ignored.
|
||||
window.history.replaceState({}, '', '/');
|
||||
expect(parseUrlState().listings).toBe('all');
|
||||
window.history.replaceState({}, '', '/?listings=bogus');
|
||||
expect(parseUrlState().listings).toBe('all');
|
||||
});
|
||||
|
||||
it('round-trips a crime-type subset when the crime overlay is active', () => {
|
||||
const params = stateToParams(
|
||||
null,
|
||||
|
|
@ -581,6 +628,63 @@ describe('url-state', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('round-trips council filters (incl. the shared social-rent column) via the council param', () => {
|
||||
const ex = createCouncilFilterKey('% Ex-council', 1);
|
||||
const current = createCouncilFilterKey('% Social rent', 2);
|
||||
|
||||
const params = stateToParams(
|
||||
null,
|
||||
{
|
||||
[ex]: [10, 90],
|
||||
[current]: [30, 100],
|
||||
},
|
||||
[],
|
||||
new Set(),
|
||||
'area'
|
||||
);
|
||||
|
||||
expect(params.getAll('council')).toEqual(['% Ex-council:10:90', '% Social rent:30:100']);
|
||||
expect(params.getAll('tenure')).toEqual([]);
|
||||
expect(params.getAll('filter')).toEqual([]);
|
||||
|
||||
window.history.replaceState({}, '', `/?${params.toString()}`);
|
||||
const state = parseUrlState();
|
||||
|
||||
expect(state.filters).toEqual({
|
||||
[createCouncilFilterKey('% Ex-council', 0)]: [10, 90],
|
||||
[createCouncilFilterKey('% Social rent', 1)]: [30, 100],
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps a Tenure social-rent filter and a Council "current" filter as distinct params', () => {
|
||||
// The shared "% Social rent" column must serialise under BOTH params without
|
||||
// colliding: the prefixes keep the keys distinct on the way back in.
|
||||
const tenureSocial = createTenureFilterKey('% Social rent', 0);
|
||||
const councilCurrent = createCouncilFilterKey('% Social rent', 0);
|
||||
|
||||
const params = stateToParams(
|
||||
null,
|
||||
{
|
||||
[tenureSocial]: [40, 100],
|
||||
[councilCurrent]: [10, 50],
|
||||
},
|
||||
[],
|
||||
new Set(),
|
||||
'area'
|
||||
);
|
||||
|
||||
expect(params.getAll('tenure')).toEqual(['% Social rent:40:100']);
|
||||
expect(params.getAll('council')).toEqual(['% Social rent:10:50']);
|
||||
|
||||
window.history.replaceState({}, '', `/?${params.toString()}`);
|
||||
const state = parseUrlState();
|
||||
|
||||
expect(state.filters).toEqual({
|
||||
[createTenureFilterKey('% Social rent', 0)]: [40, 100],
|
||||
[createCouncilFilterKey('% Social rent', 0)]: [10, 50],
|
||||
});
|
||||
});
|
||||
|
||||
it('round-trips repeated amenity distance filters with dedicated URL params', () => {
|
||||
const park = createPoiDistanceFilterKey('Distance to nearest amenity (Park) (km)', 3);
|
||||
const cafe = createPoiDistanceFilterKey('Distance to nearest amenity (Café) (km)', 4);
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
Price estimation runs on ``price_inputs.parquet`` (built by ``property_base``
|
||||
straight from epc_pp + arcgis, independently of merge's area features) and emits
|
||||
``price_estimates.parquet`` — the natural key (Postcode + coalesced address) plus
|
||||
``price_estimates.parquet``: the natural key (Postcode + coalesced address) plus
|
||||
``Estimated current price`` / ``Est. price per sqm``. This step joins those two
|
||||
columns onto properties.parquet to produce the file the server consumes.
|
||||
|
||||
|
|
@ -10,7 +10,7 @@ Why the natural key
|
|||
-------------------
|
||||
Estimates and properties are built by separate runs, so a positional row index
|
||||
would not line up. Instead both derive the key ``(Postcode, coalesce(register
|
||||
address, EPC address))`` — which is unique and non-null on the deduped dwelling
|
||||
address, EPC address))``, which is unique and non-null on the deduped dwelling
|
||||
universe (see ``property_base._dedupe_collapsed_properties``) and identical on
|
||||
both sides because both start from that same universe. So estimates map onto
|
||||
properties 1:1 regardless of row order.
|
||||
|
|
|
|||
|
|
@ -1,43 +1,43 @@
|
|||
# postcode_boundaries
|
||||
|
||||
Synthesizes postcode boundary polygons for England and Wales from three datasets. UK postcodes don't have official boundary polygons — Royal Mail defines postcodes as sets of delivery addresses, not geographic areas. This pipeline constructs a plausible polygon for every postcode by combining Output Area boundaries, UPRN point locations, and INSPIRE cadastral parcels.
|
||||
Synthesizes postcode boundary polygons for England and Wales from three datasets. UK postcodes don't have official boundary polygons: Royal Mail defines postcodes as sets of delivery addresses, not geographic areas. This pipeline constructs a plausible polygon for every postcode by combining Output Area boundaries, UPRN point locations, and INSPIRE cadastral parcels.
|
||||
|
||||
## The three input datasets
|
||||
|
||||
**1. Output Area (OA) boundaries** — ONS Census Output Areas are the smallest geographic unit in the UK census (~125 households each). They tile all of England and Wales with no gaps or overlaps. Stored in a GeoPackage in British National Grid (EPSG:27700, meters). ~190,000 OAs.
|
||||
**1. Output Area (OA) boundaries**: ONS Census Output Areas are the smallest geographic unit in the UK census (~125 households each). They tile all of England and Wales with no gaps or overlaps. Stored in a GeoPackage in British National Grid (EPSG:27700, meters). ~190,000 OAs.
|
||||
|
||||
**2. UPRN lookup** — Every Unique Property Reference Number in England and Wales, with its grid coordinates (easting/northing in BNG), its postcode (`PCDS`), and its OA code (`OA21CD`). ~37 million rows. This is the critical bridge: it tells you which postcodes exist inside each OA, and where each address physically sits.
|
||||
**2. UPRN lookup**: Every Unique Property Reference Number in England and Wales, with its grid coordinates (easting/northing in BNG), its postcode (`PCDS`), and its OA code (`OA21CD`). ~37 million rows. This is the critical bridge: it tells you which postcodes exist inside each OA, and where each address physically sits.
|
||||
|
||||
**3. INSPIRE Index Polygons** — Land Registry cadastral parcels covering most of England and Wales. Each ZIP contains a GML file with polygon coordinate lists representing individual land parcels (buildings, plots of land). ~24 million polygons. These give fine-grained building/plot outlines that are much more precise than anything you could derive from point locations alone.
|
||||
**3. INSPIRE Index Polygons**: Land Registry cadastral parcels covering most of England and Wales. Each ZIP contains a GML file with polygon coordinate lists representing individual land parcels (buildings, plots of land). ~24 million polygons. These give fine-grained building/plot outlines that are much more precise than anything you could derive from point locations alone.
|
||||
|
||||
## The four phases
|
||||
|
||||
### Phase 1: Loading data
|
||||
|
||||
**OA boundaries** (`oa_boundaries.py`): Opens the GeoPackage via SQLite, reads every row from `OA_2021_EW_BGC_V2`. Each row's `SHAPE` column is a GeoPackage binary blob — a standard 8-byte header, then a variable-size envelope (bounding box), then WKB geometry. `parse_gpkg_geometry` reads byte 3 to extract the envelope type (0-4), looks up the envelope size, skips past the header, and hands the remaining WKB bytes to Shapely. Single-polygon MultiPolygons are unwrapped. Result: `dict[oa_code, Polygon]`, all in BNG.
|
||||
**OA boundaries** (`oa_boundaries.py`): Opens the GeoPackage via SQLite, reads every row from `OA_2021_EW_BGC_V2`. Each row's `SHAPE` column is a GeoPackage binary blob: a standard 8-byte header, then a variable-size envelope (bounding box), then WKB geometry. `parse_gpkg_geometry` reads byte 3 to extract the envelope type (0-4), looks up the envelope size, skips past the header, and hands the remaining WKB bytes to Shapely. Single-polygon MultiPolygons are unwrapped. Result: `dict[oa_code, Polygon]`, all in BNG.
|
||||
|
||||
**UPRNs** (`uprn.py`): The raw parquet has far more columns than needed. The lazy scan selects only four columns, filters out Scotland (OA codes starting with `S`), drops nulls and blank postcodes (stripping whitespace first), then sorts by OA code. The sort uses `sink_parquet` to write to a temp file — this avoids polars doubling memory from an in-memory sort on ~37M rows.
|
||||
**UPRNs** (`uprn.py`): The raw parquet has far more columns than needed. The lazy scan selects only four columns, filters out Scotland (OA codes starting with `S`), drops nulls and blank postcodes (stripping whitespace first), then sorts by OA code. The sort uses `sink_parquet` to write to a temp file. This avoids polars doubling memory from an in-memory sort on ~37M rows.
|
||||
|
||||
After reading the sorted file back, it builds an offset dictionary. Rather than grouping into Python lists (which would create 37M Python string objects), it detects group boundaries by comparing each row's OA code to the previous row's. The result is `offsets[oa_code] = (start_row, end_row)` — a simple slice into the DataFrame. The OA column is then dropped since it's no longer needed, saving ~400MB.
|
||||
After reading the sorted file back, it builds an offset dictionary. Rather than grouping into Python lists (which would create 37M Python string objects), it detects group boundaries by comparing each row's OA code to the previous row's. The result is `offsets[oa_code] = (start_row, end_row)`: a simple slice into the DataFrame. The OA column is then dropped since it's no longer needed, saving ~400MB.
|
||||
|
||||
`get_oa_uprns` later retrieves a single OA's data by slicing `df[start:end]` and extracting the coordinates and postcodes.
|
||||
|
||||
### Phase 2: INSPIRE data
|
||||
|
||||
INSPIRE comes as ~350 ZIP files, each containing a GML file with thousands of `PREDEFINED` elements. Each element has a `posList` — a flat string of coordinate pairs.
|
||||
INSPIRE comes as ~350 ZIP files, each containing a GML file with thousands of `PREDEFINED` elements. Each element has a `posList`: a flat string of coordinate pairs.
|
||||
|
||||
**Parsing** (`inspire.py:parse_inspire_zip`): Uses `iterparse` for streaming XML parsing (constant memory per ZIP). For each `PREDEFINED` element, extracts the `posList` text, splits into floats, reshapes to Nx2. Calls `elem.clear()` after each element to free XML nodes immediately.
|
||||
|
||||
**Caching** (`inspire.py:cache_inspire`): Parsing 350 ZIPs takes a while, so results are cached as three files:
|
||||
- `inspire_coords.bin` — flat binary dump of all float64 coordinate pairs, streamed to disk as each ZIP is parsed
|
||||
- `inspire_bboxes.npy` — (N, 4) array of `[min_e, min_n, max_e, max_n]` per polygon
|
||||
- `inspire_offsets.npy` — (N, 2) array of `[byte_offset_into_coords_bin, n_points]`
|
||||
- `inspire_coords.bin`: flat binary dump of all float64 coordinate pairs, streamed to disk as each ZIP is parsed
|
||||
- `inspire_bboxes.npy`: (N, 4) array of `[min_e, min_n, max_e, max_n]` per polygon
|
||||
- `inspire_offsets.npy`: (N, 2) array of `[byte_offset_into_coords_bin, n_points]`
|
||||
|
||||
Pre-allocates numpy arrays at 25M capacity and grows by 1.5x if needed (using in-place `resize` with `refcheck=False`). This avoids Python list overhead for 24M polygons. The coords file is written sequentially — each polygon's raw bytes are appended, and its byte offset is recorded.
|
||||
Pre-allocates numpy arrays at 25M capacity and grows by 1.5x if needed (using in-place `resize` with `refcheck=False`). This avoids Python list overhead for 24M polygons. The coords file is written sequentially: each polygon's raw bytes are appended, and its byte offset is recorded.
|
||||
|
||||
**Loading** (`inspire.py:load_inspire`): Bboxes and offsets are loaded into RAM (~1.1GB). Coords are memory-mapped — the OS pages them in on demand from the ~3GB file, never loading the whole thing.
|
||||
**Loading** (`inspire.py:load_inspire`): Bboxes and offsets are loaded into RAM (~1.1GB). Coords are memory-mapped: the OS pages them in on demand from the ~3GB file, never loading the whole thing.
|
||||
|
||||
**Candidate retrieval** (`inspire.py:InspireIndex`): A uniform 1km grid index is built once over the 24M parcel bboxes (`build_inspire_index`). Each OA lookup (`InspireIndex.candidates`) gathers parcels from the cells its bounding box covers plus a small overflow list of parcels larger than one cell, then applies the exact bbox overlap test — O(cells + candidates) instead of an O(24M) scan per OA (the old linear scan was ~4h of the run on its own). The candidate set and order are identical to the scan. Typically matches 10-500 parcels per OA, materialized as Shapely Polygon objects by reading their coordinate slice from the memory-mapped file; invalid polygons are repaired with `make_valid`.
|
||||
**Candidate retrieval** (`inspire.py:InspireIndex`): A uniform 1km grid index is built once over the 24M parcel bboxes (`build_inspire_index`). Each OA lookup (`InspireIndex.candidates`) gathers parcels from the cells its bounding box covers plus a small overflow list of parcels larger than one cell, then applies the exact bbox overlap test: O(cells + candidates) instead of an O(24M) scan per OA (the old linear scan was ~4h of the run on its own). The candidate set and order are identical to the scan. Typically matches 10-500 parcels per OA, materialized as Shapely Polygon objects by reading their coordinate slice from the memory-mapped file; invalid polygons are repaired with `make_valid`.
|
||||
|
||||
### Phase 3: Processing OAs
|
||||
|
||||
|
|
@ -49,7 +49,7 @@ The main loop in `__main__.py` (`_process_oas`) iterates through every OA that h
|
|||
|
||||
#### Stage A: INSPIRE-based claiming
|
||||
|
||||
Build an STRtree spatial index over the INSPIRE candidate polygons. Convert all UPRN points to Shapely Point objects and batch-query the tree with `predicate="intersects"`. This returns pairs of (point_index, candidate_index) — which UPRNs fall inside which parcels.
|
||||
Build an STRtree spatial index over the INSPIRE candidate polygons. Convert all UPRN points to Shapely Point objects and batch-query the tree with `predicate="intersects"`. This returns pairs of (point_index, candidate_index): which UPRNs fall inside which parcels.
|
||||
|
||||
For each INSPIRE parcel that contains at least one UPRN, run a majority vote: whichever postcode has the most UPRNs inside that parcel wins the parcel. Accumulate winning parcels per postcode, union them, and clip to the OA boundary. The result is `claimed[postcode] = polygon_within_oa`.
|
||||
|
||||
|
|
@ -73,15 +73,15 @@ The effect: every non-parcel patch of OA gets assigned to the nearest postcode b
|
|||
|
||||
Each postcode gets its INSPIRE-claimed polygon (if any) plus its Voronoi share (if any). These are unioned together, validated, and stripped of any non-polygonal geometry debris from `make_valid`.
|
||||
|
||||
The output of `process_oa` is `list[(postcode, polygon)]` — the per-OA fragments. A single postcode that spans two OAs produces two separate fragments (one from each OA's processing).
|
||||
The output of `process_oa` is `list[(postcode, polygon)]`: the per-OA fragments. A single postcode that spans two OAs produces two separate fragments (one from each OA's processing).
|
||||
|
||||
### Phase 4: Merging and writing
|
||||
|
||||
**Fragment merging** (`output.py:merge_fragments`): Groups all fragments by postcode, unions them. If the result is a MultiPolygon (meaning the postcode has disconnected pieces — either from spanning OAs with a gap, or algorithm artifacts), applies a 5m buffer-then-unbuffer to close tiny gaps from floating-point mismatches at OA boundary edges. If still a MultiPolygon after that, keeps the largest part **plus any other part ≥ `_MIN_DETACHED_PART_AREA` (100 m²)** (`_keep_polygon_parts`); only sub-100 m² noise slivers are dropped. Keeping substantial detached parts matters because a postcode genuinely split across an OA seam (by a railway, river, or main road wider than the 5m buffer) would otherwise lose a chunk — measured at ~1.8% of merged area left as uncovered gaps (often 3000–5000 m² building blocks) before this change.
|
||||
**Fragment merging** (`output.py:merge_fragments`): Groups all fragments by postcode, unions them. If the result is a MultiPolygon (meaning the postcode has disconnected pieces: either from spanning OAs with a gap, or algorithm artifacts), applies a 5m buffer-then-unbuffer to close tiny gaps from floating-point mismatches at OA boundary edges. If still a MultiPolygon after that, keeps the largest part **plus any other part ≥ `_MIN_DETACHED_PART_AREA` (100 m²)** (`_keep_polygon_parts`); only sub-100 m² noise slivers are dropped. Keeping substantial detached parts matters because a postcode genuinely split across an OA seam (by a railway, river, or main road wider than the 5m buffer) would otherwise lose a chunk, measured at ~1.8% of merged area left as uncovered gaps (often 3000–5000 m² building blocks) before this change.
|
||||
|
||||
**Greenspace subtraction is connectivity-preserving** (`greenspace.py:subtract_greenspace`): park/water polygons are subtracted from each postcode, but greenspace that *crosses* a postcode (a river, a strip of parkland, a golf course through a village) would otherwise split it into scattered pieces. When the subtraction disconnects a postcode, `_reconnect_split` re-adds the narrowest removed necks — a morphological closing (`_RECONNECT_BRIDGE_M`, 25 m) clipped to the original postcode footprint — so parts ≤ ~50 m apart stay joined by a thin bridge of the postcode's own land (no address moves); genuinely wide barriers stay subtracted and the postcode legitimately splits.
|
||||
**Greenspace subtraction is connectivity-preserving** (`greenspace.py:subtract_greenspace`): park/water polygons are subtracted from each postcode, but greenspace that *crosses* a postcode (a river, a strip of parkland, a golf course through a village) would otherwise split it into scattered pieces. When the subtraction disconnects a postcode, `_reconnect_split` re-adds the narrowest removed necks, a morphological closing (`_RECONNECT_BRIDGE_M`, 25 m) clipped to the original postcode footprint, so parts ≤ ~50 m apart stay joined by a thin bridge of the postcode's own land (no address moves); genuinely wide barriers stay subtracted and the postcode legitimately splits.
|
||||
|
||||
**GeoJSON output** (`output.py:write_district_geojson`): three passes. Pass 1 converts every postcode from BNG to WGS84 (pyproj), simplifies with 1m tolerance (Douglas-Peucker), and snaps to 6 decimal places (~0.1m precision); multi-part postcodes become `MultiPolygon` (`to_wgs84_geojson_multi`, each part handled independently), single-part stay `Polygon`. The whole set is then made a **partition** (`_resolve_overlaps`): each postcode is trimmed by the union of its higher-priority overlapping neighbours, where **priority = ascending area** (smaller postcodes win contested ground). That single rule handles both seam overlap *and* containment — an enclosed postcode is always smaller than its container, so it keeps its area while the container gets a hole (the query uses both the `overlaps` and `contains` predicates, since `overlaps` alone excludes containment). This runs last, so nothing re-introduces overlap; a postcode that would be emptied keeps its original geometry, so no active postcode is dropped. Pass 2 **de-fragments** the partition (`_eliminate_small_detached_parts`): a detached part that is *both* small in absolute terms (< `_ELIM_ABS_MAX_M2`, 3000 m²) *and* a minor fraction (< `_ELIM_FRAC_MAX`, 15%) of its postcode is absorbed into the neighbouring postcode it shares the most boundary with — the classic GIS *eliminate*. This removes the Voronoi/INSPIRE/seam *scatter* that left ~1/3 of postcodes non-contiguous, while a genuine bisection (two substantial parts split by a river/railway) keeps both parts. The land is **reassigned**, never dropped, so the output stays a gapless partition and coverage is conserved; the largest part of every postcode is always retained, so no active postcode is dropped (a tiny neighbour-less sliver in removed greenspace is dropped, a larger isolated patch is kept). Pass 3 groups postcodes by district (the outward code, e.g. `SW1A` from `SW1A 1AA`), rounds coordinates to 6dp, and writes a `{district}.geojson` FeatureCollection. Each Feature has `postcodes` (formatted like `"SW1A 1AA"`) and `mapit_code` (no space: `"SW1A1AA"`) in its properties.
|
||||
**GeoJSON output** (`output.py:write_district_geojson`): three passes. Pass 1 converts every postcode from BNG to WGS84 (pyproj), simplifies with 1m tolerance (Douglas-Peucker), and snaps to 6 decimal places (~0.1m precision); multi-part postcodes become `MultiPolygon` (`to_wgs84_geojson_multi`, each part handled independently), single-part stay `Polygon`. The whole set is then made a **partition** (`_resolve_overlaps`): each postcode is trimmed by the union of its higher-priority overlapping neighbours, where **priority = ascending area** (smaller postcodes win contested ground). That single rule handles both seam overlap *and* containment: an enclosed postcode is always smaller than its container, so it keeps its area while the container gets a hole (the query uses both the `overlaps` and `contains` predicates, since `overlaps` alone excludes containment). This runs last, so nothing re-introduces overlap; a postcode that would be emptied keeps its original geometry, so no active postcode is dropped. Pass 2 **de-fragments** the partition (`_eliminate_small_detached_parts`): a detached part that is *both* small in absolute terms (< `_ELIM_ABS_MAX_M2`, 3000 m²) *and* a minor fraction (< `_ELIM_FRAC_MAX`, 15%) of its postcode is absorbed into the neighbouring postcode it shares the most boundary with: the classic GIS *eliminate*. This removes the Voronoi/INSPIRE/seam *scatter* that left ~1/3 of postcodes non-contiguous, while a genuine bisection (two substantial parts split by a river/railway) keeps both parts. The land is **reassigned**, never dropped, so the output stays a gapless partition and coverage is conserved; the largest part of every postcode is always retained, so no active postcode is dropped (a tiny neighbour-less sliver in removed greenspace is dropped, a larger isolated patch is kept). Pass 3 groups postcodes by district (the outward code, e.g. `SW1A` from `SW1A 1AA`), rounds coordinates to 6dp, and writes a `{district}.geojson` FeatureCollection. Each Feature has `postcodes` (formatted like `"SW1A 1AA"`) and `mapit_code` (no space: `"SW1A1AA"`) in its properties.
|
||||
|
||||
## Memory architecture
|
||||
|
||||
|
|
@ -97,7 +97,7 @@ The pipeline is designed to run in <12GB:
|
|||
| Fragments | Python list of (str, Shapely) | grows during processing |
|
||||
|
||||
Key design choices:
|
||||
- INSPIRE coords are memory-mapped, not loaded — the OS pages in only the ~100-500 polygons needed per OA
|
||||
- INSPIRE coords are memory-mapped, not loaded: the OS pages in only the ~100-500 polygons needed per OA
|
||||
- UPRNs sorted + offset dict avoids per-OA groupby allocation
|
||||
- `sink_parquet` for the sort avoids doubling memory
|
||||
- `release_memory()` calls `gc.collect()` + glibc `malloc_trim(0)` to return freed pages to the OS between phases
|
||||
|
|
@ -105,25 +105,25 @@ Key design choices:
|
|||
|
||||
## Key invariants
|
||||
|
||||
1. **No two postcodes cover the same ground in the output** — within an OA the INSPIRE claiming + Voronoi tile it with no overlap, and a final `_resolve_overlaps` partition pass removes the thin overlap strips that the merge buffer + per-postcode simplification introduce across OA seams (measured residual overlap ~0.01% of area)
|
||||
2. **Every postcode that exists in the UPRN data gets a polygon** — unless all its UPRNs share coordinates with another postcode's UPRNs (handled by jitter) or it has zero UPRNs
|
||||
3. **Postcode polygons never extend outside their OA(s)** — all geometry is clipped to OA boundaries
|
||||
4. **A postcode split across an OA seam keeps all its substantial parts** — `merge_fragments` keeps every part ≥ 100 m² and the output is emitted as a `MultiPolygon` (the Rust server `postcodes.rs` and `loader.py` both parse MultiPolygon); only sub-100 m² noise slivers are dropped
|
||||
5. **Postcodes are contiguous unless genuinely split** — most non-contiguity is *scatter* (a unit drawn as many disconnected specks) from point-Voronoi over sparse/interleaved UPRNs, greenspace cutting across a unit, and overlap/seam slivers. Connectivity-preserving greenspace subtraction + the `_eliminate_small_detached_parts` de-fragmentation pass absorb that scatter into neighbours (coverage-conserving), cutting the share of multi-part postcodes roughly in half (~30% → ~14% measured on the worst rural/coastal districts) without dropping any postcode or leaving coverage gaps. Genuine bisections (river/railway/major road, or a detached part above the absolute+fraction thresholds) are preserved.
|
||||
1. **No two postcodes cover the same ground in the output**: within an OA the INSPIRE claiming + Voronoi tile it with no overlap, and a final `_resolve_overlaps` partition pass removes the thin overlap strips that the merge buffer + per-postcode simplification introduce across OA seams (measured residual overlap ~0.01% of area)
|
||||
2. **Every postcode that exists in the UPRN data gets a polygon**: unless all its UPRNs share coordinates with another postcode's UPRNs (handled by jitter) or it has zero UPRNs
|
||||
3. **Postcode polygons never extend outside their OA(s)**: all geometry is clipped to OA boundaries
|
||||
4. **A postcode split across an OA seam keeps all its substantial parts**: `merge_fragments` keeps every part ≥ 100 m² and the output is emitted as a `MultiPolygon` (the Rust server `postcodes.rs` and `loader.py` both parse MultiPolygon); only sub-100 m² noise slivers are dropped
|
||||
5. **Postcodes are contiguous unless genuinely split**: most non-contiguity is *scatter* (a unit drawn as many disconnected specks) from point-Voronoi over sparse/interleaved UPRNs, greenspace cutting across a unit, and overlap/seam slivers. Connectivity-preserving greenspace subtraction + the `_eliminate_small_detached_parts` de-fragmentation pass absorb that scatter into neighbours (coverage-conserving), cutting the share of multi-part postcodes roughly in half (~30% → ~14% measured on the worst rural/coastal districts) without dropping any postcode or leaving coverage gaps. Genuine bisections (river/railway/major road, or a detached part above the absolute+fraction thresholds) are preserved.
|
||||
|
||||
## Module structure
|
||||
|
||||
```
|
||||
postcode_boundaries/
|
||||
__init__.py — Package docstring
|
||||
__main__.py — CLI entry point, four-phase orchestration
|
||||
memory.py — release_memory() glibc malloc_trim helper
|
||||
oa_boundaries.py — GeoPackage parsing, OA boundary loading
|
||||
uprn.py — UPRN loading (sorted DataFrame + offset dict), per-OA access
|
||||
inspire.py — INSPIRE GML parsing, caching, loading, bbox candidate retrieval
|
||||
voronoi.py — Voronoi region computation clipped to boundary
|
||||
process_oa.py — Per-OA processing (INSPIRE assignment + Voronoi fallback)
|
||||
output.py — BNG to WGS84 transform, fragment merging, GeoJSON writing
|
||||
__init__.py : Package docstring
|
||||
__main__.py : CLI entry point, four-phase orchestration
|
||||
memory.py : release_memory() glibc malloc_trim helper
|
||||
oa_boundaries.py : GeoPackage parsing, OA boundary loading
|
||||
uprn.py : UPRN loading (sorted DataFrame + offset dict), per-OA access
|
||||
inspire.py : INSPIRE GML parsing, caching, loading, bbox candidate retrieval
|
||||
voronoi.py : Voronoi region computation clipped to boundary
|
||||
process_oa.py : Per-OA processing (INSPIRE assignment + Voronoi fallback)
|
||||
output.py : BNG to WGS84 transform, fragment merging, GeoJSON writing
|
||||
```
|
||||
|
||||
Invoked as:
|
||||
|
|
|
|||
|
|
@ -192,7 +192,7 @@ def build_fragments(args: argparse.Namespace) -> list[Fragment]:
|
|||
print("Phase 3: Processing OAs")
|
||||
print("=" * 60)
|
||||
|
||||
# Build work list — precompute which OAs are single vs multi-postcode
|
||||
# Build work list: precompute which OAs are single vs multi-postcode
|
||||
oa_codes_with_data = sorted(set(oa_geoms.keys()) & set(uprn_offsets.keys()))
|
||||
skipped_no_uprn = len(oa_geoms) - len(oa_codes_with_data)
|
||||
skipped_no_boundary = len(uprn_offsets) - len(oa_codes_with_data)
|
||||
|
|
@ -275,7 +275,7 @@ def main() -> None:
|
|||
|
||||
if use_cache and fragments_cache_is_fresh(fragments_cache, fragment_inputs):
|
||||
print("=" * 60)
|
||||
print("Phase 3 cache hit — loading fragments (skipping Phases 1-3)")
|
||||
print("Phase 3 cache hit: loading fragments (skipping Phases 1-3)")
|
||||
print("=" * 60)
|
||||
all_fragments = load_fragments(fragments_cache)
|
||||
print(
|
||||
|
|
|
|||
|
|
@ -81,7 +81,7 @@ class TestFirstOADropped:
|
|||
"""E00000001 is the first OA after sorting. It must appear in offsets."""
|
||||
df, offsets = load_uprns(uprn_parquet)
|
||||
assert "E00000001" in offsets, (
|
||||
"First OA (E00000001) missing from offsets — shift(1) null comparison bug"
|
||||
"First OA (E00000001) missing from offsets: shift(1) null comparison bug"
|
||||
)
|
||||
|
||||
def test_all_oas_present(self, uprn_parquet):
|
||||
|
|
@ -221,7 +221,7 @@ class TestWhitespacePostcodes:
|
|||
|
||||
# The remapped point must be grouped under the successor's OA, not the
|
||||
# terminated postcode's OA.
|
||||
assert "E00000002" in offsets, "Successor OA missing — remap kept old OA"
|
||||
assert "E00000002" in offsets, "Successor OA missing: remap kept old OA"
|
||||
assert "E00000001" not in offsets, (
|
||||
"Remapped point still lives in the terminated postcode's OA"
|
||||
)
|
||||
|
|
@ -304,10 +304,10 @@ class TestVoronoiDeduplication:
|
|||
# Plus postcode A has one at a different location
|
||||
points = np.array(
|
||||
[
|
||||
[500020, 180050], # postcode A — unique location
|
||||
[500050, 180050], # postcode A — shared location
|
||||
[500050, 180050], # postcode B — shared location (same coords)
|
||||
[500080, 180050], # postcode B — unique location
|
||||
[500020, 180050], # postcode A: unique location
|
||||
[500050, 180050], # postcode A: shared location
|
||||
[500050, 180050], # postcode B: shared location (same coords)
|
||||
[500080, 180050], # postcode B: unique location
|
||||
]
|
||||
)
|
||||
postcodes = ["A", "A", "B", "B"]
|
||||
|
|
@ -321,7 +321,7 @@ class TestVoronoiDeduplication:
|
|||
points = np.array(
|
||||
[
|
||||
[500050, 180050], # postcode A
|
||||
[500050, 180050], # postcode B — identical coords
|
||||
[500050, 180050], # postcode B: identical coords
|
||||
]
|
||||
)
|
||||
postcodes = ["A", "B"]
|
||||
|
|
@ -362,11 +362,11 @@ class TestVoronoiCoincidentClusterNotCrushed:
|
|||
boundary = box(0, 0, 1000, 1000)
|
||||
points = np.array(
|
||||
[
|
||||
[500, 500], # A — coincident
|
||||
[500, 500], # B — coincident
|
||||
[500, 500], # C — coincident
|
||||
[500, 500], # D — coincident
|
||||
[100, 100], # OUT — elsewhere in the OA
|
||||
[500, 500], # A: coincident
|
||||
[500, 500], # B: coincident
|
||||
[500, 500], # C: coincident
|
||||
[500, 500], # D: coincident
|
||||
[100, 100], # OUT: elsewhere in the OA
|
||||
],
|
||||
dtype=np.float64,
|
||||
)
|
||||
|
|
@ -412,7 +412,7 @@ class TestVoronoiCollinear:
|
|||
"""Collinear points (handled by dummy corners) must distribute area fairly."""
|
||||
|
||||
def test_collinear_points_all_postcodes_get_area(self, square_boundary):
|
||||
"""Points along a line — every postcode must get area."""
|
||||
"""Points along a line: every postcode must get area."""
|
||||
points = np.array(
|
||||
[
|
||||
[500020, 180050],
|
||||
|
|
@ -483,14 +483,14 @@ class TestProcessOAGeometryTypes:
|
|||
def test_overlapping_inspire_no_postcode_overlap(self):
|
||||
"""Overlapping INSPIRE parcels assigned to different postcodes must not overlap."""
|
||||
oa_geom = box(500000, 180000, 500100, 180100)
|
||||
# Two overlapping parcels — left half and a wider middle section
|
||||
# Two overlapping parcels: left half and a wider middle section
|
||||
parcel_left = box(500000, 180000, 500060, 180100)
|
||||
parcel_right = box(500040, 180000, 500100, 180100) # overlaps left by 20m
|
||||
# UPRN in left parcel → postcode A, UPRN in right parcel → postcode B
|
||||
points = np.array(
|
||||
[
|
||||
[500020, 180050], # postcode A — inside left parcel
|
||||
[500080, 180050], # postcode B — inside right parcel
|
||||
[500020, 180050], # postcode A: inside left parcel
|
||||
[500080, 180050], # postcode B: inside right parcel
|
||||
]
|
||||
)
|
||||
postcodes = ["A", "B"]
|
||||
|
|
@ -744,7 +744,7 @@ class TestProcessOAInspireParcelAssignment:
|
|||
[
|
||||
[20, 50], # postcode A
|
||||
[30, 50], # postcode A (majority)
|
||||
[80, 50], # postcode B (minority — would be dropped pre-fix)
|
||||
[80, 50], # postcode B (minority, would be dropped pre-fix)
|
||||
]
|
||||
)
|
||||
postcodes = ["A", "A", "B"]
|
||||
|
|
@ -762,7 +762,7 @@ class TestProcessOAInspireParcelAssignment:
|
|||
|
||||
class TestProcessOASeedFootprintGuarantee:
|
||||
"""Every postcode with a UPRN seed in a multi-postcode OA must produce a
|
||||
fragment, even when its partition cell collapses below MIN_GEOM_AREA — an
|
||||
fragment, even when its partition cell collapses below MIN_GEOM_AREA. An
|
||||
active postcode must never be dropped (validate_outputs is zero-tolerance)."""
|
||||
|
||||
def test_collapsed_voronoi_cells_rescued_as_footprints(self):
|
||||
|
|
@ -935,7 +935,7 @@ class TestToWgs84Geojson:
|
|||
to_bng = pyproj.Transformer.from_crs(
|
||||
"EPSG:4326", "EPSG:27700", always_xy=True
|
||||
)
|
||||
# 0.9m x 0.9m square: area 0.81 m², perimeter 3.6 m — pointlike, yet
|
||||
# 0.9m x 0.9m square: area 0.81 m², perimeter 3.6 m. Pointlike, yet
|
||||
# large enough (~8 output-grid cells) to survive the 1e-6 deg snap.
|
||||
tiny = box(530000, 180000, 530000.9, 180000.9)
|
||||
from .output import _snap_to_wgs84_geojson
|
||||
|
|
@ -969,7 +969,7 @@ class TestToWgs84Geojson:
|
|||
|
||||
def test_thin_sliver_keeps_minimal_buffer(self):
|
||||
"""A genuine elongated sliver still carries length, so it is NOT inflated
|
||||
to building scale — only truly pointlike inputs are."""
|
||||
to building scale. Only truly pointlike inputs are."""
|
||||
import pyproj
|
||||
from shapely.geometry import LineString, shape
|
||||
from shapely.ops import transform as transform_geometry
|
||||
|
|
@ -1052,7 +1052,7 @@ class TestFillHoles:
|
|||
assert result.area == pytest.approx(Polygon(outer).area)
|
||||
|
||||
def test_large_hole_kept(self):
|
||||
"""A large (>=1000 m²) hole is likely a real enclosed postcode — keep it."""
|
||||
"""A large (>=1000 m²) hole is likely a real enclosed postcode, so keep it."""
|
||||
outer = [(0, 0), (100, 0), (100, 100), (0, 100), (0, 0)]
|
||||
hole = [(20, 20), (80, 20), (80, 80), (20, 80), (20, 20)] # 60x60 = 3600 m²
|
||||
poly_with_hole = Polygon(outer, [hole])
|
||||
|
|
@ -1260,7 +1260,7 @@ class TestKeepDetachedParts:
|
|||
instead of dropping all but the largest, which left ~1.8% uncovered gaps."""
|
||||
|
||||
def test_far_apart_parts_both_kept(self):
|
||||
# Two 50x50m blocks 30m apart — wider than the 10m merge buffer.
|
||||
# Two 50x50m blocks 30m apart, wider than the 10m merge buffer.
|
||||
a = box(0, 0, 50, 50) # 2500 m²
|
||||
b = box(80, 0, 130, 50) # 2500 m², 30m gap
|
||||
geom = merge_fragments([("AA1 1AA", a), ("AA1 1AA", b)])["AA1 1AA"]
|
||||
|
|
@ -1421,7 +1421,7 @@ class TestGeojsonGeometrySliverValidity:
|
|||
class TestColocatedPostcodesAllRetained:
|
||||
"""Co-located non-geographic postcodes (e.g. AL1 9xx) have heavily-overlapping
|
||||
tiny footprints; the de-overlap pass trims most to sub-grid slivers. None may
|
||||
be dropped — every active postcode must keep a (valid, non-empty) boundary."""
|
||||
be dropped: every active postcode must keep a (valid, non-empty) boundary."""
|
||||
|
||||
def test_overlapping_tiny_footprints_none_dropped(self, tmp_path):
|
||||
from shapely.geometry import Point, shape
|
||||
|
|
@ -1447,7 +1447,7 @@ class TestSafeOverlayHelpers:
|
|||
"""The robust overlay helpers retry on a fixed-precision grid after a
|
||||
GEOSException (e.g. ``side location conflict`` from near-coincident OA-seam
|
||||
edges). The grid is a caller-supplied parameter: metres for the BNG stages,
|
||||
1e-6 degrees for the WGS84 output stage — so the same helper serves both
|
||||
1e-6 degrees for the WGS84 output stage, so the same helper serves both
|
||||
without crushing degree-scale shapes on the metre default."""
|
||||
|
||||
def test_grid_param_honored_on_clean_inputs(self):
|
||||
|
|
@ -1497,7 +1497,7 @@ class TestSafeOverlayHelpers:
|
|||
|
||||
# A self-intersecting bow-tie: invalid. set_precision()'s DEFAULT
|
||||
# ('valid_output') mode runs its own noding pass that re-raises
|
||||
# 'side location conflict' on this — which is exactly how the production
|
||||
# 'side location conflict' on this, which is exactly how the production
|
||||
# crash happened (the fallback re-raised the error it was meant to absorb).
|
||||
_BOWTIE = Polygon([(0, 0), (1e-5, 1e-5), (1e-5, 0), (0, 1e-5), (0, 0)])
|
||||
# make_valid() of this spiky ring returns a mixed-dimension
|
||||
|
|
@ -1831,7 +1831,7 @@ class TestFragmentsCache:
|
|||
def test_missing_input_is_ignored(self, tmp_path):
|
||||
cache = tmp_path / "fragments_cache.parquet"
|
||||
cache.write_text("c")
|
||||
# arcgis is optional/absent — it cannot have invalidated the cache.
|
||||
# arcgis is optional/absent, so it cannot have invalidated the cache.
|
||||
assert fragments_cache_is_fresh(cache, [tmp_path / "absent.parquet"]) is True
|
||||
|
||||
|
||||
|
|
@ -1885,7 +1885,7 @@ class TestEliminateSmallDetachedParts:
|
|||
assert after == pytest.approx(before, rel=1e-6), "coverage must be conserved"
|
||||
|
||||
def test_genuine_large_split_is_kept(self):
|
||||
# Two substantial parts (both 40000 m²) far apart — a real bisection, not
|
||||
# Two substantial parts (both 40000 m²) far apart: a real bisection, not
|
||||
# scatter. Neither is below the absolute/fraction thresholds, so both stay.
|
||||
a = MultiPolygon([_mbox(0, 0, 200, 200), _mbox(400, 0, 600, 200)])
|
||||
out = _as_dict(_eliminate_small_detached_parts([("A", a)]))
|
||||
|
|
@ -1909,8 +1909,8 @@ class TestEliminateSmallDetachedParts:
|
|||
assert out["A"].geom_type == "Polygon"
|
||||
|
||||
def test_largest_part_always_retained_no_postcode_dropped(self):
|
||||
# Even a postcode that is ENTIRELY a tiny sliver keeps its (largest) part —
|
||||
# active postcodes must never be dropped (validate_outputs is zero-tolerance).
|
||||
# Even a postcode that is ENTIRELY a tiny sliver keeps its (largest) part.
|
||||
# Active postcodes must never be dropped (validate_outputs is zero-tolerance).
|
||||
tiny = _mbox(500, 500, 505, 505) # 25 m², the whole postcode
|
||||
big = _mbox(0, 0, 300, 300)
|
||||
out = _as_dict(
|
||||
|
|
@ -1961,7 +1961,7 @@ class TestEliminateSmallDetachedParts:
|
|||
assert out["A"].geom_type == "Polygon"
|
||||
|
||||
def test_gapped_sliver_within_nearest_radius_is_reassigned_not_dropped(self):
|
||||
# A 300 m² sliver 1.2 m from its only neighbour N — beyond the border probe
|
||||
# A 300 m² sliver 1.2 m from its only neighbour N: beyond the border probe
|
||||
# but inside the nearest-fallback radius. Before the gather buffer was
|
||||
# widened to the accept radius, the old (smaller) gather buffer never
|
||||
# returned N, so the sliver fell through to the drop branch. Now it is
|
||||
|
|
@ -2017,7 +2017,7 @@ class TestGreenspaceReconnect:
|
|||
from shapely.strtree import STRtree
|
||||
|
||||
postcode = box(0, 0, 200, 100)
|
||||
barrier = box(60, 0, 130, 100) # 70 m wide — beyond 2x the bridge width
|
||||
barrier = box(60, 0, 130, 100) # 70 m wide, beyond 2x the bridge width
|
||||
result = subtract_greenspace(postcode, STRtree([barrier]), [barrier])
|
||||
assert result.geom_type == "MultiPolygon", "wide barrier should stay split"
|
||||
assert len(result.geoms) == 2
|
||||
|
|
@ -2035,7 +2035,7 @@ class TestGreenspaceReconnect:
|
|||
|
||||
def test_result_is_always_polygonal(self):
|
||||
# Regression: _reconnect_split must never return a GeometryCollection
|
||||
# (line/point debris) — downstream to_wgs84_geojson_multi would truncate a
|
||||
# (line/point debris). Downstream to_wgs84_geojson_multi would truncate a
|
||||
# GC to a single part, silently dropping substantial pieces. Sweep many
|
||||
# strip widths/offsets (incl. coincident-edge-prone integer geometries).
|
||||
from shapely.strtree import STRtree
|
||||
|
|
|
|||
|
|
@ -22,13 +22,13 @@ def compute_voronoi_regions(
|
|||
points = points.astype(np.float64)
|
||||
|
||||
# Deduplicate points, keeping one per (location, postcode) pair. Coords are
|
||||
# rounded to mm precision for stable hashing — UPRN inputs are already integer
|
||||
# rounded to mm precision for stable hashing. UPRN inputs are already integer
|
||||
# metres, but the float64 cast can introduce ULP noise.
|
||||
#
|
||||
# Where several DISTINCT postcodes share one coordinate, jitter ALL of them
|
||||
# onto a small regular polygon (equal 0.01m radius, equally spaced by angle)
|
||||
# so their Voronoi cells become equal wedges and NONE is crushed. Leaving any
|
||||
# seed at the centre — or innermost on a spiral — squeezes its cell below
|
||||
# seed at the centre (or innermost on a spiral) squeezes its cell below
|
||||
# MIN_GEOM_AREA, which _clean_polygonal then drops downstream, silently losing
|
||||
# an active postcode. Seeds at a UNIQUE coordinate are left exactly on their
|
||||
# UPRN (no perturbation of normal Voronoi output). Coords are rounded to mm
|
||||
|
|
|
|||
|
|
@ -73,7 +73,7 @@ def _remap_terminated_postcodes(
|
|||
|
||||
|
||||
def _dedupe_collapsed_properties(wide: pl.LazyFrame) -> pl.LazyFrame:
|
||||
"""Keep one row per (postcode, address) — the most-recent transaction.
|
||||
"""Keep one row per (postcode, address): the most-recent transaction.
|
||||
|
||||
The terminated-postcode remap can map two distinct postcodes onto one active
|
||||
successor, collapsing the same physical address onto a single
|
||||
|
|
@ -87,7 +87,7 @@ def _dedupe_collapsed_properties(wide: pl.LazyFrame) -> pl.LazyFrame:
|
|||
(postcode, null) key and silently drop all but one. Each dwelling's coalesced
|
||||
address is unique within its postcode (the EPC frame is deduped on
|
||||
address+postcode upstream), so the coalesced key keeps them distinct while
|
||||
leaving sold-property dedup unchanged — pp_address wins the coalesce whenever
|
||||
leaving sold-property dedup unchanged, since pp_address wins the coalesce whenever
|
||||
a sale exists.
|
||||
"""
|
||||
return (
|
||||
|
|
@ -153,7 +153,7 @@ def build_postcode_centroids(arcgis_path: Path) -> pl.LazyFrame:
|
|||
This is the lat/lon source price estimation needs (index sector centroids,
|
||||
kNN). It is the same per-postcode lat/lon merge writes into postcode.parquet
|
||||
(both come from arcgis), but built straight from arcgis so the index/estimate
|
||||
steps do not depend on the merge output — adding an area column to merge
|
||||
steps do not depend on the merge output. Adding an area column to merge
|
||||
therefore does not invalidate the expensive price index/kNN.
|
||||
"""
|
||||
return _active_english_postcode_area(pl.scan_parquet(arcgis_path)).select(
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ def _require_tippecanoe() -> str:
|
|||
def _write_property_geojsonseq(inspire_dir: Path, output_path: Path) -> int:
|
||||
"""Stream INSPIRE parcels to a WGS84 GeoJSONSeq file, one feature per line.
|
||||
|
||||
Features carry no properties — the overlay only draws outlines, so dropping
|
||||
Features carry no properties. The overlay only draws outlines, so dropping
|
||||
attributes keeps the tiles as small as possible. Reprojection and GeoJSON
|
||||
encoding are vectorised per ZIP (one local authority) to bound memory while
|
||||
staying in shapely's C path.
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ are set per admission authority, only a handful of councils publish polygons,
|
|||
and the pupil-residence data behind commercial "heatmap" catchments lives in
|
||||
the restricted National Pupil Database. This module therefore COMPILES one
|
||||
from open data, estimating each school's admission cutoff distance ("last
|
||||
distance offered") — the radius within which an applicant would plausibly be
|
||||
distance offered"): the radius within which an applicant would plausibly be
|
||||
offered a place.
|
||||
|
||||
Model: English state admissions are run as deferred acceptance with distance
|
||||
|
|
@ -13,30 +13,30 @@ tie-breaks, which in a continuum economy is equivalent to finding
|
|||
market-clearing cutoff distances (Azevedo & Leshno 2016). Per phase
|
||||
(primary/secondary):
|
||||
|
||||
1. Demand — Census 2021 children per LSOA (TS007A age bands, prorated to the
|
||||
1. Demand: Census 2021 children per LSOA (TS007A age bands, prorated to the
|
||||
phase's cohort ages) split evenly across the LSOA's live postcodes.
|
||||
2. Supply — every open, non-selective state-funded school (GIAS), with a fill
|
||||
2. Supply: every open, non-selective state-funded school (GIAS), with a fill
|
||||
target of max(capacity, headcount) prorated to the phase's cohorts
|
||||
(sixth-form and nursery years carry reduced weight, since their class
|
||||
sizes differ and they are not allocated by the same admissions round).
|
||||
3. Preferences — children prefer nearby schools, trading distance against
|
||||
3. Preferences: children prefer nearby schools, trading distance against
|
||||
Ofsted grade: a school's effective distance is its real distance minus a
|
||||
grade bonus (Outstanding > Good > ungraded > below-Good). Because real
|
||||
first preferences are heterogeneous, each postcode's children split
|
||||
across nearby feasible schools with logit weights over effective
|
||||
distance rather than all picking the same one.
|
||||
4. Equilibrium — cutoffs start unbounded and tighten monotonically: each
|
||||
4. Equilibrium: cutoffs start unbounded and tighten monotonically. Each
|
||||
round, children apply to their preferred feasible school(s), and
|
||||
oversubscribed schools tighten their cutoff to the distance of their
|
||||
marginal admitted child. Converges to the deferred-acceptance outcome.
|
||||
5. Schools that never fill have no binding cutoff — anyone who applies gets
|
||||
in — so their feasibility radius is the distance within which the local
|
||||
5. Schools that never fill have no binding cutoff (anyone who applies gets
|
||||
in), so their feasibility radius is the distance within which the local
|
||||
child population would cover their fill target, capped.
|
||||
|
||||
The free parameters (preference bonuses, demand scale, choice temperature,
|
||||
residual calibration factors) are CALIBRATED against published "last
|
||||
distance offered" figures scraped from nine local authorities' allocation
|
||||
reports — see check_school_cutoffs.py and the constants below.
|
||||
reports. See check_school_cutoffs.py and the constants below.
|
||||
|
||||
A postcode is "inside the catchment" of every school whose cutoff radius
|
||||
covers it. The output counts those schools per postcode for the four
|
||||
|
|
@ -71,7 +71,7 @@ SCHOOL_GROUPS = {
|
|||
# PRIMARY-age children if its statutory lowest age is <= 10, and SECONDARY-age
|
||||
# children if its statutory highest age is >= 12. All-through (e.g. 3-18) and
|
||||
# middle-deemed-secondary (e.g. 9-13) schools satisfy BOTH and so are counted in
|
||||
# both the primary and the secondary metrics — Ofsted's coarse "Ofsted phase"
|
||||
# both the primary and the secondary metrics. Ofsted's coarse "Ofsted phase"
|
||||
# labels such schools as just "Secondary", which previously hid them from every
|
||||
# postcode's primary-school count.
|
||||
PRIMARY_MAX_AGE = 10
|
||||
|
|
@ -133,7 +133,7 @@ CHOICE_TEMPERATURE_KM = 0.3
|
|||
|
||||
# Residual calibration from the same ground truth: after the equilibrium
|
||||
# solve, modelled cutoffs still ran systematically tight (median log2 bias
|
||||
# -0.53 primary / -0.36 secondary at the settings above — published "last
|
||||
# -0.53 primary / -0.36 secondary at the settings above; published "last
|
||||
# distance offered" reflects offer-day frictions, waiting-list churn and
|
||||
# furthest-applicant noise that no clean equilibrium reproduces). Radii are
|
||||
# multiplied by 2^-bias so the modelled median matches the published median;
|
||||
|
|
@ -166,7 +166,7 @@ def classify_good_plus_schools(
|
|||
Framework). A large and growing share of schools were last inspected under an
|
||||
UNGRADED (Section 8) inspection or the post-2024 report-card framework, so
|
||||
that column is null/"Not judged" for them even when they are demonstrably
|
||||
good — their status lives in "Ungraded inspection overall outcome" ("School
|
||||
good. Their status lives in "Ungraded inspection overall outcome" ("School
|
||||
remains Good"/"School remains Outstanding"). Filtering on the graded column
|
||||
alone dropped ~7,000 genuinely good/outstanding schools. We fall back to the
|
||||
ungraded outcome, but ONLY when there is no usable graded result
|
||||
|
|
@ -296,15 +296,15 @@ def phase_intakes(gias: pl.DataFrame) -> pl.DataFrame:
|
|||
|
||||
Returns one row per open, non-selective state-funded school with valid
|
||||
coordinates: ``(urn, lat, lng, primary_intake, secondary_intake)``. The
|
||||
fill target — max(capacity, headcount), so over-full schools keep their
|
||||
demonstrated size and under-full schools can admit up to capacity — is
|
||||
fill target (max(capacity, headcount), so over-full schools keep their
|
||||
demonstrated size and under-full schools can admit up to capacity) is
|
||||
spread over the cohort ages the school teaches (parsed from ``age_range``,
|
||||
e.g. "3–11" = ages 3..10) with nursery and sixth-form ages down-weighted,
|
||||
and each phase receives the share of cohort weight in its age band.
|
||||
"""
|
||||
# gias._format_age_range emits three shapes: "{low}–{high}", "up to {high}"
|
||||
# (StatutoryLowAge missing) and "{low}+" (StatutoryHighAge missing). Parse
|
||||
# all three — the one-sided shapes previously fell through the two-number
|
||||
# all three. The one-sided shapes previously fell through the two-number
|
||||
# parse and silently dropped the school from the catchment supply.
|
||||
age = pl.col("age_range")
|
||||
leading = age.str.extract(r"^\s*(\d+)", 1).cast(pl.Int64, strict=False)
|
||||
|
|
@ -426,10 +426,10 @@ def equilibrium_cutoffs(
|
|||
|
||||
Deferred acceptance with distance priority, solved as cutoff dynamics
|
||||
(Azevedo & Leshno): cutoffs start unbounded; each round every child unit
|
||||
applies to its preferred feasible school(s) — a logit split over
|
||||
applies to its preferred feasible school(s): a logit split over
|
||||
effective distance (distance - school bonus) among schools whose cutoff
|
||||
covers it, collapsing to the single best school when ``tau_km`` is 0 —
|
||||
and each oversubscribed school tightens its cutoff to its marginal
|
||||
covers it, collapsing to the single best school when ``tau_km`` is 0.
|
||||
Each oversubscribed school then tightens its cutoff to its marginal
|
||||
admitted child's distance. Cutoffs only ever tighten, so the iteration
|
||||
converges.
|
||||
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ from pipeline.transform.merge import (
|
|||
_canonical_postcode_expr,
|
||||
_best_listing_match,
|
||||
_coalesce_direct_epc_columns,
|
||||
_epc_council_by_postcode,
|
||||
_fill_property_level_no_defaults,
|
||||
_join_area_side_tables,
|
||||
_finalize_listings,
|
||||
|
|
@ -117,6 +118,86 @@ def test_tree_density_is_area_level_and_survives_the_split() -> None:
|
|||
assert TREE_DENSITY_FEATURE not in properties_df.columns
|
||||
|
||||
|
||||
def test_epc_council_by_postcode_aggregates_social_tenure_shares() -> None:
|
||||
# 4 dwellings in one postcode, 2 ever council (was_council_house "Yes"). One
|
||||
# of the two is STILL social (latest_tenure_status "Rented (social)"); the
|
||||
# other has been sold off (latest cert owner-occupied) and is ex-council.
|
||||
# % Council housing = 2/4 = 50.0; % Ex-council = 1/4 = 25.0.
|
||||
wide = pl.LazyFrame(
|
||||
{
|
||||
"postcode": ["AB1 2CD", "AB1 2CD", "AB1 2CD", "AB1 2CD"],
|
||||
"was_council_house": ["Yes", "Yes", "No", "No"],
|
||||
"latest_tenure_status": [
|
||||
"Rented (social)", # still social -> not ex-council
|
||||
"Owner-occupied", # sold off -> ex-council
|
||||
"Owner-occupied",
|
||||
None, # never council; a null latest tenure is harmless here
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
result = _epc_council_by_postcode(wide).collect()
|
||||
|
||||
assert result.to_dicts() == [
|
||||
{"postcode": "AB1 2CD", "% Council housing": 50.0, "% Ex-council": 25.0}
|
||||
]
|
||||
|
||||
|
||||
def test_epc_council_by_postcode_null_latest_tenure_counts_as_ex_council() -> None:
|
||||
# An ever-council dwelling whose latest certificate has no recognised tenure
|
||||
# (latest_tenure_status null) is treated as no-longer-social, so it counts
|
||||
# toward % Ex-council (null currently_social -> False, per the spec).
|
||||
wide = pl.LazyFrame(
|
||||
{
|
||||
"postcode": ["AB1 2CD", "AB1 2CD"],
|
||||
"was_council_house": ["Yes", "No"],
|
||||
"latest_tenure_status": [None, None],
|
||||
}
|
||||
)
|
||||
|
||||
result = _epc_council_by_postcode(wide).collect()
|
||||
|
||||
assert result.to_dicts() == [
|
||||
{"postcode": "AB1 2CD", "% Council housing": 50.0, "% Ex-council": 50.0}
|
||||
]
|
||||
|
||||
|
||||
def test_epc_council_columns_are_area_level_and_survive_the_split() -> None:
|
||||
# The EPC council shares are postcode-level AREA columns: they must route to
|
||||
# the postcode output and NOT appear in the property output. The per-property
|
||||
# "Former council house" flag is unaffected and stays property-level.
|
||||
assert "% Council housing" in _AREA_COLUMNS
|
||||
assert "% Ex-council" in _AREA_COLUMNS
|
||||
|
||||
df = pl.DataFrame(
|
||||
{
|
||||
"Postcode": ["AA1 1AA"],
|
||||
"Last known price": [250_000],
|
||||
"Former council house": ["Yes"],
|
||||
}
|
||||
)
|
||||
postcode_features = pl.DataFrame(
|
||||
{
|
||||
"Postcode": ["AA1 1AA", "BB1 1BB"],
|
||||
"lat": [51.0, 52.0],
|
||||
"lon": [-0.1, -0.2],
|
||||
"ctry25cd": ["E92000001", "E92000001"],
|
||||
"% Council housing": [50.0, 0.0],
|
||||
"% Ex-council": [25.0, 0.0],
|
||||
}
|
||||
)
|
||||
|
||||
postcode_df, properties_df = _split_normal_outputs(
|
||||
df, postcode_features, expected_postcode_count=2
|
||||
)
|
||||
|
||||
assert postcode_df["% Council housing"].to_list() == [50.0, 0.0]
|
||||
assert postcode_df["% Ex-council"].to_list() == [25.0, 0.0]
|
||||
assert "% Council housing" not in properties_df.columns
|
||||
assert "% Ex-council" not in properties_df.columns
|
||||
assert "Former council house" in properties_df.columns
|
||||
|
||||
|
||||
def test_crime_columns_are_average_annual_counts() -> None:
|
||||
# Crime is the average annual recorded incident count (incidents/yr) over
|
||||
# 7-year and 2-year windows; the old per-1,000 "(per 1k/yr, …)" rate columns
|
||||
|
|
@ -955,7 +1036,7 @@ def test_match_direct_epc_matches_by_uprn_across_postcodes() -> None:
|
|||
|
||||
def test_match_direct_epc_ignores_nonunique_building_uprn() -> None:
|
||||
# A parent/building UPRN that resolves to several distinct (address,
|
||||
# postcode) flats cannot act as a 1:1 exact-match key — it would mis-link
|
||||
# postcode) flats cannot act as a 1:1 exact-match key: it would mis-link
|
||||
# the listing to one arbitrary flat. The listing must fall through to the
|
||||
# street-address matcher, which resolves the specific flat.
|
||||
matches = _match_direct_epc(
|
||||
|
|
@ -1490,7 +1571,7 @@ def test_match_listing_properties_uprn_wins_dedup_tie() -> None:
|
|||
|
||||
|
||||
def test_match_direct_epc_does_not_match_other_outcode_without_uprn() -> None:
|
||||
# Matching is by postcode/UPRN/street — never by coordinate proximity — and
|
||||
# Matching is by postcode/UPRN/street (never by coordinate proximity), and
|
||||
# the street fallback is outcode-scoped, so a same-street EPC in a different
|
||||
# OUTCODE with no shared UPRN is skipped.
|
||||
matches = _match_direct_epc(
|
||||
|
|
@ -1909,7 +1990,7 @@ def test_finalize_listings_dedupes_fanned_out_listing_rows() -> None:
|
|||
"Leasehold/Freehold": ["Leasehold", "Leasehold"],
|
||||
"Last known price": [500_000, 480_000],
|
||||
"Tree canopy density percentile": [42.0, 42.0],
|
||||
# Same listing URL on both collapsed rows — the fan-out to fix.
|
||||
# Same listing URL on both collapsed rows: the fan-out to fix.
|
||||
"_actual_listing_url": ["url0", "url0"],
|
||||
"_actual_asking_price": [600_000, 600_000],
|
||||
"_actual_asking_price_per_sqm": [5_000, 5_000],
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ NSPL/NSUL embed the source year in classification-index column names
|
|||
(e.g. ruc21ind, oac11ind, imd20ind) and ONS bumps those suffixes with each
|
||||
release. These columns look numeric in early rows but contain string codes
|
||||
like "UN1" (Unclassified) further down, so they must be forced to String
|
||||
before scanning — otherwise polars infers Int64 and crashes mid-stream.
|
||||
before scanning. Otherwise polars infers Int64 and crashes mid-stream.
|
||||
|
||||
Hard-coding the year suffix is fragile: polars silently ignores overrides for
|
||||
columns that don't exist, so a renamed suffix would no-op and reintroduce the
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue