Compare commits

...

8 commits

Author SHA1 Message Date
b98bc6d611 Upload videos
Some checks failed
CI / Check (push) Failing after 1m25s
Build and publish Docker image / build-and-push (push) Successful in 3m7s
2026-06-10 22:31:17 +01:00
54a5c3ca9a Fix FE 2026-06-10 22:25:15 +01:00
1241132095 Track videos with Git LFS and add social media ad clips
Add .gitattributes so *.mp4/*.mov/*.webm are stored via Git LFS, and
commit the 8 social-media ad clips (ad-01…ad-08) shown on the new
Learn → Videos subpage. The .mp4 files are stored as LFS pointers; the
.jpg poster frames remain normal blobs (force-added past the global
*.mp4/*.jpg ignore rules).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 22:11:21 +01:00
5135429d91 Fixes 2026-06-10 21:33:28 +01:00
d3418c67cc Improve videos 2026-06-10 21:28:19 +01:00
4012e4e047 Fix data pipelines once and for all 2026-06-10 21:27:32 +01:00
08560476c5 SE asian split + pass 2026-06-10 08:27:49 +01:00
85da1941aa Improve data 2026-06-10 07:54:25 +01:00
136 changed files with 7573 additions and 2597 deletions

5
.gitattributes vendored Normal file
View file

@ -0,0 +1,5 @@
*.mp4 filter=lfs diff=lfs merge=lfs -text
*.mov filter=lfs diff=lfs merge=lfs -text
*.webm filter=lfs diff=lfs merge=lfs -text
*.jpg filter=lfs diff=lfs merge=lfs -text
*.jpeg filter=lfs diff=lfs merge=lfs -text

View file

@ -32,7 +32,7 @@ PRICES_STAMP := $(DATA_DIR)/.prices_done
EPC := $(MANUAL_DATA)/domestic-csv.zip
ACTUAL_LISTINGS_RAW := $(FINDER_DATA)/online_listings_buy.parquet
ACTUAL_LISTINGS_ENRICHED := $(FINDER_DATA)/online_listings_buy_enriched.parquet
ETHNICITY := $(DATA_DIR)/ethnicity_by_la.parquet
ETHNICITY := $(DATA_DIR)/ethnicity_by_lsoa.parquet
CRIME_DIR := $(DATA_DIR)/crime
CRIME := $(DATA_DIR)/crime_by_postcode.parquet
CRIME_BY_YEAR := $(DATA_DIR)/crime_by_postcode_by_year.parquet
@ -48,7 +48,8 @@ NAPTAN := $(DATA_DIR)/naptan.parquet
BROADBAND := $(DATA_DIR)/broadband.parquet
CONSERVATION_AREAS := $(DATA_DIR)/conservation_areas.geojson
LISTED_BUILDINGS := $(DATA_DIR)/listed_buildings.gpkg
SCHOOL_PROX := $(DATA_DIR)/school_proximity.parquet
SCHOOL_CATCH := $(DATA_DIR)/school_catchments.parquet
LSOA_CHILDREN := $(DATA_DIR)/lsoa_children.parquet
RENTAL := $(DATA_DIR)/rental_prices.parquet
INSPIRE_DIR := $(DATA_DIR)/inspire
OA_BOUNDARIES := $(DATA_DIR)/oa_boundaries.gpkg
@ -100,19 +101,19 @@ PC_BOUNDARIES_DEPS := pipeline/transform/postcode_boundaries/__main__.py \
pipeline/transform/postcode_boundaries/voronoi.py
CRIME_DOWNLOAD_DEPS := pipeline/download/crime.py
INSPIRE_DOWNLOAD_DEPS := pipeline/download/inspire.py
TRANSIT_DOWNLOAD_DEPS := pipeline/download/transit_network.py pipeline/download/transxchange2gtfs_shim.js
TRANSIT_DOWNLOAD_DEPS := pipeline/download/transit_network.py
MAP_ASSETS_DEPS := pipeline/download/map_assets.py pipeline/transform/transform_poi.py
# ── Phony aliases ─────────────────────────────────────────────────────────────
.PHONY: prepare merge tiles satellite-tiles satellite-highres-tiles overlay-tiles noise-overlay-tiles crime-hotspot-tiles tree-overlay-tiles property-border-tiles \
download-arcgis download-price-paid download-deprivation download-ethnicity \
download-naptan download-pois download-grocery-retail-points download-ofsted download-gias download-broadband download-conservation-areas download-listed-buildings download-rental-prices \
download-naptan download-pois download-grocery-retail-points download-ofsted download-gias download-lsoa-children download-broadband download-conservation-areas download-listed-buildings download-rental-prices \
download-postcodes download-noise download-inspire download-crime \
download-oa-boundaries download-uprn-lookup download-transit-network download-greenspace download-os-greenspace download-pbf download-fr-tow download-nfi download-ofs-register download-places download-median-age download-england-boundary download-rightmove-outcodes \
download-map-assets \
transform-pois transform-epc-pp transform-crime transform-poi-proximity \
transform-school-proximity transform-tree-density \
transform-school-catchments transform-tree-density \
generate-postcode-boundaries generate-travel-times enrich-actual-listings
prepare: $(PRICES_STAMP) download-places tiles satellite-tiles overlay-tiles property-border-tiles tree-overlay-tiles crime-hotspot-tiles property-border-tiles generate-postcode-boundaries download-map-assets generate-travel-times | $(POSTCODES_PQ) $(PROPERTIES_PQ) $(PRICE_INDEX)
@ -139,6 +140,7 @@ download-pois: $(POIS_RAW)
download-grocery-retail-points: $(GROCERY_RETAIL_POINTS)
download-ofsted: $(OFSTED)
download-gias: $(GIAS)
download-lsoa-children: $(LSOA_CHILDREN)
download-broadband: $(BROADBAND)
download-conservation-areas: $(CONSERVATION_AREAS)
download-listed-buildings: $(LISTED_BUILDINGS)
@ -150,7 +152,7 @@ download-inspire: $(INSPIRE_STAMP)
download-oa-boundaries: $(OA_BOUNDARIES)
download-uprn-lookup: $(UPRN_LOOKUP)
download-transit-network: $(TRANSIT_STAMP)
$(VALIDATE_OUTPUTS) --file $(TRANSIT_DIR)/raw/england.osm.pbf --zip $(TRANSIT_DIR)/bods_gtfs.zip --zip $(TRANSIT_DIR)/tfl_gtfs.zip
$(VALIDATE_OUTPUTS) --file $(TRANSIT_DIR)/raw/england.osm.pbf --zip $(TRANSIT_DIR)/bods_gtfs.zip
download-greenspace: $(GREENSPACE)
download-os-greenspace: $(OS_GREENSPACE)
download-pbf: $(PBF)
@ -168,11 +170,11 @@ transform-pois: $(POIS_FILTERED)
transform-epc-pp: $(EPC_PP)
transform-crime: $(CRIME)
transform-poi-proximity: $(POI_PROXIMITY)
transform-school-proximity: $(SCHOOL_PROX)
transform-school-catchments: $(SCHOOL_CATCH)
transform-tree-density: $(TREE_DENSITY_PC)
generate-postcode-boundaries: $(PC_BOUNDARIES_STAMP)
$(PC_BOUNDARIES_STAMP): $(OA_BOUNDARIES) $(INSPIRE_STAMP) $(UPRN_LOOKUP) $(ARCGIS) $(PC_BOUNDARIES_DEPS)
$(PC_BOUNDARIES_STAMP): $(OA_BOUNDARIES) $(INSPIRE_STAMP) $(UPRN_LOOKUP) $(ARCGIS) $(GREENSPACE) $(PC_BOUNDARIES_DEPS)
@rm -f $@
$(VALIDATE_OUTPUTS) --dir $(INSPIRE_DIR) --zip-glob "$(INSPIRE_DIR)::*.zip"
uv run python -m pipeline.transform.postcode_boundaries \
@ -180,6 +182,7 @@ $(PC_BOUNDARIES_STAMP): $(OA_BOUNDARIES) $(INSPIRE_STAMP) $(UPRN_LOOKUP) $(ARCGI
--arcgis $(ARCGIS) \
--oa-boundaries $(OA_BOUNDARIES) \
--inspire $(INSPIRE_DIR) \
--greenspace $(GREENSPACE) \
--output $(PC_BOUNDARIES)
$(VALIDATE_OUTPUTS) --active-postcode-boundary-match "$(ARCGIS)::$(PC_BOUNDARIES)"
@touch $@
@ -273,6 +276,9 @@ $(OFSTED):
$(GIAS): pipeline/download/gias.py
uv run python -m pipeline.download.gias --output $@
$(LSOA_CHILDREN): pipeline/download/lsoa_children.py
uv run python -m pipeline.download.lsoa_children --output $@
$(BROADBAND):
uv run python -m pipeline.download.broadband --output $@
@ -315,7 +321,7 @@ $(UPRN_LOOKUP):
$(TRANSIT_STAMP): $(TRANSIT_DOWNLOAD_DEPS)
@rm -f $@
uv run python -m pipeline.download.transit_network --output $(TRANSIT_DIR)
$(VALIDATE_OUTPUTS) --file $(TRANSIT_DIR)/raw/england.osm.pbf --zip $(TRANSIT_DIR)/bods_gtfs.zip --zip $(TRANSIT_DIR)/tfl_gtfs.zip
$(VALIDATE_OUTPUTS) --file $(TRANSIT_DIR)/raw/england.osm.pbf --zip $(TRANSIT_DIR)/bods_gtfs.zip
@touch $@
$(RENTAL): pipeline/download/rental_prices.py
@ -364,8 +370,8 @@ $(CRIME) $(CRIME_BY_YEAR) &: $(CRIME_STAMP) $(PC_BOUNDARIES_STAMP) pipeline/tran
$(POI_PROXIMITY): $(ARCGIS) $(POIS_FILTERED) $(OS_GREENSPACE) $(POI_PROXIMITY_DEPS)
uv run python -m pipeline.transform.poi_proximity --arcgis $(ARCGIS) --pois $(POIS_FILTERED) --greenspace $(OS_GREENSPACE) --output $@
$(SCHOOL_PROX): $(OFSTED) $(ARCGIS) pipeline/transform/school_proximity.py pipeline/utils/poi_counts.py
uv run python -m pipeline.transform.school_proximity --ofsted $(OFSTED) --arcgis $(ARCGIS) --output $@
$(SCHOOL_CATCH): $(OFSTED) $(ARCGIS) $(GIAS) $(LSOA_CHILDREN) pipeline/transform/school_catchments.py pipeline/utils/poi_counts.py
uv run python -m pipeline.transform.school_catchments --ofsted $(OFSTED) --arcgis $(ARCGIS) --gias $(GIAS) --lsoa-children $(LSOA_CHILDREN) --output $@
$(TREE_DENSITY_PC): $(FR_TOW) $(NFI) $(ARCGIS) $(TREE_DENSITY_DEPS)
uv run python -m pipeline.transform.tree_density \
@ -386,6 +392,7 @@ $(PC_BOUNDARIES):
@echo " --arcgis $(ARCGIS) \\"
@echo " --oa-boundaries $(OA_BOUNDARIES) \\"
@echo " --inspire $(INSPIRE_DIR) \\"
@echo " --greenspace $(GREENSPACE) \\"
@echo " --output $@"
@echo ""
@exit 1
@ -393,7 +400,7 @@ $(PC_BOUNDARIES):
# ── Final merge → postcode.parquet + properties.parquet ──────────────────────
$(MERGE_STAMP): $(EPC_PP) $(ARCGIS) $(IOD) $(POI_PROXIMITY) \
$(ETHNICITY) $(CRIME) $(NOISE) $(SCHOOL_PROX) $(BROADBAND) $(CONSERVATION_AREAS) $(LISTED_BUILDINGS) $(RENTAL) $(MEDIAN_AGE) $(ELECTION) $(TREE_DENSITY_PC) $(MERGE_DEPS)
$(ETHNICITY) $(CRIME) $(NOISE) $(SCHOOL_CATCH) $(BROADBAND) $(CONSERVATION_AREAS) $(LISTED_BUILDINGS) $(RENTAL) $(MEDIAN_AGE) $(ELECTION) $(TREE_DENSITY_PC) $(MERGE_DEPS)
@rm -f $@
uv run python -m pipeline.transform.merge \
--epc-pp $(EPC_PP) \
@ -403,7 +410,7 @@ $(MERGE_STAMP): $(EPC_PP) $(ARCGIS) $(IOD) $(POI_PROXIMITY) \
--ethnicity $(ETHNICITY) \
--crime $(CRIME) \
--noise $(NOISE) \
--school-proximity $(SCHOOL_PROX) \
--school-catchments $(SCHOOL_CATCH) \
--broadband $(BROADBAND) \
--conservation-areas $(CONSERVATION_AREAS) \
--listed-buildings $(LISTED_BUILDINGS) \
@ -433,7 +440,7 @@ $(PRICES_STAMP): $(MERGE_STAMP) $(PRICE_INDEX) $(PRICE_ESTIMATE_DEPS) | $(PROPER
$(ACTUAL_LISTINGS_ENRICHED): $(ACTUAL_LISTINGS_RAW) $(EPC) \
$(EPC_PP) $(ARCGIS) $(IOD) $(POI_PROXIMITY) \
$(ETHNICITY) $(CRIME) $(NOISE) $(SCHOOL_PROX) $(BROADBAND) \
$(ETHNICITY) $(CRIME) $(NOISE) $(SCHOOL_CATCH) $(BROADBAND) \
$(CONSERVATION_AREAS) $(LISTED_BUILDINGS) $(RENTAL) \
$(MEDIAN_AGE) $(ELECTION) $(TREE_DENSITY_PC) \
$(MERGE_DEPS) pipeline/utils/fuzzy_join.py
@ -445,7 +452,7 @@ $(ACTUAL_LISTINGS_ENRICHED): $(ACTUAL_LISTINGS_RAW) $(EPC) \
--ethnicity $(ETHNICITY) \
--crime $(CRIME) \
--noise $(NOISE) \
--school-proximity $(SCHOOL_PROX) \
--school-catchments $(SCHOOL_CATCH) \
--broadband $(BROADBAND) \
--conservation-areas $(CONSERVATION_AREAS) \
--listed-buildings $(LISTED_BUILDINGS) \

File diff suppressed because one or more lines are too long

View file

@ -11,7 +11,7 @@ services:
command: >
bash -c "
cargo install cargo-watch &&
cargo watch --poll -i logs/ -x 'run -- --properties /app/property-data4/properties.parquet --postcode-features /app/property-data4/postcode.parquet --pois /app/property-data4/filtered_uk_pois.parquet --places /app/property-data4/places.parquet --tiles /app/property-data4/uk.pmtiles --postcodes /app/property-data4/postcode_boundaries --travel-times /app/property-data4/travel-times --satellite-tiles /app/property-data4/satellite.pmtiles --satellite-highres-tiles /app/property-data4/satellite_highres.pmtiles --noise-overlay-tiles /app/property-data4/noise_lden_10m.pmtiles --crime-hotspot-tiles /app/property-data4/crime_hotspots.pmtiles --tree-overlay-tiles /app/property-data4/trees_outside_woodlands.pmtiles --property-border-tiles /app/property-data4/property_borders.pmtiles'
cargo watch --poll -i logs/ -x 'run -- --properties /app/property-data/properties.parquet --postcode-features /app/property-data/postcode.parquet --pois /app/property-data/filtered_uk_pois.parquet --places /app/property-data/places.parquet --tiles /app/property-data/uk.pmtiles --postcodes /app/property-data/postcode_boundaries --travel-times /app/property-data/travel-times --satellite-tiles /app/property-data/satellite.pmtiles --satellite-highres-tiles /app/property-data/satellite_highres.pmtiles --noise-overlay-tiles /app/property-data/noise_lden_10m.pmtiles --crime-hotspot-tiles /app/property-data/crime_hotspots.pmtiles --tree-overlay-tiles /app/property-data/trees_outside_woodlands.pmtiles --property-border-tiles /app/property-data/property_borders.pmtiles --actual-listings-path /app/finder/data/online_listings_buy_enriched.parquet --crime-by-year-path /app/property-data/crime_by_postcode_by_year.parquet'
"
ports:
- "8001:8001"

Binary file not shown.

After

Width:  |  Height:  |  Size: 932 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 992 B

View file

@ -70,4 +70,14 @@
<changefreq>monthly</changefreq>
<priority>0.6</priority>
</url>
<url>
<loc>https://perfect-postcode.co.uk/terms</loc>
<changefreq>yearly</changefreq>
<priority>0.3</priority>
</url>
<url>
<loc>https://perfect-postcode.co.uk/privacy</loc>
<changefreq>yearly</changefreq>
<priority>0.3</priority>
</url>
</urlset>

BIN
frontend/public/video/ad-01-say-it.jpg (Stored with Git LFS) Normal file

Binary file not shown.

BIN
frontend/public/video/ad-01-say-it.mp4 (Stored with Git LFS) Normal file

Binary file not shown.

BIN
frontend/public/video/ad-02-twenty-minute-map.jpg (Stored with Git LFS) Normal file

Binary file not shown.

BIN
frontend/public/video/ad-02-twenty-minute-map.mp4 (Stored with Git LFS) Normal file

Binary file not shown.

BIN
frontend/public/video/ad-03-postcode-files.jpg (Stored with Git LFS) Normal file

Binary file not shown.

BIN
frontend/public/video/ad-03-postcode-files.mp4 (Stored with Git LFS) Normal file

Binary file not shown.

BIN
frontend/public/video/ad-04-quiet-streets.jpg (Stored with Git LFS) Normal file

Binary file not shown.

BIN
frontend/public/video/ad-04-quiet-streets.mp4 (Stored with Git LFS) Normal file

Binary file not shown.

BIN
frontend/public/video/ad-05-school-run.jpg (Stored with Git LFS) Normal file

Binary file not shown.

BIN
frontend/public/video/ad-05-school-run.mp4 (Stored with Git LFS) Normal file

Binary file not shown.

BIN
frontend/public/video/ad-06-waitrose-test.jpg (Stored with Git LFS) Normal file

Binary file not shown.

BIN
frontend/public/video/ad-06-waitrose-test.mp4 (Stored with Git LFS) Normal file

Binary file not shown.

BIN
frontend/public/video/ad-07-renters-map.jpg (Stored with Git LFS) Normal file

Binary file not shown.

BIN
frontend/public/video/ad-07-renters-map.mp4 (Stored with Git LFS) Normal file

Binary file not shown.

BIN
frontend/public/video/ad-08-cheap-insurance.jpg (Stored with Git LFS) Normal file

Binary file not shown.

BIN
frontend/public/video/ad-08-cheap-insurance.mp4 (Stored with Git LFS) Normal file

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 377 KiB

After

Width:  |  Height:  |  Size: 131 B

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

After

Width:  |  Height:  |  Size: 131 B

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 178 KiB

After

Width:  |  Height:  |  Size: 131 B

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 110 KiB

After

Width:  |  Height:  |  Size: 131 B

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 387 KiB

After

Width:  |  Height:  |  Size: 131 B

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 233 KiB

After

Width:  |  Height:  |  Size: 131 B

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 102 KiB

After

Width:  |  Height:  |  Size: 131 B

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 354 KiB

After

Width:  |  Height:  |  Size: 131 B

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 185 KiB

After

Width:  |  Height:  |  Size: 131 B

Before After
Before After

Binary file not shown.

View file

@ -107,6 +107,20 @@ const ROUTES = [
description:
'Learn how Perfect Postcode treats saved searches, account data and property research workflows with privacy and security in mind.',
},
{
path: '/terms',
output: 'terms/index.html',
title: 'Terms of Service | Perfect Postcode',
description:
'The terms that govern your use of Perfect Postcode, including lifetime access, acceptable use, data accuracy, payments and refunds.',
},
{
path: '/privacy',
output: 'privacy/index.html',
title: 'Privacy Policy | Perfect Postcode',
description:
'How Perfect Postcode collects, uses and protects your data: account details, payments, saved searches, AI queries, analytics and your UK GDPR rights.',
},
];
const FAQ_SCHEMA_ITEMS = [
@ -325,11 +339,16 @@ async function prerender() {
args: ['--no-sandbox', '--disable-setuid-sandbox'],
});
try {
const baseIndexHtml = cleanBaseIndexHtml(readFileSync(INDEX_PATH, 'utf-8'));
// Every real page renders tens of kB; a few hundred chars means the SPA
// raced hydration and we captured a loading shell.
const MIN_HTML_CHARS = 1000;
const MAX_ATTEMPTS = 3;
for (const route of ROUTES) {
const page = await browser.newPage();
async function renderRoute(route) {
// A fresh context per attempt: pages otherwise share cache/storage, and a
// poisoned chunk-fetch in the shared cache makes a route fail every retry.
const context = await browser.createBrowserContext();
const page = await context.newPage();
// Intercept API requests to prevent real fetches and retry loops.
await page.setRequestInterception(true);
@ -374,15 +393,16 @@ async function prerender() {
}
});
await page.goto(`http://127.0.0.1:${port}${route.path}`, {
waitUntil: 'networkidle0',
timeout: 30000,
});
try {
await page.goto(`http://127.0.0.1:${port}${route.path}`, {
waitUntil: 'networkidle0',
timeout: 30000,
});
await page.waitForSelector('h1', { timeout: 10000 });
await page.waitForSelector('h1', { timeout: 10000 });
// Extract and clean the rendered HTML.
const html = await page.evaluate(() => {
// Extract and clean the rendered HTML.
const html = await page.evaluate(() => {
const root = document.getElementById('root');
if (!root) return '';
@ -400,10 +420,33 @@ async function prerender() {
});
return root.innerHTML;
});
});
if (!html || html.length < 100) {
throw new Error(`Prerender produced too little HTML for ${route.path}`);
if (!html || html.length < MIN_HTML_CHARS) {
throw new Error(
`Prerender produced too little HTML for ${route.path} (${html?.length ?? 0} chars)`
);
}
return html;
} finally {
await context.close().catch(() => {});
}
}
try {
const baseIndexHtml = cleanBaseIndexHtml(readFileSync(INDEX_PATH, 'utf-8'));
for (const route of ROUTES) {
let html = null;
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt += 1) {
try {
html = await renderRoute(route);
break;
} catch (err) {
if (attempt === MAX_ATTEMPTS) throw err;
console.warn(`Retrying ${route.path} (attempt ${attempt} failed: ${err.message})`);
}
}
const updated = updateHead(baseIndexHtml, route).replace(
@ -418,7 +461,6 @@ async function prerender() {
const outputPath = join(DIST_DIR, route.output);
mkdirSync(dirname(outputPath), { recursive: true });
writeFileSync(outputPath, updated);
await page.close();
console.log(`Prerendered ${route.path} (${html.length} chars) into ${route.output}`);
}
} finally {

View file

@ -15,6 +15,7 @@ import type { FeatureMeta, FeatureGroup, POICategoriesResponse, POICategoryGroup
import { fetchWithRetry, apiUrl, logNonAbortError } from './lib/api';
import { trackEvent } from './lib/analytics';
import { parseUrlState } from './lib/url-state';
import pb from './lib/pocketbase';
import { INITIAL_VIEW_STATE } from './lib/consts';
import { useTheme } from './hooks/useTheme';
import { useIsMobile } from './hooks/useIsMobile';
@ -40,6 +41,7 @@ const SavedPage = lazy(() =>
import('./components/account/AccountPage').then((module) => ({ default: module.SavedPage }))
);
const InvitePage = lazy(() => import('./components/invite/InvitePage'));
const LegalPage = lazy(() => import('./components/legal/LegalPage'));
const MapPage = lazy(() => import('./components/map/MapPage'));
const AuthModal = lazy(() => import('./components/ui/AuthModal'));
const SaveSearchModal = lazy(() => import('./components/ui/SaveSearchModal'));
@ -77,19 +79,42 @@ function currentRelativePath(): string {
return `${window.location.pathname}${window.location.search}`;
}
const LAST_DASHBOARD_PARAMS_KEY = 'pp_last_dashboard_params';
function persistLastDashboardParams(params: string) {
try {
if (params) window.localStorage.setItem(LAST_DASHBOARD_PARAMS_KEY, params);
} catch {
// Storage unavailable (private mode/quota) — session restore is best-effort.
}
}
function readLastDashboardSearch(): string {
try {
const saved = window.localStorage.getItem(LAST_DASHBOARD_PARAMS_KEY);
return saved ? `?${saved.replace(/^\?/, '')}` : '';
} catch {
return '';
}
}
/**
* Filters and map view live only in the URL. When the dashboard is opened bare
* (no query), restore the last session's params so users pick up where they
* left off. Explicit params and shared links always win.
*/
function restoreLastDashboardSession() {
const pathname = window.location.pathname.replace(/\/+$/, '');
if (pathname !== '/dashboard' || window.location.search) return;
const saved = readLastDashboardSearch();
if (!saved) return;
window.history.replaceState(window.history.state, '', `/dashboard${saved}`);
}
function isProtectedPage(page: Page): boolean {
return page === 'account' || page === 'saved';
}
function isSharedDashboardUrl(): boolean {
const share = new URLSearchParams(window.location.search).get('share');
return !!share && /^[a-z0-9]{1,20}$/i.test(share);
}
function isAuthRequiredRoute(page: Page): boolean {
return isProtectedPage(page) || (page === 'dashboard' && !isSharedDashboardUrl());
}
function buildPageUrl(page: Page, inviteCode?: string, search = '', hash = ''): string {
const normalizedHash = normalizeHash(hash);
return `${pageToPath(page, inviteCode)}${search}${normalizedHash ? `#${normalizedHash}` : ''}`;
@ -126,6 +151,10 @@ function pageToPath(page: Page, inviteCode?: string): string {
case 'methodology':
case 'privacy-security':
return SEO_CONTENT_PATHS[page];
case 'terms':
return '/terms';
case 'privacy':
return '/privacy';
case 'saved':
return '/saved';
case 'account':
@ -140,7 +169,10 @@ function pageToPath(page: Page, inviteCode?: string): string {
}
}
function pathToPage(pathname: string): RouteMatch | null {
function pathToPage(rawPathname: string): RouteMatch | null {
// Proxies 307-redirect /learn -> /learn/; treat trailing slashes as equivalent.
const pathname =
rawPathname.length > 1 ? rawPathname.replace(/\/+$/, '') || '/' : rawPathname;
if (pathname === '/dashboard') return { page: 'dashboard' };
if (pathname === '/saved') return { page: 'saved' };
if (pathname === '/invites') return { page: 'account', hash: 'invites' };
@ -152,6 +184,8 @@ function pathToPage(pathname: string): RouteMatch | null {
if (seoContentPage) return { page: seoContentPage };
if (pathname === '/account') return { page: 'account' };
if (pathname === '/support') return { page: 'learn' };
if (pathname === '/terms') return { page: 'terms' };
if (pathname === '/privacy') return { page: 'privacy' };
if (pathname.startsWith('/invite/')) {
const code = pathname.slice('/invite/'.length);
return { page: 'invite', inviteCode: code };
@ -169,7 +203,11 @@ function isSeoContentPage(page: Page): page is SeoContentKey {
}
export default function App() {
const urlState = useMemo(() => parseUrlState(), []);
const urlState = useMemo(() => {
// Must run before any reads of window.location.search below.
restoreLastDashboardSession();
return parseUrlState();
}, []);
const initialRoute = useMemo(() => pathToPage(window.location.pathname), []);
const [mapUrlState, setMapUrlState] = useState(urlState);
const [dashboardRouteKey, setDashboardRouteKey] = useState(() =>
@ -276,7 +314,9 @@ export default function App() {
if (!completed) {
setPostAuthIntent(null);
postAuthCheckoutReturnPathRef.current = null;
if (isAuthRequiredRoute(activePageRef.current)) {
// Only protected pages bounce home; the dashboard stays open in demo
// mode (server-enforced free zone) when the modal is dismissed.
if (isProtectedPage(activePageRef.current)) {
window.history.replaceState({ page: 'home', hash: '' }, '', '/');
setRouteHash('');
setActivePage('home');
@ -300,8 +340,11 @@ export default function App() {
async function refreshOnStartup() {
if (!returnedFromCheckout) {
// Always refresh auth on startup to pick up server-side subscription changes.
refreshAuthRef.current().catch(() => {});
// Refresh auth on startup to pick up server-side subscription changes,
// but only when a token exists — logged-out visitors would just 401.
if (pb.authStore.token) {
refreshAuthRef.current().catch(() => {});
}
return;
}
@ -384,8 +427,10 @@ export default function App() {
if (infoFeature) {
window.history.replaceState({ ...window.history.state, infoFeature }, '');
}
// Restore dashboard search params when navigating back
const search = page === 'dashboard' ? dashboardSearchRef.current : '';
// Restore dashboard search params when navigating back, falling back to
// the last persisted session for first visits in this tab.
const search =
page === 'dashboard' ? dashboardSearchRef.current || readLastDashboardSearch() : '';
const url = buildPageUrl(page, inviteCode ?? undefined, search, targetHash);
window.history.pushState({ page, hash: targetHash }, '', url);
if (page === 'dashboard') {
@ -527,19 +572,20 @@ export default function App() {
}
}, [activePage, fetchSearches]);
const isAuthRequiredPage =
activePage === 'account' ||
activePage === 'saved' ||
(activePage === 'dashboard' && !mapUrlState.share);
const isProtectedPageActive = isProtectedPage(activePage);
// Only protected pages (account/saved) prompt for login on entry. The
// dashboard opens straight into demo mode (server-enforced free zone) so
// visitors can try it without logging in; the upgrade modal still appears
// when they pan outside the free zone.
useEffect(() => {
if (authLoading) return;
if (isAuthRequiredPage && !user) {
if (isProtectedPageActive && !user) {
openAuthModal('login');
}
if (activePage === 'pricing' && hasFullAccess(user)) {
navigateTo('dashboard');
}
}, [activePage, authLoading, isAuthRequiredPage, navigateTo, openAuthModal, user]);
}, [activePage, authLoading, isProtectedPageActive, navigateTo, openAuthModal, user]);
const [exportState, setExportState] = useState<ExportState | null>(null);
@ -641,6 +687,8 @@ export default function App() {
/>
) : activePage === 'learn' ? (
<LearnPage />
) : activePage === 'terms' || activePage === 'privacy' ? (
<LegalPage kind={activePage} />
) : isSeoLandingPage(activePage) ? (
<SeoLandingPage pageKey={activePage} onOpenDashboard={() => navigateTo('dashboard')} />
) : isSeoContentPage(activePage) ? (
@ -662,7 +710,7 @@ export default function App() {
}}
scrollTarget={routeHash}
/>
) : isAuthRequiredPage && !user ? (
) : isProtectedPageActive && !user ? (
<PageFallback />
) : activePage === 'invite' && inviteCode ? (
<InvitePage
@ -695,7 +743,10 @@ export default function App() {
onClearPendingInfoFeature={() => setPendingInfoFeature(null)}
onNavigateTo={navigateTo}
onExportStateChange={setExportState}
onDashboardParamsChange={setDashboardParams}
onDashboardParamsChange={(params) => {
setDashboardParams(params);
if (!mapUrlState.share) persistLastDashboardParams(params);
}}
onDashboardReadyChange={setDashboardReady}
isMobile={isMobile}
initialTravelTime={mapUrlState.travelTime}

View file

@ -8,6 +8,7 @@ import BottomIllustration from './BottomIllustration';
import { TickerValue } from '../ui/TickerValue';
import { ChevronIcon, LogoIcon, PlayIcon } from '../ui/icons';
import { trackEvent } from '../../lib/analytics';
import { apiUrl } from '../../lib/api';
const BRAND_NAME = 'Perfect Postcode';
const BRAND_TEXT_CLASS = 'text-teal-600 dark:text-teal-400';
@ -163,11 +164,78 @@ function ProductDemoVideo() {
);
}
interface PriceStripTier {
up_to: number | null;
price_pence: number;
}
/**
* Compact pricing teaser under the hero CTAs: surfaces the current lifetime
* price and tier scarcity that otherwise hide behind the Pricing nav link.
*/
function PriceStrip({
onOpenPricing,
hidePricing,
}: {
onOpenPricing: () => void;
hidePricing?: boolean;
}) {
const { t } = useTranslation();
const [pricing, setPricing] = useState<{
licensed_count: number;
current_price_pence: number;
tiers: PriceStripTier[];
} | null>(null);
useEffect(() => {
if (hidePricing) return;
const controller = new AbortController();
fetch(apiUrl('pricing'), { signal: controller.signal })
.then((res) => (res.ok ? res.json() : null))
.then(setPricing)
.catch(() => {});
return () => controller.abort();
}, [hidePricing]);
if (hidePricing || !pricing) return null;
const price = `£${pricing.current_price_pence / 100}`;
const currentTier = pricing.tiers.find(
(tier) => tier.up_to === null || pricing.licensed_count < tier.up_to
);
const spotsRemaining =
currentTier?.up_to != null ? currentTier.up_to - pricing.licensed_count : 0;
return (
<p className="text-sm text-warm-300 mb-2">
{pricing.current_price_pence === 0
? t('upgrade.freeForEarly')
: t('home.priceStrip', { price })}{' '}
{pricing.current_price_pence > 0 && spotsRemaining > 0 && (
<span className="font-semibold text-teal-300">
{spotsRemaining === 1
? t('home.priceStripSpots', { count: spotsRemaining })
: t('home.priceStripSpotsPlural', { count: spotsRemaining })}
</span>
)}{' '}
<button
onClick={() => {
trackEvent('CTA Click', { location: 'hero', label: 'price_strip' });
onOpenPricing();
}}
className="underline decoration-dotted underline-offset-2 text-teal-300 hover:text-teal-200"
>
{t('home.priceStripCta')}
</button>
</p>
);
}
export default function HomePage({
onOpenDashboard,
onOpenPricing: _onOpenPricing,
onOpenPricing,
theme = 'light',
hidePricing: _hidePricing,
hidePricing,
}: {
onOpenDashboard: () => void;
onOpenPricing: () => void;
@ -327,7 +395,7 @@ export default function HomePage({
<p className="text-base md:text-lg text-warm-200 mb-8 max-w-xl">
{highlightBrandText(t('home.heroDescription'), 'font-semibold text-teal-300')}
</p>
<div className="flex flex-col sm:flex-row sm:items-center gap-3 sm:gap-4 mb-10">
<div className="flex flex-col sm:flex-row sm:items-center gap-3 sm:gap-4 mb-5">
<button
onClick={() => {
trackEvent('CTA Click', { location: 'hero', label: 'explore_map' });
@ -347,6 +415,8 @@ export default function HomePage({
{t('home.seeTheDifference')}
</button>
</div>
<PriceStrip onOpenPricing={onOpenPricing} hidePricing={hidePricing} />
<p className="text-sm text-warm-400 mb-8">{t('home.coverageNote')}</p>
<div className="home-hero-stats flex flex-wrap pt-3 border-t border-white/10">
<div className="home-hero-stat">
<div className="home-hero-stat-value">
@ -356,7 +426,7 @@ export default function HomePage({
</div>
<div className="home-hero-stat">
<div className="home-hero-stat-value">
<TickerValue text="56" active={statsActive} />
<TickerValue text="40+" active={statsActive} />
</div>
<div className="home-hero-stat-label">{t('home.statFilters')}</div>
</div>

View file

@ -82,7 +82,7 @@ const DEMO_FEATURES: FeatureMeta[] = [
step: 1,
},
{
name: 'Good+ primary schools within 2km',
name: 'Good+ primary school catchments',
type: 'numeric',
group: 'Schools',
min: 0,
@ -300,7 +300,7 @@ function interpolateViewState(progress: number): ViewState {
function demoSliderStep(feature: FeatureMeta): number {
if (feature.name === 'Estimated price') return 1000;
if (feature.name === 'Noise (dB)') return 0.05;
if (feature.name === 'Good+ primary schools within 2km') return 0.01;
if (feature.name === 'Good+ primary school catchments') return 0.01;
if (feature.name === 'Travel time to nearest train or tube station (min)') return 0.1;
return feature.step ?? 1;
}
@ -350,7 +350,7 @@ function FilterPreviewRow({
const shortLabelKeys = {
'Estimated price': 'home.showcaseFeaturePriceShort',
'Noise (dB)': 'home.showcaseFeatureNoiseShort',
'Good+ primary schools within 2km': 'home.showcaseFeatureSchoolsShort',
'Good+ primary school catchments': 'home.showcaseFeatureSchoolsShort',
'Travel time to nearest train or tube station (min)': 'home.showcaseFeatureTravelShort',
} as const;
const shortLabelKey = shortLabelKeys[feature.name as keyof typeof shortLabelKeys];
@ -406,7 +406,7 @@ function formatDemoRange(feature: FeatureMeta, value: [number, number], t: TFunc
if (feature.name === 'Noise (dB)') {
return `${Math.round(value[0])} - ${Math.round(value[1])} dB`;
}
if (feature.name === 'Good+ primary schools within 2km') {
if (feature.name === 'Good+ primary school catchments') {
return t('home.showcaseGoodPrimariesNearby', { count: Math.round(value[0]) });
}
if (feature.name === 'Travel time to nearest train or tube station (min)') {

View file

@ -2,6 +2,7 @@ import { lazy, Suspense } from 'react';
import { useTranslation } from 'react-i18next';
import { CheckIcon } from '../ui/icons/CheckIcon';
import HomeFinalCta from '../home/HomeFinalCta';
import Footer from '../ui/Footer';
import { usePageMeta } from '../../hooks/usePageMeta';
import {
getLocalizedSeoContentPage,
@ -202,6 +203,7 @@ export default function SeoContentPage({
trackingLocation={`seo_${pageKey}_bottom`}
/>
</section>
<Footer />
</main>
);
}

View file

@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next';
import { CheckIcon } from '../ui/icons/CheckIcon';
import { LogoIcon } from '../ui/icons/LogoIcon';
import HomeFinalCta from '../home/HomeFinalCta';
import Footer from '../ui/Footer';
import { usePageMeta } from '../../hooks/usePageMeta';
import {
getLocalizedSeoLandingPage,
@ -252,6 +253,7 @@ export default function SeoLandingPage({
trackingLocation={`seo_${pageKey}_bottom`}
/>
</section>
<Footer />
</main>
);
}

View file

@ -3,9 +3,52 @@ import { useTranslation } from 'react-i18next';
import { tDynamic } from '../../i18n';
import { getLocalizedSeoPages } from '../../lib/seoLandingPages';
import { ChevronIcon } from '../ui/icons/ChevronIcon';
import { PlayIcon } from '../ui/icons';
import { SubNav } from '../ui/SubNav';
import Footer from '../ui/Footer';
type LearnTab = 'data-sources' | 'faq' | 'articles' | 'support';
type LearnTab = 'data-sources' | 'faq' | 'articles' | 'videos' | 'support';
// Social-media ad cuts rendered by the recorder pipeline (video/src/storyboard.ts).
// Each `<slug>.mp4` is a 9:16 clip with a matching `<slug>.jpg` poster in /public/video.
const SOCIAL_VIDEOS: { slug: string; titleKey: string; descKey: string }[] = [
{ slug: 'ad-01-say-it', titleKey: 'learnPage.video01Title', descKey: 'learnPage.video01Desc' },
{
slug: 'ad-02-twenty-minute-map',
titleKey: 'learnPage.video02Title',
descKey: 'learnPage.video02Desc',
},
{
slug: 'ad-03-postcode-files',
titleKey: 'learnPage.video03Title',
descKey: 'learnPage.video03Desc',
},
{
slug: 'ad-04-quiet-streets',
titleKey: 'learnPage.video04Title',
descKey: 'learnPage.video04Desc',
},
{
slug: 'ad-05-school-run',
titleKey: 'learnPage.video05Title',
descKey: 'learnPage.video05Desc',
},
{
slug: 'ad-06-waitrose-test',
titleKey: 'learnPage.video06Title',
descKey: 'learnPage.video06Desc',
},
{
slug: 'ad-07-renters-map',
titleKey: 'learnPage.video07Title',
descKey: 'learnPage.video07Desc',
},
{
slug: 'ad-08-cheap-insurance',
titleKey: 'learnPage.video08Title',
descKey: 'learnPage.video08Desc',
},
];
interface DataSourceDef {
id: string;
@ -176,6 +219,68 @@ function FAQItemCard({ question, answer }: { question: string; answer: string })
);
}
function SocialVideoCard({
slug,
title,
description,
}: {
slug: string;
title: string;
description: string;
}) {
const { t } = useTranslation();
const videoRef = useRef<HTMLVideoElement | null>(null);
const [isLoaded, setIsLoaded] = useState(false);
const [isPlaying, setIsPlaying] = useState(false);
const videoSrc = `/video/${slug}.mp4`;
const posterSrc = `/video/${slug}.jpg`;
const playVideo = () => {
const video = videoRef.current;
setIsLoaded(true);
if (!video) return;
if (video.getAttribute('src') !== videoSrc) {
video.src = videoSrc;
video.load();
}
void video.play().catch(() => setIsPlaying(false));
};
return (
<div className="flex flex-col">
<div className="relative overflow-hidden rounded-xl border border-warm-200 bg-navy-950 shadow-sm dark:border-warm-700">
<video
ref={videoRef}
src={isLoaded ? videoSrc : undefined}
poster={posterSrc}
controls={isPlaying}
playsInline
preload="none"
className="block aspect-[9/16] w-full bg-navy-950 object-contain"
aria-label={title}
onPlay={() => setIsPlaying(true)}
onPause={() => setIsPlaying(false)}
onEnded={() => setIsPlaying(false)}
/>
{!isPlaying && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center bg-navy-950/15 transition-colors">
<button
type="button"
onClick={playVideo}
className="pointer-events-auto group flex h-16 w-16 items-center justify-center rounded-full bg-white/95 text-coral-500 shadow-2xl shadow-navy-950/40 ring-1 ring-white/60 transition-transform hover:scale-105 focus:outline-none focus-visible:scale-105 focus-visible:ring-4 focus-visible:ring-teal-300/75"
aria-label={t('home.playProductDemo')}
>
<PlayIcon className="h-8 w-8 -translate-x-0.5" />
</button>
</div>
)}
</div>
<h2 className="mt-3 text-base font-bold text-warm-900 dark:text-warm-100">{title}</h2>
<p className="mt-1 text-sm leading-relaxed text-warm-600 dark:text-warm-300">{description}</p>
</div>
);
}
export default function LearnPage() {
const { t, i18n } = useTranslation();
const [tab, setTab] = useState<LearnTab>('faq');
@ -197,6 +302,7 @@ export default function LearnPage() {
{ key: 'faq', label: t('learnPage.faq') },
{ key: 'data-sources', label: t('learnPage.dataSources') },
{ key: 'articles', label: t('learnPage.articles') },
{ key: 'videos', label: t('learnPage.videos') },
{ key: 'support', label: t('learnPage.support') },
];
@ -268,6 +374,9 @@ export default function LearnPage() {
} else if (hash === 'articles') {
setTab('articles');
setHighlightedId(null);
} else if (hash === 'videos') {
setTab('videos');
setHighlightedId(null);
} else if (hash === 'support') {
setTab('support');
setHighlightedId(null);
@ -300,141 +409,163 @@ export default function LearnPage() {
<SubNav tabs={LEARN_TABS} activeTab={tab} onTabChange={switchTab} />
<div className="flex-1 overflow-y-auto flex flex-col" ref={scrollContainerRef}>
{tab === 'data-sources' ? (
<>
<div className="flex-1">
<div className="max-w-5xl mx-auto px-6 py-6">
<h1 className="text-2xl md:text-3xl font-bold text-warm-900 dark:text-warm-100 mb-3">
{t('learnPage.dataSources')}
</h1>
<p className="text-warm-600 dark:text-warm-400 mb-6">
{t('learnPage.dataSourcesIntro', { count: DATA_SOURCE_DEFS.length })}
</p>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{DATA_SOURCE_DEFS.map((source) => {
const keys = DS_KEYS[source.id];
const [nameKey, originKey, useKey] = keys;
return (
<div
key={source.id}
id={source.id}
ref={(el) => {
cardRefs.current[source.id] = el;
}}
className={`bg-white dark:bg-warm-800 rounded-lg border p-5 ${
highlightedId === source.id
? 'border-teal-400 ring-2 ring-teal-400'
: 'border-warm-200 dark:border-warm-700'
}`}
>
<div className="flex items-start justify-between gap-4 mb-2">
<h2 className="text-lg font-semibold text-warm-900 dark:text-warm-100">
{tDynamic(nameKey)}
</h2>
<span className="max-w-44 text-left text-xs leading-snug bg-warm-100 dark:bg-navy-700 text-warm-600 dark:text-warm-300 px-2 py-1 rounded">
{source.license}
</span>
</div>
<p className="text-sm text-warm-500 dark:text-warm-400 mb-2">
{t('learnPage.source')} {tDynamic(originKey)}
</p>
<p className="text-sm text-warm-700 dark:text-warm-300 mb-3">
{tDynamic(useKey)}
</p>
</div>
);
})}
</div>
</div>
</div>
<footer className="bg-navy-900 text-warm-400 px-6 py-6">
<div className="max-w-5xl mx-auto">
<h2 className="text-sm font-semibold text-warm-300 uppercase tracking-wide mb-3">
{t('learnPage.attribution')}
</h2>
<ul className="space-y-1.5 text-sm">
<li>{t('learnPage.attrLandRegistry')}</li>
<li>
{t('learnPage.attrOgl')} {t('learnPage.attrOglLink')}.
</li>
<li>{t('learnPage.attrOs')}</li>
<li>{t('learnPage.attrTfl')}</li>
<li>
{t('learnPage.attrOsm')} {t('learnPage.attrOsmContrib')},{' '}
{t('learnPage.attrOsmLicense')} {t('learnPage.attrOsmLicenseLink')}.
</li>
</ul>
</div>
</footer>
</>
) : tab === 'faq' ? (
<div className="max-w-3xl mx-auto px-6 py-6 w-full">
<h1 className="text-2xl md:text-3xl font-bold text-warm-900 dark:text-warm-100 mb-3">
{t('learnPage.faq')}
</h1>
<p className="text-warm-600 dark:text-warm-400 mb-6">{t('learnPage.faqIntro')}</p>
<div className="space-y-8">
{FAQ_SECTIONS.map((section) => (
<div key={section.title}>
<h3 className="text-sm font-semibold text-warm-500 dark:text-warm-400 uppercase tracking-wide mb-3">
{section.title}
</h3>
<div className="space-y-3">
{section.items.map((item, index) => (
<FAQItemCard key={index} question={item.question} answer={item.answer} />
))}
</div>
</div>
))}
</div>
</div>
) : tab === 'articles' ? (
<div className="max-w-5xl mx-auto px-6 py-6 w-full">
<h1 className="text-2xl md:text-3xl font-bold text-warm-900 dark:text-warm-100 mb-3">
{t('learnPage.articles')}
</h1>
<p className="text-warm-600 dark:text-warm-400 mb-6">{t('learnPage.articlesIntro')}</p>
<div className="grid gap-4 md:grid-cols-2">
{seoPageLinks.map((link) => (
<a
key={link.path}
href={link.path}
className="rounded-lg border border-warm-200 bg-white p-5 transition-colors hover:border-teal-300 hover:bg-teal-50 dark:border-warm-700 dark:bg-warm-800 dark:hover:border-teal-500/60 dark:hover:bg-teal-950/30"
>
<div className="text-xs font-semibold uppercase tracking-wide text-teal-600 dark:text-teal-400">
{link.eyebrow}
</div>
<h2 className="mt-1 text-lg font-bold text-warm-900 dark:text-warm-100">
{link.title}
</h2>
<p className="mt-2 text-sm leading-relaxed text-warm-600 dark:text-warm-300">
{link.description}
<div className="flex flex-1 flex-col">
{tab === 'data-sources' ? (
<>
<div className="flex-1">
<div className="max-w-5xl mx-auto px-6 py-6">
<h1 className="text-2xl md:text-3xl font-bold text-warm-900 dark:text-warm-100 mb-3">
{t('learnPage.dataSources')}
</h1>
<p className="text-warm-600 dark:text-warm-400 mb-6">
{t('learnPage.dataSourcesIntro', { count: DATA_SOURCE_DEFS.length })}
</p>
</a>
))}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{DATA_SOURCE_DEFS.map((source) => {
const keys = DS_KEYS[source.id];
const [nameKey, originKey, useKey] = keys;
return (
<div
key={source.id}
id={source.id}
ref={(el) => {
cardRefs.current[source.id] = el;
}}
className={`bg-white dark:bg-warm-800 rounded-lg border p-5 ${
highlightedId === source.id
? 'border-teal-400 ring-2 ring-teal-400'
: 'border-warm-200 dark:border-warm-700'
}`}
>
<div className="flex items-start justify-between gap-4 mb-2">
<h2 className="text-lg font-semibold text-warm-900 dark:text-warm-100">
{tDynamic(nameKey)}
</h2>
<span className="max-w-44 text-left text-xs leading-snug bg-warm-100 dark:bg-navy-700 text-warm-600 dark:text-warm-300 px-2 py-1 rounded">
{source.license}
</span>
</div>
<p className="text-sm text-warm-500 dark:text-warm-400 mb-2">
{t('learnPage.source')} {tDynamic(originKey)}
</p>
<p className="text-sm text-warm-700 dark:text-warm-300 mb-3">
{tDynamic(useKey)}
</p>
</div>
);
})}
</div>
</div>
</div>
<footer className="bg-navy-900 text-warm-400 px-6 py-6">
<div className="max-w-5xl mx-auto">
<h2 className="text-sm font-semibold text-warm-300 uppercase tracking-wide mb-3">
{t('learnPage.attribution')}
</h2>
<ul className="space-y-1.5 text-sm">
<li>{t('learnPage.attrLandRegistry')}</li>
<li>
{t('learnPage.attrOgl')} {t('learnPage.attrOglLink')}.
</li>
<li>{t('learnPage.attrOs')}</li>
<li>{t('learnPage.attrTfl')}</li>
<li>
{t('learnPage.attrOsm')} {t('learnPage.attrOsmContrib')},{' '}
{t('learnPage.attrOsmLicense')} {t('learnPage.attrOsmLicenseLink')}.
</li>
</ul>
</div>
</footer>
</>
) : tab === 'faq' ? (
<div className="max-w-3xl mx-auto px-6 py-6 w-full">
<h1 className="text-2xl md:text-3xl font-bold text-warm-900 dark:text-warm-100 mb-3">
{t('learnPage.faq')}
</h1>
<p className="text-warm-600 dark:text-warm-400 mb-6">{t('learnPage.faqIntro')}</p>
<div className="space-y-8">
{FAQ_SECTIONS.map((section) => (
<div key={section.title}>
<h3 className="text-sm font-semibold text-warm-500 dark:text-warm-400 uppercase tracking-wide mb-3">
{section.title}
</h3>
<div className="space-y-3">
{section.items.map((item, index) => (
<FAQItemCard key={index} question={item.question} answer={item.answer} />
))}
</div>
</div>
))}
</div>
</div>
</div>
) : (
<div className="max-w-2xl mx-auto px-6 py-6 w-full">
<h1 className="text-2xl md:text-3xl font-bold text-warm-900 dark:text-warm-100 mb-3">
{t('learnPage.support')}
</h1>
<p className="text-warm-600 dark:text-warm-400 mb-6">{t('learnPage.supportIntro')}</p>
<div className="bg-white dark:bg-warm-800 rounded-xl border border-warm-200 dark:border-warm-700 p-6 text-center">
<p className="text-warm-600 dark:text-warm-300 mb-2">{t('accountPage.needHelp')}</p>
<a
href="mailto:support@perfect-postcode.co.uk"
className="text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300 font-medium text-lg"
>
support@perfect-postcode.co.uk
</a>
<p className="text-warm-400 dark:text-warm-500 text-sm mt-2">
{t('accountPage.responseTime')}
) : tab === 'articles' ? (
<div className="max-w-5xl mx-auto px-6 py-6 w-full">
<h1 className="text-2xl md:text-3xl font-bold text-warm-900 dark:text-warm-100 mb-3">
{t('learnPage.articles')}
</h1>
<p className="text-warm-600 dark:text-warm-400 mb-6">
{t('learnPage.articlesIntro')}
</p>
<div className="grid gap-4 md:grid-cols-2">
{seoPageLinks.map((link) => (
<a
key={link.path}
href={link.path}
className="rounded-lg border border-warm-200 bg-white p-5 transition-colors hover:border-teal-300 hover:bg-teal-50 dark:border-warm-700 dark:bg-warm-800 dark:hover:border-teal-500/60 dark:hover:bg-teal-950/30"
>
<div className="text-xs font-semibold uppercase tracking-wide text-teal-600 dark:text-teal-400">
{link.eyebrow}
</div>
<h2 className="mt-1 text-lg font-bold text-warm-900 dark:text-warm-100">
{link.title}
</h2>
<p className="mt-2 text-sm leading-relaxed text-warm-600 dark:text-warm-300">
{link.description}
</p>
</a>
))}
</div>
</div>
</div>
)}
) : tab === 'videos' ? (
<div className="max-w-5xl mx-auto px-6 py-6 w-full">
<h1 className="text-2xl md:text-3xl font-bold text-warm-900 dark:text-warm-100 mb-3">
{t('learnPage.videosTitle')}
</h1>
<p className="text-warm-600 dark:text-warm-400 mb-6">{t('learnPage.videosIntro')}</p>
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 sm:gap-6 lg:grid-cols-4">
{SOCIAL_VIDEOS.map((video) => (
<SocialVideoCard
key={video.slug}
slug={video.slug}
title={tDynamic(video.titleKey)}
description={tDynamic(video.descKey)}
/>
))}
</div>
</div>
) : (
<div className="max-w-2xl mx-auto px-6 py-6 w-full">
<h1 className="text-2xl md:text-3xl font-bold text-warm-900 dark:text-warm-100 mb-3">
{t('learnPage.support')}
</h1>
<p className="text-warm-600 dark:text-warm-400 mb-6">{t('learnPage.supportIntro')}</p>
<div className="bg-white dark:bg-warm-800 rounded-xl border border-warm-200 dark:border-warm-700 p-6 text-center">
<p className="text-warm-600 dark:text-warm-300 mb-2">{t('accountPage.needHelp')}</p>
<a
href="mailto:support@perfect-postcode.co.uk"
className="text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300 font-medium text-lg"
>
support@perfect-postcode.co.uk
</a>
<p className="text-warm-400 dark:text-warm-500 text-sm mt-2">
{t('accountPage.responseTime')}
</p>
</div>
</div>
)}
</div>
<Footer />
</div>
</div>
);

View file

@ -0,0 +1,68 @@
import { useTranslation } from 'react-i18next';
import { usePageMeta } from '../../hooks/usePageMeta';
import Footer from '../ui/Footer';
import { PRIVACY, TERMS, type LegalDoc } from './legal-content';
export type LegalKind = 'terms' | 'privacy';
const DOCS: Record<LegalKind, LegalDoc> = { terms: TERMS, privacy: PRIVACY };
export default function LegalPage({ kind }: { kind: LegalKind }) {
const { t, i18n } = useTranslation();
const doc = DOCS[kind];
usePageMeta(`${doc.title} | Perfect Postcode`, doc.metaDescription);
const showEnglishNotice = !i18n.language?.toLowerCase().startsWith('en');
return (
<main className="flex-1 overflow-y-auto bg-warm-50 dark:bg-navy-950">
<div className="mx-auto max-w-3xl px-4 py-10 sm:py-14">
<h1 className="text-3xl font-bold text-navy-950 dark:text-warm-100">{doc.title}</h1>
<p className="mt-2 text-sm text-warm-500 dark:text-warm-400">
{t('legal.lastUpdated', { date: doc.lastUpdated })}
</p>
{showEnglishNotice && (
<p className="mt-2 text-sm italic text-warm-500 dark:text-warm-400">
{t('legal.englishOnly')}
</p>
)}
<div className="mt-6 space-y-4">
{doc.intro.map((paragraph) => (
<p key={paragraph} className="leading-relaxed text-warm-700 dark:text-warm-300">
{paragraph}
</p>
))}
</div>
<div className="mt-8 space-y-8">
{doc.sections.map((section) => (
<section key={section.heading}>
<h2 className="text-lg font-semibold text-navy-950 dark:text-warm-100">
{section.heading}
</h2>
{section.paragraphs.map((paragraph) => (
<p
key={paragraph}
className="mt-2 leading-relaxed text-warm-700 dark:text-warm-300"
>
{paragraph}
</p>
))}
{section.bullets && (
<ul className="mt-2 list-disc space-y-1.5 pl-5 text-warm-700 dark:text-warm-300">
{section.bullets.map((bullet) => (
<li key={bullet} className="leading-relaxed">
{bullet}
</li>
))}
</ul>
)}
</section>
))}
</div>
</div>
<Footer />
</main>
);
}

View file

@ -0,0 +1,183 @@
/**
* Legal documents are maintained in English only; the English text is the
* authoritative version (a localized notice says so on the page). Keeping
* legal copy out of the i18n catalogues avoids meaning drift in translation.
*
* TODO before launch: confirm the operator/legal-entity details below.
*/
export interface LegalSection {
heading: string;
paragraphs: string[];
bullets?: string[];
}
export interface LegalDoc {
title: string;
metaDescription: string;
lastUpdated: string;
intro: string[];
sections: LegalSection[];
}
export const SUPPORT_EMAIL = 'support@perfect-postcode.co.uk';
export const TERMS: LegalDoc = {
title: 'Terms of Service',
metaDescription:
'The terms that govern your use of Perfect Postcode, including lifetime access, acceptable use, data accuracy, payments and refunds.',
lastUpdated: '10 June 2026',
intro: [
`These terms govern your use of perfect-postcode.co.uk ("Perfect Postcode", "the service", "we", "us"). By creating an account or purchasing access you agree to them. If you have any questions, contact ${SUPPORT_EMAIL}.`,
],
sections: [
{
heading: '1. The service',
paragraphs: [
'Perfect Postcode is a research tool that combines public datasets about England — property transactions, energy certificates, schools, crime, noise, broadband, transport and more — on an interactive map, so you can shortlist areas that fit your needs before booking viewings.',
'We are not an estate agent, mortgage broker, surveyor or financial adviser, and the service does not provide financial, legal or investment advice.',
],
},
{
heading: '2. Accounts',
paragraphs: [
'You need an account to use the service beyond the free demo area. Provide a valid email address, keep your credentials secure, and do not share your account. Accounts are for one person each.',
'We may suspend or close accounts that breach these terms, abuse the service, or attempt to circumvent access restrictions. If we close your account without cause, we will refund the price you paid.',
],
},
{
heading: '3. Free demo and lifetime access',
paragraphs: [
'Free accounts can explore all features within the demo area (inner London). Lifetime access is a one-time payment that gives your account ongoing access to the paid map — every postcode, every filter — for as long as the service runs. It is not a subscription, and routine data updates are included.',
'Lifetime access is personal and non-transferable, and is for personal, non-commercial property research. If you would like to use Perfect Postcode commercially (for example in lettings, relocation or research services), contact us first.',
],
},
{
heading: '4. Acceptable use',
paragraphs: ['You agree not to:'],
bullets: [
'scrape, crawl or bulk-download data outside the export tools we provide;',
'resell, republish or redistribute the data or substantial extracts of it;',
'probe, disrupt or place unreasonable load on the service;',
'use the AI search or other features to process content you have no right to submit.',
],
},
{
heading: '5. Data accuracy',
paragraphs: [
'The maps and figures are built from public datasets (HM Land Registry, EPC register, ONS, Ofsted, DfT, police.uk and others) combined with modelling and estimation. Sources can be incomplete, out of date or wrong at the level of an individual property, and our estimates — including estimated current prices — are statistical indications, not valuations.',
'Always verify anything that matters in person and through professional advice (surveys, solicitors, mortgage advisers) before making offers or financial decisions. We provide the service "as is" and do not warrant that any figure is accurate, complete or current.',
],
},
{
heading: '6. Payments and refunds',
paragraphs: [
'Payments are processed by Stripe; we never see or store your card details. Prices are shown in pounds sterling at checkout. Early-access pricing tiers can change as tiers fill; the price shown at the moment you pay is the price you get.',
`If Perfect Postcode is not for you, email ${SUPPORT_EMAIL} within 14 days of purchase and we will refund you in full.`,
],
},
{
heading: '7. Third-party content',
paragraphs: [
'Street View imagery, listing-portal links and similar embedded content are provided by third parties and governed by their own terms. We are not responsible for their availability or accuracy.',
],
},
{
heading: '8. Liability',
paragraphs: [
'To the extent permitted by law, we are not liable for decisions made in reliance on the data, for indirect or consequential losses, or for interruptions to the service; our total liability to you is limited to the amount you paid us. Nothing in these terms excludes liability that cannot legally be excluded, and nothing affects your statutory rights as a consumer.',
],
},
{
heading: '9. Changes to the service or these terms',
paragraphs: [
'We are a small product that improves continuously; features and data sources may change. We may update these terms, and will note the date of the latest revision above. If a change is material we will flag it on the site or by email. Continued use after a change means you accept the updated terms.',
],
},
{
heading: '10. Governing law and contact',
paragraphs: [
`These terms are governed by the law of England and Wales, and disputes are subject to the jurisdiction of the courts of England and Wales (consumers keep any mandatory protections of their country of residence). Questions and complaints: ${SUPPORT_EMAIL} — we typically respond within 24 hours.`,
],
},
],
};
export const PRIVACY: LegalDoc = {
title: 'Privacy Policy',
metaDescription:
'How Perfect Postcode collects, uses and protects your data: account details, payments, saved searches, AI queries, analytics and your UK GDPR rights.',
lastUpdated: '10 June 2026',
intro: [
`This policy explains what personal data Perfect Postcode ("we", "us") collects, why, and your rights over it. We handle personal data under UK data-protection law (UK GDPR and the Data Protection Act 2018). Contact: ${SUPPORT_EMAIL}.`,
],
sections: [
{
heading: '1. What we collect',
paragraphs: [],
bullets: [
'Account data: your email address, a hashed password (or your Google account identifier if you sign in with Google), newsletter preference and access status.',
'Purchase records: what you bought and when. Payments are processed by Stripe; we never receive your card details.',
'Things you create: saved searches, shared links and their settings.',
'AI search queries: the text you type into the AI search is processed to generate filters and logged with your account so we can debug and improve the feature.',
'Usage data: which pages and features are used, collected as events for product analytics, and standard server logs (IP address, user agent) kept for security.',
],
},
{
heading: '2. How we use it',
paragraphs: [],
bullets: [
'To provide and secure the service, including signing you in and remembering your saved work (performance of contract).',
'To process payments and keep the records tax law requires (legal obligation).',
'To answer support requests (performance of contract).',
'To send the newsletter, only if you opted in — every email includes an unsubscribe link (consent).',
'To understand how features are used and improve them, using aggregated analytics and logged AI queries (legitimate interests).',
],
},
{
heading: '3. Who we share it with',
paragraphs: [
'We do not sell personal data. We use a small number of processors to run the service:',
],
bullets: [
'Stripe — payment processing.',
'Google — sign-in (if you choose Google OAuth), embedded Maps/Street View imagery, and the Gemini API which processes the text of AI searches.',
'Hosting and infrastructure providers that run our servers and store backups.',
],
},
{
heading: '4. International transfers',
paragraphs: [
'Some processors (such as Stripe and Google) process data outside the UK. Where that happens, transfers rely on UK adequacy decisions or standard contractual clauses.',
],
},
{
heading: '5. Cookies and local storage',
paragraphs: [
'We do not use advertising cookies or third-party trackers. Your browsers local storage holds your sign-in token and preferences (theme, language, tutorial progress, last map view). Embedded Google content (Street View, sign-in) may set its own cookies under Googles policies.',
],
},
{
heading: '6. Retention',
paragraphs: [
'Account data is kept while your account exists and deleted when you ask us to close it. Server logs are kept for a short period for security. Purchase records are kept for as long as tax law requires (typically six years).',
],
},
{
heading: '7. Your rights',
paragraphs: [
`You can ask for a copy of your data, have it corrected or deleted, restrict or object to processing, and receive your data in a portable format. Email ${SUPPORT_EMAIL} and we will respond promptly. If you are unhappy with how we handle your data you can complain to the Information Commissioners Office (ico.org.uk).`,
],
},
{
heading: '8. Children',
paragraphs: ['The service is aimed at home buyers and renters and is not directed at children under 16.'],
},
{
heading: '9. Changes to this policy',
paragraphs: [
'We will post any changes here and update the date at the top. Material changes will be flagged on the site or by email.',
],
},
],
};

View file

@ -1172,10 +1172,7 @@ export default memo(function Map({
? [postcodeCountRange.min, postcodeCountRange.max]
: [countRange.min, countRange.max]
}
totalCount={
totalCountProp ??
(usePostcodeView ? postcodeCountRange.total : countRange.total)
}
totalCount={totalCountProp}
showCancel={false}
onCancel={onCancelPin}
mode="density"

View file

@ -26,6 +26,7 @@ import { apiUrl, authHeaders, buildFilterString } from '../../lib/api';
import { useFilterCounts } from '../../hooks/useFilterCounts';
import { trackEvent } from '../../lib/analytics';
import { INITIAL_VIEW_STATE, POSTCODE_ZOOM_THRESHOLD } from '../../lib/consts';
import { boundsToCenterZoom } from '../../lib/fit-bounds';
import type { OverlayId } from '../../lib/overlays';
import { CRIME_TYPE_VALUES } from '../../lib/crime-types';
import type { BasemapId } from '../../lib/basemaps';
@ -257,6 +258,14 @@ export default function MapPage({
}))
);
// 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);
mapFlyToRef.current?.(target.lat, target.lng, target.zoom, getMobileMapFlyToOptions());
return;
}
const firstTravelTime = representable[0]?.tt;
if (!firstTravelTime?.slug) return;
@ -482,7 +491,15 @@ export default function MapPage({
}, []);
const shareReturnViewRef = useRef(shareCode ? initialViewState : null);
// Hide the upgrade modal as soon as the user dismisses it. We can't rely on
// the camera fly alone to close it: flying back to the free/shared zone only
// clears `licenseRequired` once the resulting refetch returns non-403, and
// when the fly target equals the current view (e.g. "back to shared area"
// while already at the shared view) `jumpTo` is a no-op, so no refetch fires
// and the modal would otherwise stay stuck open.
const [upgradeModalDismissed, setUpgradeModalDismissed] = useState(false);
const handleZoomToFreeZone = useCallback(() => {
setUpgradeModalDismissed(true);
const target = shareReturnViewRef.current ?? INITIAL_VIEW_STATE;
mapFlyToRef.current?.(target.latitude, target.longitude, target.zoom);
}, []);
@ -576,7 +593,6 @@ export default function MapPage({
const tutorial = useTutorial(initialLoading, isMobile, deferTutorial || mapData.licenseRequired);
const tutorialTheme = useMemo(() => getTutorialStyles(theme), [theme]);
const densityLabel = t('mapLegend.historicalMatches');
const hasActiveFilters = Object.keys(filters).length > 0 || activeEntries.length > 0;
const mobileLegendMeta = useMobileLegendMeta(viewFeature, features);
const mapViewFeature = useMapViewFeature(viewFeature);
const mobileDensityRange = useMobileDensityRange(mapData);
@ -661,7 +677,13 @@ export default function MapPage({
}, [onDashboardReadyChange]);
useEffect(() => {
if (mapData.licenseRequired) trackEvent('Upgrade Modal Shown');
if (mapData.licenseRequired) {
trackEvent('Upgrade Modal Shown');
} else {
// Back in a viewable area — re-arm so the modal shows again the next time
// the user pans into a gated area.
setUpgradeModalDismissed(false);
}
}, [mapData.licenseRequired]);
if (screenshotMode) {
@ -856,22 +878,25 @@ export default function MapPage({
</div>
) : null;
const upgradeModal = mapData.licenseRequired ? (
<Suspense fallback={null}>
<UpgradeModal
isLoggedIn={!!user}
onLoginClick={() =>
onCheckoutLoginClick ? onCheckoutLoginClick(checkoutReturnPath) : onLoginClick()
}
onRegisterClick={() =>
onCheckoutRegisterClick ? onCheckoutRegisterClick(checkoutReturnPath) : onRegisterClick()
}
onStartCheckout={() => license.startCheckout(checkoutReturnPath)}
onZoomToFreeZone={handleZoomToFreeZone}
isShareReturn={!!shareReturnViewRef.current}
/>
</Suspense>
) : null;
const upgradeModal =
mapData.licenseRequired && !upgradeModalDismissed ? (
<Suspense fallback={null}>
<UpgradeModal
isLoggedIn={!!user}
onLoginClick={() =>
onCheckoutLoginClick ? onCheckoutLoginClick(checkoutReturnPath) : onLoginClick()
}
onRegisterClick={() =>
onCheckoutRegisterClick
? onCheckoutRegisterClick(checkoutReturnPath)
: onRegisterClick()
}
onStartCheckout={() => license.startCheckout(checkoutReturnPath)}
onZoomToFreeZone={handleZoomToFreeZone}
isShareReturn={!!shareReturnViewRef.current}
/>
</Suspense>
) : null;
if (isMobile) {
return (
@ -980,7 +1005,7 @@ export default function MapPage({
onToggleActualListings={canSeeListings ? handleToggleActualListings : undefined}
travelTimeEntries={entries}
densityLabel={densityLabel}
totalCount={hasActiveFilters ? filterCounts.total : undefined}
totalCount={filterCounts.total ?? undefined}
poiPaneOpen={poiPaneOpen}
onTogglePoiPane={handleTogglePoiPane}
poiPane={renderPOIPane()}

View file

@ -6,11 +6,16 @@ interface PriceHistoryChartProps {
points: PricePoint[];
}
const PADDING = { top: 8, right: 24, bottom: 20, left: 42 };
const PADDING = { top: 8, right: 24, bottom: 20, left: 48 };
const HEIGHT = 120;
const PRICE_SCALE_TOP_PERCENTILE = 95;
const priceFmt = { prefix: '£' };
/** Ticks are nice round values; "£800.0k" both clips and reads worse than "£800k". */
function formatTick(tick: number): string {
return formatValue(tick, priceFmt).replace(/\.0(?=[kM])/, '');
}
interface PriceScale {
min: number;
max: number;
@ -148,7 +153,7 @@ export default function PriceHistoryChart({ points }: PriceHistoryChartProps) {
className="fill-warm-500 dark:fill-warm-400"
fontSize={10}
>
{formatValue(tick, priceFmt)}
{formatTick(tick)}
</text>
))}

View file

@ -9,7 +9,9 @@ import { TravelTimeInfoPopup } from '../ui/TravelTimeInfoPopup';
import { CloseIcon } from '../ui/icons/CloseIcon';
import { EyeIcon } from '../ui/icons/EyeIcon';
import { InfoIcon } from '../ui/icons/InfoIcon';
import { formatFilterValue, formatNumber } from '../../lib/format';
import { SliderLabels } from './filters/SliderLabels';
import { formatNumber } from '../../lib/format';
import type { FeatureMeta } from '../../types';
import { useTravelDestinations } from '../../hooks/useTravelDestinations';
import {
MAX_TRAVEL_MINUTES,
@ -56,7 +58,7 @@ export function TravelTimeCard({
dragValue,
onTogglePin,
onSetDestination,
onTimeRangeChange: _onTimeRangeChange,
onTimeRangeChange,
onDragStart,
onDragChange,
onDragEnd,
@ -86,6 +88,17 @@ export function TravelTimeCard({
const sliderMax = MAX_TRAVEL_MINUTES;
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.
const travelFeature: FeatureMeta = {
name: 'travelTime',
type: 'numeric',
min: sliderMin,
max: sliderMax,
suffix: ` ${t('common.minute')}`,
raw: true,
};
const ModeIcon = MODE_ICONS[mode];
return (
@ -211,14 +224,17 @@ export function TravelTimeCard({
onPointerDown={() => onDragStart(displayRange)}
onPointerUp={() => onDragEnd()}
/>
<div className="relative h-4 mt-1 mx-2.5 text-[10px] text-warm-500 dark:text-warm-400 leading-tight">
<span className="absolute left-0">
{formatFilterValue(displayRange[0])} {t('common.minute')}
</span>
<span className="absolute right-0">
{formatFilterValue(displayRange[1])} {t('common.minute')}
</span>
</div>
<SliderLabels
min={sliderMin}
max={sliderMax}
value={[displayRange[0], displayRange[1]]}
isAtMin={displayRange[0] <= sliderMin}
isAtMax={displayRange[1] >= sliderMax}
raw
showUnit
feature={travelFeature}
onValueChange={onTimeRangeChange}
/>
{filterImpact != null && filterImpact > 0 && (
<p className="text-[10px] text-warm-400 dark:text-warm-500 -mt-1 ml-2.5">
{t('filters.filtersOut', { value: formatNumber(filterImpact) })}

View file

@ -11,7 +11,6 @@ import {
getSchoolFilterConfig,
getSchoolFilterMeta,
replaceSchoolFilterKeySelection,
type SchoolDistance,
type SchoolPhase,
type SchoolRating,
} from '../../../lib/school-filter';
@ -74,7 +73,6 @@ export function SchoolFilterCard({
next: Partial<{
phase: SchoolPhase;
rating: SchoolRating;
distance: SchoolDistance;
}>
) => {
const nextName = replaceSchoolFilterKeySelection(schoolFeature.name, next);
@ -180,35 +178,6 @@ export function SchoolFilterCard({
</button>
</div>
</div>
<div>
<div className="mb-0.5 text-[10px] font-medium uppercase text-warm-400 dark:text-warm-500">
{t('filters.distance')}
</div>
<div
className={segmentedClass}
role="radiogroup"
aria-label={t('filters.schoolDistance')}
>
<button
type="button"
role="radio"
aria-checked={config.distance === 2}
onClick={() => replaceSchoolFeature({ distance: 2 })}
className={optionClass(config.distance === 2)}
>
2 km
</button>
<button
type="button"
role="radio"
aria-checked={config.distance === 5}
onClick={() => replaceSchoolFeature({ distance: 5 })}
className={optionClass(config.distance === 5)}
>
5 km
</button>
</div>
</div>
</div>
<Slider

View file

@ -8,6 +8,7 @@ import { logNonAbortError } from '../../lib/api';
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()
@ -39,6 +40,7 @@ export default function PricingPage({
onRegisterClick?: () => void;
}) {
const { t } = useTranslation();
const isDark = useIsDarkTheme();
const license = useLicense();
const [pricing, setPricing] = useState<PricingData | null>(null);
const [loading, setLoading] = useState(true);
@ -137,17 +139,23 @@ export default function PricingPage({
) : null;
return (
<div className="flex-1 overflow-y-auto bg-navy-950 relative">
<div className="flex-1 overflow-y-auto bg-warm-50 dark:bg-navy-950 relative">
<div className="fixed inset-0 pointer-events-none overflow-hidden">
<div className="absolute inset-0 bg-gradient-to-b from-navy-950 via-navy-900 to-navy-950" />
<HexCanvas isDark />
<div className="absolute inset-0 bg-[radial-gradient(circle_at_50%_12%,rgba(20,184,166,0.16),transparent_38%),linear-gradient(180deg,rgba(10,14,26,0.20),rgba(10,14,26,0.82))]" />
<div className="absolute inset-0 bg-gradient-to-b from-warm-100 via-warm-50 to-warm-100 dark:from-navy-950 dark:via-navy-900 dark:to-navy-950" />
<HexCanvas isDark={isDark} />
<div className="absolute inset-0 hidden dark:block bg-[radial-gradient(circle_at_50%_12%,rgba(20,184,166,0.16),transparent_38%),linear-gradient(180deg,rgba(10,14,26,0.20),rgba(10,14,26,0.82))]" />
</div>
<div className="relative z-10 max-w-5xl mx-auto px-6 pt-16 text-center mb-10">
<h1 className="text-3xl md:text-4xl font-bold text-white mb-3">{t('pricingPage.title')}</h1>
<p className="text-lg text-warm-300 max-w-lg mx-auto">{t('pricingPage.subtitle')}</p>
<p className="mt-5 text-warm-200 font-semibold">{t('pricingPage.lessThanSurvey')}</p>
<h1 className="text-3xl md:text-4xl font-bold text-navy-950 dark:text-white mb-3">
{t('pricingPage.title')}
</h1>
<p className="text-lg text-warm-600 dark:text-warm-300 max-w-lg mx-auto">
{t('pricingPage.subtitle')}
</p>
<p className="mt-5 text-warm-700 dark:text-warm-200 font-semibold">
{t('pricingPage.lessThanSurvey')}
</p>
</div>
<div className="relative z-10 max-w-5xl mx-auto px-6 pb-8 md:pb-16">
@ -203,7 +211,7 @@ export default function PricingPage({
className={`relative flex flex-col rounded-2xl border overflow-hidden w-80 shrink-0 ${
isCurrent
? 'border-teal-400 ring-2 ring-teal-400 shadow-lg'
: 'border-warm-700 shadow-md'
: 'border-warm-300 dark:border-warm-700 shadow-md'
} ${isFilled ? 'opacity-60' : ''}`}
>
{isCurrent && (
@ -266,11 +274,6 @@ export default function PricingPage({
})}
</p>
)}
{isFilled && (
<p className="text-warm-400 dark:text-warm-500 text-sm mt-2 flex items-center justify-center gap-1">
<CheckIcon className="w-4 h-4" /> {t('pricingPage.filled')}
</p>
)}
</div>
{/* Progress bar for current tier */}
@ -308,10 +311,14 @@ export default function PricingPage({
{license.error}
</p>
)}
{isFree && (
{isFree ? (
<p className="text-center text-xs text-warm-400 dark:text-warm-500 mt-2">
{t('pricingPage.noCreditCard')}
</p>
) : (
<p className="text-center text-xs text-warm-400 dark:text-warm-500 mt-2">
{t('pricingPage.moneyBack')}
</p>
)}
</>
) : isFilled ? (

View file

@ -1,5 +1,5 @@
import { useState, useCallback, useEffect, useId } from 'react';
import { useTranslation } from 'react-i18next';
import { Trans, useTranslation } from 'react-i18next';
import { CloseIcon } from './icons/CloseIcon';
import { GoogleIcon } from './icons/GoogleIcon';
import { trackEvent } from '../../lib/analytics';
@ -106,7 +106,7 @@ export default function AuthModal({
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center"
className="fixed inset-0 z-[10001] flex items-center justify-center"
onMouseDown={(e) => {
if (e.target === e.currentTarget) onClose();
}}
@ -283,6 +283,32 @@ export default function AuthModal({
{t('auth.backToLogin')}
</button>
)}
{view === 'register' && (
<p className="text-[11px] leading-relaxed text-center text-warm-400 dark:text-warm-500">
<Trans
i18nKey="auth.registerConsent"
components={{
terms: (
<a
href="/terms"
target="_blank"
rel="noopener"
className="underline hover:text-teal-600 dark:hover:text-teal-400"
/>
),
privacy: (
<a
href="/privacy"
target="_blank"
rel="noopener"
className="underline hover:text-teal-600 dark:hover:text-teal-400"
/>
),
}}
/>
</p>
)}
</form>
</div>
</div>

View file

@ -0,0 +1,79 @@
import { useTranslation } from 'react-i18next';
import { LogoIcon } from './icons/LogoIcon';
const SUPPORT_EMAIL = 'support@perfect-postcode.co.uk';
function FooterLink({ href, label }: { href: string; label: string }) {
return (
<li>
<a
href={href}
className="text-sm text-warm-500 hover:text-teal-600 dark:text-warm-400 dark:hover:text-teal-400 transition-colors"
>
{label}
</a>
</li>
);
}
export default function Footer() {
const { t } = useTranslation();
const year = new Date().getFullYear();
return (
<footer className="border-t border-warm-200 bg-warm-50 dark:border-warm-800 dark:bg-navy-950">
<div className="mx-auto max-w-6xl px-4 py-10">
<div className="grid gap-8 sm:grid-cols-2 md:grid-cols-4">
<div>
<a href="/" className="flex items-center gap-2 hover:opacity-80 transition-opacity">
<LogoIcon className="h-5 w-5 shrink-0 text-teal-500" />
<span className="text-base font-semibold text-navy-950 dark:text-teal-300">
{t('header.appName')}
</span>
</a>
<p className="mt-3 text-sm leading-relaxed text-warm-500 dark:text-warm-400">
{t('footer.tagline')}
</p>
</div>
<nav aria-label={t('footer.product')}>
<h2 className="text-xs font-semibold uppercase tracking-wide text-warm-400 dark:text-warm-500">
{t('footer.product')}
</h2>
<ul className="mt-3 space-y-2">
<FooterLink href="/dashboard" label={t('header.dashboard')} />
<FooterLink href="/pricing" label={t('header.pricing')} />
<FooterLink href="/learn" label={t('header.learn')} />
</ul>
</nav>
<nav aria-label={t('footer.resources')}>
<h2 className="text-xs font-semibold uppercase tracking-wide text-warm-400 dark:text-warm-500">
{t('footer.resources')}
</h2>
<ul className="mt-3 space-y-2">
<FooterLink href="/data-sources" label={t('footer.dataSources')} />
<FooterLink href="/methodology" label={t('footer.methodology')} />
<FooterLink href={`mailto:${SUPPORT_EMAIL}`} label={t('footer.contact')} />
</ul>
</nav>
<nav aria-label={t('footer.legal')}>
<h2 className="text-xs font-semibold uppercase tracking-wide text-warm-400 dark:text-warm-500">
{t('footer.legal')}
</h2>
<ul className="mt-3 space-y-2">
<FooterLink href="/terms" label={t('footer.terms')} />
<FooterLink href="/privacy" label={t('footer.privacy')} />
</ul>
</nav>
</div>
<div className="mt-10 flex flex-col gap-2 border-t border-warm-200 pt-6 text-xs text-warm-400 dark:border-warm-800 dark:text-warm-500 sm:flex-row sm:items-center sm:justify-between">
<p>{t('footer.copyright', { year })}</p>
<p>{t('footer.coverage')}</p>
</div>
</div>
</footer>
);
}

View file

@ -33,6 +33,8 @@ export type Page =
| 'data-sources'
| 'methodology'
| 'privacy-security'
| 'terms'
| 'privacy'
| 'account'
| 'saved'
| 'invite';
@ -58,6 +60,8 @@ export const PAGE_PATHS: Record<Page, string> = {
'data-sources': '/data-sources',
methodology: '/methodology',
'privacy-security': '/privacy-security',
terms: '/terms',
privacy: '/privacy',
saved: '/saved',
account: '/account',
invite: '/invite',

View file

@ -18,6 +18,12 @@ interface SearchHook {
handleInputChange: (value: string) => void;
handleKeyDown: (e: React.KeyboardEvent, onSelect: (result: SearchResult) => void) => void;
showEmptySearches: () => void;
close?: () => void;
}
/** Addresses arrive in raw ALL-CAPS Land Registry casing; title-case for display. */
function titleCaseAddress(address: string): string {
return address.toLowerCase().replace(/(^|[\s\-/(])([a-z])/g, (_, sep, c) => sep + c.toUpperCase());
}
interface PlaceSearchInputProps {
@ -65,6 +71,9 @@ export function PlaceSearchInput({
const dropdown = showDropdown && (
<div
className={`bg-white dark:bg-warm-800 rounded shadow-lg border border-warm-200 dark:border-warm-700 ${sm ? 'max-h-64' : 'max-h-48'} overflow-y-auto`}
// Keep focus on the input while interacting with the list (incl. its
// scrollbar) so the blur-close below doesn't fire mid-click.
onMouseDown={(e) => e.preventDefault()}
style={
portal && dropdownPos
? {
@ -116,8 +125,11 @@ export function PlaceSearchInput({
) : result.type === 'address' ? (
<>
<HouseIcon className={`${iconSize} text-warm-400 dark:text-warm-500 shrink-0`} />
<span className="min-w-0 text-warm-700 dark:text-warm-200">
<span className="block truncate">{result.address}</span>
<span
className="min-w-0 text-warm-700 dark:text-warm-200"
title={`${titleCaseAddress(result.address)}, ${result.postcode}`}
>
<span className="block truncate">{titleCaseAddress(result.address)}</span>
<span className="block truncate text-warm-400 dark:text-warm-500">
{result.postcode}
</span>
@ -126,7 +138,10 @@ export function PlaceSearchInput({
) : (
<>
<MapPinIcon className={`${iconSize} text-warm-400 dark:text-warm-500 shrink-0`} />
<span className="text-warm-700 dark:text-warm-200">
<span
className="text-warm-700 dark:text-warm-200"
title={result.city ? `${result.name} (${result.city})` : result.name}
>
{result.name}
{result.city && (
<span className="text-warm-400 dark:text-warm-500"> ({result.city})</span>
@ -154,6 +169,9 @@ export function PlaceSearchInput({
onFocus={() => {
search.showEmptySearches();
}}
// Without this, instances whose parents lack outside-click handling
// leave a detached dropdown floating over the map.
onBlur={() => search.close?.()}
onKeyDown={(e) => search.handleKeyDown(e, onSelect)}
aria-label={ariaLabel ?? placeholder}
placeholder={placeholder}

View file

@ -15,6 +15,13 @@ export interface AiTravelTimeFilter {
max?: number;
}
export interface AiMatchBounds {
south: number;
west: number;
north: number;
east: number;
}
export interface AiFiltersResult {
filters: FeatureFilters;
travelTimeFilters: AiTravelTimeFilter[];
@ -23,6 +30,8 @@ export interface AiFiltersResult {
summary: string;
/** Number of properties matching the proposed filters (excludes travel time) */
matchCount: number;
/** Bounding box of the matching properties, if any matched */
matchBounds: AiMatchBounds | null;
}
export type AiFilterErrorType = 'auth' | 'limit' | 'error';
@ -152,6 +161,7 @@ export function useAiFilters(): UseAiFiltersResult {
notes: json.notes || '',
summary: summaryText,
matchCount,
matchBounds: (json.match_bounds as AiMatchBounds | undefined) ?? null,
};
setNotes(result.notes || null);
setSummary(summaryText);

View file

@ -176,10 +176,9 @@ export function useDeckLayers({
enumPaletteRef.current = enumPalette;
const countRange = useMemo(() => {
if (data.length === 0) return { min: 0, max: 1, total: 0 };
if (data.length === 0) return { min: 0, max: 1 };
let min = Infinity;
let max = -Infinity;
let total = 0;
for (const d of data) {
if (viewportBounds) {
if (
@ -191,24 +190,22 @@ export function useDeckLayers({
continue;
}
const c = d.count as number;
total += c;
if (c <= 0) continue;
if (c < min) min = c;
if (c > max) max = c;
}
if (min === Infinity) return { min: 0, max: 1, total: 0 };
if (min === max) return { min, max: min + 1, total };
return { min, max, total };
if (min === Infinity) return { min: 0, max: 1 };
if (min === max) return { min, max: min + 1 };
return { min, max };
}, [data, viewportBounds]);
const countRangeRef = useRef(countRange);
countRangeRef.current = countRange;
const postcodeCountRange = useMemo(() => {
if (postcodeData.length === 0) return { min: 0, max: 1, total: 0 };
if (postcodeData.length === 0) return { min: 0, max: 1 };
let min = Infinity;
let max = -Infinity;
let total = 0;
for (const d of postcodeData) {
if (viewportBounds) {
const [lng, lat] = d.properties.centroid as [number, number];
@ -221,14 +218,13 @@ export function useDeckLayers({
continue;
}
const c = d.properties.count;
total += c;
if (c <= 0) continue;
if (c < min) min = c;
if (c > max) max = c;
}
if (min === Infinity) return { min: 0, max: 1, total: 0 };
if (min === max) return { min, max: min + 1, total };
return { min, max, total };
if (min === Infinity) return { min: 0, max: 1 };
if (min === max) return { min, max: min + 1 };
return { min, max };
}, [postcodeData, viewportBounds]);
const postcodeCountRangeRef = useRef(postcodeCountRange);

View file

@ -12,8 +12,11 @@ interface FilterCountsResponse {
}
/**
* Fetches per-filter rejection counts: for each active filter,
* how many in-bounds properties that filter removes.
* Single source of truth for the in-view property count. Returns:
* - `total`: exact count of in-bounds properties passing the active filters
* (or the full in-bounds total when there are none); null until first load.
* - `impacts`: per-filter rejection counts how many in-bounds properties
* each active filter removes.
*/
export function useFilterCounts(
filters: FeatureFilters,
@ -23,7 +26,10 @@ export function useFilterCounts(
shareCode?: string
) {
const [impacts, setImpacts] = useState<Record<string, number>>({});
const [total, setTotal] = useState<number>(0);
// null = not loaded yet for the current bounds (hide the counter until the
// first response). The number is the exact count of in-bounds properties
// passing the active filters — and, with no filters, the total in bounds.
const [total, setTotal] = useState<number | null>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const abortRef = useRef<AbortController | null>(null);
@ -39,16 +45,7 @@ export function useFilterCounts(
if (!bounds) {
abortRef.current?.abort();
setImpacts({});
setTotal(0);
return;
}
const filterCount = Object.keys(filters).length;
const hasTravelFilters = travelTimeEntries.some((e) => e.slug && e.timeRange);
if (filterCount === 0 && !hasTravelFilters) {
abortRef.current?.abort();
setImpacts({});
setTotal(0);
setTotal(null);
return;
}

View file

@ -180,12 +180,7 @@ export function useFilters({ initialFilters, features }: UseFiltersOptions) {
undoStackRef.current.push(prev);
if (undoStackRef.current.length > 50) undoStackRef.current.shift();
if (name === SCHOOL_FILTER_NAME) {
const schoolKey = createSchoolFilterKey(
'primary',
'good',
2,
schoolFilterIdRef.current++
);
const schoolKey = createSchoolFilterKey('primary', 'good', schoolFilterIdRef.current++);
const defaultSchoolFeatureName = getDefaultSchoolFeatureName(features);
const defaultSchoolFeature = defaultSchoolFeatureName
? features.find((feature) => feature.name === defaultSchoolFeatureName)

View file

@ -0,0 +1,18 @@
import { useEffect, useState } from 'react';
/**
* Tracks whether dark mode is active by observing the html.dark class.
* Useful in components that don't receive the theme as a prop (showcase,
* pricing backdrop) but must keep canvas/map content in sync with it.
*/
export function useIsDarkTheme(): boolean {
const [isDark, setIsDark] = useState(() => document.documentElement.classList.contains('dark'));
useEffect(() => {
const observer = new MutationObserver(() =>
setIsDark(document.documentElement.classList.contains('dark'))
);
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
return () => observer.disconnect();
}, []);
return isDark;
}

View file

@ -31,7 +31,9 @@ export function useTutorial(initialLoading: boolean, isMobile: boolean, blocked
target: '[data-tutorial="map"]',
title: t('tutorial.step3Title'),
content: t('tutorial.step3Content'),
placement: 'top' as const,
// The map fills the viewport; directional placements push the tooltip
// off-screen. Center it over the map instead.
placement: 'center' as const,
skipBeacon: true,
},
{

View file

@ -31,28 +31,20 @@ const descriptions: Record<string, Record<string, string>> = {
'Potential energy rating':
'Classe énergétique EPC possible si toutes les améliorations recommandées étaient réalisées',
'Interior height (m)': 'Hauteur intérieure moyenne relevée lors du diagnostic EPC',
'Street tree density percentile':
'Tree canopy density percentile':
'Percentile estimé de couverture arborée autour du code postal',
'Within conservation area':
'Indique si le point représentatif du code postal se situe dans une zone de conservation désignée',
'Listed building':
'Indique si le bien semble correspondre à un bâtiment classé répertorié par Historic England',
'Good+ primary schools within 2km':
'Écoles primaires notées Bien ou Excellent par Ofsted dans un rayon de 2 km',
'Good+ secondary schools within 2km':
'Collèges/lycées notés Bien ou Excellent par Ofsted dans un rayon de 2 km',
'Good+ primary schools within 5km':
'Écoles primaires notées Bien ou Excellent par Ofsted dans un rayon de 5 km',
'Good+ secondary schools within 5km':
'Collèges/lycées notés Bien ou Excellent par Ofsted dans un rayon de 5 km',
'Outstanding primary schools within 2km':
'Écoles primaires notées Excellent par Ofsted dans un rayon de 2 km',
'Outstanding secondary schools within 2km':
'Collèges/lycées notés Excellent par Ofsted dans un rayon de 2 km',
'Outstanding primary schools within 5km':
'Écoles primaires notées Excellent par Ofsted dans un rayon de 5 km',
'Outstanding secondary schools within 5km':
'Collèges/lycées notés Excellent par Ofsted dans un rayon de 5 km',
'Good+ primary school catchments':
'Écoles primaires notées Bien ou Excellent par Ofsted dont la zone de recrutement modélisée couvre ce code postal',
'Good+ secondary school catchments':
'Collèges/lycées notés Bien ou Excellent par Ofsted dont la zone de recrutement modélisée couvre ce code postal',
'Outstanding primary school catchments':
'Écoles primaires notées Excellent par Ofsted dont la zone de recrutement modélisée couvre ce code postal',
'Outstanding secondary school catchments':
'Collèges/lycées notés Excellent par Ofsted dont la zone de recrutement modélisée couvre ce code postal',
'Education, Skills and Training Score':
'Percentile de défaveur en éducation, compétences et formation (plus élevé = moins défavorisé)',
'Income Score': 'Percentile de défaveur liée au revenu (plus élevé = moins défavorisé)',
@ -88,7 +80,8 @@ const descriptions: Record<string, Record<string, string>> = {
'% White': 'Part de la population sidentifiant comme blanche',
'% South Asian': 'Part de la population sidentifiant comme sud-asiatique',
'% Black': 'Part de la population sidentifiant comme noire',
'% East/SE Asian': 'Part de la population sidentifiant comme est/sud-est asiatique',
'% East Asian': 'Part de la population sidentifiant comme est-asiatique',
'% SE Asian': 'Part de la population sidentifiant comme sud-est asiatique',
'% Mixed':
'Part de la population sidentifiant comme métisse ou de plusieurs groupes ethniques',
'% Other': 'Part de la population sidentifiant comme appartenant à un autre groupe ethnique',
@ -131,28 +124,20 @@ const descriptions: Record<string, Record<string, string>> = {
'Potential energy rating':
'Mögliche EPC-Energieeffizienzklasse, wenn alle empfohlenen Maßnahmen umgesetzt würden',
'Interior height (m)': 'Durchschnittliche Raumhöhe laut EPC-Gutachten',
'Street tree density percentile':
'Geschätztes Perzentil der Straßenbaumdichte rund um den Postcode',
'Tree canopy density percentile':
'Geschätztes Perzentil der Baumkronendichte rund um den Postcode',
'Within conservation area':
'Ob der repräsentative Punkt des Postcodes in einem ausgewiesenen Denkmalschutzgebiet liegt',
'Listed building':
'Ob die Immobilie wahrscheinlich einem Eintrag von Historic England für ein denkmalgeschütztes Gebäude entspricht',
'Good+ primary schools within 2km':
'Von Ofsted mit Gut oder Hervorragend bewertete Grundschulen im Umkreis von 2 km',
'Good+ secondary schools within 2km':
'Von Ofsted mit Gut oder Hervorragend bewertete weiterführende Schulen im Umkreis von 2 km',
'Good+ primary schools within 5km':
'Von Ofsted mit Gut oder Hervorragend bewertete Grundschulen im Umkreis von 5 km',
'Good+ secondary schools within 5km':
'Von Ofsted mit Gut oder Hervorragend bewertete weiterführende Schulen im Umkreis von 5 km',
'Outstanding primary schools within 2km':
'Von Ofsted mit Hervorragend bewertete Grundschulen im Umkreis von 2 km',
'Outstanding secondary schools within 2km':
'Von Ofsted mit Hervorragend bewertete weiterführende Schulen im Umkreis von 2 km',
'Outstanding primary schools within 5km':
'Von Ofsted mit Hervorragend bewertete Grundschulen im Umkreis von 5 km',
'Outstanding secondary schools within 5km':
'Von Ofsted mit Hervorragend bewertete weiterführende Schulen im Umkreis von 5 km',
'Good+ primary school catchments':
'Von Ofsted mit Gut oder Hervorragend bewertete Grundschulen, deren modelliertes Einzugsgebiet diese Postleitzahl abdeckt',
'Good+ secondary school catchments':
'Von Ofsted mit Gut oder Hervorragend bewertete weiterführende Schulen, deren modelliertes Einzugsgebiet diese Postleitzahl abdeckt',
'Outstanding primary school catchments':
'Von Ofsted mit Hervorragend bewertete Grundschulen, deren modelliertes Einzugsgebiet diese Postleitzahl abdeckt',
'Outstanding secondary school catchments':
'Von Ofsted mit Hervorragend bewertete weiterführende Schulen, deren modelliertes Einzugsgebiet diese Postleitzahl abdeckt',
'Education, Skills and Training Score':
'Benachteiligungsperzentil für Bildung, Kompetenzen und Ausbildung (höher = weniger benachteiligt)',
'Income Score': 'Einkommensbenachteiligungsperzentil (höher = weniger benachteiligt)',
@ -188,7 +173,8 @@ const descriptions: Record<string, Record<string, string>> = {
'% White': 'Anteil der Personen, die sich als weiß identifizieren',
'% South Asian': 'Anteil der Personen, die sich als südasiatisch identifizieren',
'% Black': 'Anteil der Personen, die sich als schwarz identifizieren',
'% East/SE Asian': 'Anteil der Personen, die sich als ost-/südostasiatisch identifizieren',
'% East Asian': 'Anteil der Personen, die sich als ostasiatisch identifizieren',
'% SE Asian': 'Anteil der Personen, die sich als südostasiatisch identifizieren',
'% Mixed':
'Anteil der Personen, die sich als gemischt oder mehreren ethnischen Gruppen zugehörig identifizieren',
'% Other': 'Anteil der Personen, die sich einer anderen ethnischen Gruppe zuordnen',
@ -229,17 +215,13 @@ const descriptions: Record<string, Record<string, string>> = {
'Current energy rating': '当前 EPC 能源评级A = 最佳G = 最差)',
'Potential energy rating': '完成所有建议改进后的潜在 EPC 评级',
'Interior height (m)': 'EPC 评估记录的平均室内层高',
'Street tree density percentile': '邮编周边估计街道树木覆盖率百分位',
'Tree canopy density percentile': '邮编周边估计树冠覆盖密度百分位',
'Within conservation area': '邮编代表点是否位于指定保护区内',
'Listed building': '该房产是否疑似对应 Historic England 的登录建筑记录',
'Good+ primary schools within 2km': '2 公里内 Ofsted 评为良好或优秀的小学',
'Good+ secondary schools within 2km': '2 公里内 Ofsted 评为良好或优秀的中学',
'Good+ primary schools within 5km': '5 公里内 Ofsted 评为良好或优秀的小学',
'Good+ secondary schools within 5km': '5 公里内 Ofsted 评为良好或优秀的中学',
'Outstanding primary schools within 2km': '2 公里内 Ofsted 评为优秀的小学',
'Outstanding secondary schools within 2km': '2 公里内 Ofsted 评为优秀的中学',
'Outstanding primary schools within 5km': '5 公里内 Ofsted 评为优秀的小学',
'Outstanding secondary schools within 5km': '5 公里内 Ofsted 评为优秀的中学',
'Good+ primary school catchments': '建模学区覆盖此邮编、Ofsted 评为良好或优秀的小学',
'Good+ secondary school catchments': '建模学区覆盖此邮编、Ofsted 评为良好或优秀的中学',
'Outstanding primary school catchments': '建模学区覆盖此邮编、Ofsted 评为优秀的小学',
'Outstanding secondary school catchments': '建模学区覆盖此邮编、Ofsted 评为优秀的中学',
'Education, Skills and Training Score': '教育、技能与培训剥夺百分位(越高 = 剥夺越少)',
'Income Score': '收入剥夺百分位(越高 = 剥夺越少)',
'Employment Score': '就业剥夺百分位(越高 = 剥夺越少)',
@ -266,7 +248,8 @@ const descriptions: Record<string, Record<string, string>> = {
'% White': '白人人口比例',
'% South Asian': '南亚裔人口比例',
'% Black': '黑人人口比例',
'% East/SE Asian': '东亚/东南亚裔人口比例',
'% East Asian': '东亚裔人口比例',
'% SE Asian': '东南亚裔人口比例',
'% Mixed': '混血或多族裔人口比例',
'% Other': '其他族裔人口比例',
'Voter turnout (%)': '2024 年大选中登记选民的投票率',
@ -304,26 +287,18 @@ const descriptions: Record<string, Record<string, string>> = {
'Current energy rating': 'मौजूदा EPC ऊर्जा रेटिंग (A = सबसे अच्छी, G = सबसे खराब)',
'Potential energy rating': 'सभी सुझाए गए सुधार होने पर संभावित EPC रेटिंग',
'Interior height (m)': 'EPC सर्वेक्षण के अनुसार औसत अंदरूनी ऊंचाई',
'Street tree density percentile': 'संपत्ति वाली सड़क का अनुमानित वृक्ष आच्छादन प्रतिशतक',
'Tree canopy density percentile': 'पोस्टकोड के आसपास का अनुमानित वृक्ष आच्छादन घनत्व प्रतिशतक',
'Within conservation area': 'पोस्टकोड प्रतिनिधि बिंदु नामित संरक्षण क्षेत्र में है या नहीं',
'Listed building':
'यह संपत्ति Historic England के सूचीबद्ध भवन रिकॉर्ड से मिलती-जुलती है या नहीं',
'Good+ primary schools within 2km':
'2 किमी के भीतर Ofsted से अच्छी या उत्कृष्ट रेटिंग वाले प्राइमरी स्कूल',
'Good+ secondary schools within 2km':
'2 किमी के भीतर Ofsted से अच्छी या उत्कृष्ट रेटिंग वाले सेकेंडरी स्कूल',
'Good+ primary schools within 5km':
'5 किमी के भीतर Ofsted से अच्छी या उत्कृष्ट रेटिंग वाले प्राइमरी स्कूल',
'Good+ secondary schools within 5km':
'5 किमी के भीतर Ofsted से अच्छी या उत्कृष्ट रेटिंग वाले सेकेंडरी स्कूल',
'Outstanding primary schools within 2km':
'2 किमी के भीतर Ofsted से उत्कृष्ट रेटिंग वाले प्राइमरी स्कूल',
'Outstanding secondary schools within 2km':
'2 किमी के भीतर Ofsted से उत्कृष्ट रेटिंग वाले सेकेंडरी स्कूल',
'Outstanding primary schools within 5km':
'5 किमी के भीतर Ofsted से उत्कृष्ट रेटिंग वाले प्राइमरी स्कूल',
'Outstanding secondary schools within 5km':
'5 किमी के भीतर Ofsted से उत्कृष्ट रेटिंग वाले सेकेंडरी स्कूल',
'Good+ primary school catchments':
'Ofsted से अच्छी या उत्कृष्ट रेटिंग वाले प्राइमरी स्कूल जिनका मॉडल किया गया कैचमेंट क्षेत्र इस पोस्टकोड को कवर करता है',
'Good+ secondary school catchments':
'Ofsted से अच्छी या उत्कृष्ट रेटिंग वाले सेकेंडरी स्कूल जिनका मॉडल किया गया कैचमेंट क्षेत्र इस पोस्टकोड को कवर करता है',
'Outstanding primary school catchments':
'Ofsted से उत्कृष्ट रेटिंग वाले प्राइमरी स्कूल जिनका मॉडल किया गया कैचमेंट क्षेत्र इस पोस्टकोड को कवर करता है',
'Outstanding secondary school catchments':
'Ofsted से उत्कृष्ट रेटिंग वाले सेकेंडरी स्कूल जिनका मॉडल किया गया कैचमेंट क्षेत्र इस पोस्टकोड को कवर करता है',
'Education, Skills and Training Score': 'शिक्षा और कौशल वंचना प्रतिशतक (अधिक = कम वंचना)',
'Income Score': 'आय वंचना प्रतिशतक (अधिक = कम वंचना)',
'Employment Score': 'रोजगार वंचना प्रतिशतक (अधिक = कम वंचना)',
@ -352,7 +327,8 @@ const descriptions: Record<string, Record<string, string>> = {
'% White': 'श्वेत के रूप में पहचान करने वाली आबादी का प्रतिशत',
'% South Asian': 'दक्षिण एशियाई के रूप में पहचान करने वाली आबादी का प्रतिशत',
'% Black': 'अश्वेत के रूप में पहचान करने वाली आबादी का प्रतिशत',
'% East/SE Asian': 'पूर्वी/दक्षिण-पूर्वी एशियाई के रूप में पहचान करने वाली आबादी का प्रतिशत',
'% East Asian': 'पूर्वी एशियाई के रूप में पहचान करने वाली आबादी का प्रतिशत',
'% SE Asian': 'दक्षिण-पूर्वी एशियाई के रूप में पहचान करने वाली आबादी का प्रतिशत',
'% Mixed': 'मिश्रित या कई जातीय समूहों से पहचान करने वाली आबादी का प्रतिशत',
'% Other': 'अन्य जातीय समूह के रूप में पहचान करने वाली आबादी का प्रतिशत',
'Voter turnout (%)': '2024 आम चुनाव में मतदान करने वाले पंजीकृत मतदाताओं का प्रतिशत',
@ -391,28 +367,20 @@ const descriptions: Record<string, Record<string, string>> = {
'Potential energy rating':
'Potenciális EPC besorolás az összes javasolt fejlesztés elvégzése után',
'Interior height (m)': 'Átlagos belmagasság az EPC felmérés alapján',
'Street tree density percentile':
'Az ingatlan utcájának becsült lombkorona-fedettségi percentilise',
'Tree canopy density percentile':
'A postai irányítószám környékének becsült lombkorona-fedettségi percentilise',
'Within conservation area':
'Az irányítószám reprezentatív pontja kijelölt műemléki területre esik-e',
'Listed building':
'Az ingatlan látszólag megfelel-e egy Historic England műemléki épület bejegyzésének',
'Good+ primary schools within 2km':
'Ofsted által Jó vagy Kiváló minősítésű általános iskolák 2 km-en belül',
'Good+ secondary schools within 2km':
'Ofsted által Jó vagy Kiváló minősítésű középiskolák 2 km-en belül',
'Good+ primary schools within 5km':
'Ofsted által Jó vagy Kiváló minősítésű általános iskolák 5 km-en belül',
'Good+ secondary schools within 5km':
'Ofsted által Jó vagy Kiváló minősítésű középiskolák 5 km-en belül',
'Outstanding primary schools within 2km':
'Ofsted által Kiváló minősítésű általános iskolák 2 km-en belül',
'Outstanding secondary schools within 2km':
'Ofsted által Kiváló minősítésű középiskolák 2 km-en belül',
'Outstanding primary schools within 5km':
'Ofsted által Kiváló minősítésű általános iskolák 5 km-en belül',
'Outstanding secondary schools within 5km':
'Ofsted által Kiváló minősítésű középiskolák 5 km-en belül',
'Good+ primary school catchments':
'Ofsted által Jó vagy Kiváló minősítésű általános iskolák, amelyek modellezett körzete lefedi ezt az irányítószámot',
'Good+ secondary school catchments':
'Ofsted által Jó vagy Kiváló minősítésű középiskolák, amelyek modellezett körzete lefedi ezt az irányítószámot',
'Outstanding primary school catchments':
'Ofsted által Kiváló minősítésű általános iskolák, amelyek modellezett körzete lefedi ezt az irányítószámot',
'Outstanding secondary school catchments':
'Ofsted által Kiváló minősítésű középiskolák, amelyek modellezett körzete lefedi ezt az irányítószámot',
'Education, Skills and Training Score':
'Oktatási és készségbeli deprivációs percentilis (magasabb = kevésbé hátrányos)',
'Income Score': 'Jövedelmi deprivációs percentilis (magasabb = kevésbé hátrányos)',
@ -444,7 +412,8 @@ const descriptions: Record<string, Record<string, string>> = {
'% White': 'A fehérként azonosított lakosság aránya',
'% South Asian': 'A dél-ázsiaiként azonosított lakosság aránya',
'% Black': 'A feketeként azonosított lakosság aránya',
'% East/SE Asian': 'A kelet-/délkelet-ázsiaiként azonosított lakosság aránya',
'% East Asian': 'A kelet-ázsiaiként azonosított lakosság aránya',
'% SE Asian': 'A délkelet-ázsiaiként azonosított lakosság aránya',
'% Mixed': 'A vegyes vagy több etnikai csoporthoz tartozóként azonosított lakosság aránya',
'% Other': 'Az egyéb etnikai csoportba tartozóként azonosított lakosság aránya',
'Voter turnout (%)':

View file

@ -35,28 +35,20 @@ export const details: Record<string, Record<string, string>> = {
"La note d'efficacité énergétique potentielle issue du certificat de performance énergétique (EPC), si toutes les améliorations rentables recommandées dans le rapport EPC étaient réalisées. Va de A (plus efficace) à G (moins efficace).",
'Interior height (m)':
"Hauteur intérieure moyenne (sol au plafond) en mètres telle qu'enregistrée lors de l'évaluation du certificat de performance énergétique (EPC). Calculée en divisant le volume intérieur total par la surface habitable totale.",
'Street tree density percentile':
"Couverture arborée approximative autour du centroïde du code postal, dérivée de la carte Trees Outside Woodland 2025 de Forest Research. Les polygones de couvert arboré des arbres isolés et groupes d'arbres sont comptés dans un rayon de 50 m de chaque centroïde de code postal, puis convertis en percentile parmi les codes postaux anglais. Il s'agit d'une approximation fondée sur le centroïde du code postal, pas d'une mesure exacte du bien ou du segment de rue.",
'Tree canopy density percentile':
"Couverture arborée approximative autour du centroïde du code postal, dérivée de la carte Trees Outside Woodland 2025 de Forest Research et de l'Inventaire forestier national (NFI). Les polygones de couvert arboré des arbres isolés, des groupes d'arbres ET des boisements de l'Inventaire forestier national sont comptés dans un rayon de 50 m de chaque centroïde de code postal, puis convertis en percentile parmi les codes postaux anglais. Il s'agit d'une approximation fondée sur le centroïde du code postal, pas d'une mesure exacte du bien ou du segment de rue.",
'Within conservation area':
"Limites des conservation areas dans Planning Data, rattachées au point représentatif du code postal. Le jeu de données national est encore en cours de constitution et peut contenir des doublons ou une couverture locale incomplète ; toute décision dépendant précisément d'une limite doit être vérifiée auprès de la local planning authority.",
'Listed building':
"Points de listed buildings dans la National Heritage List for England de Historic England, appariés prudemment aux adresses à partir du nom de l'entrée classée et de codes postaux candidats à proximité. À utiliser comme signal de présélection, pas comme conclusion juridique : vérifiez tout bien précis dans la NHLE et auprès de la local planning authority.",
'Good+ primary schools within 2km':
"Écoles primaires financées par l'État dans un rayon de 2 km ayant une note Ofsted actuelle Good ou Outstanding. Les écoles non encore inspectées sont exclues.",
'Good+ secondary schools within 2km':
"Écoles secondaires financées par l'État dans un rayon de 2 km ayant une note Ofsted actuelle Good ou Outstanding. Les établissements non encore inspectés sont exclus.",
'Good+ primary schools within 5km':
"Écoles primaires financées par l'État dans un rayon de 5 km ayant une note Ofsted actuelle Good ou Outstanding. Les écoles non encore inspectées sont exclues.",
'Good+ secondary schools within 5km':
"Écoles secondaires financées par l'État dans un rayon de 5 km ayant une note Ofsted actuelle Good ou Outstanding. Les établissements non encore inspectés sont exclus.",
'Outstanding primary schools within 2km':
"Écoles primaires financées par l'État dans un rayon de 2 km ayant une note Ofsted actuelle Outstanding. Les écoles non encore inspectées sont exclues.",
'Outstanding secondary schools within 2km':
"Écoles secondaires financées par l'État dans un rayon de 2 km ayant une note Ofsted actuelle Outstanding. Les établissements non encore inspectés sont exclus.",
'Outstanding primary schools within 5km':
"Écoles primaires financées par l'État dans un rayon de 5 km ayant une note Ofsted actuelle Outstanding. Les écoles non encore inspectées sont exclues.",
'Outstanding secondary schools within 5km':
"Écoles secondaires financées par l'État dans un rayon de 5 km ayant une note Ofsted actuelle Outstanding. Les établissements non encore inspectés sont exclus.",
'Good+ primary school catchments':
"Nombre d'écoles primaires financées par l'État ayant une note Ofsted actuelle Good ou Outstanding dont les élèves proviennent d'une zone couvrant ce code postal. Les rayons de recrutement sont modélisés à partir des effectifs de chaque école et de la population locale d'enfants (recensement 2021), en approchant des admissions fondées sur la distance ; ce sont des estimations historiques, pas des secteurs d'admission officiels. Les écoles non encore inspectées sont exclues.",
'Good+ secondary school catchments':
"Nombre d'écoles secondaires financées par l'État ayant une note Ofsted actuelle Good ou Outstanding dont les élèves proviennent d'une zone couvrant ce code postal. Les rayons de recrutement sont modélisés à partir des effectifs de chaque école et de la population locale d'enfants (recensement 2021), en approchant des admissions fondées sur la distance ; ce sont des estimations historiques, pas des secteurs d'admission officiels. Les établissements non encore inspectés sont exclus.",
'Outstanding primary school catchments':
"Nombre d'écoles primaires financées par l'État ayant une note Ofsted actuelle Outstanding dont les élèves proviennent d'une zone couvrant ce code postal. Les rayons de recrutement sont modélisés à partir des effectifs de chaque école et de la population locale d'enfants (recensement 2021), en approchant des admissions fondées sur la distance ; ce sont des estimations historiques, pas des secteurs d'admission officiels. Les écoles non encore inspectées sont exclues.",
'Outstanding secondary school catchments':
"Nombre d'écoles secondaires financées par l'État ayant une note Ofsted actuelle Outstanding dont les élèves proviennent d'une zone couvrant ce code postal. Les rayons de recrutement sont modélisés à partir des effectifs de chaque école et de la population locale d'enfants (recensement 2021), en approchant des admissions fondées sur la distance ; ce sont des estimations historiques, pas des secteurs d'admission officiels. Les établissements non encore inspectés sont exclus.",
'Education, Skills and Training Score':
"Issu des English Indices of Deprivation, converti en percentile national : 0 % correspond aux zones les plus défavorisées et 100 % aux moins défavorisées. Couvre les résultats scolaires, l'accès à l'enseignement supérieur, les qualifications des adultes et la maîtrise de l'anglais.",
'Income Score':
@ -109,8 +101,10 @@ export const details: Record<string, Record<string, string>> = {
"Provient du Census 2021. Pourcentage de la population de l'autorité locale s'identifiant comme Indien, Pakistanais, Bangladais ou toute autre origine asiatique.",
'% Black':
"Provient du Census 2021. Pourcentage de la population de l'autorité locale s'identifiant comme Noir, Noir britannique, Caribéen ou Africain.",
'% East/SE Asian':
"Provient du Census 2021. Pourcentage de la population de l'autorité locale s'identifiant comme Chinois ou d'une autre origine est/sud-est asiatique.",
'% East Asian':
"Provient du Census 2021. Pourcentage de la population de l'autorité locale s'identifiant comme Chinois.",
'% SE Asian':
"Provient du Census 2021. Pourcentage de la population de l'autorité locale s'identifiant comme appartenant à une autre origine est/sud-est asiatique (par ex. Philippin, Vietnamien ou Thaïlandais).",
'% Mixed':
"Provient du Census 2021. Pourcentage de la population de l'autorité locale s'identifiant comme Mixte ou appartenant à plusieurs groupes ethniques (Blanc et Noir caribéen, Blanc et Noir africain, Blanc et Asiatique, ou tout autre fond mixte ou multiple).",
'% Other':
@ -181,28 +175,20 @@ export const details: Record<string, Record<string, string>> = {
'Die potenzielle Energieeffizienzklasse aus dem Energieausweis-Zertifikat, wenn alle im EPC-Bericht empfohlenen kosteneffizienten Verbesserungen durchgeführt würden. Reicht von A (am effizientesten) bis G (am wenigsten effizient).',
'Interior height (m)':
'Durchschnittliche lichte Raumhöhe in Metern, wie während der Energieausweis-Begutachtung erfasst. Berechnet durch Division des gesamten Innenvolumens durch die Gesamtwohnfläche.',
'Street tree density percentile':
'Ungefähre Baumkronenbedeckung rund um den Postleitzahlen-Zentroiden aus der Forest-Research-Karte Trees Outside Woodland 2025. Baumkronen-Polygone für Einzelbäume und Baumgruppen werden im Umkreis von 50 m um jeden Postleitzahlen-Zentroiden gezählt und dann in ein Perzentil über englische Postleitzahlen umgerechnet. Dies ist ein Näherungswert auf Basis des Postleitzahlen-Zentroids, keine exakte Messung für Immobilie oder Straßenabschnitt.',
'Tree canopy density percentile':
'Ungefähre Baumkronenbedeckung rund um den Postleitzahlen-Zentroiden aus der Forest-Research-Karte Trees Outside Woodland 2025 und dem National Forest Inventory (NFI). Baumkronen-Polygone für Einzelbäume, Baumgruppen UND Waldflächen des National Forest Inventory werden im Umkreis von 50 m um jeden Postleitzahlen-Zentroiden gezählt und dann in ein Perzentil über englische Postleitzahlen umgerechnet. Dies ist ein Näherungswert auf Basis des Postleitzahlen-Zentroids, keine exakte Messung für Immobilie oder Straßenabschnitt.',
'Within conservation area':
'Planning-Data-Grenzen für Erhaltungsgebiete, dem repräsentativen Punkt der Postleitzahl zugeordnet. Der nationale Datensatz wird laufend verbessert und kann Duplikate oder unvollständige lokale Abdeckung enthalten; grenznahe Entscheidungen sollten bei der lokalen Planungsbehörde geprüft werden.',
'Listed building':
'Punktdaten zu denkmalgeschützten Gebäuden aus der National Heritage List for England von Historic England, vorsichtig mit Immobilienadressen abgeglichen anhand des Namens des Denkmaleintrags und nahegelegener Postleitzahlkandidaten. Behandle dies als Vorauswahl-Hinweis, nicht als rechtliche Feststellung: Prüfe jede konkrete Immobilie in der NHLE und bei der lokalen Planungsbehörde.',
'Good+ primary schools within 2km':
'Staatlich geförderte Grundschulen innerhalb von 2 km mit einer aktuellen Ofsted-Bewertung von Gut oder Hervorragend. Noch nicht inspizierte Schulen sind ausgeschlossen.',
'Good+ secondary schools within 2km':
'Staatlich geförderte weiterführende Schulen innerhalb von 2 km mit einer aktuellen Ofsted-Bewertung von Gut oder Hervorragend. Noch nicht inspizierte Schulen sind ausgeschlossen.',
'Good+ primary schools within 5km':
'Staatlich geförderte Grundschulen innerhalb von 5 km mit einer aktuellen Ofsted-Bewertung von Gut oder Hervorragend. Noch nicht inspizierte Schulen sind ausgeschlossen.',
'Good+ secondary schools within 5km':
'Staatlich geförderte weiterführende Schulen innerhalb von 5 km mit einer aktuellen Ofsted-Bewertung von Gut oder Hervorragend. Noch nicht inspizierte Schulen sind ausgeschlossen.',
'Outstanding primary schools within 2km':
'Staatlich geförderte Grundschulen innerhalb von 2 km mit einer aktuellen Ofsted-Bewertung von Hervorragend. Noch nicht inspizierte Schulen sind ausgeschlossen.',
'Outstanding secondary schools within 2km':
'Staatlich geförderte weiterführende Schulen innerhalb von 2 km mit einer aktuellen Ofsted-Bewertung von Hervorragend. Noch nicht inspizierte Schulen sind ausgeschlossen.',
'Outstanding primary schools within 5km':
'Staatlich geförderte Grundschulen innerhalb von 5 km mit einer aktuellen Ofsted-Bewertung von Hervorragend. Noch nicht inspizierte Schulen sind ausgeschlossen.',
'Outstanding secondary schools within 5km':
'Staatlich geförderte weiterführende Schulen innerhalb von 5 km mit einer aktuellen Ofsted-Bewertung von Hervorragend. Noch nicht inspizierte Schulen sind ausgeschlossen.',
'Good+ primary school catchments':
'Wie viele staatlich geförderte Grundschulen mit einer aktuellen Ofsted-Bewertung von Gut oder Hervorragend ihre Schüler aus einem Gebiet beziehen, das diese Postleitzahl abdeckt. Die Einzugsradien werden aus den Schülerzahlen jeder Schule und der lokalen Kinderbevölkerung (Zensus 2021) modelliert und nähern entfernungsbasierte Aufnahmen an; es sind historische Schätzungen, keine offiziellen Aufnahmegebiete. Noch nicht inspizierte Schulen sind ausgeschlossen.',
'Good+ secondary school catchments':
'Wie viele staatlich geförderte weiterführende Schulen mit einer aktuellen Ofsted-Bewertung von Gut oder Hervorragend ihre Schüler aus einem Gebiet beziehen, das diese Postleitzahl abdeckt. Die Einzugsradien werden aus den Schülerzahlen jeder Schule und der lokalen Kinderbevölkerung (Zensus 2021) modelliert und nähern entfernungsbasierte Aufnahmen an; es sind historische Schätzungen, keine offiziellen Aufnahmegebiete. Noch nicht inspizierte Schulen sind ausgeschlossen.',
'Outstanding primary school catchments':
'Wie viele staatlich geförderte Grundschulen mit einer aktuellen Ofsted-Bewertung von Hervorragend ihre Schüler aus einem Gebiet beziehen, das diese Postleitzahl abdeckt. Die Einzugsradien werden aus den Schülerzahlen jeder Schule und der lokalen Kinderbevölkerung (Zensus 2021) modelliert und nähern entfernungsbasierte Aufnahmen an; es sind historische Schätzungen, keine offiziellen Aufnahmegebiete. Noch nicht inspizierte Schulen sind ausgeschlossen.',
'Outstanding secondary school catchments':
'Wie viele staatlich geförderte weiterführende Schulen mit einer aktuellen Ofsted-Bewertung von Hervorragend ihre Schüler aus einem Gebiet beziehen, das diese Postleitzahl abdeckt. Die Einzugsradien werden aus den Schülerzahlen jeder Schule und der lokalen Kinderbevölkerung (Zensus 2021) modelliert und nähern entfernungsbasierte Aufnahmen an; es sind historische Schätzungen, keine offiziellen Aufnahmegebiete. Noch nicht inspizierte Schulen sind ausgeschlossen.',
'Education, Skills and Training Score':
'Aus den englischen Deprivationsindizes, in ein nationales Perzentil umgerechnet: 0 % steht für die am stärksten benachteiligten Gebiete, 100 % für die am wenigsten benachteiligten. Umfasst Schulleistungen, Hochschulzugang, Qualifikationen Erwachsener und Englischsprachkenntnisse.',
'Income Score':
@ -255,8 +241,10 @@ export const details: Record<string, Record<string, string>> = {
'Aus dem Census 2021. Prozentsatz der Bevölkerung der Gemeinde, die sich als Indisch, Pakistanisch, Bangladeschisch oder mit sonstigem asiatischen Hintergrund identifiziert.',
'% Black':
'Aus dem Census 2021. Prozentsatz der Bevölkerung der Gemeinde, die sich als Schwarz, Schwarz-Britisch, Karibisch oder Afrikanisch identifiziert.',
'% East/SE Asian':
'Aus dem Census 2021. Prozentsatz der Bevölkerung der Gemeinde, die sich als Chinesisch oder einer anderen ost-/südostasiatischen Herkunft identifiziert.',
'% East Asian':
'Aus dem Census 2021. Prozentsatz der Bevölkerung der Gemeinde, die sich als Chinesisch identifiziert.',
'% SE Asian':
'Aus dem Census 2021. Prozentsatz der Bevölkerung der Gemeinde, die sich mit einer anderen (nicht chinesischen) ost-/südostasiatischen Herkunft identifiziert, z. B. philippinisch, vietnamesisch oder thailändisch.',
'% Mixed':
'Aus dem Census 2021. Prozentsatz der Bevölkerung der Gemeinde, die sich als gemischt oder mit mehreren ethnischen Zugehörigkeiten identifiziert (Weiß und Schwarzkaribisch, Weiß und Schwarzafrikanisch, Weiß und Asiatisch oder sonstiger gemischter Hintergrund).',
'% Other':
@ -327,28 +315,20 @@ export const details: Record<string, Record<string, string>> = {
'若实施EPC报告中建议的所有具有成本效益的改进措施后该房产的潜在能源效率等级。从A最高效到G最低效。',
'Interior height (m)':
'EPC评估期间记录的平均室内净高。通过将室内总容积除以总建筑面积计算得出。',
'Street tree density percentile':
'基于 Forest Research 2025 年 Trees Outside Woodland 地图估算的邮编质心周边树冠覆盖率。系统会统计每个邮编质心 50 米范围内的孤立树木和树群树冠多边形,然后转换为英格兰邮编范围内的百分位。这是邮编质心近似指标,不是精确的房产或道路路段测量。',
'Tree canopy density percentile':
'基于 Forest Research 2025 年 Trees Outside Woodland 地图与国家森林清查NFI估算的邮编质心周边树冠覆盖率。系统会统计每个邮编质心 50 米范围内的孤立树木、树群以及国家森林清查林地的树冠多边形,然后转换为英格兰邮编范围内的百分位。这是邮编质心近似指标,不是精确的房产或道路路段测量。',
'Within conservation area':
'Planning Data 保护区边界,与邮编代表点匹配。全国数据集仍在完善中,可能包含重复记录或地方覆盖不完整;涉及边界的决策应向地方规划部门核实。',
'Listed building':
'Historic England 英格兰国家遗产名录NHLE中的受保护建筑点位记录会根据名录条目名称和附近候选邮编谨慎匹配到房产地址。请把它当作初筛信号而不是法律认定具体房产应在 NHLE 和地方规划部门核实。',
'Good+ primary schools within 2km':
'2km范围内Ofsted评级为“良好”或“优秀”的公立小学数量。尚未接受评估的学校不计入。',
'Good+ secondary schools within 2km':
'2km范围内Ofsted评级为“良好”或“优秀”的公立中学数量。尚未接受评估的学校不计入。',
'Good+ primary schools within 5km':
'5km范围内Ofsted评级为“良好”或“优秀”的公立小学数量。尚未接受评估的学校不计入。',
'Good+ secondary schools within 5km':
'5km范围内Ofsted评级为“良好”或“优秀”的公立中学数量。尚未接受评估的学校不计入。',
'Outstanding primary schools within 2km':
'2km范围内Ofsted评级为“优秀”的公立小学数量。尚未接受评估的学校不计入。',
'Outstanding secondary schools within 2km':
'2km范围内Ofsted评级为“优秀”的公立中学数量。尚未接受评估的学校不计入。',
'Outstanding primary schools within 5km':
'5km范围内Ofsted评级为“优秀”的公立小学数量。尚未接受评估的学校不计入。',
'Outstanding secondary schools within 5km':
'5km范围内Ofsted评级为“优秀”的公立中学数量。尚未接受评估的学校不计入。',
'Good+ primary school catchments':
'有多少所Ofsted当前评级为“良好”或“优秀”的公立小学其生源来自覆盖此邮编的区域。学区半径根据各校学生人数和当地儿童人口2021年人口普查建模近似按距离录取这些是历史估计并非官方招生范围。尚未接受评估的学校不计入。',
'Good+ secondary school catchments':
'有多少所Ofsted当前评级为“良好”或“优秀”的公立中学其生源来自覆盖此邮编的区域。学区半径根据各校学生人数和当地儿童人口2021年人口普查建模近似按距离录取这些是历史估计并非官方招生范围。尚未接受评估的学校不计入。',
'Outstanding primary school catchments':
'有多少所Ofsted当前评级为“优秀”的公立小学其生源来自覆盖此邮编的区域。学区半径根据各校学生人数和当地儿童人口2021年人口普查建模近似按距离录取这些是历史估计并非官方招生范围。尚未接受评估的学校不计入。',
'Outstanding secondary school catchments':
'有多少所Ofsted当前评级为“优秀”的公立中学其生源来自覆盖此邮编的区域。学区半径根据各校学生人数和当地儿童人口2021年人口普查建模近似按距离录取这些是历史估计并非官方招生范围。尚未接受评估的学校不计入。',
'Education, Skills and Training Score':
'来自英格兰剥夺指数转换为全国百分位0%表示最贫困100%表示最不贫困。涵盖学校成绩、高等教育入学率、成人学历和英语水平。',
'Income Score':
@ -398,7 +378,9 @@ export const details: Record<string, Record<string, string>> = {
'% South Asian':
'来自2021年Census。地方政府人口中认同为印度人、巴基斯坦人、孟加拉国人或其他亚洲背景的百分比。',
'% Black': '来自2021年Census。地方政府人口中认同为黑人、英国黑人、加勒比人或非洲人的百分比。',
'% East/SE Asian': '来自2021年Census。地方政府人口中认同为华人或其他东亚/东南亚裔的百分比。',
'% East Asian': '来自2021年Census。地方政府人口中认同为华人的百分比。',
'% SE Asian':
'来自2021年Census。地方政府人口中认同为其他非华人东亚/东南亚裔(如菲律宾裔、越南裔或泰裔)的百分比。',
'% Mixed':
'来自2021年 Census。地方政府人口中认同为混血或多种族群体白人与黑人加勒比裔、白人与黑人非洲裔、白人与亚洲裔或其他混血或多种族背景的百分比。',
'% Other':
@ -465,28 +447,20 @@ export const details: Record<string, Record<string, string>> = {
'EPC से संभावित ऊर्जा दक्षता रेटिंग, यदि EPC रिपोर्ट में सुझाए गए सभी किफायती सुधार कर दिए जाएं. यह A (सबसे दक्ष) से G (सबसे कम दक्ष) तक होती है.',
'Interior height (m)':
'EPC आकलन के दौरान दर्ज औसत अंदरूनी फर्श-से-छत ऊंचाई, मीटर में. कुल आंतरिक आयतन को कुल फर्श क्षेत्र से भाग देकर निकाली जाती है.',
'Street tree density percentile':
'Forest Research के 2025 Trees Outside Woodland नक्शे से निकाला गया पोस्टकोड केंद्र के आसपास का अनुमानित वृक्ष आच्छादन. अकेले पेड़ों और पेड़ों के समूहों के वृक्ष-शिखर बहुभुजों को हर पोस्टकोड केंद्र से 50m के भीतर गिना जाता है, फिर इंग्लैंड के पोस्टकोडों के मुकाबले प्रतिशतक में बदला जाता है. यह पोस्टकोड-केंद्र पर आधारित अनुमानक है, किसी संपत्ति या सड़क-खंड की सटीक माप नहीं.',
'Tree canopy density percentile':
'Forest Research के 2025 Trees Outside Woodland नक्शे और राष्ट्रीय वन सूची (NFI) से निकाला गया पोस्टकोड केंद्र के आसपास का अनुमानित वृक्ष आच्छादन. अकेले पेड़ों, पेड़ों के समूहों और राष्ट्रीय वन सूची की वनभूमि के वृक्ष-शिखर बहुभुजों को हर पोस्टकोड केंद्र से 50m के भीतर गिना जाता है, फिर इंग्लैंड के पोस्टकोडों के मुकाबले प्रतिशतक में बदला जाता है. यह पोस्टकोड-केंद्र पर आधारित अनुमानक है, किसी संपत्ति या सड़क-खंड की सटीक माप नहीं.',
'Within conservation area':
'Planning Data संरक्षण क्षेत्र सीमाएं पोस्टकोड प्रतिनिधि बिंदु से मिलाई जाती हैं. राष्ट्रीय डेटासेट अभी बेहतर किया जा रहा है और इसमें डुप्लीकेट या अधूरी स्थानीय कवरेज हो सकती है; सीमा-संवेदनशील निर्णय स्थानीय योजना प्राधिकरण से जांचे जाने चाहिए.',
'Listed building':
'Historic England की इंग्लैंड की राष्ट्रीय धरोहर सूची (NHLE) में सूचीबद्ध भवनों के बिंदु रिकॉर्ड, जिन्हें सूचीबद्ध प्रविष्टि के नाम और पास के संभावित पोस्टकोड के आधार पर संपत्ति पते से सावधानी से मिलाया गया है. इसे केवल प्रारंभिक जांच संकेत मानें, कानूनी निर्णय नहीं: किसी भी विशिष्ट संपत्ति को NHLE और स्थानीय योजना प्राधिकरण से सत्यापित करें.',
'Good+ primary schools within 2km':
'2 km के भीतर सरकारी वित्तपोषित प्राइमरी स्कूल जिनकी मौजूदा Ofsted रेटिंग अच्छी या उत्कृष्ट है. जिन स्कूलों का अभी निरीक्षण नहीं हुआ है, उन्हें शामिल नहीं किया गया है.',
'Good+ secondary schools within 2km':
'2 km के भीतर सरकारी वित्तपोषित सेकेंडरी स्कूल जिनकी मौजूदा Ofsted रेटिंग अच्छी या उत्कृष्ट है. जिन स्कूलों का अभी निरीक्षण नहीं हुआ है, उन्हें शामिल नहीं किया गया है.',
'Good+ primary schools within 5km':
'5 km के भीतर सरकारी वित्तपोषित प्राइमरी स्कूल जिनकी मौजूदा Ofsted रेटिंग अच्छी या उत्कृष्ट है. जिन स्कूलों का अभी निरीक्षण नहीं हुआ है, उन्हें शामिल नहीं किया गया है.',
'Good+ secondary schools within 5km':
'5 km के भीतर सरकारी वित्तपोषित सेकेंडरी स्कूल जिनकी मौजूदा Ofsted रेटिंग अच्छी या उत्कृष्ट है. जिन स्कूलों का अभी निरीक्षण नहीं हुआ है, उन्हें शामिल नहीं किया गया है.',
'Outstanding primary schools within 2km':
'2 km के भीतर सरकारी वित्तपोषित प्राइमरी स्कूल जिनकी मौजूदा Ofsted रेटिंग उत्कृष्ट है. जिन स्कूलों का अभी निरीक्षण नहीं हुआ है, उन्हें शामिल नहीं किया गया है.',
'Outstanding secondary schools within 2km':
'2 km के भीतर सरकारी वित्तपोषित सेकेंडरी स्कूल जिनकी मौजूदा Ofsted रेटिंग उत्कृष्ट है. जिन स्कूलों का अभी निरीक्षण नहीं हुआ है, उन्हें शामिल नहीं किया गया है.',
'Outstanding primary schools within 5km':
'5 km के भीतर सरकारी वित्तपोषित प्राइमरी स्कूल जिनकी मौजूदा Ofsted रेटिंग उत्कृष्ट है. जिन स्कूलों का अभी निरीक्षण नहीं हुआ है, उन्हें शामिल नहीं किया गया है.',
'Outstanding secondary schools within 5km':
'5 km के भीतर सरकारी वित्तपोषित सेकेंडरी स्कूल जिनकी मौजूदा Ofsted रेटिंग उत्कृष्ट है. जिन स्कूलों का अभी निरीक्षण नहीं हुआ है, उन्हें शामिल नहीं किया गया है.',
'Good+ primary school catchments':
'कितने सरकारी वित्तपोषित प्राइमरी स्कूल, जिनकी मौजूदा Ofsted रेटिंग अच्छी या उत्कृष्ट है, अपने छात्र इस पोस्टकोड को कवर करने वाले क्षेत्र से लेते हैं. कैचमेंट दायरे हर स्कूल की छात्र संख्या और स्थानीय बाल जनसंख्या (जनगणना 2021) से मॉडल किए जाते हैं, जो दूरी-आधारित दाखिलों का अनुमान है; ये ऐतिहासिक अनुमान हैं, आधिकारिक दाखिला क्षेत्र नहीं. जिन स्कूलों का अभी निरीक्षण नहीं हुआ है, उन्हें शामिल नहीं किया गया है.',
'Good+ secondary school catchments':
'कितने सरकारी वित्तपोषित सेकेंडरी स्कूल, जिनकी मौजूदा Ofsted रेटिंग अच्छी या उत्कृष्ट है, अपने छात्र इस पोस्टकोड को कवर करने वाले क्षेत्र से लेते हैं. कैचमेंट दायरे हर स्कूल की छात्र संख्या और स्थानीय बाल जनसंख्या (जनगणना 2021) से मॉडल किए जाते हैं, जो दूरी-आधारित दाखिलों का अनुमान है; ये ऐतिहासिक अनुमान हैं, आधिकारिक दाखिला क्षेत्र नहीं. जिन स्कूलों का अभी निरीक्षण नहीं हुआ है, उन्हें शामिल नहीं किया गया है.',
'Outstanding primary school catchments':
'कितने सरकारी वित्तपोषित प्राइमरी स्कूल, जिनकी मौजूदा Ofsted रेटिंग उत्कृष्ट है, अपने छात्र इस पोस्टकोड को कवर करने वाले क्षेत्र से लेते हैं. कैचमेंट दायरे हर स्कूल की छात्र संख्या और स्थानीय बाल जनसंख्या (जनगणना 2021) से मॉडल किए जाते हैं, जो दूरी-आधारित दाखिलों का अनुमान है; ये ऐतिहासिक अनुमान हैं, आधिकारिक दाखिला क्षेत्र नहीं. जिन स्कूलों का अभी निरीक्षण नहीं हुआ है, उन्हें शामिल नहीं किया गया है.',
'Outstanding secondary school catchments':
'कितने सरकारी वित्तपोषित सेकेंडरी स्कूल, जिनकी मौजूदा Ofsted रेटिंग उत्कृष्ट है, अपने छात्र इस पोस्टकोड को कवर करने वाले क्षेत्र से लेते हैं. कैचमेंट दायरे हर स्कूल की छात्र संख्या और स्थानीय बाल जनसंख्या (जनगणना 2021) से मॉडल किए जाते हैं, जो दूरी-आधारित दाखिलों का अनुमान है; ये ऐतिहासिक अनुमान हैं, आधिकारिक दाखिला क्षेत्र नहीं. जिन स्कूलों का अभी निरीक्षण नहीं हुआ है, उन्हें शामिल नहीं किया गया है.',
'Education, Skills and Training Score':
'English Indices of Deprivation से लिया गया और राष्ट्रीय प्रतिशतक में बदला गया: 0% सबसे अधिक वंचित क्षेत्रों और 100% सबसे कम वंचित क्षेत्रों को दर्शाता है. इसमें स्कूल उपलब्धि, उच्च शिक्षा में प्रवेश, वयस्क योग्यता और अंग्रेजी भाषा दक्षता शामिल है.',
'Income Score':
@ -539,8 +513,10 @@ export const details: Record<string, Record<string, string>> = {
'Census 2021 से. स्थानीय प्राधिकरण की आबादी का प्रतिशत जो खुद को भारतीय, पाकिस्तानी, बांग्लादेशी या किसी अन्य एशियाई पृष्ठभूमि के रूप में पहचानता है.',
'% Black':
'Census 2021 से. स्थानीय प्राधिकरण की आबादी का प्रतिशत जो खुद को अश्वेत, अश्वेत ब्रिटिश, कैरिबियाई या अफ्रीकी के रूप में पहचानता है.',
'% East/SE Asian':
'Census 2021 से. स्थानीय प्राधिकरण की आबादी का प्रतिशत जो खुद को चीनी या अन्य पूर्वी/दक्षिण-पूर्वी एशियाई के रूप में पहचानता है.',
'% East Asian':
'Census 2021 से. स्थानीय प्राधिकरण की आबादी का प्रतिशत जो खुद को चीनी के रूप में पहचानता है.',
'% SE Asian':
'Census 2021 से. स्थानीय प्राधिकरण की आबादी का प्रतिशत जो खुद को अन्य (गैर-चीनी) पूर्वी/दक्षिण-पूर्वी एशियाई (जैसे फिलिपिनो, वियतनामी या थाई) के रूप में पहचानता है.',
'% Mixed':
'Census 2021 से. स्थानीय प्राधिकरण की आबादी का प्रतिशत जो खुद को मिश्रित या कई जातीय समूहों (श्वेत और अश्वेत कैरिबियाई, श्वेत और अश्वेत अफ्रीकी, श्वेत और एशियाई या अन्य मिश्रित/बहुजातीय पृष्ठभूमि) के रूप में पहचानता है.',
'% Other':
@ -611,28 +587,20 @@ export const details: Record<string, Record<string, string>> = {
'Az EPC-tanúsítvány potenciális energiahatékonysági besorolása, amennyiben az EPC-jelentésben ajánlott összes költséghatékony fejlesztést elvégeznék. A-tól (leghatékonyabb) G-ig (legkevésbé hatékony) terjed.',
'Interior height (m)':
'Az EPC-tanúsítvány felmérése során rögzített átlagos belső padló-mennyezet magasság méterben. A teljes belső térfogatot osztják a teljes alapterülettel.',
'Street tree density percentile':
'A Forest Research 2025-os Trees Outside Woodland térképéből származó hozzávetőleges lombkorona-fedettség az irányítószám-középpont körül. A magányos fák és facsoportok lombkorona-poligonjait minden irányítószám-középpont 50 méteres körzetében számoljuk, majd az angliai irányítószámok közötti percentilissé alakítjuk. Ez az irányítószám-középponton alapuló közelítő mutató, nem pontos ingatlan- vagy utcaszakasz-mérés.',
'Tree canopy density percentile':
'A Forest Research 2025-os Trees Outside Woodland térképéből és a National Forest Inventory (NFI) adataiból származó hozzávetőleges lombkorona-fedettség az irányítószám-középpont körül. A magányos fák, facsoportok ÉS a National Forest Inventory erdőterületeinek lombkorona-poligonjait minden irányítószám-középpont 50 méteres körzetében számoljuk, majd az angliai irányítószámok közötti percentilissé alakítjuk. Ez az irányítószám-középponton alapuló közelítő mutató, nem pontos ingatlan- vagy utcaszakasz-mérés.',
'Within conservation area':
'A Planning Data műemléki területeinek határai az irányítószám reprezentatív pontjához rendelve. Az országos adatállomány fejlesztés alatt áll, és tartalmazhat duplikátumokat vagy hiányos helyi lefedettséget; határérzékeny döntéseknél a helyi tervezési hatóság adatait kell ellenőrizni.',
'Listed building':
'A Historic England National Heritage List for England műemlékiépület-pontrekordjai, amelyeket óvatosan egyeztetünk ingatlancímekhez a műemléki bejegyzés neve és a közeli irányítószám-jelöltek alapján. Előszűrési jelzésként kezelendő, nem jogi megállapításként: minden konkrét ingatlant ellenőrizni kell az NHLE-ben és a helyi tervezési hatóságnál.',
'Good+ primary schools within 2km':
'2 km-en belüli állami fenntartású általános iskolák, amelyek aktuális Ofsted besorolása Jó vagy Kiemelkedő. A még nem vizsgált iskolák ki vannak zárva.',
'Good+ secondary schools within 2km':
'2 km-en belüli állami fenntartású középiskolák, amelyek aktuális Ofsted besorolása Jó vagy Kiemelkedő. A még nem vizsgált iskolák ki vannak zárva.',
'Good+ primary schools within 5km':
'5 km-en belüli állami fenntartású általános iskolák, amelyek aktuális Ofsted besorolása Jó vagy Kiemelkedő. A még nem vizsgált iskolák ki vannak zárva.',
'Good+ secondary schools within 5km':
'5 km-en belüli állami fenntartású középiskolák, amelyek aktuális Ofsted besorolása Jó vagy Kiemelkedő. A még nem vizsgált iskolák ki vannak zárva.',
'Outstanding primary schools within 2km':
'2 km-en belüli állami fenntartású általános iskolák, amelyek aktuális Ofsted besorolása Kiemelkedő. A még nem vizsgált iskolák ki vannak zárva.',
'Outstanding secondary schools within 2km':
'2 km-en belüli állami fenntartású középiskolák, amelyek aktuális Ofsted besorolása Kiemelkedő. A még nem vizsgált iskolák ki vannak zárva.',
'Outstanding primary schools within 5km':
'5 km-en belüli állami fenntartású általános iskolák, amelyek aktuális Ofsted besorolása Kiemelkedő. A még nem vizsgált iskolák ki vannak zárva.',
'Outstanding secondary schools within 5km':
'5 km-en belüli állami fenntartású középiskolák, amelyek aktuális Ofsted besorolása Kiemelkedő. A még nem vizsgált iskolák ki vannak zárva.',
'Good+ primary school catchments':
'Hány olyan állami fenntartású általános iskola van, amelynek aktuális Ofsted besorolása Jó vagy Kiemelkedő, és amely a tanulóit az ezt az irányítószámot lefedő területről veszi fel. A körzetsugarakat az egyes iskolák tanulólétszámából és a helyi gyermeknépességből (2021-es népszámlálás) modellezzük, távolságalapú felvételt közelítve; történeti becslések, nem hivatalos felvételi körzetek. A még nem vizsgált iskolák ki vannak zárva.',
'Good+ secondary school catchments':
'Hány olyan állami fenntartású középiskola van, amelynek aktuális Ofsted besorolása Jó vagy Kiemelkedő, és amely a tanulóit az ezt az irányítószámot lefedő területről veszi fel. A körzetsugarakat az egyes iskolák tanulólétszámából és a helyi gyermeknépességből (2021-es népszámlálás) modellezzük, távolságalapú felvételt közelítve; történeti becslések, nem hivatalos felvételi körzetek. A még nem vizsgált iskolák ki vannak zárva.',
'Outstanding primary school catchments':
'Hány olyan állami fenntartású általános iskola van, amelynek aktuális Ofsted besorolása Kiemelkedő, és amely a tanulóit az ezt az irányítószámot lefedő területről veszi fel. A körzetsugarakat az egyes iskolák tanulólétszámából és a helyi gyermeknépességből (2021-es népszámlálás) modellezzük, távolságalapú felvételt közelítve; történeti becslések, nem hivatalos felvételi körzetek. A még nem vizsgált iskolák ki vannak zárva.',
'Outstanding secondary school catchments':
'Hány olyan állami fenntartású középiskola van, amelynek aktuális Ofsted besorolása Kiemelkedő, és amely a tanulóit az ezt az irányítószámot lefedő területről veszi fel. A körzetsugarakat az egyes iskolák tanulólétszámából és a helyi gyermeknépességből (2021-es népszámlálás) modellezzük, távolságalapú felvételt közelítve; történeti becslések, nem hivatalos felvételi körzetek. A még nem vizsgált iskolák ki vannak zárva.',
'Education, Skills and Training Score':
'Az Angol Nélkülözési Indexekből származik, országos percentilissé alakítva: 0% a leginkább hátrányos, 100% a legkevésbé hátrányos területeket jelzi. Az iskolai teljesítményt, a felsőoktatásba való bejutást, a felnőttkori képesítéseket és az angol nyelvi jártasságot foglalja magában.',
'Income Score':
@ -685,8 +653,10 @@ export const details: Record<string, Record<string, string>> = {
'A 2021-es Census alapján. A helyi hatóság területén indiai, pakisztáni, bangladesi vagy bármely más ázsiai háttérként azonosított népesség százaléka.',
'% Black':
'A 2021-es Census alapján. A helyi hatóság területén fekete, brit fekete, karibi vagy afrikai háttérként azonosított népesség százaléka.',
'% East/SE Asian':
'A 2021-es Census alapján. A helyi hatóság területén kínai vagy más kelet-/délkelet-ázsiai származásúként azonosított népesség százaléka.',
'% East Asian':
'A 2021-es Census alapján. A helyi hatóság területén kínaiként azonosított népesség százaléka.',
'% SE Asian':
'A 2021-es Census alapján. A helyi hatóság területén más (nem kínai) kelet-/délkelet-ázsiai származásúként (pl. filippínó, vietnámi vagy thai) azonosított népesség százaléka.',
'% Mixed':
'A 2021-es Census alapján. A helyi hatóság területén vegyes vagy többes etnikai csoportként (fehér és fekete karibi, fehér és fekete afrikai, fehér és ázsiai, vagy bármely más vegyes vagy többes háttér) azonosított népesség százaléka.',
'% Other':

View file

@ -609,6 +609,8 @@ const de: Translations = {
pleaseWait: 'Bitte warten...',
sendResetLink: 'Link zum Zurücksetzen senden',
backToLogin: 'Zurück zur Anmeldung',
registerConsent:
'Mit der Kontoerstellung akzeptierst du unsere <terms>Nutzungsbedingungen</terms> und unsere <privacy>Datenschutzerklärung</privacy>.',
},
// ── Upgrade Modal ──────────────────────────────────
@ -666,7 +668,7 @@ const de: Translations = {
addFiltersHint:
'Füge unten Filter hinzu, um die Karte auf Gebiete einzugrenzen, die zu deinen Kriterien passen',
upgradePrompt:
'Finde passende Postcodes mit Kriminalität, Schulen, Lärm, Breitband, Preisen und über 50 weiteren Filtern in ganz England.',
'Finde passende Postcodes mit Kriminalität, Schulen, Lärm, Breitband, Preisen und über 40 kombinierbaren Filtern in ganz England.',
oneTimeLifetime: 'Einmalzahlung, lebenslanger Zugang.',
upgradeToFullMap: 'Auf die vollständige Karte upgraden',
chooseFilters:
@ -698,7 +700,6 @@ const de: Translations = {
filtersOut: 'schließt {{value}} aus',
schoolType: 'Schultyp',
schoolRating: 'Schulbewertung',
schoolDistance: 'Schulentfernung',
primary: 'Grundschule',
secondary: 'Weiterführende Schule',
rating: 'Bewertung',
@ -943,7 +944,7 @@ const de: Translations = {
showcaseFeatureNoiseShort: 'Lärm',
showcaseFeatureSchoolsShort: 'Schulen',
showcaseFeatureTravelShort: 'Fahrzeit',
showcaseGoodPrimariesNearby: '{{count}}+ Good Primaries in der Nähe',
showcaseGoodPrimariesNearby: 'In {{count}}+ Einzugsgebieten guter Grundschulen',
showcaseWithinRail: 'Innerhalb von {{count}} Min. zur Bahn',
showcaseMatchingHomesLabel: 'Passende Immobilien',
showcaseMatchingHomes: '{{value}} passende Immobilien',
@ -1004,6 +1005,13 @@ const de: Translations = {
statFilters: 'kombinierbare Filter',
statEvery: 'Jeder',
statPostcodeInEngland: 'Postcode in England',
coverageNote:
'Deckt jeden Postcode in England ab — je 200+ Datenfelder. Schottland und Wales sind in Planung.',
priceStrip:
'Lebenslanger Zugang, aktuell {{price}} — der Preis steigt, sobald sich die Stufen füllen.',
priceStripSpots: 'Noch {{count}} Platz zu diesem Preis.',
priceStripSpotsPlural: 'Noch {{count}} Plätze zu diesem Preis.',
priceStripCta: 'Preise ansehen',
ourPhilosophy: 'Beginne mit dem, was zählt, und finde den passenden Postcode',
philosophyP1:
'Die meisten Immobilienseiten fragen, wo du wohnen möchtest. In London ist das besonders schwierig, aber dasselbe Problem gibt es in ganz England: Käufer starten mit wenigen bekannten Orten und prüfen dann Pendelzeit, Schulen, Kriminalität, Street View, Breitband und Verkaufspreise in getrennten Tabs.',
@ -1027,7 +1035,7 @@ const de: Translations = {
compAreaDataSub: '(Kriminalität, Schulen, Lärm, Breitband, Infrastruktur)',
compPropertyData: 'Historie auf Immobilienebene',
compPropertyDataSub: '(Verkaufspreise, EPC, Wohnfläche, Schätzwert)',
compFilters: '56 Filter, die zusammenarbeiten',
compFilters: '40+ Filter, die zusammenarbeiten',
compFiltersSub: '(nicht ein Postcode oder ein Inserat nach dem anderen)',
ctaTitle: 'Hör auf zu raten, wo du kaufen sollst.',
ctaDescription:
@ -1035,13 +1043,36 @@ const de: Translations = {
},
// ── Pricing Page ───────────────────────────────────
// ── Footer ─────────────────────────────────────────
footer: {
tagline:
'Finde die Postcodes, die zu deinem Leben passen. Datenbasierte Gegendrecherche für England.',
product: 'Produkt',
resources: 'Ressourcen',
legal: 'Rechtliches',
dataSources: 'Datenquellen',
methodology: 'Methodik',
contact: 'Support kontaktieren',
terms: 'Nutzungsbedingungen',
privacy: 'Datenschutzerklärung',
copyright: '© {{year}} Perfect Postcode. Alle Rechte vorbehalten.',
coverage: 'Deckt jeden Postcode in England ab.',
},
// ── Legal pages ────────────────────────────────────
legal: {
lastUpdated: 'Zuletzt aktualisiert: {{date}}',
englishOnly: 'Dieses Dokument liegt auf Englisch vor; die englische Fassung ist maßgeblich.',
},
pricingPage: {
title: 'Mit einem besseren Suchgebiet kaufen',
subtitle:
'Lebenslanger Zugang zu der Karte, die zeigt, wo du suchen solltest, bevor du Besichtigungen buchst.',
costContext:
'Käufer verbringen oft Abende damit, Inserate, Pendelzeiten, Schulberichte, Kriminalitätskarten, Street View und Verkaufspreise zusammenzuführen. In London ist das besonders mühsam, aber dasselbe Rechercheproblem gibt es in ganz England. Perfect Postcode bringt die Gebietsrecherche auf eine Karte, bevor du Wochenenden, Gebühren und Aufmerksamkeit investierst.',
lessThanSurvey: 'Weniger als ein Survey. Deutlich wirksamer, um deine Auswahl zu steuern.',
lessThanSurvey:
'Kostet weniger als ein Zehntel eines Gutachtens — und prägt eine weit größere Entscheidung.',
currentTier: 'Aktuelle Stufe',
firstNUsers: 'Erste {{count}} Nutzer',
everyoneAfter: 'Alle danach',
@ -1054,11 +1085,12 @@ const de: Translations = {
getStarted: 'Jetzt starten',
getStartedPrice: 'Jetzt starten — {{price}}',
noCreditCard: 'Keine Kreditkarte erforderlich',
moneyBack: '14 Tage Geld-zurück-Garantie.',
soldOut: 'Ausverkauft',
upcoming: 'Demnächst',
failedToLoad: 'Preise konnten nicht geladen werden. Bitte später erneut versuchen.',
feat1: '56 Filter in ganz England',
feat1: '40+ Filter und 200+ Datenfelder',
feat2: 'Jeder Postcode nach deinen Bedürfnissen durchsuchbar',
feat3: 'Unbegrenztes Erkunden der Karte, gespeicherte Suchen und Exporte',
feat4: '13 Mio. historische Transaktionen und Preiskontext',
@ -1079,6 +1111,34 @@ const de: Translations = {
articlesIntro:
'Durchsuche die öffentlichen Leitfäden zu Immobiliensuche, Pendeln, Schulen, Postleitzahlprüfungen, regionalen Vergleichen, Datenabdeckung, Methodik und Datenschutz.',
supportIntro: 'Hast du eine Frage? Schau in unsere FAQ oder kontaktiere uns direkt.',
videos: 'Videos',
videosTitle: 'Social-Media-Videos',
videosIntro:
'Kurze Clips aus unseren Social-Media-Kanälen jeder zeigt eine einzelne Suche in Aktion, von ruhigen Straßen über Schuleinzugsgebiete bis zu Pendelzeiten.',
video01Title: 'Ein Satz, jede Postleitzahl',
video01Desc:
'Gib deinen kompletten Wohnungswunsch in normaler Sprache ein und sieh zu, wie jede passende Postleitzahl in England aufleuchtet.',
video02Title: 'Die 20-Minuten-Karte',
video02Desc:
'Färbe die Karte nach Pendelzeit und sieh genau, was dir 20 Minuten ins Zentrum von London wirklich übrig lassen.',
video03Title: 'Jede Postleitzahl hat eine Akte',
video03Desc:
'Tippe auf eine Postleitzahl und lies ihre Akte verkaufte Preise, Schulen, Kriminalität und Street View an einem Ort.',
video04Title: 'Ein Foto kann man nicht hören',
video04Desc:
'Anzeigenfotos sind stumm. Filtere nach Lärmpegel und finde die wirklich ruhigen Straßen unter 55 Dezibel.',
video05Title: 'Die Schulweg-Karte',
video05Desc:
'Gute Grundschul-Einzugsgebiete, wenig Kriminalität und ein Budget der Familienwunsch, über eine ganze Stadt kartiert.',
video06Title: 'Der Waitrose-Test',
video06Desc:
'Zu Fuß zu einem Supermarkt, einer U-Bahn-Station und einem Park filtere nach dem Leben, nicht nur nach dem Grundriss.',
video07Title: 'Auch Mieter bekommen eine Karte',
video07Desc:
'Miete im Budget, kurzer Arbeitsweg und eine ruhige Straße Mietportale zeigen Wohnungen, das hier zeigt dir Gegenden.',
video08Title: '9,99 £ gegen einen verlorenen Samstag',
video08Desc:
'Eine schlechte Besichtigung kostet eine Zugfahrt und ein halbes Wochenende. Sieh vorher, wo du nicht hinmusst.',
source: 'Quelle:',
optOut: 'Widerspruch gegen öffentliche Offenlegung',
attribution: 'Quellenangaben',
@ -1111,7 +1171,7 @@ const de: Translations = {
dsEthnicityName: 'Bevölkerung nach Ethnie (Zensus 2021)',
dsEthnicityOrigin: 'ONS',
dsEthnicityUse:
'Bevölkerungsanteile nach ethnischer Gruppe (südasiatisch, ostasiatisch, schwarz, gemischt, weiß, andere) pro Bezirk.',
'Bevölkerungsanteile nach ethnischer Gruppe (südasiatisch, ostasiatisch, südostasiatisch, schwarz, gemischt, weiß, andere) pro Bezirk.',
dsCrimeName: 'Kriminalitätsdaten auf Straßenebene',
dsCrimeOrigin: 'data.police.uk',
dsCrimeUse:
@ -1298,9 +1358,10 @@ const de: Translations = {
faqBehindData3Q: 'Warum zeigen benachbarte Postleitzahlen identische Kriminalitätszahlen?',
faqBehindData3A:
'Polizeilich erfasste Kriminalität auf Straßenebene wird auf LSOA-Ebene veröffentlicht — kleine Nachbarschaftsgebiete mit etwa 1.500 Einwohnern. Jede Postleitzahl innerhalb derselben LSOA übernimmt dieselben Jahreszahlen, sodass eine ruhige Wohnstraße und eine Hauptstraße einen Block entfernt identische Werte zeigen können, wenn sie auf derselben Seite der Grenze liegen. Die Pro-Kopf-Rate kann in Postleitzahlen mit Krankenhäusern, Universitätsgeländen oder Industriegebieten ungewöhnlich hoch wirken, weil dort viele Vorfälle gezählt werden, aber wenige Einwohner gemeldet sind.',
faqBehindData4Q: 'Bedeutet „Gute Schulen in 2 km Umkreis“, dass mein Kind dorthin gehen kann?',
faqBehindData4Q:
'Was bedeutet die Zahl der „Schul-Einzugsgebiete“ — kann mein Kind diese Schulen besuchen?',
faqBehindData4A:
'Nein. Die Zählung sucht staatliche Schulen, deren eigene Postleitzahl in einem Kreis um deinen Postleitzahl-Mittelpunkt liegt. Einzugsgebiete, konfessionelle oder selektive Aufnahmekriterien, Geschwisterregelungen und Anmeldebedingungen werden nicht modelliert — eine nahegelegene Gute oder Hervorragende Schule kann von deiner Adresse aus unerreichbar sein. Nutze die Zahl, um Gebiete zu vergleichen, und prüfe tatsächliche Aufnahmebedingungen bei der Schule oder Gemeinde, bevor du dich darauf verlässt.',
'Jede Zahl gibt an, wie viele staatliche Schulen mit der Bewertung Gut oder Hervorragend ein modelliertes historisches Einzugsgebiet haben, das die Postleitzahl abdeckt. Wir simulieren, wie Englands entfernungsbasierte Aufnahmen Plätze vergeben: Kinder (Zensus 2021) bewerben sich an nahegelegenen Schulen und wägen dabei Entfernung gegen Ofsted-Bewertung ab; eine überbuchte Schule nimmt die nächstgelegenen Bewerber auf, bis sie voll ist — ihr Einzugsradius ist die Entfernung des zuletzt aufgenommenen Kindes, genau die „zuletzt vergebene Entfernung“, die Gemeinden veröffentlichen. Das Modell ist an Hunderten dieser veröffentlichten Werte kalibriert. Es schätzt, wo man plausibel einen Platz bekommt; es ist kein offizielles Aufnahmegebiet. Konfessionelle und selektive Aufnahmen, Geschwisterpriorität und jährliche Grenzänderungen werden nicht modelliert — prüfe Einzugsgebiete und Aufnahmeregeln daher immer bei der Schule oder Gemeinde, bevor du eine Entscheidung darauf stützt.',
faqBehindData5Q:
'Warum zeigt eine Postleitzahl „Gigabit“, wenn nicht jedes Haus Glasfaser hat?',
faqBehindData5A:
@ -1459,7 +1520,7 @@ const de: Translations = {
'Current energy rating': 'Aktuelle Energieeffizienzklasse',
'Potential energy rating': 'Mögliche Energieeffizienzklasse',
'Interior height (m)': 'Raumhöhe (m)',
'Street tree density percentile': 'Perzentil der Straßenbaumdichte',
'Tree canopy density percentile': 'Perzentil der Baumkronendichte',
'Within conservation area': 'In Denkmalschutzgebiet',
'Listed building': 'Denkmalgeschütztes Gebäude',
@ -1468,16 +1529,11 @@ const de: Translations = {
'Fahrzeit zum nächsten Bahn- oder U-Bahnhof (Min.)',
// ─ Feature names (Education) ─
'Good+ primary schools within 2km': 'Gute+ Grundschulen im Umkreis von 2 km',
'Good+ secondary schools within 2km': 'Gute+ weiterführende Schulen im Umkreis von 2 km',
'Good+ primary schools within 5km': 'Gute+ Grundschulen im Umkreis von 5 km',
'Good+ secondary schools within 5km': 'Gute+ weiterführende Schulen im Umkreis von 5 km',
'Outstanding primary schools within 2km': 'Hervorragende Grundschulen im Umkreis von 2 km',
'Outstanding secondary schools within 2km':
'Hervorragende weiterführende Schulen im Umkreis von 2 km',
'Outstanding primary schools within 5km': 'Hervorragende Grundschulen im Umkreis von 5 km',
'Outstanding secondary schools within 5km':
'Hervorragende weiterführende Schulen im Umkreis von 5 km',
'Good+ primary school catchments': 'Einzugsgebiete guter+ Grundschulen',
'Good+ secondary school catchments': 'Einzugsgebiete guter+ weiterführender Schulen',
'Outstanding primary school catchments': 'Einzugsgebiete hervorragender Grundschulen',
'Outstanding secondary school catchments':
'Einzugsgebiete hervorragender weiterführender Schulen',
'Education, Skills and Training Score': 'Wert für Bildung, Kompetenzen und Ausbildung',
// ─ Feature names (Area development) ─
@ -1511,7 +1567,8 @@ const de: Translations = {
'% White': '% weiß',
'% South Asian': '% südasiatisch',
'% Black': '% schwarz',
'% East/SE Asian': '% ost-/südostasiatisch',
'% East Asian': '% ostasiatisch',
'% SE Asian': '% südostasiatisch',
'% Mixed': '% gemischt',
'% Other': '% Sonstige',
@ -1579,6 +1636,7 @@ const de: Translations = {
'Bus station': 'Busbahnhof',
'Taxi rank': 'Taxistand',
'Tube station': 'U-Bahn-Station',
'Tram & Metro stop': 'Tram- & Metro-Haltestelle',
Café: 'Café',
Restaurant: 'Restaurant',
Pub: 'Pub',
@ -1625,7 +1683,8 @@ const de: Translations = {
'GP Surgery': 'Hausarztpraxis',
Dentist: 'Zahnarzt',
Pharmacy: 'Apotheke',
'Hospital & Clinic': 'Krankenhaus & Klinik',
Hospital: 'Krankenhaus',
Clinic: 'Klinik',
Optician: 'Optiker',
Physiotherapy: 'Physiotherapie',
'Counselling & Therapy': 'Beratung & Therapie',

View file

@ -597,6 +597,8 @@ const en = {
pleaseWait: 'Please wait...',
sendResetLink: 'Send reset link',
backToLogin: 'Back to login',
registerConsent:
'By creating an account you agree to our <terms>Terms of Service</terms> and <privacy>Privacy Policy</privacy>.',
},
// ── Upgrade Modal ──────────────────────────────────
@ -653,7 +655,7 @@ const en = {
findingPerfectPostcode: 'Finding the Perfect Postcode',
addFiltersHint: 'Add filters below to narrow the map to areas that match your criteria',
upgradePrompt:
'Find matching postcodes using crime, schools, noise, broadband, prices, and 50+ more filters across England.',
'Find matching postcodes using crime, schools, noise, broadband, prices, and 40+ combinable filters across England.',
oneTimeLifetime: 'One-time payment, lifetime access.',
upgradeToFullMap: 'Upgrade to full map',
chooseFilters: 'Click Add to filter. The small buttons show data details or colour the map.',
@ -684,7 +686,6 @@ const en = {
filtersOut: 'filters out {{value}}',
schoolType: 'School type',
schoolRating: 'School rating',
schoolDistance: 'School distance',
primary: 'Primary',
secondary: 'Secondary',
rating: 'Rating',
@ -927,7 +928,7 @@ const en = {
showcaseFeatureNoiseShort: 'Noise',
showcaseFeatureSchoolsShort: 'Schools',
showcaseFeatureTravelShort: 'Travel',
showcaseGoodPrimariesNearby: '{{count}}+ good primaries nearby',
showcaseGoodPrimariesNearby: 'In {{count}}+ good primary catchments',
showcaseWithinRail: 'Within {{count}} min of rail',
showcaseMatchingHomesLabel: 'Matching homes',
showcaseMatchingHomes: '{{value}} matching homes',
@ -987,6 +988,12 @@ const en = {
statFilters: 'combinable filters',
statEvery: 'Every',
statPostcodeInEngland: 'postcode in England',
coverageNote:
'Covers every postcode in England — 200+ data fields each. Scotland & Wales are on the roadmap.',
priceStrip: 'Lifetime access, currently {{price}} — the price rises as tiers fill.',
priceStripSpots: '{{count}} spot left at this price.',
priceStripSpotsPlural: '{{count}} spots left at this price.',
priceStripCta: 'See pricing',
ourPhilosophy: 'Start with what matters, then find the right postcode',
philosophyP1:
'Most property sites ask where you want to live. In London thats painfully hard, but the same problem shows up across England: buyers choose from the few places they know, then cross-check commute tools, Ofsted, police data, Street View, broadband checkers, and sold prices in separate tabs.',
@ -1010,7 +1017,7 @@ const en = {
compAreaDataSub: '(crime, schools, noise, broadband, amenities)',
compPropertyData: 'Property-level history',
compPropertyDataSub: '(sold prices, EPC, floor area, estimated value)',
compFilters: '56 filters working together',
compFilters: '40+ filters working together',
compFiltersSub: '(not one postcode or one listing at a time)',
ctaTitle: 'Stop guessing where to buy.',
ctaDescription:
@ -1018,13 +1025,34 @@ const en = {
},
// ── Pricing Page ───────────────────────────────────
// ── Footer ─────────────────────────────────────────
footer: {
tagline: 'Find the postcodes that fit your life. Evidence-first area research for England.',
product: 'Product',
resources: 'Resources',
legal: 'Legal',
dataSources: 'Data sources',
methodology: 'Methodology',
contact: 'Contact support',
terms: 'Terms of Service',
privacy: 'Privacy Policy',
copyright: '© {{year}} Perfect Postcode. All rights reserved.',
coverage: 'Covers every postcode in England.',
},
// ── Legal pages ────────────────────────────────────
legal: {
lastUpdated: 'Last updated: {{date}}',
englishOnly: 'This document is provided in English; the English version is authoritative.',
},
pricingPage: {
title: 'Buy with a better search area',
subtitle:
'Lifetime access to the map that helps you find where to look before you book viewings.',
costContext:
'Buyers often spend evenings stitching together listings, commute checks, school reports, crime maps, Street View, and sold prices. In London this is relentless, but the same research problem appears across England. Perfect Postcode puts the area research on one map before you commit your weekends, fees, and attention.',
lessThanSurvey: 'Less than a survey. Vastly more impactful in guiding your choices.',
lessThanSurvey: 'Costs less than a tenth of a survey — and shapes a far bigger decision.',
currentTier: 'Current tier',
firstNUsers: 'First {{count}} users',
everyoneAfter: 'Everyone after',
@ -1037,11 +1065,12 @@ const en = {
getStarted: 'Get started',
getStartedPrice: 'Get started - {{price}}',
noCreditCard: 'No credit card required',
moneyBack: '14-day money-back guarantee.',
soldOut: 'Sold out',
upcoming: 'Upcoming',
failedToLoad: 'Failed to load pricing. Please try again later.',
feat1: '56 filters across England',
feat1: '40+ filters and 200+ data fields',
feat2: 'Every postcode searchable from your needs',
feat3: 'Unlimited map exploration, saved searches and exports',
feat4: '13M historical transactions and price context',
@ -1062,6 +1091,34 @@ const en = {
articlesIntro:
'Browse the public guides for property search, commute, schools, postcode checks, regional comparisons, data coverage, methodology, and privacy.',
supportIntro: 'Have a question? Check our FAQ or reach out to us directly.',
videos: 'Videos',
videosTitle: 'Social media videos',
videosIntro:
'Short clips from our social channels — each one shows a single search in action, from quiet streets to school catchments and commute times.',
video01Title: 'One sentence, every postcode',
video01Desc:
'Type your whole house brief in plain English and watch every matching postcode in England light up.',
video02Title: 'The 20-minute map',
video02Desc:
'Colour the map by commute time and see exactly what a 20-minute journey to central London actually leaves you.',
video03Title: 'Every postcode has a file',
video03Desc:
'Tap any postcode to read its file — sold prices, schools, crime and Street View, all in one place.',
video04Title: 'You cant hear a photo',
video04Desc:
'Listing photos are silent. Filter by noise level to find the genuinely quiet streets under 55 decibels.',
video05Title: 'The school-run map',
video05Desc:
'Good primary catchments, low crime and a budget — the family brief, mapped across a whole city.',
video06Title: 'The Waitrose test',
video06Desc:
'Walking distance to a Waitrose, a tube station and a park — filter for the life, not just the floor plan.',
video07Title: 'Renters get a map too',
video07Desc:
'Rent under budget, a short commute and a quiet street — letting sites show flats, this shows you areas.',
video08Title: '£9.99 vs a wasted Saturday',
video08Desc:
'A bad viewing costs a train ticket and half a weekend. See where not to go before you book one.',
source: 'Source:',
optOut: 'Opt out of public disclosure',
attribution: 'Attribution',
@ -1093,7 +1150,7 @@ const en = {
dsEthnicityName: 'Population by Ethnicity (2021 Census)',
dsEthnicityOrigin: 'ONS',
dsEthnicityUse:
'Population percentages by ethnic group (South Asian, East Asian, Black, Mixed, White, Other) per local authority.',
'Population percentages by ethnic group (South Asian, East Asian, SE Asian, Black, Mixed, White, Other) per local authority.',
dsCrimeName: 'Street-level Crime Data',
dsCrimeOrigin: 'data.police.uk',
dsCrimeUse:
@ -1277,9 +1334,10 @@ const en = {
faqBehindData3Q: 'Why do nearby postcodes share the same crime numbers?',
faqBehindData3A:
'Police-recorded street-level crime is published at LSOA level — small neighbourhood areas of about 1,500 residents. Every postcode inside the same LSOA inherits the same yearly totals, so a quiet residential street and a high street one block over can show identical figures if they fall on the same side of the boundary. Per-capita rates can also look unusually high in postcodes covering hospitals, university campuses or industrial estates, because those areas record incidents normally but have very few residents on paper to divide the count across.',
faqBehindData4Q: 'Does "Good schools within 2km" mean my child can attend them?',
faqBehindData4Q:
'What does a "school catchments" count mean — can my child attend those schools?',
faqBehindData4A:
'No. The count looks for state schools whose own postcode falls inside a circle around your postcodes centroid. Catchment areas, faith and selection criteria, sibling priority and admission rules are not modelled — a Good or Outstanding school nearby may still be unreachable from your address. Use the count to compare areas, then confirm actual admissions with the school or local authority before relying on it for a decision.',
'Each count is the number of Good or Outstanding state schools whose modelled historical catchment area covers the postcode. We simulate how Englands distance-based admissions allocate places: children (Census 2021) apply to nearby schools, trading distance against Ofsted rating, and an oversubscribed school admits its closest applicants until full — its catchment radius is the distance of the last child admitted, the same "last distance offered" councils publish, and the model is calibrated against hundreds of those published figures. It estimates where pupils plausibly get a place; it is not an official admission area. Faith and selective admissions, sibling priority and yearly boundary changes are not modelled, so always confirm catchments and admission rules with the school or local authority before relying on them for a decision.',
faqBehindData5Q: 'Why does a postcode show "Gigabit" when not every home has fibre?',
faqBehindData5A:
'Broadband coverage from Ofcom Connected Nations is reported per postcode as the percentage of premises that can get each speed tier. We display the highest tier with any availability, so a postcode where even one home can reach Gigabit reads "Gigabit available". It is the right answer for "is full-fibre on this street at all?", but does not guarantee every flat in a block can be ordered today. Always verify with the providers for your specific address before signing.',
@ -1436,7 +1494,7 @@ const en = {
'Current energy rating': 'Current energy rating',
'Potential energy rating': 'Potential energy rating',
'Interior height (m)': 'Interior height (m)',
'Street tree density percentile': 'Street tree density percentile',
'Tree canopy density percentile': 'Tree canopy density percentile',
'Within conservation area': 'Within conservation area',
'Listed building': 'Listed building',
@ -1445,14 +1503,10 @@ const en = {
'Travel time to nearest train or tube station (min)',
// ─ Feature names (Education) ─
'Good+ primary schools within 2km': 'Good+ primary schools within 2km',
'Good+ secondary schools within 2km': 'Good+ secondary schools within 2km',
'Good+ primary schools within 5km': 'Good+ primary schools within 5km',
'Good+ secondary schools within 5km': 'Good+ secondary schools within 5km',
'Outstanding primary schools within 2km': 'Outstanding primary schools within 2km',
'Outstanding secondary schools within 2km': 'Outstanding secondary schools within 2km',
'Outstanding primary schools within 5km': 'Outstanding primary schools within 5km',
'Outstanding secondary schools within 5km': 'Outstanding secondary schools within 5km',
'Good+ primary school catchments': 'Good+ primary school catchments',
'Good+ secondary school catchments': 'Good+ secondary school catchments',
'Outstanding primary school catchments': 'Outstanding primary school catchments',
'Outstanding secondary school catchments': 'Outstanding secondary school catchments',
'Education, Skills and Training Score': 'Education, Skills and Training Score',
// ─ Feature names (Area development) ─
@ -1485,7 +1539,8 @@ const en = {
'% White': '% White',
'% South Asian': '% South Asian',
'% Black': '% Black',
'% East/SE Asian': '% East/SE Asian',
'% East Asian': '% East Asian',
'% SE Asian': '% SE Asian',
'% Mixed': '% Mixed',
'% Other': '% Other',
@ -1553,6 +1608,7 @@ const en = {
'Bus station': 'Bus station',
'Taxi rank': 'Taxi rank',
'Tube station': 'Tube station',
'Tram & Metro stop': 'Tram & Metro stop',
Café: 'Café',
Restaurant: 'Restaurant',
Pub: 'Pub',
@ -1599,7 +1655,8 @@ const en = {
'GP Surgery': 'GP Surgery',
Dentist: 'Dentist',
Pharmacy: 'Pharmacy',
'Hospital & Clinic': 'Hospital & Clinic',
Hospital: 'Hospital',
Clinic: 'Clinic',
Optician: 'Optician',
Physiotherapy: 'Physiotherapy',
'Counselling & Therapy': 'Counselling & Therapy',

View file

@ -622,6 +622,8 @@ const fr: Translations = {
pleaseWait: 'Veuillez patienter...',
sendResetLink: 'Envoyer le lien de réinitialisation',
backToLogin: 'Retour à la connexion',
registerConsent:
'En créant un compte, vous acceptez nos <terms>Conditions dutilisation</terms> et notre <privacy>Politique de confidentialité</privacy>.',
},
// ── Upgrade Modal ──────────────────────────────────
@ -680,7 +682,7 @@ const fr: Translations = {
addFiltersHint:
'Ajoutez des filtres ci-dessous pour limiter la carte aux secteurs adaptés à vos critères',
upgradePrompt:
'Trouvez les codes postaux compatibles grâce aux filtres de criminalité, décoles, de bruit, de haut débit, de prix et plus de 50 autres filtres dans toute lAngleterre.',
'Trouvez les codes postaux compatibles grâce aux filtres de criminalité, décoles, de bruit, de haut débit, de prix et plus de 40 filtres combinables dans toute lAngleterre.',
oneTimeLifetime: 'Paiement unique, accès à vie.',
upgradeToFullMap: 'Passer à la carte complète',
chooseFilters:
@ -712,7 +714,6 @@ const fr: Translations = {
filtersOut: 'exclut {{value}}',
schoolType: 'Type décole',
schoolRating: 'Évaluation Ofsted',
schoolDistance: 'Distance de lécole',
primary: 'Primaire',
secondary: 'Secondaire',
rating: 'Note',
@ -956,7 +957,7 @@ const fr: Translations = {
showcaseFeatureNoiseShort: 'Bruit',
showcaseFeatureSchoolsShort: 'Écoles',
showcaseFeatureTravelShort: 'Trajet',
showcaseGoodPrimariesNearby: '{{count}}+ écoles primaires Good+ à proximité',
showcaseGoodPrimariesNearby: 'Dans {{count}}+ zones de recrutement de primaires Good+',
showcaseWithinRail: 'À moins de {{count}} min du train',
showcaseMatchingHomesLabel: 'Biens compatibles',
showcaseMatchingHomes: '{{value}} biens compatibles',
@ -1018,6 +1019,13 @@ const fr: Translations = {
statFilters: 'filtres combinables',
statEvery: 'Chaque',
statPostcodeInEngland: 'code postal dAngleterre',
coverageNote:
'Couvre tous les codes postaux dAngleterre — plus de 200 champs de données chacun. LÉcosse et le pays de Galles sont sur la feuille de route.',
priceStrip:
'Accès à vie, actuellement {{price}} — le prix augmente à mesure que les paliers se remplissent.',
priceStripSpots: '{{count}} place restante à ce prix.',
priceStripSpotsPlural: '{{count}} places restantes à ce prix.',
priceStripCta: 'Voir les tarifs',
ourPhilosophy: 'Commencez par ce qui compte, puis trouvez le bon code postal',
philosophyP1:
'La plupart des sites immobiliers demandent où vous voulez vivre. À Londres, cest particulièrement difficile, mais le même problème existe partout en Angleterre : les acheteurs partent des quelques lieux quils connaissent, puis vérifient séparément trajets, écoles, criminalité, Street View, haut débit et prix vendus.',
@ -1041,7 +1049,7 @@ const fr: Translations = {
compAreaDataSub: '(criminalité, écoles, bruit, haut débit, services)',
compPropertyData: 'Historique par bien',
compPropertyDataSub: '(prix vendus, EPC, surface, valeur estimée)',
compFilters: '56 filtres qui fonctionnent ensemble',
compFilters: '40+ filtres qui fonctionnent ensemble',
compFiltersSub: '(pas un code postal ou une annonce à la fois)',
ctaTitle: 'Arrêtez de deviner où acheter ou louer.',
ctaDescription:
@ -1049,13 +1057,36 @@ const fr: Translations = {
},
// ── Pricing Page ───────────────────────────────────
// ── Footer ─────────────────────────────────────────
footer: {
tagline:
'Trouvez les codes postaux qui correspondent à votre vie. Une recherche de quartier fondée sur les données, pour lAngleterre.',
product: 'Produit',
resources: 'Ressources',
legal: 'Légal',
dataSources: 'Sources de données',
methodology: 'Méthodologie',
contact: 'Contacter le support',
terms: 'Conditions dutilisation',
privacy: 'Politique de confidentialité',
copyright: '© {{year}} Perfect Postcode. Tous droits réservés.',
coverage: 'Couvre tous les codes postaux dAngleterre.',
},
// ── Legal pages ────────────────────────────────────
legal: {
lastUpdated: 'Dernière mise à jour : {{date}}',
englishOnly: 'Ce document est fourni en anglais ; la version anglaise fait foi.',
},
pricingPage: {
title: 'Acheter avec un meilleur secteur de recherche',
subtitle:
'Accès à vie à la carte qui vous aide à savoir où chercher avant de réserver des visites.',
costContext:
'Les acheteurs passent souvent leurs soirées à recouper annonces, trajets, rapports scolaires, cartes de criminalité, Street View et prix vendus. À Londres, cest incessant, mais le même problème existe dans toute lAngleterre. Perfect Postcode rassemble la recherche de secteur sur une seule carte avant que vous nengagiez vos week-ends, vos frais et votre attention.',
lessThanSurvey: 'Moins cher quun survey immobilier. Bien plus utile pour guider vos choix.',
lessThanSurvey:
'Moins dun dixième du prix dune expertise — pour une décision bien plus importante.',
currentTier: 'Offre actuelle',
firstNUsers: '{{count}} premiers utilisateurs',
everyoneAfter: 'Utilisateurs suivants',
@ -1068,11 +1099,12 @@ const fr: Translations = {
getStarted: 'Commencer',
getStartedPrice: 'Commencer - {{price}}',
noCreditCard: 'Aucune carte bancaire requise',
moneyBack: 'Garantie satisfait ou remboursé de 14 jours.',
soldOut: 'Épuisé',
upcoming: 'À venir',
failedToLoad: 'Échec du chargement des tarifs. Veuillez réessayer plus tard.',
feat1: '56 filtres dans toute lAngleterre',
feat1: '40+ filtres et 200+ champs de données',
feat2: 'Chaque code postal recherchable selon vos besoins',
feat3: 'Exploration illimitée de la carte, recherches enregistrées et exportations',
feat4: '13 M de ventes historiques et contexte des prix',
@ -1093,6 +1125,34 @@ const fr: Translations = {
articlesIntro:
'Parcourez les guides publics sur la recherche immobilière, les trajets, les écoles, les codes postaux, les comparaisons régionales, la couverture des données, la méthodologie et la confidentialité.',
supportIntro: 'Vous avez une question ? Consultez notre FAQ ou contactez-nous directement.',
videos: 'Vidéos',
videosTitle: 'Vidéos pour les réseaux sociaux',
videosIntro:
'De courtes vidéos de nos réseaux sociaux — chacune montre une recherche en action, des rues calmes aux secteurs scolaires en passant par les temps de trajet.',
video01Title: 'Une phrase, chaque code postal',
video01Desc:
'Décrivez tout votre projet immobilier en langage courant et voyez sallumer chaque code postal correspondant en Angleterre.',
video02Title: 'La carte des 20 minutes',
video02Desc:
'Colorez la carte selon le temps de trajet et voyez exactement ce que 20 minutes du centre de Londres vous laissent vraiment.',
video03Title: 'Chaque code postal a sa fiche',
video03Desc:
'Touchez un code postal pour lire sa fiche — prix de vente, écoles, criminalité et Street View, au même endroit.',
video04Title: 'Une photo, ça ne sentend pas',
video04Desc:
'Les photos dannonces sont muettes. Filtrez par niveau de bruit pour trouver les rues vraiment calmes, sous 55 décibels.',
video05Title: 'La carte du trajet décole',
video05Desc:
'Bons secteurs décole primaire, faible criminalité et un budget — le projet des familles, cartographié sur toute une ville.',
video06Title: 'Le test Waitrose',
video06Desc:
'À pied dun supermarché, dune station de métro et dun parc — filtrez selon votre vie, pas seulement selon le plan.',
video07Title: 'Les locataires aussi ont une carte',
video07Desc:
'Loyer dans le budget, trajet court et rue calme — les sites de location montrent des logements, ceci vous montre des quartiers.',
video08Title: '9,99 £ contre un samedi gâché',
video08Desc:
'Une mauvaise visite coûte un billet de train et la moitié dun week-end. Voyez où ne pas aller avant den réserver une.',
source: 'Source :',
optOut: 'Refus de la publication publique',
attribution: 'Attribution',
@ -1125,7 +1185,7 @@ const fr: Translations = {
dsEthnicityName: 'Population par ethnie (recensement 2021)',
dsEthnicityOrigin: 'ONS',
dsEthnicityUse:
'Pourcentages de population par groupe ethnique (sud-asiatique, est-asiatique, noir, mixte, blanc, autre) par autorité locale.',
'Pourcentages de population par groupe ethnique (sud-asiatique, est-asiatique, sud-est asiatique, noir, mixte, blanc, autre) par autorité locale.',
dsCrimeName: 'Données de criminalité au niveau rue',
dsCrimeOrigin: 'data.police.uk',
dsCrimeUse:
@ -1316,9 +1376,9 @@ const fr: Translations = {
faqBehindData3A:
'La criminalité enregistrée par la police au niveau rue est publiée à léchelle LSOA — de petits quartiers denviron 1 500 habitants. Chaque code postal situé dans la même LSOA hérite des mêmes totaux annuels, donc une rue résidentielle calme et une rue commerçante un pâté de maisons plus loin peuvent afficher des chiffres identiques si elles sont du même côté de la limite. Les taux par habitant peuvent sembler anormalement élevés dans des codes postaux couvrant des hôpitaux, des campus ou des zones industrielles, car ils enregistrent des incidents normalement mais comptent peu de résidents officiels.',
faqBehindData4Q:
'« Bonnes écoles dans un rayon de 2 km » signifie-t-il que mon enfant peut y aller ?',
'Que signifie le nombre de « zones de recrutement » — mon enfant peut-il fréquenter ces écoles ?',
faqBehindData4A:
'Non. Le décompte cherche les écoles publiques dont le propre code postal tombe dans un cercle autour du centroïde de votre code postal. Les secteurs scolaires, critères religieux ou sélectifs, priorité fratrie et règles dadmission ne sont pas modélisés — une école Bonne ou Excellente proche peut rester inaccessible depuis votre adresse. Utilisez le décompte pour comparer des zones, puis confirmez les conditions dadmission auprès de lécole ou de la mairie avant de vous y fier.',
'Chaque nombre correspond aux écoles publiques notées Bien ou Excellent dont la zone de recrutement historique modélisée couvre le code postal. Nous simulons la façon dont les admissions anglaises fondées sur la distance attribuent les places : les enfants (recensement 2021) candidatent aux écoles proches en arbitrant entre distance et note Ofsted, et une école sursouscrite admet les candidats les plus proches jusquà être pleine — son rayon de recrutement est la distance du dernier enfant admis, la « dernière distance dadmission » que publient les collectivités. Le modèle est calibré sur des centaines de ces distances publiées. Il estime où une place est plausible ; ce nest pas un secteur dadmission officiel. Les critères religieux ou sélectifs, la priorité fratrie et les changements annuels de limites ne sont pas modélisés : confirmez toujours les zones et les règles dadmission auprès de lécole ou de la mairie avant de vous y fier pour une décision.',
faqBehindData5Q:
'Pourquoi un code postal affiche-t-il « Gigabit » quand toutes les maisons nen ont pas ?',
faqBehindData5A:
@ -1479,7 +1539,7 @@ const fr: Translations = {
'Current energy rating': 'Classe énergétique actuelle',
'Potential energy rating': 'Classe énergétique potentielle',
'Interior height (m)': 'Hauteur intérieure (m)',
'Street tree density percentile': 'Percentile de densité arborée de la rue',
'Tree canopy density percentile': 'Percentile de densité de couvert arboré',
'Within conservation area': 'Dans une zone de conservation',
'Listed building': 'Bâtiment classé',
@ -1488,14 +1548,10 @@ const fr: Translations = {
'Temps de trajet jusquà la gare ou station de métro la plus proche (min)',
// ─ Feature names (Education) ─
'Good+ primary schools within 2km': 'Écoles primaires Bien+ dans un rayon de 2 km',
'Good+ secondary schools within 2km': 'Collèges/lycées Bien+ dans un rayon de 2 km',
'Good+ primary schools within 5km': 'Écoles primaires Bien+ dans un rayon de 5 km',
'Good+ secondary schools within 5km': 'Collèges/lycées Bien+ dans un rayon de 5 km',
'Outstanding primary schools within 2km': 'Écoles primaires Excellent dans un rayon de 2 km',
'Outstanding secondary schools within 2km': 'Collèges/lycées Excellent dans un rayon de 2 km',
'Outstanding primary schools within 5km': 'Écoles primaires Excellent dans un rayon de 5 km',
'Outstanding secondary schools within 5km': 'Collèges/lycées Excellent dans un rayon de 5 km',
'Good+ primary school catchments': 'Zones de recrutement décoles primaires Bien+',
'Good+ secondary school catchments': 'Zones de recrutement de collèges/lycées Bien+',
'Outstanding primary school catchments': 'Zones de recrutement décoles primaires Excellent',
'Outstanding secondary school catchments': 'Zones de recrutement de collèges/lycées Excellent',
'Education, Skills and Training Score': 'Score éducation, compétences et formation',
// ─ Feature names (Area development) ─
@ -1528,7 +1584,8 @@ const fr: Translations = {
'% White': '% Blancs',
'% South Asian': '% Sud-Asiatiques',
'% Black': '% Noirs',
'% East/SE Asian': '% est/sud-est asiatique',
'% East Asian': '% est-asiatique',
'% SE Asian': '% sud-est asiatique',
'% Mixed': '% Métis',
'% Other': '% Autres',
@ -1595,7 +1652,8 @@ const fr: Translations = {
'Bus stop': 'Arrêt de bus',
'Bus station': 'Gare routière',
'Taxi rank': 'Station de taxi',
'Tube station': 'Station de métro',
'Tube station': 'Station de métro londonien',
'Tram & Metro stop': 'Arrêt de tramway et métro',
Café: 'Café',
Restaurant: 'Restaurant',
Pub: 'Pub',
@ -1642,7 +1700,8 @@ const fr: Translations = {
'GP Surgery': 'Cabinet médical',
Dentist: 'Dentiste',
Pharmacy: 'Pharmacie',
'Hospital & Clinic': 'Hôpital et clinique',
Hospital: 'Hôpital',
Clinic: 'Clinique',
Optician: 'Opticien',
Physiotherapy: 'Kinésithérapie',
'Counselling & Therapy': 'Soutien psychologique et thérapie',

View file

@ -595,6 +595,8 @@ const hi: Translations = {
pleaseWait: 'कृपया प्रतीक्षा करें...',
sendResetLink: 'रीसेट लिंक भेजें',
backToLogin: 'लॉग इन पर वापस जाएं',
registerConsent:
'खाता बनाकर आप हमारी <terms>सेवा की शर्तों</terms> और <privacy>गोपनीयता नीति</privacy> से सहमत होते हैं.',
},
upgrade: {
@ -647,7 +649,7 @@ const hi: Translations = {
findingPerfectPostcode: 'Perfect Postcode खोजा जा रहा है',
addFiltersHint: 'अपनी शर्तों से मेल खाने वाले क्षेत्र पाने के लिए नीचे फ़िल्टर जोड़ें',
upgradePrompt:
'इंग्लैंड भर में अपराध, स्कूल, शोर, ब्रॉडबैंड, कीमतें और 50+ अन्य फ़िल्टर से मेल खाने वाले पोस्टकोड खोजें.',
'इंग्लैंड भर में अपराध, स्कूल, शोर, ब्रॉडबैंड, कीमतें और 40+ संयोजित फ़िल्टर से मेल खाने वाले पोस्टकोड खोजें.',
oneTimeLifetime: 'एक बार भुगतान, आजीवन पहुँच.',
upgradeToFullMap: 'पूरे मानचित्र पर अपग्रेड करें',
chooseFilters:
@ -679,7 +681,6 @@ const hi: Translations = {
filtersOut: '{{value}} को बाहर करता है',
schoolType: 'स्कूल प्रकार',
schoolRating: 'स्कूल रेटिंग',
schoolDistance: 'स्कूल दूरी',
primary: 'प्राइमरी',
secondary: 'सेकेंडरी',
rating: 'रेटिंग',
@ -909,7 +910,7 @@ const hi: Translations = {
showcaseFeatureNoiseShort: 'शोर',
showcaseFeatureSchoolsShort: 'स्कूल',
showcaseFeatureTravelShort: 'यात्रा',
showcaseGoodPrimariesNearby: '{{count}}+ अच्छे प्राइमरी स्कूल पास में',
showcaseGoodPrimariesNearby: '{{count}}+ अच्छे प्राइमरी स्कूलों के कैचमेंट क्षेत्र में',
showcaseWithinRail: 'रेल से {{count}} मिनट के भीतर',
showcaseMatchingHomesLabel: 'मेल खाते घर',
showcaseMatchingHomes: '{{value}} मेल खाते घर',
@ -969,6 +970,12 @@ const hi: Translations = {
statFilters: 'जोड़े जा सकने वाले फ़िल्टर',
statEvery: 'इंग्लैंड का हर',
statPostcodeInEngland: 'पोस्टकोड',
coverageNote:
'इंग्लैंड का हर पोस्टकोड कवर — हर एक के लिए 200+ डेटा फ़ील्ड. स्कॉटलैंड और वेल्स रोडमैप पर हैं.',
priceStrip: 'लाइफ़टाइम एक्सेस, अभी {{price}} — टियर भरने पर क़ीमत बढ़ती है.',
priceStripSpots: 'इस क़ीमत पर सिर्फ़ {{count}} जगह बची है.',
priceStripSpotsPlural: 'इस क़ीमत पर सिर्फ़ {{count}} जगहें बची हैं.',
priceStripCta: 'क़ीमतें देखें',
ourPhilosophy: 'जो मायने रखता है उससे शुरू करें, फिर सही पोस्टकोड खोजें',
philosophyP1:
'अधिकांश संपत्ति साइटें पूछती हैं कि आप कहां रहना चाहते हैं. लंदन में यह बहुत मुश्किल है, लेकिन यही समस्या पूरे इंग्लैंड में आती है: खरीदार उन कुछ जगहों में से चुनते हैं जिन्हें वे जानते हैं, फिर आवागमन साधन, Ofsted, पुलिस डेटा, Street View, ब्रॉडबैंड जांच और बेचे गए दामों को अलग-अलग टैब में मिलाते हैं.',
@ -992,20 +999,43 @@ const hi: Translations = {
compAreaDataSub: '(अपराध, स्कूल, शोर, ब्रॉडबैंड, सुविधाएं)',
compPropertyData: 'संपत्ति-स्तर इतिहास',
compPropertyDataSub: '(बेची कीमतें, EPC, फर्श क्षेत्रफल, अनुमानित मूल्य)',
compFilters: '56 फ़िल्टर साथ काम करते हुए',
compFilters: '40+ फ़िल्टर साथ काम करते हुए',
compFiltersSub: '(एक समय में केवल एक पोस्टकोड या एक लिस्टिंग नहीं)',
ctaTitle: 'कहां खरीदना है, इसका अनुमान लगाना बंद करें.',
ctaDescription:
'उन पोस्टकोड की शॉर्टलिस्ट बनाएं जो आपकी वास्तविक जिंदगी से मेल खाते हैं, फिर उन्हें खुद जांचें.',
},
// ── Footer ─────────────────────────────────────────
footer: {
tagline:
'अपनी ज़िंदगी से मेल खाते पोस्टकोड खोजें. इंग्लैंड के लिए डेटा-आधारित इलाक़े की रिसर्च.',
product: 'प्रोडक्ट',
resources: 'संसाधन',
legal: 'क़ानूनी',
dataSources: 'डेटा स्रोत',
methodology: 'कार्यप्रणाली',
contact: 'सपोर्ट से संपर्क करें',
terms: 'सेवा की शर्तें',
privacy: 'गोपनीयता नीति',
copyright: '© {{year}} Perfect Postcode. सर्वाधिकार सुरक्षित.',
coverage: 'इंग्लैंड का हर पोस्टकोड कवर करता है.',
},
// ── Legal pages ────────────────────────────────────
legal: {
lastUpdated: 'आख़िरी अपडेट: {{date}}',
englishOnly: 'यह दस्तावेज़ अंग्रेज़ी में उपलब्ध है; अंग्रेज़ी संस्करण ही मान्य है.',
},
pricingPage: {
title: 'बेहतर खोज क्षेत्र के साथ खरीदें',
subtitle:
'उस मानचित्र की आजीवन पहुँच जो मकान देखने की बुकिंग से पहले यह तय करने में मदद करता है कि कहां देखना है.',
costContext:
'खरीदार अक्सर शामें लिस्टिंग, आवागमन जांच, स्कूल रिपोर्ट, अपराध मानचित्र, Street View और बेचे गए दामों को जोड़ने में बिताते हैं. लंदन में यह लगातार होता है, लेकिन यही शोध समस्या पूरे इंग्लैंड में दिखती है. Perfect Postcode आपके सप्ताहांत, फीस और ध्यान लगाने से पहले क्षेत्र शोध को एक मानचित्र पर रखता है.',
lessThanSurvey: 'एक मकान सर्वेक्षण से कम खर्च. आपके चुनावों को दिशा देने में कहीं अधिक असरदार.',
lessThanSurvey:
'एक मकान सर्वेक्षण की क़ीमत के दसवें हिस्से से भी कम — और कहीं बड़े फ़ैसले में मददगार.',
currentTier: 'मौजूदा स्तर',
firstNUsers: 'पहले {{count}} उपयोगकर्ता',
everyoneAfter: 'उसके बाद सभी',
@ -1018,10 +1048,11 @@ const hi: Translations = {
getStarted: 'शुरू करें',
getStartedPrice: 'शुरू करें - {{price}}',
noCreditCard: 'क्रेडिट कार्ड की जरूरत नहीं',
moneyBack: '14 दिन की मनी-बैक गारंटी.',
soldOut: 'बिक गया',
upcoming: 'आने वाला',
failedToLoad: 'कीमतें लोड नहीं हो सकीं. कृपया बाद में फिर कोशिश करें.',
feat1: 'इंग्लैंड भर में 56 फ़िल्टर',
feat1: '40+ फ़िल्टर और 200+ डेटा फ़ील्ड',
feat2: 'आपकी जरूरतों से हर पोस्टकोड खोजने योग्य',
feat3: 'असीमित मानचित्र खोज, सहेजी गई खोजें और निर्यात',
feat4: '1.3 करोड़ ऐतिहासिक लेनदेन और कीमत संदर्भ',
@ -1041,6 +1072,34 @@ const hi: Translations = {
articlesIntro:
'संपत्ति खोज, आवागमन, स्कूल, पोस्टकोड जांच, क्षेत्रीय तुलना, डेटा कवरेज, कार्यप्रणाली और गोपनीयता पर सार्वजनिक गाइड देखें.',
supportIntro: 'कोई सवाल है? हमारे प्रश्नोत्तर देखें या सीधे संपर्क करें.',
videos: 'वीडियो',
videosTitle: 'सोशल मीडिया वीडियो',
videosIntro:
'हमारे सोशल चैनलों की छोटी क्लिप्स — हर एक एक खोज को क्रिया में दिखाती है, शांत गलियों से लेकर स्कूल कैचमेंट और सफर के समय तक.',
video01Title: 'एक वाक्य, हर पोस्टकोड',
video01Desc:
'अपनी पूरी घर की ज़रूरत सामान्य भाषा में लिखें और इंग्लैंड का हर मेल खाता पोस्टकोड जगमगाते देखें.',
video02Title: '20 मिनट का नक्शा',
video02Desc:
'नक्शे को सफर के समय के अनुसार रंगें और देखें कि सेंट्रल लंदन से 20 मिनट वास्तव में आपको क्या देते हैं.',
video03Title: 'हर पोस्टकोड की एक फ़ाइल है',
video03Desc:
'किसी भी पोस्टकोड पर टैप करके उसकी फ़ाइल पढ़ें — बिक्री मूल्य, स्कूल, अपराध और स्ट्रीट व्यू, सब एक जगह.',
video04Title: 'फ़ोटो सुनी नहीं जा सकती',
video04Desc:
'विज्ञापन की तस्वीरें खामोश होती हैं. शोर के स्तर से छानकर 55 डेसिबल से नीचे की सचमुच शांत गलियाँ खोजें.',
video05Title: 'स्कूल-रन नक्शा',
video05Desc:
'अच्छे प्राइमरी कैचमेंट, कम अपराध और एक बजट — परिवार की ज़रूरत, पूरे शहर पर मैप की गई.',
video06Title: 'वेट्रोज़ टेस्ट',
video06Desc:
'सुपरमार्केट, ट्यूब स्टेशन और पार्क से पैदल दूरी — सिर्फ़ फ़्लोर प्लान नहीं, अपनी ज़िंदगी के हिसाब से छानें.',
video07Title: 'किराएदारों के लिए भी नक्शा',
video07Desc:
'बजट में किराया, छोटा सफर और शांत गली — किराये की साइटें फ़्लैट दिखाती हैं, यह आपको इलाके दिखाता है.',
video08Title: '£9.99 बनाम एक बर्बाद शनिवार',
video08Desc:
'एक ख़राब विज़िट एक ट्रेन टिकट और आधा सप्ताहांत ले जाती है. बुकिंग से पहले देखें कि कहाँ नहीं जाना है.',
source: 'स्रोत:',
optOut: 'सार्वजनिक प्रकटीकरण से बाहर निकलें',
attribution: 'श्रेय',
@ -1071,7 +1130,7 @@ const hi: Translations = {
dsEthnicityName: 'जातीयता के अनुसार जनसंख्या (2021 जनगणना)',
dsEthnicityOrigin: 'ONS',
dsEthnicityUse:
'स्थानीय प्राधिकरण के अनुसार जातीय समूहों (दक्षिण एशियाई, पूर्वी एशियाई, अश्वेत, मिश्रित, श्वेत, अन्य) की जनसंख्या प्रतिशत.',
'स्थानीय प्राधिकरण के अनुसार जातीय समूहों (दक्षिण एशियाई, पूर्वी एशियाई, दक्षिण-पूर्वी एशियाई, अश्वेत, मिश्रित, श्वेत, अन्य) की जनसंख्या प्रतिशत.',
dsCrimeName: 'सड़क-स्तर अपराध डेटा',
dsCrimeOrigin: 'data.police.uk',
dsCrimeUse:
@ -1242,9 +1301,10 @@ const hi: Translations = {
faqBehindData3Q: 'पास के पोस्टकोड में अपराध संख्या समान क्यों होती है?',
faqBehindData3A:
'पुलिस द्वारा दर्ज सड़क-स्तरीय अपराध डेटा LSOA स्तर पर प्रकाशित होता है — लगभग 1,500 निवासियों वाले छोटे पड़ोस क्षेत्र. एक ही LSOA के सभी पोस्टकोड को समान वार्षिक संख्याएँ मिलती हैं, इसलिए एक शांत आवासीय सड़क और एक ब्लॉक दूर मुख्य सड़क समान आँकड़े दिखा सकती हैं अगर वे सीमा के एक ही ओर हों. अस्पतालों, विश्वविद्यालय परिसरों या औद्योगिक क्षेत्रों को कवर करने वाले पोस्टकोड में प्रति-व्यक्ति दर असामान्य रूप से ऊँची लग सकती है, क्योंकि वहाँ घटनाएँ सामान्य रूप से दर्ज होती हैं पर कागज़ पर निवासी कम होते हैं.',
faqBehindData4Q: '"2 किमी के भीतर अच्छे स्कूल" का मतलब क्या मेरा बच्चा वहाँ जा सकता है?',
faqBehindData4Q:
'"स्कूल कैचमेंट क्षेत्र" की संख्या का क्या मतलब है — क्या मेरा बच्चा उन स्कूलों में जा सकता है?',
faqBehindData4A:
'नहीं. यह गणना उन सरकारी स्कूलों को खोजती है जिनका अपना पोस्टकोड आपके पोस्टकोड के केंद्र के चारों ओर एक वृत्त के भीतर आता है. प्रवेश क्षेत्र, धार्मिक या चयन मानदंड, भाई-बहन प्राथमिकता और दाखिला नियम मॉडल नहीं किए जाते — पास का अच्छा या उत्कृष्ट स्कूल आपके पते से पहुंच से बाहर भी हो सकता है. क्षेत्रों की तुलना के लिए इस संख्या का उपयोग करें, फिर निर्णय से पहले स्कूल या स्थानीय प्राधिकरण से वास्तविक दाखिले की पुष्टि करें.',
'हर संख्या उन अच्छी या उत्कृष्ट रेटिंग वाले सरकारी स्कूलों की गिनती है जिनका मॉडल किया गया ऐतिहासिक कैचमेंट क्षेत्र इस पोस्टकोड को कवर करता है. हम अनुकरण करते हैं कि इंग्लैंड के दूरी-आधारित दाखिले सीटें कैसे बाँटते हैं: बच्चे (जनगणना 2021) दूरी और Ofsted रेटिंग को तौलते हुए पास के स्कूलों में आवेदन करते हैं, और ज़्यादा माँग वाला स्कूल भरने तक सबसे नज़दीकी आवेदकों को लेता है — उसका कैचमेंट दायरा आखिरी दाखिल बच्चे की दूरी है, वही "आखिरी दाखिला दूरी" जो परिषदें प्रकाशित करती हैं. मॉडल ऐसी सैकड़ों प्रकाशित दूरियों पर कैलिब्रेट किया गया है. यह अनुमान है कि कहाँ सीट मिलना संभव है; यह आधिकारिक दाखिला क्षेत्र नहीं है. धार्मिक या चयनात्मक दाखिले, भाई-बहन प्राथमिकता और सीमाओं में वार्षिक बदलाव मॉडल नहीं किए जाते, इसलिए निर्णय से पहले कैचमेंट और दाखिला नियमों की पुष्टि हमेशा स्कूल या स्थानीय प्राधिकरण से करें.',
faqBehindData5Q: 'जब हर घर में फ़ाइबर नहीं है, तो पोस्टकोड "Gigabit" क्यों दिखाता है?',
faqBehindData5A:
'Ofcom Connected Nations का ब्रॉडबैंड कवरेज प्रति पोस्टकोड इस प्रतिशत के रूप में दिया जाता है कि कितने परिसर हर गति स्तर पा सकते हैं. हम किसी भी उपलब्धता वाला सर्वोच्च स्तर दिखाते हैं, इसलिए जिस पोस्टकोड में सिर्फ एक घर Gigabit पा सकता है वह "Gigabit उपलब्ध" दिखाता है. "क्या इस सड़क पर कहीं भी फुल-फ़ाइबर है?" के लिए यह सही उत्तर है, पर इससे यह गारंटी नहीं मिलती कि ब्लॉक के हर फ्लैट में आज सेवा लगवाई जा सकती है. अनुबंध करने से पहले अपने सटीक पते के लिए हमेशा प्रदाताओं से जाँच करें.',
@ -1390,21 +1450,17 @@ const hi: Translations = {
'Current energy rating': 'मौजूदा ऊर्जा रेटिंग',
'Potential energy rating': 'संभावित ऊर्जा रेटिंग',
'Interior height (m)': 'भीतरी ऊँचाई (मी)',
'Street tree density percentile': 'सड़क वृक्ष घनत्व प्रतिशतक',
'Tree canopy density percentile': 'वृक्ष आच्छादन घनत्व प्रतिशतक',
'Within conservation area': 'संरक्षण क्षेत्र में',
'Listed building': 'सूचीबद्ध भवन',
'Travel time to nearest train or tube station (min)':
'निकटतम ट्रेन या ट्यूब स्टेशन तक यात्रा समय (मिनट)',
'Good+ primary schools within 2km': '2 किमी के अंदर अच्छी या बेहतर रेटिंग वाले प्राथमिक स्कूल',
'Good+ secondary schools within 2km':
'2 किमी के अंदर अच्छी या बेहतर रेटिंग वाले सेकेंडरी स्कूल',
'Good+ primary schools within 5km': '5 किमी के अंदर अच्छी या बेहतर रेटिंग वाले प्राथमिक स्कूल',
'Good+ secondary schools within 5km':
'5 किमी के अंदर अच्छी या बेहतर रेटिंग वाले सेकेंडरी स्कूल',
'Outstanding primary schools within 2km': '2 किमी के अंदर उत्कृष्ट प्राथमिक स्कूल',
'Outstanding secondary schools within 2km': '2 किमी के अंदर उत्कृष्ट सेकेंडरी स्कूल',
'Outstanding primary schools within 5km': '5 किमी के अंदर उत्कृष्ट प्राथमिक स्कूल',
'Outstanding secondary schools within 5km': '5 किमी के अंदर उत्कृष्ट सेकेंडरी स्कूल',
'Good+ primary school catchments':
'अच्छी या बेहतर रेटिंग वाले प्राथमिक स्कूलों के कैचमेंट क्षेत्र',
'Good+ secondary school catchments':
'अच्छी या बेहतर रेटिंग वाले सेकेंडरी स्कूलों के कैचमेंट क्षेत्र',
'Outstanding primary school catchments': 'उत्कृष्ट प्राथमिक स्कूलों के कैचमेंट क्षेत्र',
'Outstanding secondary school catchments': 'उत्कृष्ट सेकेंडरी स्कूलों के कैचमेंट क्षेत्र',
'Education, Skills and Training Score': 'शिक्षा, कौशल और प्रशिक्षण स्कोर',
'Income Score': 'आय स्कोर',
'Employment Score': 'रोजगार स्कोर',
@ -1431,7 +1487,8 @@ const hi: Translations = {
'% White': '% श्वेत',
'% South Asian': '% दक्षिण एशियाई',
'% Black': '% अश्वेत',
'% East/SE Asian': '% पूर्वी/दक्षिण-पूर्वी एशियाई',
'% East Asian': '% पूर्वी एशियाई',
'% SE Asian': '% दक्षिण-पूर्वी एशियाई',
'% Mixed': '% मिश्रित',
'% Other': '% अन्य',
'Voter turnout (%)': 'मतदाता भागीदारी (%)',
@ -1486,6 +1543,7 @@ const hi: Translations = {
'Bus station': 'बस स्टेशन',
'Taxi rank': 'टैक्सी स्टैंड',
'Tube station': 'ट्यूब स्टेशन',
'Tram & Metro stop': 'ट्राम और मेट्रो स्टॉप',
Café: 'कैफे',
Restaurant: 'रेस्तरां',
Pub: 'पब',
@ -1532,7 +1590,8 @@ const hi: Translations = {
'GP Surgery': 'GP क्लिनिक',
Dentist: 'दंत चिकित्सक',
Pharmacy: 'दवा दुकान',
'Hospital & Clinic': 'अस्पताल और क्लिनिक',
Hospital: 'अस्पताल',
Clinic: 'क्लिनिक',
Optician: 'ऑप्टिशियन',
Physiotherapy: 'फिजियोथेरेपी',
'Counselling & Therapy': 'काउंसलिंग और थेरेपी',

View file

@ -613,6 +613,8 @@ const hu: Translations = {
pleaseWait: 'Egy pillanat...',
sendResetLink: 'Visszaállító hivatkozás küldése',
backToLogin: 'Vissza a bejelentkezéshez',
registerConsent:
'A fiók létrehozásával elfogadod a <terms>Felhasználási feltételeket</terms> és az <privacy>Adatvédelmi tájékoztatót</privacy>.',
},
// ── Upgrade Modal ──────────────────────────────────
@ -669,7 +671,7 @@ const hu: Translations = {
findingPerfectPostcode: 'Tökéletes irányítószám keresése',
addFiltersHint: 'Adj hozzá szűrőket a térkép szűkítéséhez a feltételeidnek megfelelően',
upgradePrompt:
'Találj megfelelő irányítószámokat bűnözés, iskolák, zaj, szélessáv, árak és több mint 50 további szűrő alapján egész Angliában.',
'Találj megfelelő irányítószámokat bűnözés, iskolák, zaj, szélessáv, árak és több mint 40 kombinálható szűrő alapján egész Angliában.',
oneTimeLifetime: 'Egyszeri fizetés, élethosszig tartó hozzáférés.',
upgradeToFullMap: 'Teljes térkép megnyitása',
chooseFilters:
@ -701,7 +703,6 @@ const hu: Translations = {
filtersOut: '{{value}} helyet kiszűr',
schoolType: 'Iskolatípus',
schoolRating: 'Iskolai értékelés',
schoolDistance: 'Iskolatávolság',
primary: 'Általános iskola',
secondary: 'Középiskola',
rating: 'Értékelés',
@ -944,7 +945,7 @@ const hu: Translations = {
showcaseFeatureNoiseShort: 'Zaj',
showcaseFeatureSchoolsShort: 'Iskolák',
showcaseFeatureTravelShort: 'Utazás',
showcaseGoodPrimariesNearby: '{{count}}+ jó általános iskola a közelben',
showcaseGoodPrimariesNearby: '{{count}}+ jó általános iskola körzetében',
showcaseWithinRail: 'Vasút {{count}} percen belül',
showcaseMatchingHomesLabel: 'Megfelelő otthonok',
showcaseMatchingHomes: '{{value}} megfelelő otthon',
@ -1005,6 +1006,12 @@ const hu: Translations = {
statFilters: 'kombinálható szűrő',
statEvery: 'Minden',
statPostcodeInEngland: 'irányítószám Angliában',
coverageNote:
'Anglia összes irányítószámát lefedi — egyenként 200+ adatmezővel. Skócia és Wales a terveink között szerepel.',
priceStrip: 'Örökös hozzáférés, jelenleg {{price}} — az ár a szintek betelésével emelkedik.',
priceStripSpots: 'Már csak {{count}} hely ezen az áron.',
priceStripSpotsPlural: 'Már csak {{count}} hely ezen az áron.',
priceStripCta: 'Árak megtekintése',
ourPhilosophy: 'Indulj ki abból, ami számít, majd találd meg a megfelelő irányítószámot',
philosophyP1:
'A legtöbb ingatlanoldal először azt kérdezi, hol szeretnél élni. Londonban ez különösen nehéz, de ugyanez a probléma egész Angliában megjelenik: a vevők néhány ismert helyből indulnak ki, majd külön füleken ellenőrzik az ingázást, iskolákat, bűnözést, Street View-t, internetet és eladási árakat.',
@ -1028,7 +1035,7 @@ const hu: Translations = {
compAreaDataSub: '(bűnözés, iskolák, zaj, internet, szolgáltatások)',
compPropertyData: 'Ingatlanszintű előzmények',
compPropertyDataSub: '(eladási árak, EPC, alapterület, becsült érték)',
compFilters: '56 együtt működő szűrő',
compFilters: '40+ együtt működő szűrő',
compFiltersSub: '(nem egyenkénti irányítószám- vagy hirdetésellenőrzés)',
ctaTitle: 'Ne találgasd, hol vegyél.',
ctaDescription:
@ -1036,14 +1043,34 @@ const hu: Translations = {
},
// ── Pricing Page ───────────────────────────────────
// ── Footer ─────────────────────────────────────────
footer: {
tagline: 'Találd meg az életedhez illő irányítószámokat. Adatalapú környékkutatás Angliához.',
product: 'Termék',
resources: 'Források',
legal: 'Jogi információk',
dataSources: 'Adatforrások',
methodology: 'Módszertan',
contact: 'Kapcsolatfelvétel',
terms: 'Felhasználási feltételek',
privacy: 'Adatvédelmi tájékoztató',
copyright: '© {{year}} Perfect Postcode. Minden jog fenntartva.',
coverage: 'Anglia összes irányítószámát lefedi.',
},
// ── Legal pages ────────────────────────────────────
legal: {
lastUpdated: 'Utolsó frissítés: {{date}}',
englishOnly: 'Ez a dokumentum angol nyelven készült; az angol változat az irányadó.',
},
pricingPage: {
title: 'Vásárolj jobb keresési területből kiindulva',
subtitle:
'Élethosszig tartó hozzáférés a térképhez, amely segít eldönteni, hol érdemes keresni, mielőtt megtekintéseket foglalnál.',
costContext:
'A vevők gyakran estéket töltenek hirdetések, ingázási ellenőrzések, iskolai jelentések, bűnözési térképek, Street View és eladási árak összeillesztésével. Londonban ez kimerítő, de ugyanez a kutatási probléma egész Angliában megjelenik. A Perfect Postcode egy térképre teszi a területkutatást, mielőtt a hétvégéidet, díjaidat és figyelmedet rászánnád.',
lessThanSurvey:
'Kevesebb, mint egy műszaki felmérés. Sokkal többet segít a döntések irányításában.',
lessThanSurvey: 'Egy műszaki felmérés árának töredékéért — egy sokkal nagyobb döntéshez.',
currentTier: 'Jelenlegi szint',
firstNUsers: 'Első {{count}} felhasználó',
everyoneAfter: 'Mindenki más utána',
@ -1056,11 +1083,12 @@ const hu: Translations = {
getStarted: 'Kezdjük el',
getStartedPrice: 'Kezdjük el {{price}}',
noCreditCard: 'Nem szükséges bankkártya',
moneyBack: '14 napos pénzvisszafizetési garancia.',
soldOut: 'Elfogyott',
upcoming: 'Következő',
failedToLoad: 'Nem sikerült betölteni az árakat. Kérjük, próbáld újra később.',
feat1: '56 szűrő egész Angliában',
feat1: '40+ szűrő és 200+ adatmező',
feat2: 'Minden irányítószám kereshető a saját igényeid alapján',
feat3: 'Korlátlan térképfelfedezés, mentett keresések és exportálás',
feat4: '13 millió korábbi tranzakció és árkörnyezet',
@ -1081,6 +1109,34 @@ const hu: Translations = {
articlesIntro:
'Böngészd a nyilvános útmutatókat ingatlankeresésről, ingázásról, iskolákról, irányítószám-ellenőrzésről, regionális összehasonlításokról, adatlefedettségről, módszertanról és adatvédelemről.',
supportIntro: 'Kérdésed van? Nézd meg a GYIK-et, vagy írj nekünk közvetlenül.',
videos: 'Videók',
videosTitle: 'Közösségimédia-videók',
videosIntro:
'Rövid klipek a közösségi csatornáinkról mindegyik egy-egy keresést mutat be működés közben, a csendes utcáktól az iskolai körzeteken át az utazási időkig.',
video01Title: 'Egy mondat, minden irányítószám',
video01Desc:
'Írd le a teljes lakáskeresési igényedet hétköznapi nyelven, és nézd, ahogy Anglia minden illeszkedő irányítószáma felgyúl.',
video02Title: 'A 20 perces térkép',
video02Desc:
'Színezd a térképet utazási idő szerint, és lásd pontosan, mit hagy neked valójában 20 perc London központjától.',
video03Title: 'Minden irányítószámnak van aktája',
video03Desc:
'Koppints bármelyik irányítószámra, és olvasd el az aktáját eladási árak, iskolák, bűnözés és Street View egy helyen.',
video04Title: 'Egy fotót nem lehet meghallani',
video04Desc:
'A hirdetésfotók némák. Szűrj zajszint szerint, és találd meg a valóban csendes, 55 decibel alatti utcákat.',
video05Title: 'Az iskolába vezető út térképe',
video05Desc:
'Jó általános iskolai körzetek, alacsony bűnözés és egy költségvetés a családi igény, egy egész városra térképezve.',
video06Title: 'A Waitrose-teszt',
video06Desc:
'Sétatávolságra egy szupermarkettől, egy metrómegállótól és egy parktól az életedre szűrj, ne csak az alaprajzra.',
video07Title: 'A bérlőknek is jár térkép',
video07Desc:
'Költségvetésbe férő bérleti díj, rövid ingázás és csendes utca a bérleti oldalak lakásokat mutatnak, ez környékeket.',
video08Title: '9,99 £ egy elpazarolt szombat ellen',
video08Desc:
'Egy rossz megtekintés egy vonatjegybe és egy fél hétvégébe kerül. Még foglalás előtt lásd, hova ne menj.',
source: 'Forrás:',
optOut: 'Nyilvános közzététel visszautasítása',
attribution: 'Forrásmegnevezés',
@ -1113,7 +1169,7 @@ const hu: Translations = {
dsEthnicityName: 'Népesség etnikai megoszlás szerint (2021-es népszámlálás)',
dsEthnicityOrigin: 'ONS',
dsEthnicityUse:
'Népesség százalékos megoszlása etnikai csoportonként (dél-ázsiai, kelet-ázsiai, fekete, vegyes, fehér, egyéb) helyi önkormányzatonként.',
'Népesség százalékos megoszlása etnikai csoportonként (dél-ázsiai, kelet-ázsiai, délkelet-ázsiai, fekete, vegyes, fehér, egyéb) helyi önkormányzatonként.',
dsCrimeName: 'Utcaszintű bűnözési adatok',
dsCrimeOrigin: 'data.police.uk',
dsCrimeUse:
@ -1300,9 +1356,9 @@ const hu: Translations = {
faqBehindData3A:
'A rendőrségi utcaszintű bűnözési adatok LSOA-szinten kerülnek közzétételre — ezek kb. 1500 lakosú kis környékek. Az ugyanazon LSOA-ban lévő minden irányítószám ugyanazokat az éves számokat kapja, így egy csendes lakóutca és egy egy háztömbnyire lévő főutca azonos értékeket mutathat, ha ugyanazon az oldalon vannak a határnak. Az egy főre jutó ráta szokatlanul magasnak tűnhet kórházakat, egyetemi kampuszokat vagy ipari területeket lefedő irányítószámoknál, mert ott normál mennyiségű incidens történik, de papíron kevés a lakos.',
faqBehindData4Q:
'A „2 km-en belüli Jó iskolák” azt jelenti, hogy oda be is iratkozhat a gyerekem?',
'Mit jelent az „iskolai körzetek” száma — beiratkozhat a gyerekem ezekbe az iskolákba?',
faqBehindData4A:
'Nem. A számláló azokat az állami iskolákat keresi, amelyek saját irányítószáma az irányítószámod középpontja körüli körben van. A körzethatárokat, vallási vagy felvételi kritériumokat, testvérprioritást és felvételi szabályokat nem modellezzük — egy közeli Jó vagy Kiváló iskola lehet, hogy a te címedről mégsem elérhető. Használd a számot területek összehasonlítására, majd a tényleges felvételi feltételeket egyeztesd az iskolával vagy az önkormányzattal, mielőtt erre alapoznál.',
'Mindegyik szám azt mutatja, hány Jó vagy Kiváló minősítésű állami iskola modellezett történeti körzete fedi le az irányítószámot. Azt szimuláljuk, ahogyan az angol távolságalapú felvételi elosztja a helyeket: a gyerekek (2021-es népszámlálás) a közeli iskolákba jelentkeznek, mérlegelve a távolságot és az Ofsted-minősítést, a túljelentkezéses iskola pedig a legközelebbi jelentkezőket veszi fel, amíg meg nem telik — körzetének sugara az utolsóként felvett gyerek távolsága, pontosan az önkormányzatok által közzétett „utolsó felvett távolság”. A modellt több száz ilyen közzétett értékhez kalibráltuk. Azt becsüli, hol kapható életszerűen hely; nem hivatalos felvételi körzet. A vallási és szelektív felvételit, a testvérprioritást és az évenkénti határváltozásokat nem modellezzük, ezért a körzeteket és a felvételi szabályokat mindig egyeztesd az iskolával vagy az önkormányzattal, mielőtt döntést alapoznál rájuk.',
faqBehindData5Q: 'Miért mutat „Gigabit”-et egy irányítószám, ha nem minden otthon kapja?',
faqBehindData5A:
'Az Ofcom Connected Nations szélessáv-lefedettsége irányítószámonként az egyes sebességszinteket elérni képes ingatlanok százalékát adja meg. Mi a legmagasabb elérhető szintet jelenítjük meg, így ha akár egyetlen otthon eléri a gigabites sebességet, az irányítószám „Gigabit elérhető”-ként jelenik meg. Ez jól válaszol arra, hogy „van-e egyáltalán optikai internet ezen az utcán?”, de nem garantálja, hogy a tömbben minden lakásba megrendelhető. Mindig ellenőrizd a szolgáltatóknál a saját címedre vonatkozóan, mielőtt szerződnél.',
@ -1462,7 +1518,7 @@ const hu: Translations = {
'Current energy rating': 'Jelenlegi energetikai besorolás',
'Potential energy rating': 'Lehetséges energetikai besorolás',
'Interior height (m)': 'Belmagasság (m)',
'Street tree density percentile': 'Utcai fasűrűségi percentilis',
'Tree canopy density percentile': 'Lombkorona-sűrűségi percentilis',
'Within conservation area': 'Műemlékvédelmi területen',
'Listed building': 'Műemlék épület',
@ -1471,14 +1527,10 @@ const hu: Translations = {
'Utazási idő a legközelebbi vonat- vagy metróállomásig (perc)',
// ─ Feature names (Education) ─
'Good+ primary schools within 2km': 'Jó+ általános iskolák 2 km-en belül',
'Good+ secondary schools within 2km': 'Jó+ középiskolák 2 km-en belül',
'Good+ primary schools within 5km': 'Jó+ általános iskolák 5 km-en belül',
'Good+ secondary schools within 5km': 'Jó+ középiskolák 5 km-en belül',
'Outstanding primary schools within 2km': 'Kiváló általános iskolák 2 km-en belül',
'Outstanding secondary schools within 2km': 'Kiváló középiskolák 2 km-en belül',
'Outstanding primary schools within 5km': 'Kiváló általános iskolák 5 km-en belül',
'Outstanding secondary schools within 5km': 'Kiváló középiskolák 5 km-en belül',
'Good+ primary school catchments': 'Jó+ általános iskolai körzetek',
'Good+ secondary school catchments': 'Jó+ középiskolai körzetek',
'Outstanding primary school catchments': 'Kiváló általános iskolai körzetek',
'Outstanding secondary school catchments': 'Kiváló középiskolai körzetek',
'Education, Skills and Training Score': 'Oktatás, készségek és képzés pontszám',
// ─ Feature names (Area development) ─
@ -1511,7 +1563,8 @@ const hu: Translations = {
'% White': '% fehér',
'% South Asian': '% dél-ázsiai',
'% Black': '% fekete',
'% East/SE Asian': '% kelet-/dél-kelet-ázsiai',
'% East Asian': '% kelet-ázsiai',
'% SE Asian': '% délkelet-ázsiai',
'% Mixed': '% vegyes',
'% Other': '% egyéb',
@ -1578,7 +1631,8 @@ const hu: Translations = {
'Bus stop': 'Buszmegálló',
'Bus station': 'Buszpályaudvar',
'Taxi rank': 'Taxiállomás',
'Tube station': 'Metróállomás',
'Tube station': 'Londoni metróállomás',
'Tram & Metro stop': 'Villamos- és metrómegálló',
Café: 'Kávézó',
Restaurant: 'Étterem',
Pub: 'Kocsma',
@ -1625,7 +1679,8 @@ const hu: Translations = {
'GP Surgery': 'Háziorvosi rendelő',
Dentist: 'Fogorvos',
Pharmacy: 'Gyógyszertár',
'Hospital & Clinic': 'Kórház és klinika',
Hospital: 'Kórház',
Clinic: 'Klinika',
Optician: 'Optikus',
Physiotherapy: 'Fizioterápia',
'Counselling & Therapy': 'Tanácsadás és terápia',

View file

@ -561,6 +561,8 @@ const zh: Translations = {
pleaseWait: '请稍候...',
sendResetLink: '发送重置链接',
backToLogin: '返回登录',
registerConsent:
'创建账户即表示您同意我们的<terms>服务条款</terms>和<privacy>隐私政策</privacy>。',
},
// ── Upgrade Modal ──────────────────────────────────
@ -617,7 +619,7 @@ const zh: Translations = {
findingPerfectPostcode: '正在寻找理想邮编',
addFiltersHint: '添加以下筛选条件,将地图缩小到符合您要求的区域',
upgradePrompt:
'用治安、学校、噪音、宽带、价格和 50 多项其他筛选条件,在整个英格兰找到匹配的邮编。',
'用治安、学校、噪音、宽带、价格等 40 多项联动筛选条件,在整个英格兰找到匹配的邮编。',
oneTimeLifetime: '一次性付款,终身访问。',
upgradeToFullMap: '升级到完整地图',
chooseFilters: '点击“添加”来筛选。小按钮可查看数据说明或给地图着色。',
@ -647,7 +649,6 @@ const zh: Translations = {
filtersOut: '会筛掉 {{value}}',
schoolType: '学校类型',
schoolRating: '学校评级',
schoolDistance: '学校距离',
primary: '小学',
secondary: '中学',
rating: '评级',
@ -886,7 +887,7 @@ const zh: Translations = {
showcaseFeatureNoiseShort: '噪音',
showcaseFeatureSchoolsShort: '学校',
showcaseFeatureTravelShort: '出行',
showcaseGoodPrimariesNearby: '附近 {{count}}+ 所良好及以上小学',
showcaseGoodPrimariesNearby: '位于 {{count}}+ 个良好及以上小学学区内',
showcaseWithinRail: '到火车/地铁站 {{count}} 分钟内',
showcaseMatchingHomesLabel: '匹配房源',
showcaseMatchingHomes: '{{value}} 套匹配房源',
@ -945,6 +946,11 @@ const zh: Translations = {
statFilters: '可组合筛选条件',
statEvery: '覆盖',
statPostcodeInEngland: '英格兰每个邮编',
coverageNote: '覆盖英格兰所有邮编——每个邮编 200 多个数据字段。苏格兰和威尔士已在计划中。',
priceStrip: '终身使用权,当前价格 {{price}}——名额售出后价格将上调。',
priceStripSpots: '此价格仅剩 {{count}} 个名额。',
priceStripSpotsPlural: '此价格仅剩 {{count}} 个名额。',
priceStripCta: '查看价格',
ourPhilosophy: '先明确重要条件,再找到合适的邮编',
philosophyP1:
'大多数房源网站一上来就问:您想住哪儿?在伦敦尤其难答,英格兰其他地方也是一样。买家通常只能从几个熟悉的地方入手,再分别去查通勤、学校、治安、街景、宽带和成交价。',
@ -967,19 +973,40 @@ const zh: Translations = {
compAreaDataSub: '(治安、学校、噪音、宽带、周边设施)',
compPropertyData: '房产级的历史记录',
compPropertyDataSub: '成交价、EPC、室内面积、估值',
compFilters: '56 项联动筛选',
compFilters: '40+ 项联动筛选',
compFiltersSub: '(不必一次只查一个邮编或一套房源)',
ctaTitle: '别再猜哪里值得买。',
ctaDescription: '先按真实生活需求建好邮编候选名单,再去实地感受。',
},
// ── Pricing Page ───────────────────────────────────
// ── Footer ─────────────────────────────────────────
footer: {
tagline: '找到适合您生活的邮编。基于数据的英格兰社区调研。',
product: '产品',
resources: '资源',
legal: '法律',
dataSources: '数据来源',
methodology: '方法论',
contact: '联系客服',
terms: '服务条款',
privacy: '隐私政策',
copyright: '© {{year}} Perfect Postcode。保留所有权利。',
coverage: '覆盖英格兰所有邮编。',
},
// ── Legal pages ────────────────────────────────────
legal: {
lastUpdated: '最后更新:{{date}}',
englishOnly: '本文件以英文提供,以英文版本为准。',
},
pricingPage: {
title: '用更靠谱的搜索范围去买房',
subtitle: '终身访问这张地图,预约看房前先弄清楚该去哪儿看。',
costContext:
'买家常常把晚上耗在拼凑房源、通勤查询、学校报告、治安地图、Street View 和成交价上。在伦敦这尤其费力但同样的研究困境存在于整个英格兰。Perfect Postcode 先把区域研究汇到一张地图上,再让您决定把周末、费用和精力投向哪里。',
lessThanSurvey: '费用低于一次验房,却能更早影响您的选择。',
lessThanSurvey: '不到一次验房费用的十分之一,却能影响更重大的决定。',
currentTier: '当前档位',
firstNUsers: '前 {{count}} 名用户',
everyoneAfter: '之后的所有人',
@ -992,11 +1019,12 @@ const zh: Translations = {
getStarted: '立即开始',
getStartedPrice: '立即开始:{{price}}',
noCreditCard: '无需信用卡',
moneyBack: '14 天退款保证。',
soldOut: '已售罄',
upcoming: '即将开放',
failedToLoad: '加载价格信息失败,请稍后重试。',
feat1: '覆盖英格兰的 56 项筛选条件',
feat1: '40+ 项筛选条件和 200+ 个数据字段',
feat2: '从您的需求出发搜索每个邮编',
feat3: '无限地图探索、保存搜索和导出',
feat4: '1300 万笔历史交易和价格背景',
@ -1017,6 +1045,26 @@ const zh: Translations = {
articlesIntro:
'浏览关于找房、通勤、学校、邮编速查、区域对比、数据覆盖、方法论和隐私的公开指南。',
supportIntro: '还有疑问?欢迎查看常见问题,或直接与我们联系。',
videos: '视频',
videosTitle: '社交媒体视频',
videosIntro:
'来自我们社交平台的短片——每一个都展示一次实际搜索,从安静街道到学校学区,再到通勤时间。',
video01Title: '一句话,每个邮编',
video01Desc: '用日常语言写下你的全部购房需求,看着英格兰每个符合条件的邮编亮起来。',
video02Title: '20 分钟地图',
video02Desc: '按通勤时间为地图着色,看清从伦敦市中心 20 分钟究竟能到达哪里。',
video03Title: '每个邮编都有一份档案',
video03Desc: '点按任意邮编即可查看其档案——成交价、学校、犯罪和街景,尽在一处。',
video04Title: '照片听不见声音',
video04Desc: '房源照片是无声的。按噪音水平筛选,找到真正安静、低于 55 分贝的街道。',
video05Title: '上学路线地图',
video05Desc: '优质小学学区、低犯罪率和预算——把家庭需求映射到整座城市。',
video06Title: 'Waitrose 测试',
video06Desc: '步行可达超市、地铁站和公园——按你的生活方式筛选,而不只是户型图。',
video07Title: '租房者也有地图',
video07Desc: '预算内的租金、短通勤和安静街道——租房网站展示房源,这里为你展示区域。',
video08Title: '£9.99 对比浪费的周六',
video08Desc: '一次糟糕的看房要花一张车票和半个周末。在预约之前就看清哪里不该去。',
source: '来源:',
optOut: '选择不公开',
attribution: '数据引用声明',
@ -1046,7 +1094,7 @@ const zh: Translations = {
dsEthnicityName: '按族裔划分的人口2021 年人口普查)',
dsEthnicityOrigin: 'ONS',
dsEthnicityUse:
'按族裔群体(南亚裔、东亚裔、黑人、混血、白人、其他)划分的各地方政府辖区人口百分比。',
'按族裔群体(南亚裔、东亚裔、东南亚裔、黑人、混血、白人、其他)划分的各地方政府辖区人口百分比。',
dsCrimeName: '街道级犯罪数据',
dsCrimeOrigin: 'data.police.uk',
dsCrimeUse:
@ -1226,9 +1274,9 @@ const zh: Translations = {
faqBehindData3Q: '为什么相邻邮编的犯罪数字相同?',
faqBehindData3A:
'警方街道级犯罪数据按 LSOA 发布,即大约 1,500 名居民的小型社区单元。同一 LSOA 内每个邮编都继承同一年度总数,因此一条安静的住宅街和一个街区外的繁华街道,如果在同一边界内,可能显示完全相同的数据。覆盖医院、大学校园或工业园区的邮编,人均率可能异常偏高,因为那里事件数正常但登记居民很少。',
faqBehindData4Q: '“2 公里内的好学校”是否意味着我孩子可以入读',
faqBehindData4Q: '“学区数量”是什么意思——我的孩子能入读这些学校吗',
faqBehindData4A:
'不一定。统计查找的是自身邮编落在您邮编中心点周围圆形范围内的公立学校。招生范围、宗教或选拔标准、兄弟姐妹优先以及录取规则都没有建模。附近的“良好”或“优秀”学校,您家未必实际有资格申请。请用此数字对比区域,决策前向学校或地方政府确认实际录取条件。',
'每个数字表示有多少所评级为“良好”或“优秀”的公立学校其建模的历史学区覆盖该邮编。我们模拟英格兰按距离录取的实际分配过程儿童2021 年人口普查)在距离与 Ofsted 评级之间权衡后向附近学校申请,报名超额的学校按距离由近及远录取直至满额——其学区半径就是最后一名被录取儿童的距离,即各地方政府公布的“最远录取距离”。模型已用数百个此类公布距离进行校准。它估计的是在哪里大概率能获得学位,并非官方招生范围。宗教或选拔性录取、兄弟姐妹优先以及每年的边界变化均未建模,因此在做决定前,请务必向学校或地方政府确认学区和录取规则。',
faqBehindData5Q: '为什么并非每户都有光纤的邮编也显示“Gigabit”',
faqBehindData5A:
'Ofcom Connected Nations 的宽带覆盖按邮编给出可达到每个速度档的房产百分比。我们显示有任何可用性的最高档,因此只要邮编内有一户能达到 Gigabit就会显示“Gigabit 可用”。这回答的是“这条街上到底有没有光纤?”,但并不保证楼里每一套今天都能下单。签约前,请始终就您的具体地址向运营商核实。',
@ -1382,7 +1430,7 @@ const zh: Translations = {
'Current energy rating': '当前能源评级',
'Potential energy rating': '潜在能源评级',
'Interior height (m)': '室内层高(米)',
'Street tree density percentile': '街道树木覆盖率百分位',
'Tree canopy density percentile': '树冠覆盖密度百分位',
'Within conservation area': '位于保护区内',
'Listed building': '登录建筑',
@ -1390,14 +1438,10 @@ const zh: Translations = {
'Travel time to nearest train or tube station (min)': '到最近火车或地铁站的出行时间(分钟)',
// ─ Feature names (Education) ─
'Good+ primary schools within 2km': '2 公里内良好及以上小学数量',
'Good+ secondary schools within 2km': '2 公里内良好及以上中学数量',
'Good+ primary schools within 5km': '5 公里内良好及以上小学数量',
'Good+ secondary schools within 5km': '5 公里内良好及以上中学数量',
'Outstanding primary schools within 2km': '2 公里内优秀小学数量',
'Outstanding secondary schools within 2km': '2 公里内优秀中学数量',
'Outstanding primary schools within 5km': '5 公里内优秀小学数量',
'Outstanding secondary schools within 5km': '5 公里内优秀中学数量',
'Good+ primary school catchments': '良好及以上小学学区数量',
'Good+ secondary school catchments': '良好及以上中学学区数量',
'Outstanding primary school catchments': '优秀小学学区数量',
'Outstanding secondary school catchments': '优秀中学学区数量',
'Education, Skills and Training Score': '教育、技能与培训得分',
// ─ Feature names (Area development) ─
@ -1430,7 +1474,8 @@ const zh: Translations = {
'% White': '% 白人',
'% South Asian': '% 南亚裔',
'% Black': '% 黑人',
'% East/SE Asian': '% 东亚/东南亚裔',
'% East Asian': '% 东亚裔',
'% SE Asian': '% 东南亚裔',
'% Mixed': '% 混血',
'% Other': '% 其他',
@ -1497,7 +1542,8 @@ const zh: Translations = {
'Bus stop': '公交站',
'Bus station': '公交枢纽',
'Taxi rank': '出租车站',
'Tube station': '地铁站',
'Tube station': '伦敦地铁站',
'Tram & Metro stop': '有轨电车与城市轨道站',
Café: '咖啡馆',
Restaurant: '餐厅',
Pub: '酒吧',
@ -1544,7 +1590,8 @@ const zh: Translations = {
'GP Surgery': '全科诊所',
Dentist: '牙科',
Pharmacy: '药房',
'Hospital & Clinic': '医院与诊所',
Hospital: '医院',
Clinic: '诊所',
Optician: '眼镜店',
Physiotherapy: '理疗',
'Counselling & Therapy': '心理咨询与治疗',

View file

@ -82,18 +82,18 @@ describe('api utilities', () => {
it('deduplicates repeated synthetic school filters before backend routes', () => {
const features: FeatureMeta[] = [
{ name: 'Good+ primary schools within 2km', type: 'numeric', min: 0, max: 10 },
{ name: 'Good+ primary school catchments', type: 'numeric', min: 0, max: 10 },
];
expect(
buildFilterString(
{
[createSchoolFilterKey('primary', 'good', 2, 1)]: [1, 10],
[createSchoolFilterKey('primary', 'good', 2, 2)]: [2, 8],
[createSchoolFilterKey('primary', 'good', 1)]: [1, 10],
[createSchoolFilterKey('primary', 'good', 2)]: [2, 8],
},
features
)
).toBe('Good+ primary schools within 2km:2:8');
).toBe('Good+ primary school catchments:2:8');
});
it('serializes specific crime filters using their selected backend crime feature', () => {

View file

@ -267,7 +267,15 @@ export const STACKED_GROUPS: Record<
{
label: 'Ethnic composition',
unit: '%',
components: ['% White', '% South Asian', '% East/SE Asian', '% Black', '% Mixed', '% Other'],
components: [
'% White',
'% South Asian',
'% East Asian',
'% SE Asian',
'% Black',
'% Mixed',
'% Other',
],
},
{
label: 'Political vote share',
@ -444,7 +452,8 @@ export const STACKED_SEGMENT_COLORS: Record<string, string> = {
'Other crime (avg/yr)': '#6b7280',
'% White': '#3b82f6',
'% South Asian': '#f97316',
'% East/SE Asian': '#eab308',
'% East Asian': '#eab308',
'% SE Asian': '#ec4899',
'% Black': '#8b5cf6',
'% Mixed': '#14b8a6',
'% Other': '#6b7280',

View file

@ -6,7 +6,8 @@ export const ETHNICITIES_FILTER_KEY_PREFIX = `${ETHNICITIES_FILTER_NAME}:`;
export const ETHNICITY_FEATURE_NAMES = [
'% White',
'% South Asian',
'% East/SE Asian',
'% East Asian',
'% SE Asian',
'% Black',
'% Mixed',
'% Other',

View file

@ -102,7 +102,7 @@ const FEATURE_ICON_PATHS: Record<string, ReactNode> = {
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2" />
</>
),
'Street tree density percentile': (
'Tree canopy density percentile': (
<>
<path d="M12 22V12" />
<path d="M6 22h12" />
@ -146,53 +146,27 @@ const FEATURE_ICON_PATHS: Record<string, ReactNode> = {
<path d="M2 3h6a4 4 0 014 4 4 4 0 014-4h6v18a2 2 0 01-2 2h-4a4 4 0 00-4 4 4 4 0 00-4-4H4a2 2 0 01-2-2z" />
</>
),
'Good+ primary schools within 5km': (
'Good+ primary school catchments': (
<>
<path d="M4 19V9l8-6 8 6v10" />
<path d="M9 19v-6h6v6" />
<line x1="4" y1="19" x2="20" y2="19" />
</>
),
'Good+ secondary schools within 5km': (
'Good+ secondary school catchments': (
<>
<path d="M22 10v6M2 10l10-5 10 5-10 5z" />
<path d="M6 12v5c0 2.5 3 4 6 4s6-1.5 6-4v-5" />
</>
),
'Outstanding primary schools within 5km': (
'Outstanding primary school catchments': (
<>
<path d="M4 19V9l8-6 8 6v10" />
<path d="M9 19v-6h6v6" />
<line x1="4" y1="19" x2="20" y2="19" />
</>
),
'Outstanding secondary schools within 5km': (
<>
<path d="M22 10v6M2 10l10-5 10 5-10 5z" />
<path d="M6 12v5c0 2.5 3 4 6 4s6-1.5 6-4v-5" />
</>
),
'Good+ primary schools within 2km': (
<>
<path d="M4 19V9l8-6 8 6v10" />
<path d="M9 19v-6h6v6" />
<line x1="4" y1="19" x2="20" y2="19" />
</>
),
'Good+ secondary schools within 2km': (
<>
<path d="M22 10v6M2 10l10-5 10 5-10 5z" />
<path d="M6 12v5c0 2.5 3 4 6 4s6-1.5 6-4v-5" />
</>
),
'Outstanding primary schools within 2km': (
<>
<path d="M4 19V9l8-6 8 6v10" />
<path d="M9 19v-6h6v6" />
<line x1="4" y1="19" x2="20" y2="19" />
</>
),
'Outstanding secondary schools within 2km': (
'Outstanding secondary school catchments': (
<>
<path d="M22 10v6M2 10l10-5 10 5-10 5z" />
<path d="M6 12v5c0 2.5 3 4 6 4s6-1.5 6-4v-5" />
@ -367,7 +341,15 @@ const FEATURE_ICON_PATHS: Record<string, ReactNode> = {
<path d="M16 3.13a4 4 0 010 7.75" />
</>
),
'% East/SE Asian': (
'% East Asian': (
<>
<path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M23 21v-2a4 4 0 00-3-3.87" />
<path d="M16 3.13a4 4 0 010 7.75" />
</>
),
'% SE Asian': (
<>
<path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2" />
<circle cx="9" cy="7" r="4" />

View file

@ -0,0 +1,35 @@
import { describe, expect, it } from 'vitest';
import { MAP_MIN_ZOOM } from './consts';
import { boundsToCenterZoom } from './fit-bounds';
describe('boundsToCenterZoom', () => {
it('centers on the middle of the box', () => {
const target = boundsToCenterZoom({ south: 51.4, north: 51.6, west: -0.3, east: 0.1 });
expect(target.lat).toBeCloseTo(51.5, 5);
expect(target.lng).toBeCloseTo(-0.1, 5);
});
it('zooms close for a small box and far out for a country-sized box', () => {
const street = boundsToCenterZoom({ south: 51.5, north: 51.51, west: -0.11, east: -0.1 });
const england = boundsToCenterZoom({ south: 50.0, north: 55.5, west: -5.7, east: 1.8 });
expect(street.zoom).toBeGreaterThan(england.zoom);
expect(england.zoom).toBeGreaterThanOrEqual(MAP_MIN_ZOOM);
// Greater London-ish box should land in a sensible city-scale zoom range
const london = boundsToCenterZoom({ south: 51.44, north: 51.59, west: -0.31, east: 0.05 });
expect(london.zoom).toBeGreaterThan(8);
expect(london.zoom).toBeLessThan(12);
});
it('caps zoom-in for degenerate (single point) boxes', () => {
const point = boundsToCenterZoom({ south: 51.5, north: 51.5, west: -0.1, east: -0.1 });
expect(point.zoom).toBeLessThanOrEqual(13);
});
it('tolerates swapped corners', () => {
const target = boundsToCenterZoom({ south: 51.6, north: 51.4, west: 0.1, east: -0.3 });
expect(target.lat).toBeCloseTo(51.5, 5);
expect(target.lng).toBeCloseTo(-0.1, 5);
expect(Number.isFinite(target.zoom)).toBe(true);
});
});

View file

@ -0,0 +1,45 @@
import { MAP_MIN_ZOOM } from './consts';
export interface GeoBounds {
south: number;
west: number;
north: number;
east: number;
}
/**
* Nominal viewport used to derive a zoom from a bounding box. The map only
* exposes flyTo(lat, lng, zoom), so we approximate fitBounds; being half a
* zoom level off for unusual window sizes is fine for "show me the matches".
*/
const NOMINAL_VIEWPORT = { width: 1000, height: 700 };
const TILE_SIZE = 512;
/** Keep matches comfortably inside the viewport edges. */
const ZOOM_PADDING = 0.4;
const MAX_FIT_ZOOM = 13;
function mercatorY(lat: number): number {
const rad = (lat * Math.PI) / 180;
return Math.log(Math.tan(Math.PI / 4 + rad / 2));
}
/** Convert a bounding box into a flyTo target that roughly fits it on screen. */
export function boundsToCenterZoom(bounds: GeoBounds): { lat: number; lng: number; zoom: number } {
const south = Math.min(bounds.south, bounds.north);
const north = Math.max(bounds.south, bounds.north);
const west = Math.min(bounds.west, bounds.east);
const east = Math.max(bounds.west, bounds.east);
const lonSpan = Math.max(east - west, 1e-6);
const mercSpan = Math.max(mercatorY(north) - mercatorY(south), 1e-6);
const zoomX = Math.log2((NOMINAL_VIEWPORT.width * 360) / (TILE_SIZE * lonSpan));
const zoomY = Math.log2((NOMINAL_VIEWPORT.height * 2 * Math.PI) / (TILE_SIZE * mercSpan));
const zoom = Math.max(MAP_MIN_ZOOM, Math.min(MAX_FIT_ZOOM, Math.min(zoomX, zoomY) - ZOOM_PADDING));
return {
lat: (south + north) / 2,
lng: (west + east) / 2,
zoom,
};
}

View file

@ -24,6 +24,7 @@ const TRANSPORT_POI_CATEGORIES = new Set([
'Ferry',
'Rail station',
'Taxi rank',
'Tram & Metro stop',
'Tube station',
]);

View file

@ -5,12 +5,10 @@ export const SCHOOL_FILTER_KEY_PREFIX = `${SCHOOL_FILTER_NAME}:`;
export type SchoolPhase = 'primary' | 'secondary';
export type SchoolRating = 'good' | 'outstanding';
export type SchoolDistance = 2 | 5;
export interface SchoolFilterConfig {
phase: SchoolPhase;
rating: SchoolRating;
distance: SchoolDistance;
featureName: string;
}
@ -18,50 +16,22 @@ export const SCHOOL_FILTERS: SchoolFilterConfig[] = [
{
phase: 'primary',
rating: 'good',
distance: 2,
featureName: 'Good+ primary schools within 2km',
featureName: 'Good+ primary school catchments',
},
{
phase: 'secondary',
rating: 'good',
distance: 2,
featureName: 'Good+ secondary schools within 2km',
featureName: 'Good+ secondary school catchments',
},
{
phase: 'primary',
rating: 'outstanding',
distance: 2,
featureName: 'Outstanding primary schools within 2km',
featureName: 'Outstanding primary school catchments',
},
{
phase: 'secondary',
rating: 'outstanding',
distance: 2,
featureName: 'Outstanding secondary schools within 2km',
},
{
phase: 'primary',
rating: 'good',
distance: 5,
featureName: 'Good+ primary schools within 5km',
},
{
phase: 'secondary',
rating: 'good',
distance: 5,
featureName: 'Good+ secondary schools within 5km',
},
{
phase: 'primary',
rating: 'outstanding',
distance: 5,
featureName: 'Outstanding primary schools within 5km',
},
{
phase: 'secondary',
rating: 'outstanding',
distance: 5,
featureName: 'Outstanding secondary schools within 5km',
featureName: 'Outstanding secondary school catchments',
},
];
@ -81,42 +51,34 @@ export function getSchoolFilterConfig(name: string): SchoolFilterConfig | null {
return SCHOOL_FILTERS.find((filter) => filter.featureName === name) ?? null;
}
export function getSchoolFeatureName(
phase: SchoolPhase,
rating: SchoolRating,
distance: SchoolDistance
): string {
export function getSchoolFeatureName(phase: SchoolPhase, rating: SchoolRating): string {
return (
SCHOOL_FILTERS.find(
(filter) => filter.phase === phase && filter.rating === rating && filter.distance === distance
)?.featureName ?? SCHOOL_FILTERS[0].featureName
SCHOOL_FILTERS.find((filter) => filter.phase === phase && filter.rating === rating)
?.featureName ?? SCHOOL_FILTERS[0].featureName
);
}
export function createSchoolFilterKey(
phase: SchoolPhase,
rating: SchoolRating,
distance: SchoolDistance,
id: number | string
): string {
return `${SCHOOL_FILTER_KEY_PREFIX}${phase}:${rating}:${distance}:${id}`;
return `${SCHOOL_FILTER_KEY_PREFIX}${phase}:${rating}:${id}`;
}
export function getSchoolFilterKeyId(name: string): string | null {
if (!name.startsWith(SCHOOL_FILTER_KEY_PREFIX)) return null;
return name.split(':')[4] ?? null;
return name.split(':')[3] ?? null;
}
export function parseSchoolFilterKey(name: string): SchoolFilterConfig | null {
if (!name.startsWith(SCHOOL_FILTER_KEY_PREFIX)) return null;
const [, phaseRaw, ratingRaw, distanceRaw] = name.split(':');
const [, phaseRaw, ratingRaw] = name.split(':');
const phase = phaseRaw as SchoolPhase;
const rating = ratingRaw as SchoolRating;
const distance = Number(distanceRaw) as SchoolDistance;
if (
(phase !== 'primary' && phase !== 'secondary') ||
(rating !== 'good' && rating !== 'outstanding') ||
(distance !== 2 && distance !== 5)
(rating !== 'good' && rating !== 'outstanding')
) {
return null;
}
@ -124,8 +86,7 @@ export function parseSchoolFilterKey(name: string): SchoolFilterConfig | null {
return {
phase,
rating,
distance,
featureName: getSchoolFeatureName(phase, rating, distance),
featureName: getSchoolFeatureName(phase, rating),
};
}
@ -139,18 +100,12 @@ export function replaceSchoolFilterKeySelection(
next: {
phase?: SchoolPhase;
rating?: SchoolRating;
distance?: SchoolDistance;
}
): string {
const config = getSchoolFilterConfig(key) ?? SCHOOL_FILTERS[0];
const parts = key.startsWith(SCHOOL_FILTER_KEY_PREFIX) ? key.split(':') : [];
const id = parts[4] ?? '0';
return createSchoolFilterKey(
next.phase ?? config.phase,
next.rating ?? config.rating,
next.distance ?? config.distance,
id
);
const id = parts[3] ?? '0';
return createSchoolFilterKey(next.phase ?? config.phase, next.rating ?? config.rating, id);
}
export function getDefaultSchoolFeatureName(features: FeatureMeta[]): string | null {
@ -171,14 +126,7 @@ export function normalizeSchoolFilters(filters: FeatureFilters): FeatureFilters
if (isBackendSchoolFeatureName(name)) {
const config = getSchoolFilterConfig(name);
if (!config) continue;
next[
createSchoolFilterKey(
config.phase,
config.rating,
config.distance,
Object.keys(next).length
)
] = value;
next[createSchoolFilterKey(config.phase, config.rating, Object.keys(next).length)] = value;
changed = true;
continue;
}
@ -201,9 +149,9 @@ export function getSchoolFilterMeta(features: FeatureMeta[]): FeatureMeta {
min: sourceFeature?.min ?? 0,
max: sourceFeature?.max ?? 10,
step: 1,
description: 'Rated primary and secondary schools nearby',
description: 'Rated schools whose catchment area likely covers the postcode',
detail:
'Filter by primary or secondary schools, Ofsted rating, and whether schools are within 2km or 5km.',
'Filter by how many Good+ or Outstanding primary or secondary schools have a historical catchment area covering the postcode. Catchments are modelled from each schools pupil numbers and local child population, approximating distance-based admissions.',
source: 'ofsted',
raw: true,
};

View file

@ -352,8 +352,8 @@ describe('url-state', () => {
});
it('round-trips repeated school filters with dedicated URL params', () => {
const schoolOne = createSchoolFilterKey('primary', 'good', 2, 1);
const schoolTwo = createSchoolFilterKey('secondary', 'outstanding', 5, 2);
const schoolOne = createSchoolFilterKey('primary', 'good', 1);
const schoolTwo = createSchoolFilterKey('secondary', 'outstanding', 2);
const params = stateToParams(
null,
@ -366,18 +366,22 @@ describe('url-state', () => {
'area'
);
expect(params.getAll('school')).toEqual([
'primary:good:2:1:10',
'secondary:outstanding:5:2:15',
]);
expect(params.getAll('school')).toEqual(['primary:good:1:10', 'secondary:outstanding:2:15']);
expect(params.getAll('filter')).toEqual([]);
window.history.replaceState({}, '', `/?${params.toString()}`);
const state = parseUrlState();
expect(state.filters).toEqual({
[createSchoolFilterKey('primary', 'good', 2, 0)]: [1, 10],
[createSchoolFilterKey('secondary', 'outstanding', 5, 1)]: [2, 15],
[createSchoolFilterKey('primary', 'good', 0)]: [1, 10],
[createSchoolFilterKey('secondary', 'outstanding', 1)]: [2, 15],
});
});
it('parses legacy school URL params that still carry a distance segment', () => {
window.history.replaceState({}, '', '/?school=primary%3Agood%3A2%3A1%3A10');
expect(parseUrlState().filters).toEqual({
[createSchoolFilterKey('primary', 'good', 0)]: [1, 10],
});
});

View file

@ -12,7 +12,6 @@ import {
createSchoolFilterKey,
getSchoolFilterConfig,
isSchoolFilterName,
type SchoolDistance,
type SchoolPhase,
type SchoolRating,
} from './school-filter';
@ -122,22 +121,22 @@ function parseFilters(params: URLSearchParams): FeatureFilters {
schoolParams.forEach((entry, index) => {
const parts = entry.split(':');
if (parts.length !== 5) return;
// 4 parts is the current phase:rating:min:max form; 5 parts is the legacy
// phase:rating:distance:min:max form, whose distance segment is ignored.
if (parts.length !== 4 && parts.length !== 5) return;
const phase = parts[0] as SchoolPhase;
const rating = parts[1] as SchoolRating;
const distance = Number(parts[2]) as SchoolDistance;
const min = Number(parts[3]);
const max = Number(parts[4]);
const min = Number(parts[parts.length - 2]);
const max = Number(parts[parts.length - 1]);
if (
(phase !== 'primary' && phase !== 'secondary') ||
(rating !== 'good' && rating !== 'outstanding') ||
(distance !== 2 && distance !== 5) ||
isNaN(min) ||
isNaN(max)
) {
return;
}
filters[createSchoolFilterKey(phase, rating, distance, index)] = [min, max];
filters[createSchoolFilterKey(phase, rating, index)] = [min, max];
});
crimeParams.forEach((entry, index) => {
@ -379,10 +378,7 @@ export function stateToParams(
const schoolConfig = getSchoolFilterConfig(name);
if (schoolConfig && isSchoolFilterName(name)) {
const [min, max] = value as [number, number];
params.append(
'school',
`${schoolConfig.phase}:${schoolConfig.rating}:${schoolConfig.distance}:${min}:${max}`
);
params.append('school', `${schoolConfig.phase}:${schoolConfig.rating}:${min}:${max}`);
continue;
}

View file

@ -0,0 +1,297 @@
"""Evaluate modelled school catchment radii against published cutoffs.
Local authorities publish each school's "last distance offered" in their
yearly allocation reports; ``property-data/ground_truth/cutoffs_*.json``
holds a scraped sample of those figures (see the collection notes in each
file's ``source_url`` fields). This script matches them to the per-school
radii emitted by ``pipeline.transform.school_catchments --schools-output``
and reports how well the model reproduces reality, so the preference-bonus
constants can be calibrated.
Headline metrics use non-faith schools whose published cutoff was a binding
distance. Faith schools are reported separately (their distance criterion
applies within faith priority, so published figures aren't comparable), as
are "all applicants offered" schools, where the model should ideally show no
binding cutoff.
"""
import argparse
import difflib
import json
import re
from pathlib import Path
import numpy as np
import polars as pl
_NOISE_WORDS = re.compile(
r"\b(the|of|and|c\s*of\s*e|cofe|ce|rc|voluntary|aided|controlled|va|vc)\b"
)
_NON_ALNUM = re.compile(r"[^a-z0-9 ]")
_SCHOOL_WORDS = re.compile(
r"\b(school|academy|primary|secondary|junior|infant|community|college|high)\b"
)
def normalize_name(name: str, strip_school_words: bool = False) -> str:
s = name.lower().replace("&", " and ").replace("st.", "st ").replace("'", "")
s = _NON_ALNUM.sub(" ", s)
s = _NOISE_WORDS.sub(" ", s)
if strip_school_words:
s = _SCHOOL_WORDS.sub(" ", s)
return " ".join(s.split())
def normalize_la(la: str) -> str:
s = _NON_ALNUM.sub(" ", la.lower().replace("&", " and "))
return " ".join(s.replace("city of", "").split())
def load_ground_truth(directory: Path) -> pl.DataFrame:
rows = []
for path in sorted(directory.glob("cutoffs_*.json")):
for row in json.loads(path.read_text()):
rows.append(
{
"school_name": row["school_name"],
"la": row["la"],
"phase": row["phase"],
"entry_year": int(row.get("entry_year") or 0),
"cutoff_km": (
float(row["cutoff_km"]) if row.get("cutoff_km") is not None else None
),
"all_offered": bool(row.get("all_offered", False)),
"faith_school": bool(row.get("faith_school", False)),
"school_postcode": row.get("school_postcode"),
"source_url": row.get("source_url", ""),
}
)
if not rows:
raise SystemExit(f"No cutoffs_*.json files with rows under {directory}")
df = pl.DataFrame(rows, schema_overrides={"school_postcode": pl.Utf8})
print(f"Ground truth rows: {len(df)} from {directory}")
return df
def match_schools(truth: pl.DataFrame, gias: pl.DataFrame) -> pl.DataFrame:
"""Attach GIAS URNs to ground-truth rows by postcode, then name."""
def stripped(name: str) -> str:
return normalize_name(name, strip_school_words=True)
gias = gias.with_columns(
pl.col("name")
.map_elements(normalize_name, return_dtype=pl.Utf8)
.alias("_name_norm"),
pl.col("name")
.map_elements(stripped, return_dtype=pl.Utf8)
.alias("_name_stripped"),
pl.col("local_authority")
.map_elements(normalize_la, return_dtype=pl.Utf8)
.alias("_la_norm"),
pl.col("postcode").str.replace_all(" ", "").str.to_uppercase().alias("_pc"),
)
truth = truth.with_columns(
pl.col("school_name")
.map_elements(normalize_name, return_dtype=pl.Utf8)
.alias("_name_norm"),
pl.col("school_name")
.map_elements(stripped, return_dtype=pl.Utf8)
.alias("_name_stripped"),
pl.col("la")
.map_elements(normalize_la, return_dtype=pl.Utf8)
.alias("_la_norm"),
pl.col("school_postcode")
.str.replace_all(" ", "")
.str.to_uppercase()
.alias("_pc"),
).with_row_index("_row_id")
# 1. Exact postcode match (unique postcodes only — site-sharing schools
# would mismatch phases otherwise; those fall through to name matching).
pc_unique = gias.filter(pl.col("_pc").is_not_null()).unique(
subset="_pc", keep="none"
)
by_pc = truth.filter(pl.col("_pc").is_not_null()).join(
pc_unique.select("_pc", "urn"), on="_pc", how="inner"
)
matched_ids = set(by_pc["_row_id"].to_list())
# 2. Exact normalized (name, LA) match, unique on both sides.
gias_named = gias.unique(subset=["_name_norm", "_la_norm"], keep="none")
remaining = truth.filter(~pl.col("_row_id").is_in(list(matched_ids)))
by_name = remaining.join(
gias_named.select("_name_norm", "_la_norm", "urn"),
on=["_name_norm", "_la_norm"],
how="inner",
)
matched_ids |= set(by_name["_row_id"].to_list())
# 3. Reports often print informal names ("Ashmole Primary" for "Ashmole
# Primary School"): match on names with school-type words stripped,
# unique on both sides so site-sharing infant/junior pairs fall through.
gias_stripped = gias.filter(pl.col("_name_stripped") != "").unique(
subset=["_name_stripped", "_la_norm"], keep="none"
)
remaining = truth.filter(
(~pl.col("_row_id").is_in(list(matched_ids))) & (pl.col("_name_stripped") != "")
).unique(subset=["_name_stripped", "_la_norm", "phase"], keep="none")
by_stripped = remaining.join(
gias_stripped.select("_name_stripped", "_la_norm", "urn"),
on=["_name_stripped", "_la_norm"],
how="inner",
)
matched_ids |= set(by_stripped["_row_id"].to_list())
# 4. Fuzzy name match within the LA: unique best candidate >= 0.87.
remaining = truth.filter(~pl.col("_row_id").is_in(list(matched_ids)))
fuzzy_rows = []
gias_by_la: dict[str, pl.DataFrame] = {}
for row in remaining.iter_rows(named=True):
la = row["_la_norm"]
if la not in gias_by_la:
gias_by_la[la] = gias.filter(pl.col("_la_norm") == la)
candidates = gias_by_la[la]
if candidates.is_empty():
continue
scores = [
difflib.SequenceMatcher(None, row["_name_norm"], cand).ratio()
for cand in candidates["_name_norm"].to_list()
]
order = np.argsort(scores)[::-1]
if scores[order[0]] >= 0.87 and (
len(order) == 1 or scores[order[1]] < scores[order[0]] - 0.04
):
fuzzy_rows.append({**row, "urn": candidates["urn"][int(order[0])]})
by_fuzzy = (
pl.DataFrame(fuzzy_rows).with_columns(pl.col("_row_id").cast(pl.UInt32))
if fuzzy_rows
else None
)
parts = [by_pc, by_name, by_stripped] + ([by_fuzzy] if by_fuzzy is not None else [])
matched = pl.concat(
[p.select(truth.columns + ["urn"]) for p in parts if not p.is_empty()]
).unique(subset="_row_id", keep="first")
print(
f"Matched {len(matched)}/{len(truth)} ground-truth rows to GIAS URNs "
f"(postcode {len(by_pc)}, exact name {len(by_name)}, "
f"stripped {len(by_stripped)}, fuzzy {0 if by_fuzzy is None else len(by_fuzzy)})"
)
return matched
def evaluate(matched: pl.DataFrame, radii: pl.DataFrame) -> pl.DataFrame:
joined = matched.join(radii, on=["urn", "phase"], how="inner")
print(f"Joined to modelled radii: {len(joined)} rows")
# Published figures occasionally include non-typical admits (a child who
# moved mid-process can print as hundreds of km); cap at distances a
# distance criterion can plausibly produce.
binding = joined.filter(
~pl.col("all_offered")
& pl.col("cutoff_km").is_between(0.05, 20.0)
)
def report(df: pl.DataFrame, label: str) -> None:
if df.is_empty():
print(f"\n{label}: no rows")
return
truth_km = df["cutoff_km"].to_numpy()
model_km = df["radius_km"].to_numpy()
log_ratio = np.log2(model_km / truth_km)
within2 = float(np.mean(np.abs(log_ratio) <= 1))
rank = (
pl.DataFrame({"t": truth_km, "m": model_km})
.select(pl.corr("t", "m", method="spearman"))
.item()
)
print(
f"\n{label} (n={len(df)}):\n"
f" median bias (log2 model/truth): {np.median(log_ratio):+.2f} "
f"(x{2 ** np.median(log_ratio):.2f})\n"
f" median |log2 error|: {np.median(np.abs(log_ratio)):.2f} "
f"(x{2 ** np.median(np.abs(log_ratio)):.2f})\n"
f" within factor 2: {within2:.0%}\n"
f" Spearman rank corr: {rank:.2f}"
)
for phase in ("primary", "secondary"):
report(
binding.filter((pl.col("phase") == phase) & ~pl.col("faith_school")),
f"BINDING, non-faith, {phase}",
)
report(binding.filter(pl.col("faith_school")), "BINDING, faith (informational)")
offered = joined.filter(pl.col("all_offered"))
if not offered.is_empty():
unbound_share = float((~offered["filled"]).mean())
print(
f"\nALL-OFFERED schools (n={len(offered)}): model agrees no binding "
f"cutoff for {unbound_share:.0%}; median modelled radius "
f"{offered['radius_km'].median():.2f} km"
)
return binding
def main() -> None:
parser = argparse.ArgumentParser(
description="Compare modelled catchment radii with published cutoffs"
)
parser.add_argument(
"--ground-truth-dir",
type=Path,
default=Path("property-data/ground_truth"),
)
parser.add_argument(
"--radii",
type=Path,
default=Path("property-data/school_catchment_radii.parquet"),
help="Per-school radii parquet from school_catchments --schools-output",
)
parser.add_argument("--gias", type=Path, default=Path("property-data/gias.parquet"))
parser.add_argument(
"--matched-out",
type=Path,
default=None,
help="Optional CSV of matched rows for inspection",
)
args = parser.parse_args()
truth = load_ground_truth(args.ground_truth_dir)
# One row per school+phase: keep the most recent entry year.
truth = (
truth.sort("entry_year", descending=True)
.unique(subset=["school_name", "la", "phase"], keep="first")
)
gias = pl.read_parquet(args.gias).select(
"urn", "name", "postcode", "local_authority", "religious_character"
)
radii = pl.read_parquet(args.radii).unique(subset=["urn", "phase"], keep="first")
matched = match_schools(truth, gias.drop("religious_character"))
# GIAS religious character is authoritative; the scraped name-based flag
# only covers rows that failed to match.
matched = matched.join(
gias.select("urn", "religious_character"), on="urn", how="left"
).with_columns(
pl.when(pl.col("religious_character").is_not_null())
.then(~pl.col("religious_character").is_in(["None", "Does not apply"]))
.otherwise(pl.col("faith_school"))
.alias("faith_school")
)
binding = evaluate(matched, radii)
if args.matched_out is not None:
out = matched.join(radii, on=["urn", "phase"], how="inner").drop(
"_row_id", "_name_norm", "_la_norm", "_pc"
)
args.matched_out.parent.mkdir(parents=True, exist_ok=True)
out.write_csv(args.matched_out)
print(f"\nWrote matched rows to {args.matched_out}")
if binding.is_empty():
raise SystemExit("No binding, matchable cutoffs — nothing to calibrate on")
if __name__ == "__main__":
main()

View file

@ -1,4 +1,22 @@
"""Download Census 2021 ethnic group (TS021) by LSOA.
Downloads the 20-category ethnic-group breakdown (TS021, classification
C2021_ETH_20) from the NOMIS API at LSOA 2021 granularity, folds the 19 detailed
leaf categories into our 7 output buckets, and emits one row per LSOA with the
percentage in each bucket.
Sourcing at LSOA (~33,755 England areas) rather than Local Authority (~319) is a
~100x granularity gain with no change to the 7-bucket output schema: two very
different neighbourhoods in one borough no longer share an identical ethnicity
profile. The join key downstream (merge.py) is `lsoa21`, the same key already
used for median age and IoD.
Source: NOMIS (ONS Census 2021 TS021 dataset, NM_2041_1)
License: Open Government Licence v3.0
"""
import argparse
from io import BytesIO
from pathlib import Path
import httpx
@ -6,143 +24,169 @@ import polars as pl
pl.Config.set_tbl_cols(-1)
# NOMIS API: Census 2021 TS021 (ethnic group, 20 categories) by LSOA 2021
# (TYPE151). c2021_eth_20=1..19 selects the 19 detailed leaf categories
# (excluding the 5 broad aggregates 1001-1005 and the 0 = Total, which we
# re-derive ourselves). measures=20100 selects the absolute count.
BASE_URL = (
"https://www.nomisweb.co.uk/api/v01/dataset/NM_2041_1.data.csv"
"?geography=TYPE151"
"&c2021_eth_20=1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19"
"&measures=20100"
"&select=GEOGRAPHY_CODE,C2021_ETH_20_NAME,OBS_VALUE"
)
PAGE_SIZE = 25000
URL = "https://www.ethnicity-facts-figures.service.gov.uk/uk-population-by-ethnicity/national-and-regional-populations/regional-ethnic-diversity/latest/downloads/population-by-ethnicity-and-local-authority-2021.csv"
GEOGRAPHY_CODE_REPLACEMENTS = {
# 2023 Cumberland unitary authority
"E07000026": "E06000063", # Allerdale
"E07000028": "E06000063", # Carlisle
"E07000029": "E06000063", # Copeland
# 2023 Westmorland and Furness unitary authority
"E07000027": "E06000064", # Barrow-in-Furness
"E07000030": "E06000064", # Eden
"E07000031": "E06000064", # South Lakeland
# 2023 North Yorkshire unitary authority
"E07000163": "E06000065", # Craven
"E07000164": "E06000065", # Hambleton
"E07000165": "E06000065", # Harrogate
"E07000166": "E06000065", # Richmondshire
"E07000167": "E06000065", # Ryedale
"E07000168": "E06000065", # Scarborough
"E07000169": "E06000065", # Selby
# 2023 Somerset unitary authority
"E07000187": "E06000066", # Mendip
"E07000188": "E06000066", # Sedgemoor
"E07000189": "E06000066", # South Somerset
"E07000246": "E06000066", # Somerset West and Taunton
# Map the 19 detailed NOMIS C2021_ETH_20 leaf categories to our 7 output groups.
# The Asian split:
# * "Chinese" routes to East Asian.
# * "Other Asian" routes to SE Asian (not South Asian). The ONS "Other Asian"
# bucket is predominantly East/Southeast Asian (Filipino, Vietnamese, Thai,
# Japanese, Korean, ...) rather than South Asian, so routing it here avoids
# inflating "% South Asian". The split is approximate (the bucket also holds
# some South Asian groups such as Sri Lankan/Nepalese).
GROUP_MAP = {
# White
"White: English, Welsh, Scottish, Northern Irish or British": "White",
"White: Irish": "White",
"White: Gypsy or Irish Traveller": "White",
"White: Roma": "White",
"White: Other White": "White",
# South Asian
"Asian, Asian British or Asian Welsh: Indian": "South Asian",
"Asian, Asian British or Asian Welsh: Pakistani": "South Asian",
"Asian, Asian British or Asian Welsh: Bangladeshi": "South Asian",
# East / Southeast Asian
"Asian, Asian British or Asian Welsh: Chinese": "East Asian",
"Asian, Asian British or Asian Welsh: Other Asian": "SE Asian",
# Black
"Black, Black British, Black Welsh, Caribbean or African: African": "Black",
"Black, Black British, Black Welsh, Caribbean or African: Caribbean": "Black",
"Black, Black British, Black Welsh, Caribbean or African: Other Black": "Black",
# Mixed
"Mixed or Multiple ethnic groups: White and Asian": "Mixed",
"Mixed or Multiple ethnic groups: White and Black African": "Mixed",
"Mixed or Multiple ethnic groups: White and Black Caribbean": "Mixed",
"Mixed or Multiple ethnic groups: Other Mixed or Multiple ethnic groups": "Mixed",
# Other
"Other ethnic group: Arab": "Other",
"Other ethnic group: Any other ethnic group": "Other",
}
# The 7 output groups, in a fixed order so the largest-remainder rounding below
# is deterministic regardless of pivot column ordering.
OUTPUT_GROUPS = ["White", "South Asian", "East Asian", "SE Asian", "Black", "Mixed", "Other"]
assert set(GROUP_MAP.values()) == set(OUTPUT_GROUPS), (
"GROUP_MAP values must be exactly the OUTPUT_GROUPS"
)
def _ethnicity_percentages(df: pl.DataFrame) -> pl.DataFrame:
# Use the detailed 19+1 breakdown to get sub-categories for Asian ethnicity,
# then aggregate back to the broad groups plus a South Asian / East/SE Asian
# split (Indian/Pakistani/Bangladeshi vs Chinese + other East/SE Asian).
detailed = df.filter(
(pl.col("Ethnicity_type") == "ONS 2021 19+1") & (pl.col("Ethnicity") != "All")
"""Fold the 19 NOMIS leaf categories into 7-bucket percentages per LSOA.
`df` is the long-format NOMIS download with columns GEOGRAPHY_CODE,
C2021_ETH_20_NAME (the detailed leaf label) and OBS_VALUE (a count). A
missing/extra/relabelled leaf category would silently drop people from the
denominator, so we validate the category set against GROUP_MAP first and
fail loudly otherwise.
"""
found = set(df["C2021_ETH_20_NAME"].unique().to_list())
expected = set(GROUP_MAP)
if found != expected:
missing = sorted(expected - found)
unexpected = sorted(found - expected)
raise ValueError(
"Census ethnic-group categories do not match the expected NOMIS "
"TS021 C2021_ETH_20 leaf set.\n"
f" expected {len(expected)} categories, found {len(found)}\n"
f" missing: {missing}\n"
f" unexpected: {unexpected}\n"
"Refusing to compute percentages against an unrecognised breakdown."
)
# Map each leaf to its output group and sum counts per (LSOA, group). Summing
# counts (not rounded percentages) keeps the denominator exact.
grouped = (
df.with_columns(
pl.col("C2021_ETH_20_NAME").replace_strict(GROUP_MAP).alias("group"),
pl.col("OBS_VALUE").cast(pl.Float64, strict=False).alias("_count"),
)
.group_by("GEOGRAPHY_CODE", "group")
.agg(pl.col("_count").sum())
)
wide = grouped.pivot(on="group", index="GEOGRAPHY_CODE", values="_count").rename(
{"GEOGRAPHY_CODE": "lsoa21"}
)
# Map detailed categories to our output groups
group_map = {
# White
"White British": "White",
"White Irish": "White",
"Gypsy Or Irish Traveller": "White",
"Roma": "White",
"Any Other White Background": "White",
# South Asian
"Indian": "South Asian",
"Pakistani": "South Asian",
"Bangladeshi": "South Asian",
# East / Southeast Asian. The ONS "Any Other Asian Background" bucket is
# predominantly East/Southeast Asian (Filipino, Vietnamese, Thai,
# Japanese, Korean, ...) rather than South Asian, so route it here rather
# than inflating "% South Asian". The split is approximate (the ONS
# bucket also holds some South Asian groups such as Sri Lankan/Nepalese).
"Chinese": "East/SE Asian",
"Any Other Asian Background": "East/SE Asian",
# Black
"Black African": "Black",
"Black Caribbean": "Black",
"Any Other Black Background": "Black",
# Mixed
"Mixed White And Asian": "Mixed",
"Mixed White And Black African": "Mixed",
"Mixed White And Black Caribbean": "Mixed",
"Any Other Mixed/Multiple Ethnic Background": "Mixed",
# Other
"Arab": "Other",
"Any Other Ethnic Background": "Other",
}
# A group with no people in an LSOA is absent from the long rows, so the pivot
# leaves a null; treat it as 0 before normalising.
wide = wide.with_columns(pl.col(OUTPUT_GROUPS).fill_null(0.0))
detailed = detailed.with_columns(
pl.col("Ethnicity").replace_strict(group_map).alias("group"),
pl.col("Geography_code")
.replace(GEOGRAPHY_CODE_REPLACEMENTS)
.alias("output_geography_code"),
pl.col("Ethnic Population").cast(pl.Float64, strict=False).alias("_population"),
)
# Sum counts, not rounded percentages, so old districts can be safely
# recombined into their current unitary authorities.
grouped = detailed.group_by("output_geography_code", "group").agg(
pl.col("_population").sum()
)
wide = grouped.pivot(
on="group", index="output_geography_code", values="_population"
).rename({"output_geography_code": "Geography_code"})
# Normalize so each row sums to exactly 100%, then round using largest-remainder
# method to preserve the sum. Independent rounding of 6 values can drift ±0.3.
group_cols = [c for c in wide.columns if c != "Geography_code"]
row_total = sum(pl.col(c) for c in group_cols)
# Scale each group so they sum to exactly 100
# Normalize so each row sums to exactly 100%, then round with the
# largest-remainder method to preserve the sum. Independent rounding of 6
# values can drift +/-0.3.
row_total = sum(pl.col(c) for c in OUTPUT_GROUPS)
wide = wide.with_columns(
[(pl.col(c) / row_total * 100.0).alias(c) for c in group_cols]
[(pl.col(c) / row_total * 100.0).alias(c) for c in OUTPUT_GROUPS]
)
# Round to 1 decimal, then adjust the largest group to absorb residual
rounded_cols = [pl.col(c).round(1).alias(c) for c in group_cols]
wide = wide.with_columns(rounded_cols)
rounded_sum = sum(pl.col(c) for c in group_cols)
# Round to 1 decimal, then adjust the largest group to absorb the residual.
wide = wide.with_columns([pl.col(c).round(1).alias(c) for c in OUTPUT_GROUPS])
rounded_sum = sum(pl.col(c) for c in OUTPUT_GROUPS)
residual = (100.0 - rounded_sum).round(1)
# Find which group is largest per row and add the residual there
largest_col = pl.concat_list(group_cols).list.arg_max()
largest_col = pl.concat_list(OUTPUT_GROUPS).list.arg_max()
wide = wide.with_columns(
[
pl.when(largest_col == i)
.then(pl.col(c) + residual)
.otherwise(pl.col(c))
.alias(c)
for i, c in enumerate(group_cols)
for i, c in enumerate(OUTPUT_GROUPS)
]
)
# Rename columns to be descriptive
rename_map = {col: f"% {col}" for col in wide.columns if col != "Geography_code"}
wide = wide.rename(rename_map)
return wide
rename_map = {col: f"% {col}" for col in OUTPUT_GROUPS}
return wide.rename(rename_map)
def download_and_convert(output_path: Path) -> None:
print("Downloading ethnicity data...")
response = httpx.get(URL, follow_redirects=True, timeout=60)
response.raise_for_status()
print("Downloading Census 2021 ethnic group (TS021) by LSOA from NOMIS...")
frames = []
offset = 0
while True:
url = f"{BASE_URL}&recordoffset={offset}"
response = httpx.get(url, follow_redirects=True, timeout=120)
response.raise_for_status()
if len(response.content) == 0:
break
chunk = pl.read_csv(BytesIO(response.content))
if chunk.height == 0:
break
frames.append(chunk)
print(f" Fetched {chunk.height} rows (offset={offset})")
if chunk.height < PAGE_SIZE:
break
offset += PAGE_SIZE
df = pl.read_csv(response.content)
print(f"Raw shape: {df.head(100)}")
df = pl.concat(frames)
print(f"Total rows: {df.height}")
# Filter to England only (E-prefixed LSOA codes); the merge joins on the
# English postcode universe and the IoD coverage check is England-wide.
df = df.filter(pl.col("GEOGRAPHY_CODE").str.starts_with("E"))
wide = _ethnicity_percentages(df)
print(f"Output shape: {wide.shape}")
print(f"England LSOAs: {wide.height}")
print(f"Columns: {wide.columns}")
output_path.parent.mkdir(parents=True, exist_ok=True)
wide.write_parquet(output_path, compression="zstd")
print(f"Saved to {output_path}")
def main() -> None:
parser = argparse.ArgumentParser(
description="Download and convert ethnicity by local authority data"
description="Download Census 2021 ethnic group (TS021) by LSOA"
)
parser.add_argument(
"--output", type=Path, required=True, help="Output parquet file path"

View file

@ -192,6 +192,10 @@ def _read_csv_from_zip(zip_bytes: bytes) -> pl.DataFrame:
infer_schema_length=20000,
null_values=_NULL_VALUES,
truncate_ragged_lines=True,
# Force the phone number to stay a string: schema inference reads it as
# an integer and strips the leading 0 (e.g. 020 8427 7222 -> 2084277222),
# making nearly every school phone number un-diallable.
schema_overrides={"TelephoneNum": pl.String},
)

View file

@ -0,0 +1,93 @@
"""Download Census 2021 children by five-year age band per LSOA.
Source: NOMIS (ONS Census 2021 TS007A dataset, age by five-year bands)
License: Open Government Licence v3.0
Used to estimate how many primary-age (4-10) and secondary-age (11-15)
children live in each LSOA, which drives the school catchment model. Census
bands don't align with school phases, so phase totals take fractional shares
of the 0-4, 10-14 and 15-19 bands (one fifth per single year of age).
"""
import argparse
from io import BytesIO
from pathlib import Path
import httpx
import polars as pl
# NOMIS API: Census 2021 TS007A (age, five-year bands) by LSOA 2021 (TYPE151).
# c2021_age_19 codes: 1 = 0-4, 2 = 5-9, 3 = 10-14, 4 = 15-19.
# NOMIS paginates at 25,000 rows by default, so we paginate with recordoffset.
BASE_URL = (
"https://www.nomisweb.co.uk/api/v01/dataset/NM_2020_1.data.csv"
"?date=latest&geography=TYPE151&measures=20100&c2021_age_19=1,2,3,4"
"&select=GEOGRAPHY_CODE,C2021_AGE_19,OBS_VALUE"
)
PAGE_SIZE = 25000
AGE_BAND_COLUMNS = {
1: "aged_0_4",
2: "aged_5_9",
3: "aged_10_14",
4: "aged_15_19",
}
def download_and_convert(output_path: Path) -> None:
print("Downloading Census 2021 LSOA age bands from NOMIS...")
frames = []
offset = 0
while True:
url = f"{BASE_URL}&recordoffset={offset}"
response = httpx.get(url, follow_redirects=True, timeout=120)
response.raise_for_status()
if len(response.content) == 0:
break
chunk = pl.read_csv(BytesIO(response.content))
if chunk.height == 0:
break
frames.append(chunk)
print(f" Fetched {chunk.height} rows (offset={offset})")
if chunk.height < PAGE_SIZE:
break
offset += PAGE_SIZE
df = pl.concat(frames)
print(f"Total rows: {df.height}")
result = (
df.rename({"GEOGRAPHY_CODE": "lsoa21"})
.pivot(on="C2021_AGE_19", index="lsoa21", values="OBS_VALUE")
.rename({str(code): name for code, name in AGE_BAND_COLUMNS.items()})
.with_columns(pl.col(name).cast(pl.UInt32) for name in AGE_BAND_COLUMNS.values())
.filter(pl.col("lsoa21").str.starts_with("E"))
.sort("lsoa21")
)
missing = [c for c in AGE_BAND_COLUMNS.values() if c not in result.columns]
if missing:
raise ValueError(f"NOMIS response missing age bands: {missing}")
print(f"England LSOAs: {result.height}")
for name in AGE_BAND_COLUMNS.values():
print(f" {name}: total {result[name].sum():,}")
output_path.parent.mkdir(parents=True, exist_ok=True)
result.write_parquet(output_path, compression="zstd")
print(f"Saved to {output_path}")
def main() -> None:
parser = argparse.ArgumentParser(
description="Download Census 2021 age bands (children) by LSOA"
)
parser.add_argument(
"--output", type=Path, required=True, help="Output parquet file path"
)
args = parser.parse_args()
download_and_convert(args.output)
if __name__ == "__main__":
main()

View file

@ -12,8 +12,18 @@ import polars as pl
NAPTAN_CSV_URL = "https://naptan.api.dft.gov.uk/v1/access-nodes?dataFormat=csv"
TUBE_STATION_CATEGORY = "Tube station"
TRAM_METRO_CATEGORY = "Tram & Metro stop"
TUBE_STATION_MERGE_RADIUS_DEGREES = 0.01
# London Underground ATCO codes are "<area><kind>ZZLU<station>": a 3-digit
# AdministrativeAreaCode (940 national, 490 London, plus 150/210/040/... for
# LU stations outside Greater London such as Epping or Amersham), then "0"
# (platform/entrance node) or "G" (station group node), then the system code.
# "ZZLU" is unique to London Underground, which cleanly separates genuine Tube
# stations from every other TMU/MET network (Metrolink, Supertram, T&W Metro,
# WM Metro, Blackpool Tramway, heritage railways, ...).
LONDON_UNDERGROUND_ATCO_PATTERN = r"(?i)^\d{3}[0G]ZZLU"
STOP_TYPES = {
"AIR": "Airport",
@ -25,25 +35,110 @@ STOP_TYPES = {
"RLY": "Rail station",
"RSE": "Rail station",
"BCT": "Bus stop",
# Bus/coach stations: BST is the station access-area node, BCS/BCQ are
# bays/stands within the station and BCE is a station entrance. NaPTAN maps
# very few BCE nodes (~80 GB-wide), so without BST/BCS/BCQ the category was
# so sparse that 20% of England showed the nearest bus station >100km away.
# Bays and entrances collapse to one POI per station via
# STATION_MERGE_CATEGORIES below.
"BST": "Bus station",
"BCS": "Bus station",
"BCQ": "Bus station",
"BCE": "Bus station",
"TXR": "Taxi rank",
"TMU": "Tube station",
"MET": "Tube station",
# Tram/Metro/Underground: TMU is an entrance node, MET the station access
# area. Both start as "Tram & Metro stop"; merged stations whose ATCO codes
# mark them as London Underground (ZZLU) are reclassified to "Tube station"
# after dedup (see _deduplicate_station_areas). Heritage railways (RHDR,
# Severn Valley, ...) are TMU/MET in NaPTAN with no machine-readable
# "heritage" flag, so they remain in "Tram & Metro stop".
"TMU": TRAM_METRO_CATEGORY,
"MET": TRAM_METRO_CATEGORY,
}
# Stop types that are access/entrance nodes rather than the primary station or
# terminal node. During dedup the primary node (e.g. RLY/FER) wins so a station
# with both a station node and entrances yields one POI at the station node.
ENTRANCE_STOP_TYPES = {"RSE", "FTD"}
# terminal node. During dedup the primary node (e.g. RLY/FER/MET) wins so a
# station with both a station node and entrances yields one POI at the station
# node.
ENTRANCE_STOP_TYPES = {"RSE", "FTD", "TMU", "BCE"}
# Categories whose entrances/variants are merged into a single station-level POI
# by normalized name + area (like Tube stations), so an RLY node and its RSE
# entrances collapse to one POI at the station node.
STATION_MERGE_CATEGORIES = {TUBE_STATION_CATEGORY, "Rail station", "Ferry"}
STATION_MERGE_CATEGORIES = {
TRAM_METRO_CATEGORY,
TUBE_STATION_CATEGORY,
"Rail station",
"Ferry",
"Bus station",
}
OUTPUT_COLUMNS = ["id", "name", "category", "lat", "lng"]
# Trailing entrance designators ("North East Ent", "Main Entrance No 2",
# "West Station Entrance", ...) are stripped from canonical names so a
# station's individually-named entrance nodes collapse into the station.
# A trailing run of filler words is only stripped when it contains at least
# one entrance word, so "Maze Hill North" or "Platform 1" are untouched.
_ENTRANCE_NAME_WORDS = {"ent", "entrance", "entrances", "access"}
_ENTRANCE_FILLER_WORDS = {
"north",
"south",
"east",
"west",
"ne",
"nw",
"se",
"sw",
"n",
"s",
"e",
"w",
"wt",
"main",
"side",
"no",
"station",
"stop",
"platform",
}
_ENTRANCE_WORDS_RE = "(?:ent|entrance|entrances|access)"
_ENTRANCE_FILLER_RE = (
r"(?:north|south|east|west|ne|nw|se|sw|n|s|e|w|wt|main|side|no|station|stop"
r"|platform|\d+)"
)
_ENTRANCE_SUFFIX_RE = (
rf"(?:\s+(?:{_ENTRANCE_FILLER_RE}|{_ENTRANCE_WORDS_RE}))*"
rf"\s+{_ENTRANCE_WORDS_RE}"
rf"(?:\s+(?:{_ENTRANCE_FILLER_RE}|{_ENTRANCE_WORDS_RE}))*$"
)
# Bus-station bay/stand designators ("Stand A3", "Bay 2", "Stance 5") are
# stripped so every bay of one station shares a canonical name. The designator
# word must be followed by a short alphanumeric token, so place names ending in
# a bare "Bay" (Colwyn Bay, Herne Bay) are untouched.
_BAY_WORDS = {"stand", "stance", "bay", "gate"}
_BAY_SUFFIX_RE = r"\s+(?:stand|stance|bay|gate)\s+[a-z0-9]{1,3}$"
def _strip_entrance_suffix(words: list[str]) -> list[str]:
"""Drop a trailing entrance designator (direction/number filler around an
entrance word) from a tokenized stop name; no-op when no entrance word."""
idx = len(words)
saw_entrance = False
while idx > 0:
word = words[idx - 1]
if word in _ENTRANCE_NAME_WORDS:
saw_entrance = True
elif word.isdigit() or word in _ENTRANCE_FILLER_WORDS:
pass
else:
break
idx -= 1
return words[:idx] if saw_entrance else words
def canonical_station_name(name: str | None) -> str:
"""Normalize station names so entrances/transport-mode variants collapse."""
@ -55,18 +150,24 @@ def canonical_station_name(name: str | None) -> str:
normalized = re.sub(r"['`]", "", normalized)
normalized = normalized.replace("&", " and ")
normalized = re.sub(r"[^a-z0-9]+", " ", normalized)
words = normalized.split()
words = _strip_entrance_suffix(normalized.split())
if len(words) >= 3 and words[-2] in _BAY_WORDS and len(words[-1]) <= 3:
del words[-2:]
suffixes = (
("underground", "station"),
("tube", "station"),
("dlr", "station"),
("metro", "station"),
("metrolink", "station"),
("metrolink", "stop"),
("tram", "stop"),
("rail", "station"),
("railway", "station"),
("station",),
("stop",),
("metrolink",),
)
while True:
suffix = next(
@ -88,11 +189,14 @@ def canonical_station_name_expr(name_col: str = "name") -> pl.Expr:
expr = expr.str.replace_all(r"&", " and ")
expr = expr.str.replace_all(r"[^a-z0-9]+", " ")
expr = expr.str.replace_all(r"\s+", " ").str.strip_chars()
expr = expr.str.replace_all(_ENTRANCE_SUFFIX_RE, "")
expr = expr.str.replace_all(_BAY_SUFFIX_RE, "")
expr = expr.str.replace_all(
r"\s+(underground|tube|dlr|metro|rail|railway)\s+station$", ""
r"\s+(underground|tube|dlr|metro|metrolink|rail|railway)\s+station$", ""
)
expr = expr.str.replace_all(r"\s+tram\s+stop$", "")
expr = expr.str.replace_all(r"\s+(metrolink|tram)\s+stop$", "")
expr = expr.str.replace_all(r"\s+(station|stop)$", "")
expr = expr.str.replace_all(r"\s+metrolink$", "")
return expr.str.strip_chars()
@ -140,6 +244,7 @@ class StationAccumulator:
lat_sum: float
lng_sum: float
entrance: bool = False
is_lu: bool = False
count: int = 1
@property
@ -159,6 +264,7 @@ class StationAccumulator:
self.lat_sum += float(row["lat"])
self.lng_sum += float(row["lng"])
self.count += 1
self.is_lu = self.is_lu or bool(row.get("is_lu"))
name = str(row["name"] or "")
entrance = bool(row.get("entrance"))
@ -169,6 +275,16 @@ class StationAccumulator:
self.name = name
self.entrance = entrance
@property
def output_category(self) -> str:
# A merged tram/metro station is a genuine Tube station when ANY of its
# constituent nodes carries a London Underground ATCO code. Checking
# the whole group (not just the winning node) matters because LU
# entrance nodes often carry non-ZZLU codes (e.g. 4900VICT...).
if self.category == TRAM_METRO_CATEGORY and self.is_lu:
return TUBE_STATION_CATEGORY
return self.category
def _station_from_row(row: dict[str, object]) -> StationAccumulator:
return StationAccumulator(
@ -178,6 +294,7 @@ def _station_from_row(row: dict[str, object]) -> StationAccumulator:
lat_sum=float(row["lat"]),
lng_sum=float(row["lng"]),
entrance=bool(row.get("entrance")),
is_lu=bool(row.get("is_lu")),
)
@ -217,7 +334,7 @@ def _deduplicate_station_areas(df: pl.DataFrame) -> pl.DataFrame:
{
"id": [station.id for station in selected],
"name": [station.name for station in selected],
"category": [station.category for station in selected],
"category": [station.output_category for station in selected],
"lat": [station.lat for station in selected],
"lng": [station.lng for station in selected],
}
@ -258,10 +375,12 @@ def _deduplicate_local_stops(df: pl.DataFrame) -> pl.DataFrame:
def deduplicate_naptan(df: pl.DataFrame) -> pl.DataFrame:
"""Deduplicate NaPTAN stops, merging station/terminal entrances by area.
Tube, rail and ferry POIs are merged to one record per station by
normalized name + area, with the primary station/terminal node (e.g. RLY,
FER) winning over an entrance node (RSE, FTD). Other stops are deduplicated
by exact name+category+locality.
Tram/metro, rail, ferry and bus-station POIs are merged to one record per
station by normalized name + area, with the primary station/terminal node
(e.g. RLY, FER, MET, BST) winning over an entrance node (RSE, FTD, TMU,
BCE). Merged tram/metro stations with a London Underground ATCO code in
the group become "Tube station". Other stops are deduplicated by exact
name+category+locality.
"""
station = df.filter(pl.col("category").is_in(list(STATION_MERGE_CATEGORIES)))
other = df.filter(~pl.col("category").is_in(list(STATION_MERGE_CATEGORIES)))
@ -274,6 +393,29 @@ def deduplicate_naptan(df: pl.DataFrame) -> pl.DataFrame:
).select(OUTPUT_COLUMNS)
def filter_active_stops(df: pl.DataFrame) -> pl.DataFrame:
"""Keep only active NaPTAN stops.
The NaPTAN export's Status column marks stops as active/inactive/pending;
without this filter closed stations ("(closed)", "not in use") ship as
live POIs. Rows with a null Status are kept (benefit of the doubt); a
missing column is tolerated so older extracts still load.
"""
if "Status" not in df.columns:
print("WARNING: NaPTAN data has no Status column; keeping all stops")
return df
before = len(df)
df = df.filter(
pl.col("Status").is_null()
| pl.col("Status").str.strip_chars().str.to_lowercase().is_in(["active", "act"])
)
dropped = before - len(df)
if dropped:
print(f"Dropped {dropped:,} non-active stops (Status != active)")
return df
def download_naptan(output: Path) -> None:
output.parent.mkdir(parents=True, exist_ok=True)
@ -291,15 +433,19 @@ def download_naptan(output: Path) -> None:
)
.drop_nulls(subset=["Latitude", "Longitude"])
.filter(pl.col("StopType").is_in(list(STOP_TYPES.keys())))
.select(
pl.col("ATCOCode").alias("id"),
pl.col("CommonName").alias("name"),
pl.col("StopType").replace(STOP_TYPES).alias("category"),
pl.col("Latitude").alias("lat"),
pl.col("Longitude").alias("lng"),
pl.col("NptgLocalityCode").alias("locality"),
pl.col("StopType").is_in(list(ENTRANCE_STOP_TYPES)).alias("entrance"),
)
)
df = filter_active_stops(df).select(
pl.col("ATCOCode").alias("id"),
pl.col("CommonName").alias("name"),
pl.col("StopType").replace(STOP_TYPES).alias("category"),
pl.col("Latitude").alias("lat"),
pl.col("Longitude").alias("lng"),
pl.col("NptgLocalityCode").alias("locality"),
pl.col("StopType").is_in(list(ENTRANCE_STOP_TYPES)).alias("entrance"),
pl.col("ATCOCode")
.str.contains(LONDON_UNDERGROUND_ATCO_PATTERN)
.fill_null(False)
.alias("is_lu"),
)
before = len(df)

View file

@ -2,12 +2,15 @@
Downloads the OS Open Greenspace dataset as ESRI Shapefile and extracts
access point locations (park entrances). Each access point is tagged with
its parent site's function type (e.g. Public Park Or Garden). Sites without
access points fall back to polygon centroids.
its parent site's function type (e.g. Public Park Or Garden), the parent
site id and the site's polygon centroid. Sites without access points fall
back to polygon centroids.
Using access points rather than polygon centroids gives much more accurate
distance calculations a property next to Hyde Park won't show 400m just
because the centroid is in the middle of the park.
because the centroid is in the middle of the park. The site id / centroid
columns let downstream consumers (poi_proximity) collapse the frame back to
one row per SITE for counting, so a park with 30 gates counts as one park.
Source: https://osdatahub.os.uk/downloads/open/OpenGreenspace
License: Open Government Licence v3.0
@ -65,8 +68,8 @@ def _read_site_functions(shp_path: Path) -> dict[str, str]:
def _read_access_points(
shp_path: Path, site_funcs: dict[str, str]
) -> tuple[list[float], list[float], list[str]]:
"""Read access points, tagging each with its parent site's function."""
) -> tuple[list[float], list[float], list[str], list[str]]:
"""Read access points, tagging each with its parent site's function and id."""
reader = shp.Reader(str(shp_path), encoding="latin-1")
field_names = [f[0] for f in reader.fields[1:]]
@ -80,6 +83,7 @@ def _read_access_points(
lats: list[float] = []
lngs: list[float] = []
categories: list[str] = []
site_ids: list[str] = []
skipped = 0
error_skipped = 0
@ -107,6 +111,7 @@ def _read_access_points(
lats.append(lat)
lngs.append(lng)
categories.append(func)
site_ids.append(str(site_id))
if skipped:
print(f" Skipped {skipped:,} access points with unknown site ID")
@ -116,31 +121,26 @@ def _read_access_points(
error_skipped,
)
return lats, lngs, categories
return lats, lngs, categories, site_ids
def _read_site_centroids(
shp_path: Path, site_funcs: dict[str, str], covered_ids: set[str]
) -> tuple[list[float], list[float], list[str]]:
"""Read polygon centroids for sites that have no access points (fallback)."""
def _read_site_centroids(shp_path: Path) -> dict[str, tuple[float, float]]:
"""Compute the WGS84 polygon centroid of every greenspace site.
Used both as the representative point for site-level counting and as the
location fallback for sites that have no access points.
"""
reader = shp.Reader(str(shp_path), encoding="latin-1")
field_names = [f[0] for f in reader.fields[1:]]
id_idx = _find_field(field_names, "id")
func_idx = _find_field(field_names, "funct")
if id_idx is None or func_idx is None:
return [], [], []
if id_idx is None:
return {}
lats: list[float] = []
lngs: list[float] = []
categories: list[str] = []
centroids: dict[str, tuple[float, float]] = {}
error_skipped = 0
for sr in reader.shapeRecords():
site_id = sr.record[id_idx]
if site_id in covered_ids:
continue
func = sr.record[func_idx]
try:
geom = to_shapely(sr.shape.__geo_interface__)
if geom.is_empty or not geom.is_valid:
@ -156,9 +156,7 @@ def _read_site_centroids(
)
continue
lats.append(lat)
lngs.append(lng)
categories.append(func)
centroids[str(site_id)] = (lat, lng)
if error_skipped:
logger.warning(
@ -166,7 +164,7 @@ def _read_site_centroids(
error_skipped,
)
return lats, lngs, categories
return centroids
def download_greenspace(output: Path) -> None:
@ -194,33 +192,53 @@ def download_greenspace(output: Path) -> None:
# Step 2: Read access points (primary — park entrances)
print(f"Reading {access_shps[0].name}...")
ap_lats, ap_lngs, ap_cats = _read_access_points(access_shps[0], site_funcs)
ap_lats, ap_lngs, ap_cats, ap_site_ids = _read_access_points(
access_shps[0], site_funcs
)
print(f" {len(ap_lats):,} access points loaded")
# Step 3: Fall back to centroids for sites without any access points
covered_ids = set()
reader = shp.Reader(str(access_shps[0]), encoding="latin-1")
field_names = [f[0] for f in reader.fields[1:]]
ref_idx = _find_field(field_names, "refto", "ref_to", "greensp")
if ref_idx is not None:
for rec in reader.iterRecords():
covered_ids.add(rec[ref_idx])
# Step 3: Compute every site's centroid: the representative point for
# site-level counting, and the location fallback for sites without any
# access points.
print("Computing site centroids...")
centroids = _read_site_centroids(site_shps[0])
print(f" {len(centroids):,} site centroids computed")
print("Adding centroids for sites without access points...")
fb_lats, fb_lngs, fb_cats = _read_site_centroids(
site_shps[0], site_funcs, covered_ids
)
covered_ids = set(ap_site_ids)
fb_lats: list[float] = []
fb_lngs: list[float] = []
fb_cats: list[str] = []
fb_site_ids: list[str] = []
for site_id, (lat, lng) in centroids.items():
if site_id in covered_ids:
continue
func = site_funcs.get(site_id)
if func is None:
continue
fb_lats.append(lat)
fb_lngs.append(lng)
fb_cats.append(func)
fb_site_ids.append(site_id)
print(f" {len(fb_lats):,} centroid fallbacks added")
lats = ap_lats + fb_lats
lngs = ap_lngs + fb_lngs
categories = ap_cats + fb_cats
site_ids = ap_site_ids + fb_site_ids
site_lats = [centroids.get(site_id, (None, None))[0] for site_id in site_ids]
site_lngs = [centroids.get(site_id, (None, None))[1] for site_id in site_ids]
df = pl.DataFrame(
{
"lat": np.array(lats, dtype=np.float64),
"lng": np.array(lngs, dtype=np.float64),
"category": categories,
"site_id": site_ids,
# Site polygon centroid (null when the centroid could not be
# computed): the representative point when collapsing to one row
# per site for counting.
"site_lat": pl.Series(site_lats, dtype=pl.Float64),
"site_lng": pl.Series(site_lngs, dtype=pl.Float64),
}
)

View file

@ -641,7 +641,7 @@ def _naptan_dlr_stations(naptan_path: Path) -> list[dict]:
match = _DLR_CODE_RE.search(atco_id)
if not match:
continue
if row["category"] not in {"Tube station", "Rail station"}:
if row["category"] not in {"Tube station", "Tram & Metro stop", "Rail station"}:
continue
code = match.group(1)

View file

@ -1,65 +1,119 @@
import polars as pl
import pytest
from pipeline.download.ethnicity import _ethnicity_percentages
from pipeline.download.ethnicity import GROUP_MAP, OUTPUT_GROUPS, _ethnicity_percentages
def test_ethnicity_percentages_recombines_predecessor_lads_by_population():
rows = []
for code, white, indian in [
("E07000026", 80, 20),
("E07000028", 10, 90),
]:
total = white + indian
rows.extend(
[
{
"Geography_code": code,
"Ethnicity_type": "ONS 2021 19+1",
"Ethnicity": "White British",
"Ethnic Population": white,
"Value1": white / total * 100,
},
{
"Geography_code": code,
"Ethnicity_type": "ONS 2021 19+1",
"Ethnicity": "Indian",
"Ethnic Population": indian,
"Value1": indian / total * 100,
},
]
)
def _long_rows(geo: str, counts: dict[str, int]) -> list[dict]:
"""Build NOMIS-shaped long rows for one LSOA from {leaf_label: count}.
result = _ethnicity_percentages(pl.DataFrame(rows))
cumberland = result.filter(pl.col("Geography_code") == "E06000063")
assert cumberland.select("% White", "% South Asian").to_dicts() == [
{"% White": 45.0, "% South Asian": 55.0}
]
def test_ethnicity_routes_any_other_asian_to_east_se_asian():
"""'Any Other Asian Background' and 'Chinese' both fold into '% East/SE Asian'
(not '% South Asian'), fixing the East/SE Asian undercount."""
rows = [
Every one of the 19 leaf categories must be present in the download (NOMIS
emits a 0-count row when an LSOA has none), so categories not given default
to 0 to mirror that.
"""
return [
{
"Geography_code": "E06000001",
"Ethnicity_type": "ONS 2021 19+1",
"Ethnicity": ethnicity,
"Ethnic Population": pop,
"Value1": 0.0,
"GEOGRAPHY_CODE": geo,
"C2021_ETH_20_NAME": label,
"OBS_VALUE": counts.get(label, 0),
}
for ethnicity, pop in [
("Chinese", 30),
("Any Other Asian Background", 20),
("Indian", 50),
for label in GROUP_MAP
]
def test_ethnicity_percentages_keyed_by_lsoa_with_seven_buckets():
df = pl.DataFrame(
_long_rows(
"E01000001",
{
"White: English, Welsh, Scottish, Northern Irish or British": 60,
"White: Other White": 10,
"Asian, Asian British or Asian Welsh: Indian": 20,
"Black, Black British, Black Welsh, Caribbean or African: African": 10,
},
)
)
result = _ethnicity_percentages(df)
assert result.columns[0] == "lsoa21"
assert set(result.columns) == {"lsoa21", *(f"% {g}" for g in OUTPUT_GROUPS)}
row = result.filter(pl.col("lsoa21") == "E01000001").to_dicts()[0]
assert row["% White"] == 70.0
assert row["% South Asian"] == 20.0
assert row["% Black"] == 10.0
# Percentages always sum to exactly 100 (largest-remainder rounding).
assert round(sum(row[f"% {g}"] for g in OUTPUT_GROUPS), 1) == 100.0
def test_ethnicity_routes_chinese_to_east_and_other_asian_to_se():
"""'Chinese' folds into '% East Asian' and 'Other Asian' into '% SE Asian'
(neither into '% South Asian'), keeping the two Asian buckets distinct."""
df = pl.DataFrame(
_long_rows(
"E01000002",
{
"Asian, Asian British or Asian Welsh: Chinese": 30,
"Asian, Asian British or Asian Welsh: Other Asian": 20,
"Asian, Asian British or Asian Welsh: Indian": 50,
},
)
)
result = _ethnicity_percentages(df)
area = result.filter(pl.col("lsoa21") == "E01000002")
assert "% East Asian" in result.columns
assert "% SE Asian" in result.columns
assert "% East/SE Asian" not in result.columns
assert area.select("% East Asian", "% SE Asian", "% South Asian").to_dicts() == [
{"% East Asian": 30.0, "% SE Asian": 20.0, "% South Asian": 50.0}
]
def test_ethnicity_percentages_independent_per_lsoa():
"""Two LSOAs get independent profiles — the LSOA granularity is the point."""
df = pl.concat(
[
pl.DataFrame(
_long_rows(
"E01000010",
{"White: Other White": 100},
)
),
pl.DataFrame(
_long_rows(
"E01000011",
{"Asian, Asian British or Asian Welsh: Pakistani": 100},
)
),
]
]
)
result = _ethnicity_percentages(pl.DataFrame(rows))
area = result.filter(pl.col("Geography_code") == "E06000001")
result = _ethnicity_percentages(df).sort("lsoa21")
assert "% East/SE Asian" in result.columns
assert "% East Asian" not in result.columns
assert area.select("% East/SE Asian", "% South Asian").to_dicts() == [
{"% East/SE Asian": 50.0, "% South Asian": 50.0}
]
assert result["% White"].to_list() == [100.0, 0.0]
assert result["% South Asian"].to_list() == [0.0, 100.0]
def test_ethnicity_percentages_rejects_unexpected_category():
rows = _long_rows("E01000003", {"White: Other White": 10})
rows.append(
{
"GEOGRAPHY_CODE": "E01000003",
"C2021_ETH_20_NAME": "White: A Brand New Census Category",
"OBS_VALUE": 5,
}
)
with pytest.raises(ValueError, match="do not match the expected"):
_ethnicity_percentages(pl.DataFrame(rows))
def test_ethnicity_percentages_rejects_missing_category():
# Drop one leaf entirely: its people would vanish from the denominator.
rows = [r for r in _long_rows("E01000004", {"White: Other White": 10}) if
r["C2021_ETH_20_NAME"] != "Other ethnic group: Arab"]
with pytest.raises(ValueError, match="missing"):
_ethnicity_percentages(pl.DataFrame(rows))

View file

@ -2,9 +2,12 @@ import polars as pl
import pytest
from pipeline.download.naptan import (
TRAM_METRO_CATEGORY,
TUBE_STATION_CATEGORY,
canonical_station_name,
canonical_station_name_expr,
deduplicate_naptan,
filter_active_stops,
)
@ -34,6 +37,127 @@ def test_canonical_station_name_expr_normalizes_transport_suffixes():
assert [canonical_station_name(name) for name in names] == result
def test_canonical_station_name_strips_entrance_suffixes():
# Real shipped NaPTAN entrance names that previously failed to merge with
# their station node (79 stray entrance POIs).
cases = {
"Weaste Metrolink Station North East Entrance": "weaste",
"Weaste Metrolink Station North Entrance No 2": "weaste",
"Whitefield Metrolink Station Main Entrance": "whitefield",
"Radcliffe Metrolink Station Entrance": "radcliffe",
"Stretford Metrolink Station Wt Platform Entrance": "stretford",
"Salford Quays Metrolink Station SW entrance": "salford quays",
"Bank Station Ent 2": "bank",
"Hainault": "hainault",
# The Metrolink MET node names collapse to the same key.
"Weaste (Manchester Metrolink)": "weaste",
# No entrance word: direction/filler words must NOT be stripped.
"Maze Hill North": "maze hill north",
"Bus Station Entrance": "bus",
# Bus-station bay/stand designators collapse to the station name…
"Tonypandy Bus Station Stand A3": "tonypandy bus",
"Caerphilly Interchange Stand 5": "caerphilly interchange",
"Stanley Bus Station Stand G": "stanley bus",
# …but a bare trailing "Bay" (place names) is untouched.
"Colwyn Bay": "colwyn bay",
}
for name, expected in cases.items():
assert canonical_station_name(name) == expected, name
df = pl.DataFrame({"name": list(cases.keys())})
expr_result = df.select(canonical_station_name_expr().alias("key"))["key"].to_list()
assert expr_result == list(cases.values())
def test_filter_active_stops_drops_non_active():
df = pl.DataFrame(
{
"ATCOCode": ["a", "b", "c", "d"],
"Status": ["active", "inactive", None, "Pending"],
}
)
result = filter_active_stops(df)
# Active and unknown (null) statuses survive; inactive/pending are dropped.
assert result["ATCOCode"].to_list() == ["a", "c"]
def test_filter_active_stops_tolerates_missing_status_column():
df = pl.DataFrame({"ATCOCode": ["a"]})
assert filter_active_stops(df)["ATCOCode"].to_list() == ["a"]
def test_deduplicate_naptan_splits_london_underground_from_tram_metro():
# MET station nodes plus TMU entrances, pre-categorised as the tram/metro
# family. The Hainault group contains a 940GZZLU station node, so the
# merged POI is a genuine "Tube station" even though its entrance carries a
# non-ZZLU ATCO code; the Metrolink group stays "Tram & Metro stop".
df = pl.DataFrame(
{
"id": [
"940GZZLUHLT",
"490000095003",
"9400ZZMAWST",
"1800NFR2691",
],
"name": [
"Hainault Underground Station",
"Hainault",
"Weaste (Manchester Metrolink)",
"Weaste Metrolink Station North West Entrance",
],
"category": [TRAM_METRO_CATEGORY] * 4,
"lat": [51.6034, 51.6037, 53.4826, 53.4826],
"lng": [0.0933, 0.0931, -2.3087, -2.3086],
"locality": [None, None, None, None],
"entrance": [False, True, False, True],
"is_lu": [True, False, False, False],
}
)
result = deduplicate_naptan(df).sort("category")
assert len(result) == 2
assert result["category"].to_list() == [
TRAM_METRO_CATEGORY,
TUBE_STATION_CATEGORY,
]
tube = result.filter(pl.col("category") == TUBE_STATION_CATEGORY)
# The station node (not the entrance) represents the merged POI.
assert tube["id"][0] == "940GZZLUHLT"
tram = result.filter(pl.col("category") == TRAM_METRO_CATEGORY)
assert tram["id"][0] == "9400ZZMAWST"
def test_deduplicate_naptan_merges_bus_station_bays_and_entrances():
# BCS bays and a BCE entrance of one bus station collapse to a single POI
# represented by a non-entrance node; a different bus station in another
# area survives separately.
df = pl.DataFrame(
{
"id": ["bay-1", "bay-2", "ent-1", "other"],
"name": [
"Bury Interchange",
"Bury Interchange",
"Bury Interchange East Entrance",
"Rochdale Interchange",
],
"category": ["Bus station"] * 4,
"lat": [53.5907, 53.5908, 53.5909, 53.6160],
"lng": [-2.2958, -2.2957, -2.2956, -2.1561],
"locality": ["BURY", "BURY", "BURY", "ROCHDALE"],
"entrance": [False, False, True, False],
}
)
result = deduplicate_naptan(df).sort("name")
assert result["name"].to_list() == ["Bury Interchange", "Rochdale Interchange"]
assert result.filter(pl.col("name") == "Bury Interchange")["id"][0] == "bay-1"
def test_deduplicate_naptan_merges_tube_station_variants_by_area():
df = pl.DataFrame(
{

View file

@ -86,7 +86,7 @@ def test_naptan_dlr_stations_are_deduplicated_by_atco_code(tmp_path):
"Bank",
],
"category": [
"Tube station",
"Tram & Metro stop",
"Tube station",
"Rail station",
"Bus stop",

View file

@ -1,11 +1,15 @@
"""Tests for transit_network GTFS processing."""
import datetime as dt
import zipfile
from pathlib import Path
import pytest
from pipeline.download.transit_network import convert_high_freq_to_frequency_based
from pipeline.download.transit_network import (
convert_high_freq_to_frequency_based,
validate_gtfs_feed,
)
def _write_gtfs(path: Path, *, stop_times: str) -> None:
@ -77,3 +81,162 @@ def test_raises_when_no_first_stops_found(tmp_path: Path) -> None:
with pytest.raises(RuntimeError, match="no first stops"):
convert_high_freq_to_frequency_based(src, dst)
# ── validate_gtfs_feed ────────────────────────────────────────────────────────
TODAY = dt.date(2026, 6, 10)
def _make_gtfs(
path: Path,
*,
calendar: str | None = (
"service_id,monday,tuesday,wednesday,thursday,friday,saturday,sunday,"
"start_date,end_date\n"
"S1,1,1,1,1,1,0,0,20260101,20271231\n"
),
calendar_dates: str | None = None,
stops: str = (
"stop_id,stop_name,stop_lat,stop_lon\n"
"STOP_A,Bank,51.5133,-0.0886\n"
"STOP_B,Liverpool Street,51.5178,-0.0823\n"
),
routes: str = "route_id,agency_id,route_short_name,route_type\nR1,OP1,Central,1\n",
trips: str = "trip_id,route_id,service_id\nT1,R1,S1\n",
stop_times: str = (
"trip_id,stop_sequence,departure_time,stop_id\n"
"T1,0,06:00:00,STOP_A\n"
"T1,1,06:02:00,STOP_B\n"
),
) -> Path:
"""Write a tiny synthetic GTFS zip; defaults form a valid current feed."""
with zipfile.ZipFile(path, "w") as z:
if calendar is not None:
z.writestr("calendar.txt", calendar)
if calendar_dates is not None:
z.writestr("calendar_dates.txt", calendar_dates)
z.writestr("stops.txt", stops)
z.writestr("routes.txt", routes)
z.writestr("trips.txt", trips)
z.writestr("stop_times.txt", stop_times)
return path
def test_validate_gtfs_feed_happy_path(tmp_path: Path) -> None:
feed = _make_gtfs(tmp_path / "feed.zip")
validate_gtfs_feed(feed, "test feed", today=TODAY) # must not raise
def test_validate_gtfs_feed_expired_calendar(tmp_path: Path) -> None:
"""The 2010 TfL snapshot failure mode: all calendars ended years ago."""
feed = _make_gtfs(
tmp_path / "feed.zip",
calendar=(
"service_id,monday,tuesday,wednesday,thursday,friday,saturday,sunday,"
"start_date,end_date\n"
"S1,1,1,1,1,1,0,0,20091201,20101224\n"
),
)
with pytest.raises(RuntimeError, match=r"'stale tfl'.*no service active"):
validate_gtfs_feed(feed, "stale tfl", today=TODAY)
def test_validate_gtfs_feed_calendar_starting_after_window_fails(
tmp_path: Path,
) -> None:
feed = _make_gtfs(
tmp_path / "feed.zip",
calendar=(
"service_id,monday,tuesday,wednesday,thursday,friday,saturday,sunday,"
"start_date,end_date\n"
"S1,1,1,1,1,1,0,0,20270101,20271231\n"
),
)
with pytest.raises(RuntimeError, match="no service active"):
validate_gtfs_feed(feed, "future feed", today=TODAY)
def test_validate_gtfs_feed_calendar_dates_rescues_expired_calendar(
tmp_path: Path,
) -> None:
"""An expired calendar.txt passes if calendar_dates.txt adds service now."""
feed = _make_gtfs(
tmp_path / "feed.zip",
calendar=(
"service_id,monday,tuesday,wednesday,thursday,friday,saturday,sunday,"
"start_date,end_date\n"
"S1,1,1,1,1,1,0,0,20091201,20101224\n"
),
calendar_dates="service_id,date,exception_type\nS1,20260615,1\n",
)
validate_gtfs_feed(feed, "rescued feed", today=TODAY) # must not raise
def test_validate_gtfs_feed_removed_service_exception_does_not_count(
tmp_path: Path,
) -> None:
feed = _make_gtfs(
tmp_path / "feed.zip",
calendar=None,
calendar_dates="service_id,date,exception_type\nS1,20260615,2\n",
)
with pytest.raises(RuntimeError, match="no service active"):
validate_gtfs_feed(feed, "removed-only feed", today=TODAY)
def test_validate_gtfs_feed_zero_and_empty_coords(tmp_path: Path) -> None:
"""The 2010 TfL snapshot's other failure mode: empty or 0,0 stop coords."""
feed = _make_gtfs(
tmp_path / "feed.zip",
stops=(
"stop_id,stop_name,stop_lat,stop_lon\n"
"STOP_A,Nowhere,0,0\n"
"STOP_B,Blank,,\n"
),
)
with pytest.raises(RuntimeError, match=r"plausible UK coordinates"):
validate_gtfs_feed(feed, "coordless feed", today=TODAY)
def test_validate_gtfs_feed_non_uk_coords_fail(tmp_path: Path) -> None:
feed = _make_gtfs(
tmp_path / "feed.zip",
stops=(
"stop_id,stop_name,stop_lat,stop_lon\n"
"STOP_A,New York,40.71,-74.0\n"
"STOP_B,Sydney,-33.87,151.21\n"
),
)
with pytest.raises(RuntimeError, match="plausible UK coordinates"):
validate_gtfs_feed(feed, "abroad feed", today=TODAY)
def test_validate_gtfs_feed_minority_bad_coords_pass(tmp_path: Path) -> None:
"""One bad stop out of 30 (3.3%) stays under the 5% tolerance."""
rows = [f"STOP_{i},Stop {i},51.5,{-0.1 + i * 0.001}\n" for i in range(29)]
rows.append("STOP_BAD,Broken,0,0\n")
feed = _make_gtfs(
tmp_path / "feed.zip",
stops="stop_id,stop_name,stop_lat,stop_lon\n" + "".join(rows),
)
validate_gtfs_feed(feed, "mostly good feed", today=TODAY) # must not raise
def test_validate_gtfs_feed_empty_trips(tmp_path: Path) -> None:
feed = _make_gtfs(tmp_path / "feed.zip", trips="trip_id,route_id,service_id\n")
with pytest.raises(RuntimeError, match="trips.txt has no data rows"):
validate_gtfs_feed(feed, "tripless feed", today=TODAY)
def test_validate_gtfs_feed_missing_calendar_files(tmp_path: Path) -> None:
feed = _make_gtfs(tmp_path / "feed.zip", calendar=None)
with pytest.raises(RuntimeError, match="neither calendar.txt nor calendar_dates"):
validate_gtfs_feed(feed, "calendarless feed", today=TODAY)
def test_validate_gtfs_feed_not_a_zip(tmp_path: Path) -> None:
bogus = tmp_path / "feed.zip"
bogus.write_text("not a zip")
with pytest.raises(RuntimeError, match="not a valid zip"):
validate_gtfs_feed(bogus, "bogus feed", today=TODAY)

View file

@ -2,24 +2,32 @@
Downloads:
- England OSM PBF from Geofabrik (~1.5GB)
- BODS GTFS from Bus Open Data Service (~1.5GB, all England bus/tram/ferry)
- TfL TransXChange timetables converted to GTFS
- National Rail CIF timetable converted to GTFS (requires credentials)
- BODS GTFS from Bus Open Data Service (~1.5GB; all England bus/tram/ferry,
plus London Underground, DLR, London Tramlink and the IFS Cloud Cable Car)
- National Rail CIF timetable converted to GTFS (requires credentials;
includes the Elizabeth line, TOC "XR")
Then processes for R5 compatibility:
- Cleans BODS GTFS (fixes stop_times >72h, feed_info year >2100)
- Converts high-frequency metro/tram services to frequency-based GTFS
- Converts TfL TransXChange to GTFS via transxchange2gtfs
- Converts National Rail CIF to GTFS via dtd2mysql (requires MariaDB Docker)
- Validates every produced GTFS zip (active calendar window, plausible UK
stop coordinates, non-empty routes/trips/stop_times)
Requires: osmium-tool, Node.js (npx), Docker (for national rail)
Note: the legacy TfL TransXChange feed (tfl.gov.uk journey-planner-timetables)
was removed: that URL serves a 2010-10-28 snapshot whose calendars all expired
in 2010 and whose stops have empty/0,0 coordinates, so it contributed zero
service. BODS covers all TfL modes that feed nominally provided.
Requires: osmium-tool, Docker (for national rail)
Output directory: property-data/transit/
raw/england.osm.pbf + bods_gtfs.zip + tfl_gtfs.zip + national_rail_gtfs.zip
raw/england.osm.pbf + bods_gtfs.zip + national_rail_gtfs.zip
"""
import argparse
import csv
import datetime as dt
import io
import json
import os
@ -45,20 +53,18 @@ ENGLAND_PBF_URL = (
# Bus Open Data Service — pre-converted GTFS covering all England bus/tram/ferry
BODS_GTFS_URL = "https://data.bus-data.dft.gov.uk/timetable/download/gtfs-file/all/"
# TfL TransXChange timetables (tube, DLR, tram, buses, river bus, cable car)
TFL_TRANSXCHANGE_URL = (
"https://tfl.gov.uk/cdn/static/cms/documents/journey-planner-timetables.zip"
)
# NaPTAN stops data — needed by transxchange2gtfs (its built-in URL is broken)
NAPTAN_URL = "https://naptan.api.dft.gov.uk/v1/access-nodes?dataFormat=csv"
# National Rail Open Data API
NR_AUTH_URL = "https://opendata.nationalrail.co.uk/authenticate"
NR_TIMETABLE_URL = "https://opendata.nationalrail.co.uk/api/staticfeeds/3.0/timetable"
USER_AGENT = "property-map-pipeline/1.0 (https://github.com)"
TRANSXCHANGE2GTFS_PACKAGE = "transxchange2gtfs@1.12.0"
# GTFS validation: a feed must have service within this many days of the build
# date, and at least this fraction of stops must have plausible UK coordinates.
GTFS_CALENDAR_LOOKAHEAD_DAYS = 60
GTFS_MIN_VALID_STOP_FRACTION = 0.95
UK_LAT_RANGE = (49.0, 61.0)
UK_LON_RANGE = (-9.0, 2.5)
def _download_http(
@ -468,89 +474,175 @@ def convert_high_freq_to_frequency_based(
print(f" Saved to {dst}")
def download_tfl_transxchange(raw_dir: Path) -> Path:
"""Download TfL TransXChange timetable bundle."""
dest = raw_dir / "tfl_transxchange.zip"
if dest.exists():
print(f"TfL TransXChange already exists: {dest}")
return dest
print("Downloading TfL TransXChange timetables...")
_download_http(TFL_TRANSXCHANGE_URL, dest, desc="tfl_transxchange.zip")
return dest
def _gtfs_has_data_row(z: zipfile.ZipFile, filename: str) -> bool:
"""True if a GTFS file has at least one non-empty data row after the header."""
with z.open(filename) as f:
f.readline() # header
for line in f:
if _parse_csv_line(line):
return True
return False
def download_naptan() -> None:
"""Download NaPTAN stops to the local temp dir for transxchange2gtfs."""
dest = local_tmp_dir() / "Stops.csv"
if dest.exists():
print(f"NaPTAN Stops.csv already exists: {dest}")
return
def _calendar_active_in_window(
z: zipfile.ZipFile, names: set[str], window_start: int, window_end: int
) -> bool:
"""True if calendar.txt/calendar_dates.txt have service in [start, end].
print("Downloading NaPTAN stops data...")
_download_http(NAPTAN_URL, dest, desc="Stops.csv")
def convert_tfl_to_gtfs(raw_dir: Path, output_dir: Path) -> Path:
"""Convert TfL TransXChange to GTFS using transxchange2gtfs."""
dest = output_dir / "tfl_gtfs.zip"
if dest.exists():
print(f"TfL GTFS already exists: {dest}")
return dest
txc_path = raw_dir / "tfl_transxchange.zip"
# Ensure NaPTAN is available (transxchange2gtfs has a broken download URL)
download_naptan()
print("Converting TfL TransXChange → GTFS...")
# The shim patches known packaging/runtime issues in the pinned npm package
# before loading its CLI from npx's temporary install.
shim_path = Path(__file__).with_name("transxchange2gtfs_shim.js")
subprocess.run(
[
"npx",
"--yes",
"--package",
TRANSXCHANGE2GTFS_PACKAGE,
"sh",
"-c",
"\n".join(
[
'bin="$(command -v transxchange2gtfs)"',
'script="$(readlink -f "$bin")"',
'pkg_dir="$(dirname "$(dirname "$script")")"',
'shim="$1"',
"shift",
'exec node "$shim" "$pkg_dir" "$@"',
]
),
"transxchange2gtfs",
str(shim_path.resolve()),
str(txc_path.resolve()),
str(dest.resolve()),
],
check=True,
Dates are compared as YYYYMMDD integers. A calendar.txt row counts when its
date range overlaps the window AND at least one weekday flag is set; a
calendar_dates.txt row counts when it adds service (exception_type=1) on a
date inside the window.
"""
weekdays = (
"monday",
"tuesday",
"wednesday",
"thursday",
"friday",
"saturday",
"sunday",
)
if "calendar.txt" in names:
with z.open("calendar.txt") as f:
cols = _parse_csv_line(f.readline())
try:
start_idx = cols.index("start_date")
end_idx = cols.index("end_date")
except ValueError:
return False
day_idxs = [cols.index(d) for d in weekdays if d in cols]
for line in f:
parts = _parse_csv_line(line)
if not parts:
continue
try:
start = int(parts[start_idx].strip('"'))
end = int(parts[end_idx].strip('"'))
except (ValueError, IndexError):
continue
if start > window_end or end < window_start:
continue
if day_idxs and not any(
parts[i].strip('"') == "1" for i in day_idxs if i < len(parts)
):
continue
return True
if "calendar_dates.txt" in names:
with z.open("calendar_dates.txt") as f:
cols = _parse_csv_line(f.readline())
try:
date_idx = cols.index("date")
exc_idx = cols.index("exception_type")
except ValueError:
return False
for line in f:
parts = _parse_csv_line(line)
if not parts:
continue
try:
date = int(parts[date_idx].strip('"'))
except (ValueError, IndexError):
continue
if exc_idx < len(parts) and parts[exc_idx].strip('"') != "1":
continue
if window_start <= date <= window_end:
return True
return False
def validate_gtfs_feed(path: Path, feed_name: str, *, today: dt.date | None = None) -> None:
"""Sanity-check a produced/downloaded GTFS zip; raise RuntimeError if dead.
Guards against silently shipping a feed that contributes zero service (as
the old TfL dump did: 2010 calendars, empty/0,0 stop coordinates). Checks:
(a) calendar.txt/calendar_dates.txt have at least one service active
within [today, today + GTFS_CALENDAR_LOOKAHEAD_DAYS];
(b) stops.txt is non-empty and >= GTFS_MIN_VALID_STOP_FRACTION of stops
have plausible UK coordinates (lat 49-61, lon -9..2.5, not 0,0);
(c) routes.txt, trips.txt and stop_times.txt each have data rows.
"""
if today is None:
today = dt.date.today()
window_start = int(today.strftime("%Y%m%d"))
window_end = int(
(today + dt.timedelta(days=GTFS_CALENDAR_LOOKAHEAD_DAYS)).strftime("%Y%m%d")
)
def fail(reason: str) -> None:
raise RuntimeError(
f"GTFS validation failed for feed '{feed_name}' ({path}): {reason}"
)
print(f"Validating GTFS feed '{feed_name}'...")
if not path.exists() or not zipfile.is_zipfile(path):
fail("not a valid zip file")
with zipfile.ZipFile(path) as z:
names = set(z.namelist())
# (c) core files present and non-empty
for required in ("routes.txt", "trips.txt", "stop_times.txt", "stops.txt"):
if required not in names:
fail(f"missing {required}")
if not _gtfs_has_data_row(z, required):
fail(f"{required} has no data rows")
# (a) at least one service active in the routing window
if "calendar.txt" not in names and "calendar_dates.txt" not in names:
fail("has neither calendar.txt nor calendar_dates.txt")
if not _calendar_active_in_window(z, names, window_start, window_end):
fail(
f"no service active between {window_start} and {window_end}"
"the feed's calendars are stale/expired and it would contribute "
"zero service to routing"
)
# (b) stops have plausible UK coordinates
total_stops = 0
valid_stops = 0
with z.open("stops.txt") as f:
cols = _parse_csv_line(f.readline())
try:
lat_idx = cols.index("stop_lat")
lon_idx = cols.index("stop_lon")
except ValueError:
fail("stops.txt is missing stop_lat/stop_lon columns")
for line in f:
parts = _parse_csv_line(line)
if not parts:
continue
total_stops += 1
try:
lat = float(parts[lat_idx].strip('"'))
lon = float(parts[lon_idx].strip('"'))
except (ValueError, IndexError):
continue # empty/garbage coordinate → invalid
if lat == 0.0 and lon == 0.0:
continue
if (
UK_LAT_RANGE[0] <= lat <= UK_LAT_RANGE[1]
and UK_LON_RANGE[0] <= lon <= UK_LON_RANGE[1]
):
valid_stops += 1
if total_stops == 0:
fail("stops.txt has no stops")
fraction = valid_stops / total_stops
if fraction < GTFS_MIN_VALID_STOP_FRACTION:
fail(
f"only {valid_stops}/{total_stops} stops "
f"({fraction:.1%}) have plausible UK coordinates "
f"(lat {UK_LAT_RANGE[0]}-{UK_LAT_RANGE[1]}, "
f"lon {UK_LON_RANGE[0]}..{UK_LON_RANGE[1]}, non-null, not 0,0); "
f"need >= {GTFS_MIN_VALID_STOP_FRACTION:.0%}"
)
print(
f" OK: service active in window, {valid_stops}/{total_stops} stops "
f"({fraction:.1%}) with plausible UK coordinates"
)
required_files = {
"agency.txt",
"calendar.txt",
"calendar_dates.txt",
"routes.txt",
"stop_times.txt",
"stops.txt",
"trips.txt",
}
if not dest.exists() or not zipfile.is_zipfile(dest):
raise RuntimeError(f"transxchange2gtfs did not create a valid GTFS zip: {dest}")
with zipfile.ZipFile(dest) as z:
missing = required_files - set(z.namelist())
if missing:
missing_str = ", ".join(sorted(missing))
raise RuntimeError(f"TfL GTFS zip is missing required files: {missing_str}")
size_mb = dest.stat().st_size / (1024 * 1024)
print(f" Saved to {dest} ({size_mb:.1f} MB)")
return dest
def download_national_rail_cif(raw_dir: Path) -> Path | None:
@ -560,8 +652,9 @@ def download_national_rail_cif(raw_dir: Path) -> Path | None:
print(f"National Rail CIF already exists: {dest}")
return dest
email = os.environ.get("NATIONAL_RAIL_EMAIL")
password = os.environ.get("NATIONAL_RAIL_PASSWORD")
# Free National Rail Open Data account; env vars override the baked-in default.
email = os.environ.get("NATIONAL_RAIL_EMAIL", "schmelczerandras@gmail.com")
password = os.environ.get("NATIONAL_RAIL_PASSWORD", "z8^b!4GhCS8kj1Vp")
if not email or not password:
print(
"Warning: NATIONAL_RAIL_EMAIL/NATIONAL_RAIL_PASSWORD not set, skipping national rail"
@ -1006,23 +1099,15 @@ def main() -> None:
required=True,
help="Output directory for transit data",
)
parser.add_argument(
"--skip-tfl",
action="store_true",
help="Skip TfL TransXChange download and conversion",
)
parser.add_argument(
"--skip-national-rail",
action="store_true",
help="Skip National Rail CIF download and conversion",
)
args = parser.parse_args()
output_dir: Path = args.output
raw_dir = output_dir / "raw"
raw_dir.mkdir(parents=True, exist_ok=True)
# 1. Download, clean, and frequency-convert BODS GTFS
# 1. Download, clean, and frequency-convert BODS GTFS. BODS covers all
# England bus/tram/ferry plus London Underground, DLR, London Tramlink and
# the IFS Cloud Cable Car, so no separate TfL feed is needed.
download_osm_pbf(raw_dir)
bods_raw = download_bods_gtfs(raw_dir)
@ -1031,21 +1116,23 @@ def main() -> None:
bods_final = output_dir / "bods_gtfs.zip"
convert_high_freq_to_frequency_based(bods_cleaned, bods_final)
validate_gtfs_feed(bods_final, "BODS GTFS")
# 2. TfL TransXChange → GTFS
if args.skip_tfl:
print("Skipping TfL (--skip-tfl)")
else:
download_tfl_transxchange(raw_dir)
convert_tfl_to_gtfs(raw_dir, output_dir)
# 3. National Rail CIF → GTFS
if args.skip_national_rail:
print("Skipping National Rail (--skip-national-rail)")
else:
cif = download_national_rail_cif(raw_dir)
if cif is not None:
convert_national_rail_to_gtfs(raw_dir, output_dir)
# 2. National Rail CIF → GTFS. Heavy rail is mandatory: trains are how people
# reach the ~2,725 railway-station destinations, so a bus/metro-only network
# silently overstates every train commute. Missing credentials are a HARD
# error, so a rail-less network can never ship.
cif = download_national_rail_cif(raw_dir)
if cif is None:
raise RuntimeError(
"National Rail timetable was not downloaded — set "
"NATIONAL_RAIL_EMAIL / NATIONAL_RAIL_PASSWORD (register free at "
"https://opendata.nationalrail.co.uk/). National Rail heavy rail is "
"required; without it the transit network models every train journey "
"as bus-only and overstates commute times."
)
nr_final = convert_national_rail_to_gtfs(raw_dir, output_dir)
validate_gtfs_feed(nr_final, "National Rail GTFS")
# Summary
print()

View file

@ -1,106 +0,0 @@
#!/usr/bin/env node
"use strict";
const fs = require("fs");
const path = require("path");
const { createRequire } = require("module");
const [pkgDirArg, ...converterArgs] = process.argv.slice(2);
if (!pkgDirArg || converterArgs.length < 2) {
console.error(
"Usage: transxchange2gtfs_shim.js <package-dir> <input...> <output>",
);
process.exit(2);
}
const pkgDir = path.resolve(pkgDirArg);
const defaultTmpDir = path.resolve(__dirname, "..", "..", ".tmp");
const localTmpDir =
process.env.TMPDIR || process.env.TEMP || process.env.TMP || defaultTmpDir;
const stopsCsv = path.join(localTmpDir, "Stops.csv");
const converterTmpPrefix = path.join(localTmpDir, "transxchange2gtfs_");
const converterTmpPatch =
`static TMP = ${JSON.stringify(converterTmpPrefix)}` +
` + process.pid + ${JSON.stringify(path.sep)};`;
fs.mkdirSync(localTmpDir, { recursive: true });
function replaceOnce(relativePath, before, after) {
const file = path.join(pkgDir, relativePath);
const original = fs.readFileSync(file, "utf8");
if (original.includes(before)) {
fs.writeFileSync(file, original.replace(before, after));
} else if (original.includes(after)) {
return;
} else {
throw new Error(`Could not patch ${relativePath}: expected text not found`);
}
}
// The published 1.12.0 package has a few compatibility issues with current
// TfL TransXChange exports:
// - the bin script points at dist/src/cli.js, but the package ships dist/cli.js
// - the compiled date-holidays import expects a synthetic default export
// - some TfL journeys reference timing links without matching route-link geometry
//
// GTFS shapes are optional for R5 routing. Clear shape references and omit
// shapes.txt so missing route geometry does not drop otherwise usable trips.
function patchPackage() {
replaceOnce(
"dist/Container.js",
"static TMP = `/tmp/transxchange2gtfs_${process.pid}/`;",
converterTmpPatch,
);
replaceOnce(
"dist/Container.js",
'fs.existsSync("/tmp/Stops.csv")',
`fs.existsSync(${JSON.stringify(stopsCsv)})`,
);
replaceOnce(
"dist/Container.js",
'fs.createReadStream("/tmp/Stops.csv", "utf8")',
`fs.createReadStream(${JSON.stringify(stopsCsv)}, "utf8")`,
);
replaceOnce(
"dist/converter/GetStopData.js",
'fs.createWriteStream("/tmp/Stops.csv")',
`fs.createWriteStream(${JSON.stringify(stopsCsv)})`,
);
replaceOnce(
"dist/transxchange/TransXChangeJourneyStream.js",
"distanceSoFarM += routeLink.Distance;",
"distanceSoFarM += routeLink ? routeLink.Distance : 0;",
);
replaceOnce(
"dist/gtfs/TripsStream.js",
"(0, crypto_1.createHash)('md5').update(JSON.stringify({ routeId: journey.route, routeLinkSeq: journey.routeLinkIds })).digest(\"hex\"));",
"\"\");",
);
replaceOnce(
"dist/gtfs/StopTimesStream.js",
"stop.shapeDistTraveled, stop.exactTime ? \"1\" : \"0\");",
"\"\", stop.exactTime ? \"1\" : \"0\");",
);
replaceOnce(
"dist/Container.js",
"\"stops.txt\": transxchange.pipe(new StopsStream_1.StopsStream(naptanIndex)),\n \"shapes.txt\": journeyStream.pipe(new ShapesStream_1.ShapesStream())",
"\"stops.txt\": transxchange.pipe(new StopsStream_1.StopsStream(naptanIndex))",
);
replaceOnce(
"dist/Container.js",
"\"routes.txt\": transxchange.pipe(new RoutesStream_1.RoutesStream()),\n \"transfers.txt\": transxchange.pipe(new TransfersStream_1.TransfersStream(naptanIndex, locationIndex)),\n \"stops.txt\": transxchange.pipe(new StopsStream_1.StopsStream(naptanIndex))",
"\"routes.txt\": transxchange.pipe(new RoutesStream_1.RoutesStream()),\n \"stops.txt\": transxchange.pipe(new StopsStream_1.StopsStream(naptanIndex))",
);
}
patchPackage();
const pkgRequire = createRequire(path.join(pkgDir, "package.json"));
const Holidays = pkgRequire("date-holidays");
if (!Holidays.default) {
Holidays.default = Holidays;
}
process.argv = [process.argv[0], "transxchange2gtfs", ...converterArgs];
require(path.join(pkgDir, "dist", "cli.js"));

View file

@ -273,27 +273,24 @@ def _write_avg_yr(
for type_idx, name in enumerate(ALL_CRIME_TYPES):
data[f"{name} (avg/yr)"] = avg[:, type_idx]
# Serious/Minor rollup headlines, computed the SAME way as the by-year rollup
# bars (_write_by_year/_rollup_long): sum the rollup's types per year, then
# average over the years in which ANY of those types occurred. This keeps the
# headline equal to the mean of the "Serious/Minor crime (by year)" bars.
# Summing the per-type avg/yr values instead (as the merge previously did)
# divides each type by its OWN years-present and overstates the rollup when a
# postcode's serious/minor types occur in disjoint years.
# Serious/Minor rollup headlines = the exact SUM of their component (avg/yr)
# columns, so each rollup always equals the sum of the parts shown beside it
# and can never fall below one of its own components. (Previously the rollup
# re-derived a union-years-present mean: it divided the summed counts by the
# number of years in which ANY component type occurred, whereas each
# component divides by its OWN years-present. When a postcode's serious/minor
# types occurred in disjoint years the union denominator was larger, so the
# rollup came out smaller than the sum of its parts.) The by-year rollup
# series in _write_by_year is likewise the per-year sum of the component
# bars, so headline and chart both present the rollup as the sum of its parts.
for rollup_name, rollup_types in (
("Serious crime", SERIOUS_CRIME_TYPES),
("Minor crime", MINOR_CRIME_TYPES),
):
rollup_idx = [ALL_CRIME_TYPES.index(name) for name in rollup_types]
rollup_counts = counts[:, rollup_idx, :].sum(axis=1) # (n_postcodes, n_years)
rollup_per_year = per_year[:, rollup_idx, :].sum(axis=1)
rollup_years_present = np.clip(
(rollup_counts > 0).sum(axis=1), 1, None
).astype(np.float64)
rollup_avg = rollup_per_year.sum(axis=1) / rollup_years_present
data[f"{rollup_name} (avg/yr)"] = np.round(rollup_avg * norm, 1).astype(
np.float32
)
data[f"{rollup_name} (avg/yr)"] = np.round(
avg[:, rollup_idx].sum(axis=1), 1
).astype(np.float32)
output_path.parent.mkdir(parents=True, exist_ok=True)
pl.DataFrame(data).write_parquet(output_path, compression="zstd")

View file

@ -36,6 +36,16 @@ MIN_PRICE = 10_000
MIN_BUILD_YEAR = 1700
MAX_BUILD_YEAR = 2030
# Plausibility bounds for raw EPC dimensions. EPC lodgements contain data-entry
# errors (0 m storey heights, 116 m "interior height", 9,210 m² floor areas, 99
# habitable rooms) that otherwise propagate verbatim into the published per-
# property columns. Values outside these bands are nulled (treated as unknown)
# rather than shown. Bounds are deliberately wide so only clear errors are cut.
MIN_FLOOR_HEIGHT_M = 1.5 # below this a storey is not habitable
MAX_FLOOR_HEIGHT_M = 6.0 # above this is a data error, not a normal storey
MAX_TOTAL_FLOOR_AREA_M2 = 2000.0 # ~21,500 sqft; larger is a bulk/garbage record
MAX_HABITABLE_ROOMS = 20 # dwellings above this are data errors
def epc_band_to_year(band: pl.Expr) -> pl.Expr:
"""Map an EPC construction age band to a single representative build year.
@ -99,6 +109,27 @@ def _clean_number(column: str, dtype: pl.DataType) -> pl.Expr:
return _clean_string(column).cast(dtype, strict=False)
def _join_address_parts(*columns: str) -> pl.Expr:
"""Join address components into one display address, single-spaced.
Price-paid SAON/PAON/STREET are EMPTY STRINGS (not null) when absent
saon is "" on ~88% of rows and ``concat_str(..., ignore_nulls=True)``
skips only nulls, so empty components still contributed their separator
(``' 10 PALACE GREEN'``, doubled spaces when a middle part was empty).
Convert ``''``null per component so ignore_nulls works as intended, then
defensively collapse residual whitespace runs and strip the result. A
fully-empty address becomes null (dropped by the downstream
``pp_address.is_not_null()`` filter) instead of whitespace junk.
"""
joined = pl.concat_str(
[_clean_string(column) for column in columns],
separator=" ",
ignore_nulls=True,
)
cleaned = joined.str.replace_all(r"\s+", " ").str.strip_chars()
return pl.when(cleaned == "").then(None).otherwise(cleaned)
def _select_epc_columns(raw: pl.LazyFrame) -> pl.LazyFrame:
return (
raw.select(
@ -132,10 +163,28 @@ def _select_epc_columns(raw: pl.LazyFrame) -> pl.LazyFrame:
)
.filter(pl.col("epc_address").is_not_null())
.with_columns(
pl.when(pl.col("number_habitable_rooms") == 0)
.then(None)
.otherwise(pl.col("number_habitable_rooms"))
# Null implausible EPC dimensions so data-entry errors don't reach
# the published per-property columns (Interior height, Total floor
# area, Number of bedrooms & living rooms). Treated as unknown.
pl.when(
(pl.col("number_habitable_rooms") >= 1)
& (pl.col("number_habitable_rooms") <= MAX_HABITABLE_ROOMS)
)
.then(pl.col("number_habitable_rooms"))
.otherwise(None)
.alias("number_habitable_rooms"),
pl.when(
pl.col("floor_height").is_between(
MIN_FLOOR_HEIGHT_M, MAX_FLOOR_HEIGHT_M
)
)
.then(pl.col("floor_height"))
.otherwise(None)
.alias("floor_height"),
pl.when(pl.col("total_floor_area") <= MAX_TOTAL_FLOOR_AREA_M2)
.then(pl.col("total_floor_area"))
.otherwise(None)
.alias("total_floor_area"),
)
)
@ -408,11 +457,7 @@ def _run(epc_path: Path, price_paid_path: Path, output_path: Path, temp_dir: Pat
)
.filter(pl.col("pp_property_type") != "Other")
.with_columns(
pl.concat_str(
[pl.col("saon"), pl.col("paon"), pl.col("street")],
separator=" ",
ignore_nulls=True,
).alias("pp_address"),
_join_address_parts("saon", "paon", "street").alias("pp_address"),
)
.with_columns(
normalize_address_key(pl.col("pp_address")).alias("_pp_match_address"),

View file

@ -2,6 +2,7 @@ import argparse
import re
import tempfile
from dataclasses import dataclass
from datetime import date
from typing import Literal
import numpy as np
@ -30,7 +31,10 @@ from pipeline.utils.postcode_mapping import build_postcode_mapping
MIN_FLOOR_AREA_M2 = 10
CONSERVATION_AREA_FEATURE = "Within conservation area"
TREE_DENSITY_FEATURE = "Street tree density percentile"
# Named "Tree canopy" (not "Street tree") because the underlying density unions
# Forest Research TOW lone-tree/group crowns AND NFI woodland canopy, so a
# woodland-edge postcode's score reflects forest canopy, not only street trees.
TREE_DENSITY_FEATURE = "Tree canopy density percentile"
LISTED_BUILDING_FEATURE = "Listed building"
LISTED_BUILDING_MATCH_RADIUS_M = 250.0
LISTED_BUILDING_NEAREST_POSTCODES = 3
@ -64,7 +68,8 @@ _AREA_COLUMNS = [
"Air Quality and Road Safety Score",
# Ethnicity
"% South Asian",
"% East/SE Asian",
"% East Asian",
"% SE Asian",
"% Black",
"% Mixed",
"% White",
@ -97,15 +102,11 @@ _AREA_COLUMNS = [
# is postcode-grain: it belongs in the area output (one value per postcode,
# covering property-less postcodes too) rather than duplicated per property.
TREE_DENSITY_FEATURE,
# Schools
"Good+ primary schools within 5km",
"Good+ secondary schools within 5km",
"Good+ primary schools within 2km",
"Good+ secondary schools within 2km",
"Outstanding primary schools within 5km",
"Outstanding secondary schools within 5km",
"Outstanding primary schools within 2km",
"Outstanding secondary schools within 2km",
# Schools (modelled historical catchment areas covering the postcode)
"Good+ primary school catchments",
"Good+ secondary school catchments",
"Outstanding primary school catchments",
"Outstanding secondary school catchments",
# Demographics
"Median age",
# Politics
@ -167,14 +168,10 @@ _FINAL_RENAME_COLUMNS = {
"latest_price": "Last known price",
"number_habitable_rooms": "Number of bedrooms & living rooms",
"noise_lden_db": "Noise (dB)",
"good_primary_5km": "Good+ primary schools within 5km",
"good_secondary_5km": "Good+ secondary schools within 5km",
"good_primary_2km": "Good+ primary schools within 2km",
"good_secondary_2km": "Good+ secondary schools within 2km",
"outstanding_primary_5km": "Outstanding primary schools within 5km",
"outstanding_secondary_5km": "Outstanding secondary schools within 5km",
"outstanding_primary_2km": "Outstanding primary schools within 2km",
"outstanding_secondary_2km": "Outstanding secondary schools within 2km",
"good_primary_catchments": "Good+ primary school catchments",
"good_secondary_catchments": "Good+ secondary school catchments",
"outstanding_primary_catchments": "Outstanding primary school catchments",
"outstanding_secondary_catchments": "Outstanding secondary school catchments",
"max_download_speed": "Max available download speed (Mbps)",
"serious_crime_avg_yr": "Serious crime (avg/yr)",
"minor_crime_avg_yr": "Minor crime (avg/yr)",
@ -528,10 +525,22 @@ def _is_planning_conservation_area_record(dataset: object) -> bool:
def _is_current_planning_record(end_date: object) -> bool:
"""A planning record is current when it has no end-date OR its end-date is
still in the future. The planning.data.gov.uk `end-date` field marks when a
designation is RETIRED, so a future date (e.g. 2029-12-31) is a still-current
area and must NOT be dropped the previous "any non-empty date = ended"
logic wrongly excluded those (e.g. 22 current Gateshead conservation areas)."""
if end_date is None:
return True
if isinstance(end_date, str):
return end_date.strip() == ""
text = end_date.strip()
if text == "":
return True
try:
return date.fromisoformat(text[:10]) > date.today()
except ValueError:
# Unparseable end-date: keep the record rather than silently drop it.
return True
return False
@ -706,8 +715,32 @@ def _tree_density_by_postcode(tree_density_postcodes_path: Path) -> pl.LazyFrame
)
def _validate_lsoa_source_coverage(iod_path: Path, ethnicity_path: Path) -> None:
"""Fail if ethnicity (now LSOA-keyed) misses any IoD LSOA.
Ethnicity is sourced from Census 2021 TS021 at LSOA, then joined on `lsoa21`
like median age and IoD. The IoD table defines the LSOA universe every
postcode resolves into, so a missing LSOA would silently null the ethnicity
columns for those postcodes; require full coverage instead.
"""
iod_lsoas = pl.read_parquet(
iod_path, columns=["LSOA code (2021)"]
).rename({"LSOA code (2021)": "lsoa21"})
ethnicity_lsoas = pl.read_parquet(ethnicity_path, columns=["lsoa21"])
missing_ethnicity = iod_lsoas.join(
ethnicity_lsoas, on="lsoa21", how="anti"
).sort("lsoa21")
if missing_ethnicity.height > 0:
raise ValueError(
"Ethnicity data is missing LSOA coverage: "
f"{missing_ethnicity.height} LSOAs, e.g. "
f"{missing_ethnicity.head(10).to_dicts()}"
)
def _validate_lad_source_coverage(
iod_path: Path, ethnicity_path: Path, rental_prices_path: Path
iod_path: Path, rental_prices_path: Path
) -> None:
iod_lads = (
pl.read_parquet(
@ -726,16 +759,6 @@ def _validate_lad_source_coverage(
.unique(["lad"])
)
ethnicity_lads = pl.read_parquet(ethnicity_path, columns=["Geography_code"]).rename(
{"Geography_code": "lad"}
)
missing_ethnicity = iod_lads.join(ethnicity_lads, on="lad", how="anti").sort("lad")
if missing_ethnicity.height > 0:
raise ValueError(
"Ethnicity data is missing 2024 LAD coverage: "
f"{missing_ethnicity.to_dicts()}"
)
rental_lads = pl.read_parquet(rental_prices_path, columns=["area_code"]).rename(
{"area_code": "lad"}
)
@ -843,18 +866,16 @@ def _join_area_side_tables(
election: pl.LazyFrame,
poi_counts: pl.LazyFrame,
noise: pl.LazyFrame,
school_proximity: pl.LazyFrame,
school_catchments: pl.LazyFrame,
conservation_areas: pl.LazyFrame,
tree_density: pl.LazyFrame | None,
broadband: pl.LazyFrame,
) -> pl.LazyFrame:
base = base.join(iod, left_on="lsoa21", right_on="LSOA code (2021)", how="left")
base = base.join(
ethnicity,
left_on="Local Authority District code (2024)",
right_on="Geography_code",
how="left",
)
# Ethnicity is Census 2021 TS021 at LSOA (~33,755 areas), joined on the same
# `lsoa21` key as median age and IoD — a ~100x granularity gain over the old
# Local-Authority broadcast, with no change to the 6-bucket output schema.
base = base.join(ethnicity, on="lsoa21", how="left")
# Crime is counted spatially per postcode (incidents within 50m of the
# postcode boundary), so it joins on postcode rather than LSOA. crime_spatial
@ -876,7 +897,7 @@ def _join_area_side_tables(
base = base.join(election, on="pcon", how="left")
base = base.join(poi_counts, on="postcode", how="left")
base = base.join(noise, on="postcode", how="left")
base = base.join(school_proximity, on="postcode", how="left")
base = base.join(school_catchments, on="postcode", how="left")
base = base.join(conservation_areas, on="postcode", how="left").with_columns(
pl.col(CONSERVATION_AREA_FEATURE).fill_null("No")
)
@ -1941,7 +1962,7 @@ def _build(
ethnicity_path: Path,
crime_path: Path,
noise_path: Path,
school_proximity_path: Path,
school_catchments_path: Path,
broadband_path: Path,
conservation_areas_path: Path,
rental_prices_path: Path,
@ -1966,7 +1987,8 @@ def _build(
"""
if mode == "listings" and actual_listings_path is None:
raise ValueError("listings mode requires actual_listings_path")
_validate_lad_source_coverage(iod_path, ethnicity_path, rental_prices_path)
_validate_lsoa_source_coverage(iod_path, ethnicity_path)
_validate_lad_source_coverage(iod_path, rental_prices_path)
wide = pl.scan_parquet(epc_pp_path).filter(
pl.col("total_floor_area").is_null()
@ -2050,7 +2072,7 @@ def _build(
)
.select("postcode", "noise_lden_db")
)
school_proximity = pl.scan_parquet(school_proximity_path)
school_catchments = pl.scan_parquet(school_catchments_path)
conservation_areas = _conservation_area_by_postcode(
arcgis.select("postcode", "lat", "lon"), conservation_areas_path
)
@ -2090,7 +2112,7 @@ def _build(
"election": election,
"poi_counts": poi_counts,
"noise": noise,
"school_proximity": school_proximity,
"school_catchments": school_catchments,
"conservation_areas": conservation_areas,
"tree_density": tree_density,
"broadband": broadband,
@ -2225,7 +2247,7 @@ def main():
"--ethnicity",
type=Path,
required=True,
help="Ethnicity by local authority parquet file (optional)",
help="Census 2021 ethnic group (TS021) by LSOA parquet file",
)
parser.add_argument(
"--crime",
@ -2237,10 +2259,10 @@ def main():
"--noise", type=Path, required=True, help="Road noise by postcode parquet file"
)
parser.add_argument(
"--school-proximity",
"--school-catchments",
type=Path,
required=True,
help="School proximity counts parquet file",
help="School catchment counts parquet file",
)
parser.add_argument(
"--broadband",
@ -2346,7 +2368,7 @@ def main():
ethnicity_path=args.ethnicity,
crime_path=args.crime,
noise_path=args.noise,
school_proximity_path=args.school_proximity,
school_catchments_path=args.school_catchments,
broadband_path=args.broadband,
conservation_areas_path=args.conservation_areas,
rental_prices_path=args.rental_prices,

View file

@ -25,11 +25,30 @@ POI_GROUPS_2KM = {
# Greengrocer, ...) and the GEOLYTIX brand categories (Tesco, Aldi, ...).
GROCERIES_GROUP = "Groceries"
# Groceries categories EXCLUDED from the static "Number of grocery shops and
# supermarkets within 2km" metric. Bakeries, butchers, delis and off-licences
# are speciality food retail, not somewhere you do a grocery shop; together
# they were ~a third of the group and inflated the headline count. The metric
# keeps Supermarket, Convenience Store, Greengrocer and every GEOLYTIX brand.
GROCERY_STATIC_EXCLUDED_CATEGORIES = {
"Bakery",
"Butcher & Fishmonger",
"Deli & Specialty",
"Off-Licence",
}
# OS Open Greenspace function types used for park counts and distance calculation.
# Uses the authoritative OS dataset instead of OSM point POIs for better coverage
# of green spaces that are only mapped as polygons in OSM.
# Scope: "Public Park Or Garden" is the core park function. "Playing Field"
# (open public recreation grounds) is borderline but kept: outside big cities
# the local rec ground is the de facto park. "Play Space" (playgrounds) is
# excluded — a playground is not a park, and "Playground" is already its own
# OSM-derived category. The remaining functions (Religious Grounds, Golf
# Course, Cemetery, Allotments, Bowling Green, Tennis Court, Other Sports
# Facility) are clearly not parks.
GREENSPACE_PARK_FUNCTIONS = {
"parks": ["Public Park Or Garden", "Playing Field", "Play Space"],
"parks": ["Public Park Or Garden", "Playing Field"],
}
GROCERY_DYNAMIC_FILTER_MIN_POIS = 100
@ -50,17 +69,22 @@ def _poi_category_slug(category: str) -> str:
def _groceries_categories(pois: pl.DataFrame) -> list[str]:
"""Return the distinct `category` values for the Groceries group.
"""Return the distinct `category` values for the static groceries metric.
`count_pois_per_postcode` matches POIs on `category`, but the authoritative
GEOLYTIX grocery dataset stores the brand name there (e.g. "Tesco", "Aldi")
with group "Groceries"; it never emits the literal "Supermarket". Collecting
every Groceries category captures both the OSM strings and the brand names.
Speciality food retail (bakeries, butchers, delis, off-licences) is
excluded see GROCERY_STATIC_EXCLUDED_CATEGORIES.
"""
if "group" not in pois.columns:
raise ValueError("POI dataframe must include a 'group' column")
return (
pois.filter(pl.col("group") == GROCERIES_GROUP)
pois.filter(
(pl.col("group") == GROCERIES_GROUP)
& ~pl.col("category").is_in(list(GROCERY_STATIC_EXCLUDED_CATEGORIES))
)
.select("category")
.unique()
.sort("category")
@ -109,6 +133,40 @@ def _build_poi_category_groups(
return groups, display_names
def _greenspace_count_frame(greenspace: pl.DataFrame) -> pl.DataFrame:
"""Collapse the greenspace frame to ONE representative row per site.
os_greenspace.parquet is one row per ACCESS POINT (park gate), which is the
right grain for nearest-distance (the nearest gate is what matters) but
wildly over-counts "Number of amenities (Park) within Xkm" a large park
with 30 gates counted as 30 parks. Counting uses one row per site at the
site centroid (falling back to the first access point when no centroid is
available). Degrades gracefully: a legacy parquet without `site_id` is
returned unchanged (gate-grain counts) rather than crashing.
"""
if "site_id" not in greenspace.columns:
print(
"WARNING: greenspace parquet has no site_id column; park counts "
"will count access points, not sites (regenerate os_greenspace)"
)
return greenspace
keyed = greenspace.filter(pl.col("site_id").is_not_null())
unkeyed = greenspace.filter(pl.col("site_id").is_null())
representatives = keyed.unique(subset=["site_id"], keep="first")
if {"site_lat", "site_lng"}.issubset(greenspace.columns):
representatives = representatives.with_columns(
pl.coalesce([pl.col("site_lat"), pl.col("lat")]).alias("lat"),
pl.coalesce([pl.col("site_lng"), pl.col("lng")]).alias("lng"),
)
frames = [representatives.select(greenspace.columns)]
if len(unkeyed) > 0:
frames.append(unkeyed)
return pl.concat(frames)
def _dynamic_poi_metric_renames(display_names: dict[str, str]) -> dict[str, str]:
renames: dict[str, str] = {}
for group_key, category in display_names.items():
@ -185,13 +243,16 @@ def main():
# Park counts and distances from OS Open Greenspace. They use the dynamic
# amenity metric names so filters read through the same side-table path as
# OSM-derived amenity metrics.
# OSM-derived amenity metrics. Distances use the access-point grain (the
# nearest park GATE is the right semantics); counts use one row per SITE so
# a park with many gates counts once.
greenspace = pl.read_parquet(args.greenspace)
greenspace_sites = _greenspace_count_frame(greenspace)
park_counts_2km = count_pois_per_postcode(
postcodes, greenspace, groups=GREENSPACE_PARK_FUNCTIONS, radius_km=2
postcodes, greenspace_sites, groups=GREENSPACE_PARK_FUNCTIONS, radius_km=2
)
park_counts_5km = count_pois_per_postcode(
postcodes, greenspace, groups=GREENSPACE_PARK_FUNCTIONS, radius_km=5
postcodes, greenspace_sites, groups=GREENSPACE_PARK_FUNCTIONS, radius_km=5
)
park_distances = min_distance_per_postcode(
postcodes, greenspace, groups=GREENSPACE_PARK_FUNCTIONS

Some files were not shown because too many files have changed in this diff Show more