From c2070693fb8b34c421480048094eb30f11c18c29 Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Sun, 28 Jun 2026 11:59:44 +0100 Subject: [PATCH 1/9] wip --- .forgejo/workflows/ci.yml | 2 +- .forgejo/workflows/docker-publish.yml | 4 +- .../cheaper-twin__br3-3-vs-cr0-7.json | 42 +++ .../cheaper-twin__ha7-2-vs-ha3-0.json | 42 +++ .../cheaper-twin__ig8-7-vs-ig6-2.json | 42 +++ .../cheaper-twin__l16-7-vs-l14-6.json | 42 +++ .../findings/cheaper-twin__m40-5-vs-m9-4.json | 42 +++ .../cheaper-twin__rm14-2-vs-rm12-5.json | 42 +++ .../cheaper-twin__se28-8-vs-da18-4.json | 42 +++ .../cheaper-twin__sw1x-8-vs-sw7-2.json | 42 +++ .../cheaper-twin__tw12-3-vs-kt8-1.json | 42 +++ .../cheaper-twin__tw2-7-vs-tw3-2.json | 42 +++ .../cheaper-twin__w1j-7-vs-sw7-3.json | 42 +++ .../cheaper-twin__w1j-8-vs-sw1a-2.json | 42 +++ .../cheaper-twin__w1k-2-vs-sw1x-0.json | 42 +++ .../cheaper-twin__w1u-4-vs-nw1-4.json | 42 +++ .../cheaper-twin__wc2a-2-vs-ec2a-2.json | 42 +++ .../out/findings/square-metres-per-100k.json | 28 ++ analysis/out/video_scripts/INDEX.md | 23 ++ analysis/out/video_scripts/br3-3-vs-cr0-7.md | 79 ++++++ analysis/out/video_scripts/ha7-2-vs-ha3-0.md | 79 ++++++ analysis/out/video_scripts/ig8-7-vs-ig6-2.md | 79 ++++++ analysis/out/video_scripts/l16-7-vs-l14-6.md | 79 ++++++ analysis/out/video_scripts/m40-5-vs-m9-4.md | 79 ++++++ .../out/video_scripts/rm14-2-vs-rm12-5.md | 79 ++++++ .../out/video_scripts/se28-8-vs-da18-4.md | 79 ++++++ analysis/out/video_scripts/sw1x-8-vs-sw7-2.md | 79 ++++++ analysis/out/video_scripts/tw12-3-vs-kt8-1.md | 79 ++++++ analysis/out/video_scripts/tw2-7-vs-tw3-2.md | 79 ++++++ analysis/out/video_scripts/w1j-7-vs-sw7-3.md | 79 ++++++ analysis/out/video_scripts/w1j-8-vs-sw1a-2.md | 79 ++++++ analysis/out/video_scripts/w1k-2-vs-sw1x-0.md | 79 ++++++ analysis/out/video_scripts/w1u-4-vs-nw1-4.md | 79 ++++++ .../out/video_scripts/wc2a-2-vs-ec2a-2.md | 79 ++++++ docker-compose.yml | 2 +- finder/README.md | 22 +- finder/constants.py | 21 +- finder/flaresolverr.py | 4 +- finder/main.py | 2 +- finder/onthemarket.py | 20 +- .../src/components/map/FeatureBrowser.tsx | 7 +- .../map/filters/ActiveFiltersPanel.tsx | 30 ++- .../components/map/filters/AddFilterPanel.tsx | 29 +- r5-java/run.sh | 6 +- r5-java/src/main/java/propertymap/App.java | 8 +- .../src/main/java/propertymap/Parquet.java | 2 +- r5-java/src/main/java/propertymap/Router.java | 10 +- server-rs/src/consts.rs | 4 +- server-rs/src/features.rs | 57 +++- server-rs/src/routes/ai_filters/prompt.rs | 8 +- server-rs/src/routes/ai_filters/usage.rs | 2 +- server-rs/src/routes/stats.rs | 12 +- server-rs/src/routes/travel_destinations.rs | 2 +- server-rs/src/utils/grid_index.rs | 2 +- video/render.sh | 28 +- video/src/auth.ts | 4 +- video/src/browser.ts | 2 +- video/src/config.ts | 4 +- video/src/dashboard.ts | 8 +- video/src/dom.ts | 16 +- video/src/preflight.ts | 16 +- video/src/probe.ts | 2 +- video/src/runner.ts | 10 +- video/src/script.ts | 24 +- video/src/storyboard.ts | 247 ++++++++++++++++-- video/tts/mux.py | 6 +- video/tts/pyproject.toml | 6 +- video/tts/synth.py | 22 +- 68 files changed, 2305 insertions(+), 212 deletions(-) create mode 100644 analysis/out/findings/cheaper-twin__br3-3-vs-cr0-7.json create mode 100644 analysis/out/findings/cheaper-twin__ha7-2-vs-ha3-0.json create mode 100644 analysis/out/findings/cheaper-twin__ig8-7-vs-ig6-2.json create mode 100644 analysis/out/findings/cheaper-twin__l16-7-vs-l14-6.json create mode 100644 analysis/out/findings/cheaper-twin__m40-5-vs-m9-4.json create mode 100644 analysis/out/findings/cheaper-twin__rm14-2-vs-rm12-5.json create mode 100644 analysis/out/findings/cheaper-twin__se28-8-vs-da18-4.json create mode 100644 analysis/out/findings/cheaper-twin__sw1x-8-vs-sw7-2.json create mode 100644 analysis/out/findings/cheaper-twin__tw12-3-vs-kt8-1.json create mode 100644 analysis/out/findings/cheaper-twin__tw2-7-vs-tw3-2.json create mode 100644 analysis/out/findings/cheaper-twin__w1j-7-vs-sw7-3.json create mode 100644 analysis/out/findings/cheaper-twin__w1j-8-vs-sw1a-2.json create mode 100644 analysis/out/findings/cheaper-twin__w1k-2-vs-sw1x-0.json create mode 100644 analysis/out/findings/cheaper-twin__w1u-4-vs-nw1-4.json create mode 100644 analysis/out/findings/cheaper-twin__wc2a-2-vs-ec2a-2.json create mode 100644 analysis/out/findings/square-metres-per-100k.json create mode 100644 analysis/out/video_scripts/INDEX.md create mode 100644 analysis/out/video_scripts/br3-3-vs-cr0-7.md create mode 100644 analysis/out/video_scripts/ha7-2-vs-ha3-0.md create mode 100644 analysis/out/video_scripts/ig8-7-vs-ig6-2.md create mode 100644 analysis/out/video_scripts/l16-7-vs-l14-6.md create mode 100644 analysis/out/video_scripts/m40-5-vs-m9-4.md create mode 100644 analysis/out/video_scripts/rm14-2-vs-rm12-5.md create mode 100644 analysis/out/video_scripts/se28-8-vs-da18-4.md create mode 100644 analysis/out/video_scripts/sw1x-8-vs-sw7-2.md create mode 100644 analysis/out/video_scripts/tw12-3-vs-kt8-1.md create mode 100644 analysis/out/video_scripts/tw2-7-vs-tw3-2.md create mode 100644 analysis/out/video_scripts/w1j-7-vs-sw7-3.md create mode 100644 analysis/out/video_scripts/w1j-8-vs-sw1a-2.md create mode 100644 analysis/out/video_scripts/w1k-2-vs-sw1x-0.md create mode 100644 analysis/out/video_scripts/w1u-4-vs-nw1-4.md create mode 100644 analysis/out/video_scripts/wc2a-2-vs-ec2a-2.md diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml index 2da418d..5b13a93 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -30,7 +30,7 @@ jobs: - name: Install frontend dependencies working-directory: frontend # Chrome isn't needed for these checks (lint/typecheck/vitest-jsdom), so - # skip puppeteer's postinstall browser download — it's slow and a flaky + # skip puppeteer's postinstall browser download: it's slow and a flaky # point of failure. The prerender build installs Chrome explicitly. env: PUPPETEER_SKIP_DOWNLOAD: "true" diff --git a/.forgejo/workflows/docker-publish.yml b/.forgejo/workflows/docker-publish.yml index d1abd17..be9c91a 100644 --- a/.forgejo/workflows/docker-publish.yml +++ b/.forgejo/workflows/docker-publish.yml @@ -13,7 +13,7 @@ jobs: # The product-demo videos and their poster JPGs live in Git LFS (see # .gitattributes). The checkout below needs `lfs: true` to smudge the real # binaries, but the runner image ships without the git-lfs executable, so - # install it first — otherwise checkout fails with "Unable to locate + # install it first. Otherwise checkout fails with "Unable to locate # executable file: git-lfs". - name: Install Git LFS run: | @@ -25,7 +25,7 @@ jobs: uses: actions/checkout@v4 # Without lfs, checkout writes ~130-byte LFS pointer text files, the # Docker build copies those stubs into frontend/dist/video/*, and the - # server serves text as video/mp4 — so the videos never load in + # server serves text as video/mp4, so the videos never load in # production. Smudge the real binaries instead. with: lfs: true diff --git a/analysis/out/findings/cheaper-twin__br3-3-vs-cr0-7.json b/analysis/out/findings/cheaper-twin__br3-3-vs-cr0-7.json new file mode 100644 index 0000000..abfcca9 --- /dev/null +++ b/analysis/out/findings/cheaper-twin__br3-3-vs-cr0-7.json @@ -0,0 +1,42 @@ +{ + "slug": "cheaper-twin/br3-3-vs-cr0-7", + "type": "cheaper_twin", + "page_path": "/cheaper-twin/br3-3-vs-cr0-7", + "title": "Beckenham vs Croydon: the same terraced house, about 31% cheaper per m\u00b2", + "hook": "\u00a3201,870 less for an equivalent terraced house: same station, similar schools, ~2.02km apart", + "shocking_number": "31%", + "pricey": { + "sector": "BR3 3", + "name": "Beckenham", + "label": "Beckenham (BR3 3)", + "named": true, + "est_psqm": 7153, + "n": 4514 + }, + "twin": { + "sector": "CR0 7", + "name": "Croydon", + "label": "Croydon (CR0 7)", + "named": true, + "est_psqm": 4910, + "n": 5143 + }, + "stats": { + "gap_pct": 31.4, + "gap_per_sqm": 2243, + "gap_on_90sqm": 201870, + "gap_on_avg_home": 214206, + "dominant_type": "Terraced", + "build_year": 1940, + "good_secondary_catchments": 7.8, + "station_km": 0.73, + "distance_km": 2.02 + }, + "map_query": "lat=51.38969&lon=-0.04244&zoom=12.5&filter=Est.%20price%20per%20sqm:0:5200&filter=Good%2B%20secondary%20school%20catchments:1:11", + "map_url": "https://perfect-postcode.co.uk/?lat=51.38969&lon=-0.04244&zoom=12.5&filter=Est.%20price%20per%20sqm:0:5200&filter=Good%2B%20secondary%20school%20catchments:1:11", + "og_image": "https://perfect-postcode.co.uk/api/screenshot?og=1&lat=51.38969&lon=-0.04244&zoom=12.5&filter=Est.%20price%20per%20sqm:0:5200&filter=Good%2B%20secondary%20school%20catchments:1:11", + "methodology": "Postcode sectors (e.g. N10 3) compared on estimated \u00a3/m\u00b2 of floor space. A pair is only called a 'twin' when the two sectors share the dominant property type, build era (\u00b130y), good-school catchment provision, station access, deprivation/tenure, education, age and home size, so the price gap reflects a name premium, not a different kind of area. Estimates, not valuations; aggregated to sector, never address-level.", + "needs_name_check": false, + "attribution": "Contains HM Land Registry data \u00a9 Crown copyright and database right. Licensed under the Open Government Licence v3.0.", + "sources": "HM Land Registry \u00b7 EPC (DLUHC) \u00b7 Ofsted \u00b7 DfT \u00b7 ONS \u00b7 Police.uk" +} \ No newline at end of file diff --git a/analysis/out/findings/cheaper-twin__ha7-2-vs-ha3-0.json b/analysis/out/findings/cheaper-twin__ha7-2-vs-ha3-0.json new file mode 100644 index 0000000..7322d97 --- /dev/null +++ b/analysis/out/findings/cheaper-twin__ha7-2-vs-ha3-0.json @@ -0,0 +1,42 @@ +{ + "slug": "cheaper-twin/ha7-2-vs-ha3-0", + "type": "cheaper_twin", + "page_path": "/cheaper-twin/ha7-2-vs-ha3-0", + "title": "Stanmore vs Kenton: the same semi-detached house, about 17% cheaper per m\u00b2", + "hook": "\u00a3106,920 less for an equivalent semi-detached house: same station, similar schools, ~2.57km apart", + "shocking_number": "17%", + "pricey": { + "sector": "HA7 2", + "name": "Stanmore", + "label": "Stanmore (HA7 2)", + "named": true, + "est_psqm": 6834, + "n": 2775 + }, + "twin": { + "sector": "HA3 0", + "name": "Kenton", + "label": "Kenton (HA3 0)", + "named": true, + "est_psqm": 5646, + "n": 3122 + }, + "stats": { + "gap_pct": 17.4, + "gap_per_sqm": 1188, + "gap_on_90sqm": 106920, + "gap_on_avg_home": 108108, + "dominant_type": "Semi-Detached", + "build_year": 1940, + "good_secondary_catchments": 2.9, + "station_km": 1.31, + "distance_km": 2.57 + }, + "map_query": "lat=51.59199&lon=-0.3079&zoom=12.5&filter=Est.%20price%20per%20sqm:0:5900&filter=Good%2B%20secondary%20school%20catchments:1:11", + "map_url": "https://perfect-postcode.co.uk/?lat=51.59199&lon=-0.3079&zoom=12.5&filter=Est.%20price%20per%20sqm:0:5900&filter=Good%2B%20secondary%20school%20catchments:1:11", + "og_image": "https://perfect-postcode.co.uk/api/screenshot?og=1&lat=51.59199&lon=-0.3079&zoom=12.5&filter=Est.%20price%20per%20sqm:0:5900&filter=Good%2B%20secondary%20school%20catchments:1:11", + "methodology": "Postcode sectors (e.g. N10 3) compared on estimated \u00a3/m\u00b2 of floor space. A pair is only called a 'twin' when the two sectors share the dominant property type, build era (\u00b130y), good-school catchment provision, station access, deprivation/tenure, education, age and home size, so the price gap reflects a name premium, not a different kind of area. Estimates, not valuations; aggregated to sector, never address-level.", + "needs_name_check": false, + "attribution": "Contains HM Land Registry data \u00a9 Crown copyright and database right. Licensed under the Open Government Licence v3.0.", + "sources": "HM Land Registry \u00b7 EPC (DLUHC) \u00b7 Ofsted \u00b7 DfT \u00b7 ONS \u00b7 Police.uk" +} \ No newline at end of file diff --git a/analysis/out/findings/cheaper-twin__ig8-7-vs-ig6-2.json b/analysis/out/findings/cheaper-twin__ig8-7-vs-ig6-2.json new file mode 100644 index 0000000..44fa34e --- /dev/null +++ b/analysis/out/findings/cheaper-twin__ig8-7-vs-ig6-2.json @@ -0,0 +1,42 @@ +{ + "slug": "cheaper-twin/ig8-7-vs-ig6-2", + "type": "cheaper_twin", + "page_path": "/cheaper-twin/ig8-7-vs-ig6-2", + "title": "Woodford Green vs Barkingside: the same terraced house, about 26% cheaper per m\u00b2", + "hook": "\u00a3164,070 less for an equivalent terraced house: same station, similar schools, ~2.98km apart", + "shocking_number": "26%", + "pricey": { + "sector": "IG8 7", + "name": "Woodford Green", + "label": "Woodford Green (IG8 7)", + "named": true, + "est_psqm": 7148, + "n": 2965 + }, + "twin": { + "sector": "IG6 2", + "name": "Barkingside", + "label": "Barkingside (IG6 2)", + "named": true, + "est_psqm": 5325, + "n": 4423 + }, + "stats": { + "gap_pct": 25.5, + "gap_per_sqm": 1823, + "gap_on_90sqm": 164070, + "gap_on_avg_home": 143105, + "dominant_type": "Terraced", + "build_year": 1958, + "good_secondary_catchments": 2.8, + "station_km": 0.58, + "distance_km": 2.98 + }, + "map_query": "lat=51.60238&lon=0.06063&zoom=12.5&filter=Est.%20price%20per%20sqm:0:5600&filter=Good%2B%20secondary%20school%20catchments:1:11", + "map_url": "https://perfect-postcode.co.uk/?lat=51.60238&lon=0.06063&zoom=12.5&filter=Est.%20price%20per%20sqm:0:5600&filter=Good%2B%20secondary%20school%20catchments:1:11", + "og_image": "https://perfect-postcode.co.uk/api/screenshot?og=1&lat=51.60238&lon=0.06063&zoom=12.5&filter=Est.%20price%20per%20sqm:0:5600&filter=Good%2B%20secondary%20school%20catchments:1:11", + "methodology": "Postcode sectors (e.g. N10 3) compared on estimated \u00a3/m\u00b2 of floor space. A pair is only called a 'twin' when the two sectors share the dominant property type, build era (\u00b130y), good-school catchment provision, station access, deprivation/tenure, education, age and home size, so the price gap reflects a name premium, not a different kind of area. Estimates, not valuations; aggregated to sector, never address-level.", + "needs_name_check": false, + "attribution": "Contains HM Land Registry data \u00a9 Crown copyright and database right. Licensed under the Open Government Licence v3.0.", + "sources": "HM Land Registry \u00b7 EPC (DLUHC) \u00b7 Ofsted \u00b7 DfT \u00b7 ONS \u00b7 Police.uk" +} \ No newline at end of file diff --git a/analysis/out/findings/cheaper-twin__l16-7-vs-l14-6.json b/analysis/out/findings/cheaper-twin__l16-7-vs-l14-6.json new file mode 100644 index 0000000..c14ae70 --- /dev/null +++ b/analysis/out/findings/cheaper-twin__l16-7-vs-l14-6.json @@ -0,0 +1,42 @@ +{ + "slug": "cheaper-twin/l16-7-vs-l14-6", + "type": "cheaper_twin", + "page_path": "/cheaper-twin/l16-7-vs-l14-6", + "title": "Childwall vs Broadgreen: the same semi-detached house, about 30% cheaper per m\u00b2", + "hook": "\u00a3106,740 less for an equivalent semi-detached house: same station, similar schools, ~1.88km apart", + "shocking_number": "30%", + "pricey": { + "sector": "L16 7", + "name": "Childwall", + "label": "Childwall (L16 7)", + "named": true, + "est_psqm": 4026, + "n": 500 + }, + "twin": { + "sector": "L14 6", + "name": "Broadgreen", + "label": "Broadgreen (L14 6)", + "named": true, + "est_psqm": 2840, + "n": 809 + }, + "stats": { + "gap_pct": 29.5, + "gap_per_sqm": 1186, + "gap_on_90sqm": 106740, + "gap_on_avg_home": 117414, + "dominant_type": "Semi-Detached", + "build_year": 1940, + "good_secondary_catchments": 5.1, + "station_km": 1.22, + "distance_km": 1.88 + }, + "map_query": "lat=53.40344&lon=-2.88529&zoom=12.5&filter=Est.%20price%20per%20sqm:0:3000&filter=Good%2B%20secondary%20school%20catchments:1:11", + "map_url": "https://perfect-postcode.co.uk/?lat=53.40344&lon=-2.88529&zoom=12.5&filter=Est.%20price%20per%20sqm:0:3000&filter=Good%2B%20secondary%20school%20catchments:1:11", + "og_image": "https://perfect-postcode.co.uk/api/screenshot?og=1&lat=53.40344&lon=-2.88529&zoom=12.5&filter=Est.%20price%20per%20sqm:0:3000&filter=Good%2B%20secondary%20school%20catchments:1:11", + "methodology": "Postcode sectors (e.g. N10 3) compared on estimated \u00a3/m\u00b2 of floor space. A pair is only called a 'twin' when the two sectors share the dominant property type, build era (\u00b130y), good-school catchment provision, station access, deprivation/tenure, education, age and home size, so the price gap reflects a name premium, not a different kind of area. Estimates, not valuations; aggregated to sector, never address-level.", + "needs_name_check": false, + "attribution": "Contains HM Land Registry data \u00a9 Crown copyright and database right. Licensed under the Open Government Licence v3.0.", + "sources": "HM Land Registry \u00b7 EPC (DLUHC) \u00b7 Ofsted \u00b7 DfT \u00b7 ONS \u00b7 Police.uk" +} \ No newline at end of file diff --git a/analysis/out/findings/cheaper-twin__m40-5-vs-m9-4.json b/analysis/out/findings/cheaper-twin__m40-5-vs-m9-4.json new file mode 100644 index 0000000..6b4e2ad --- /dev/null +++ b/analysis/out/findings/cheaper-twin__m40-5-vs-m9-4.json @@ -0,0 +1,42 @@ +{ + "slug": "cheaper-twin/m40-5-vs-m9-4", + "type": "cheaper_twin", + "page_path": "/cheaper-twin/m40-5-vs-m9-4", + "title": "Newton Heath vs Harpurhey: the same terraced house, about 42% cheaper per m\u00b2", + "hook": "\u00a3106,740 less for an equivalent terraced house: same station, similar schools, ~1.18km apart", + "shocking_number": "42%", + "pricey": { + "sector": "M40 5", + "name": "Newton Heath", + "label": "Newton Heath (M40 5)", + "named": true, + "est_psqm": 2812, + "n": 1632 + }, + "twin": { + "sector": "M9 4", + "name": "Harpurhey", + "label": "Harpurhey (M9 4)", + "named": true, + "est_psqm": 1626, + "n": 3530 + }, + "stats": { + "gap_pct": 42.2, + "gap_per_sqm": 1186, + "gap_on_90sqm": 106740, + "gap_on_avg_home": 91915, + "dominant_type": "Terraced", + "build_year": 1958, + "good_secondary_catchments": 2.6, + "station_km": 0.72, + "distance_km": 1.18 + }, + "map_query": "lat=53.51293&lon=-2.19574&zoom=12.5&filter=Est.%20price%20per%20sqm:0:1700&filter=Good%2B%20secondary%20school%20catchments:1:11", + "map_url": "https://perfect-postcode.co.uk/?lat=53.51293&lon=-2.19574&zoom=12.5&filter=Est.%20price%20per%20sqm:0:1700&filter=Good%2B%20secondary%20school%20catchments:1:11", + "og_image": "https://perfect-postcode.co.uk/api/screenshot?og=1&lat=53.51293&lon=-2.19574&zoom=12.5&filter=Est.%20price%20per%20sqm:0:1700&filter=Good%2B%20secondary%20school%20catchments:1:11", + "methodology": "Postcode sectors (e.g. N10 3) compared on estimated \u00a3/m\u00b2 of floor space. A pair is only called a 'twin' when the two sectors share the dominant property type, build era (\u00b130y), good-school catchment provision, station access, deprivation/tenure, education, age and home size, so the price gap reflects a name premium, not a different kind of area. Estimates, not valuations; aggregated to sector, never address-level.", + "needs_name_check": false, + "attribution": "Contains HM Land Registry data \u00a9 Crown copyright and database right. Licensed under the Open Government Licence v3.0.", + "sources": "HM Land Registry \u00b7 EPC (DLUHC) \u00b7 Ofsted \u00b7 DfT \u00b7 ONS \u00b7 Police.uk" +} \ No newline at end of file diff --git a/analysis/out/findings/cheaper-twin__rm14-2-vs-rm12-5.json b/analysis/out/findings/cheaper-twin__rm14-2-vs-rm12-5.json new file mode 100644 index 0000000..b795599 --- /dev/null +++ b/analysis/out/findings/cheaper-twin__rm14-2-vs-rm12-5.json @@ -0,0 +1,42 @@ +{ + "slug": "cheaper-twin/rm14-2-vs-rm12-5", + "type": "cheaper_twin", + "page_path": "/cheaper-twin/rm14-2-vs-rm12-5", + "title": "Upminster vs Hornchurch: the same semi-detached house, about 20% cheaper per m\u00b2", + "hook": "\u00a3115,290 less for an equivalent semi-detached house: same station, similar schools, ~2.99km apart", + "shocking_number": "20%", + "pricey": { + "sector": "RM14 2", + "name": "Upminster", + "label": "Upminster (RM14 2)", + "named": true, + "est_psqm": 6360, + "n": 3026 + }, + "twin": { + "sector": "RM12 5", + "name": "Hornchurch", + "label": "Hornchurch (RM12 5)", + "named": true, + "est_psqm": 5079, + "n": 3133 + }, + "stats": { + "gap_pct": 20.1, + "gap_per_sqm": 1281, + "gap_on_90sqm": 115290, + "gap_on_avg_home": 111447, + "dominant_type": "Semi-Detached", + "build_year": 1940, + "good_secondary_catchments": 3.7, + "station_km": 0.77, + "distance_km": 2.99 + }, + "map_query": "lat=51.54892&lon=0.22193&zoom=12.5&filter=Est.%20price%20per%20sqm:0:5300&filter=Good%2B%20secondary%20school%20catchments:1:11", + "map_url": "https://perfect-postcode.co.uk/?lat=51.54892&lon=0.22193&zoom=12.5&filter=Est.%20price%20per%20sqm:0:5300&filter=Good%2B%20secondary%20school%20catchments:1:11", + "og_image": "https://perfect-postcode.co.uk/api/screenshot?og=1&lat=51.54892&lon=0.22193&zoom=12.5&filter=Est.%20price%20per%20sqm:0:5300&filter=Good%2B%20secondary%20school%20catchments:1:11", + "methodology": "Postcode sectors (e.g. N10 3) compared on estimated \u00a3/m\u00b2 of floor space. A pair is only called a 'twin' when the two sectors share the dominant property type, build era (\u00b130y), good-school catchment provision, station access, deprivation/tenure, education, age and home size, so the price gap reflects a name premium, not a different kind of area. Estimates, not valuations; aggregated to sector, never address-level.", + "needs_name_check": false, + "attribution": "Contains HM Land Registry data \u00a9 Crown copyright and database right. Licensed under the Open Government Licence v3.0.", + "sources": "HM Land Registry \u00b7 EPC (DLUHC) \u00b7 Ofsted \u00b7 DfT \u00b7 ONS \u00b7 Police.uk" +} \ No newline at end of file diff --git a/analysis/out/findings/cheaper-twin__se28-8-vs-da18-4.json b/analysis/out/findings/cheaper-twin__se28-8-vs-da18-4.json new file mode 100644 index 0000000..d7021a8 --- /dev/null +++ b/analysis/out/findings/cheaper-twin__se28-8-vs-da18-4.json @@ -0,0 +1,42 @@ +{ + "slug": "cheaper-twin/se28-8-vs-da18-4", + "type": "cheaper_twin", + "page_path": "/cheaper-twin/se28-8-vs-da18-4", + "title": "SE28 8 vs DA18 4: the same terraced house, about 30% cheaper per m\u00b2", + "hook": "\u00a3129,690 less for an equivalent terraced house: same station, similar schools, ~1.72km apart", + "shocking_number": "30%", + "pricey": { + "sector": "SE28 8", + "name": null, + "label": "SE28 8", + "named": false, + "est_psqm": 4850, + "n": 5033 + }, + "twin": { + "sector": "DA18 4", + "name": null, + "label": "DA18 4", + "named": false, + "est_psqm": 3409, + "n": 1063 + }, + "stats": { + "gap_pct": 29.7, + "gap_per_sqm": 1441, + "gap_on_90sqm": 129690, + "gap_on_avg_home": 104112, + "dominant_type": "Terraced", + "build_year": 1993, + "good_secondary_catchments": 2.8, + "station_km": 1.39, + "distance_km": 1.72 + }, + "map_query": "lat=51.50039&lon=0.12568&zoom=12.5&filter=Est.%20price%20per%20sqm:0:3600&filter=Good%2B%20secondary%20school%20catchments:1:11", + "map_url": "https://perfect-postcode.co.uk/?lat=51.50039&lon=0.12568&zoom=12.5&filter=Est.%20price%20per%20sqm:0:3600&filter=Good%2B%20secondary%20school%20catchments:1:11", + "og_image": "https://perfect-postcode.co.uk/api/screenshot?og=1&lat=51.50039&lon=0.12568&zoom=12.5&filter=Est.%20price%20per%20sqm:0:3600&filter=Good%2B%20secondary%20school%20catchments:1:11", + "methodology": "Postcode sectors (e.g. N10 3) compared on estimated \u00a3/m\u00b2 of floor space. A pair is only called a 'twin' when the two sectors share the dominant property type, build era (\u00b130y), good-school catchment provision, station access, deprivation/tenure, education, age and home size, so the price gap reflects a name premium, not a different kind of area. Estimates, not valuations; aggregated to sector, never address-level.", + "needs_name_check": true, + "attribution": "Contains HM Land Registry data \u00a9 Crown copyright and database right. Licensed under the Open Government Licence v3.0.", + "sources": "HM Land Registry \u00b7 EPC (DLUHC) \u00b7 Ofsted \u00b7 DfT \u00b7 ONS \u00b7 Police.uk" +} \ No newline at end of file diff --git a/analysis/out/findings/cheaper-twin__sw1x-8-vs-sw7-2.json b/analysis/out/findings/cheaper-twin__sw1x-8-vs-sw7-2.json new file mode 100644 index 0000000..b021cd3 --- /dev/null +++ b/analysis/out/findings/cheaper-twin__sw1x-8-vs-sw7-2.json @@ -0,0 +1,42 @@ +{ + "slug": "cheaper-twin/sw1x-8-vs-sw7-2", + "type": "cheaper_twin", + "page_path": "/cheaper-twin/sw1x-8-vs-sw7-2", + "title": "SW1X 8 vs SW7 2: the same flat, about 42% cheaper per m\u00b2", + "hook": "\u00a31,001,160 less for an equivalent flat: same station, similar schools, ~1.31km apart", + "shocking_number": "42%", + "pricey": { + "sector": "SW1X 8", + "name": null, + "label": "SW1X 8", + "named": false, + "est_psqm": 26735, + "n": 1410 + }, + "twin": { + "sector": "SW7 2", + "name": null, + "label": "SW7 2", + "named": false, + "est_psqm": 15611, + "n": 1126 + }, + "stats": { + "gap_pct": 41.6, + "gap_per_sqm": 11124, + "gap_on_90sqm": 1001160, + "gap_on_avg_home": 1301508, + "dominant_type": "Flats/Maisonettes", + "build_year": 1890, + "good_secondary_catchments": 3.7, + "station_km": 0.43, + "distance_km": 1.31 + }, + "map_query": "lat=51.49758&lon=-0.16439&zoom=12.5&filter=Est.%20price%20per%20sqm:0:16400&filter=Good%2B%20secondary%20school%20catchments:1:11", + "map_url": "https://perfect-postcode.co.uk/?lat=51.49758&lon=-0.16439&zoom=12.5&filter=Est.%20price%20per%20sqm:0:16400&filter=Good%2B%20secondary%20school%20catchments:1:11", + "og_image": "https://perfect-postcode.co.uk/api/screenshot?og=1&lat=51.49758&lon=-0.16439&zoom=12.5&filter=Est.%20price%20per%20sqm:0:16400&filter=Good%2B%20secondary%20school%20catchments:1:11", + "methodology": "Postcode sectors (e.g. N10 3) compared on estimated \u00a3/m\u00b2 of floor space. A pair is only called a 'twin' when the two sectors share the dominant property type, build era (\u00b130y), good-school catchment provision, station access, deprivation/tenure, education, age and home size, so the price gap reflects a name premium, not a different kind of area. Estimates, not valuations; aggregated to sector, never address-level.", + "needs_name_check": true, + "attribution": "Contains HM Land Registry data \u00a9 Crown copyright and database right. Licensed under the Open Government Licence v3.0.", + "sources": "HM Land Registry \u00b7 EPC (DLUHC) \u00b7 Ofsted \u00b7 DfT \u00b7 ONS \u00b7 Police.uk" +} \ No newline at end of file diff --git a/analysis/out/findings/cheaper-twin__tw12-3-vs-kt8-1.json b/analysis/out/findings/cheaper-twin__tw12-3-vs-kt8-1.json new file mode 100644 index 0000000..f4c8eaa --- /dev/null +++ b/analysis/out/findings/cheaper-twin__tw12-3-vs-kt8-1.json @@ -0,0 +1,42 @@ +{ + "slug": "cheaper-twin/tw12-3-vs-kt8-1", + "type": "cheaper_twin", + "page_path": "/cheaper-twin/tw12-3-vs-kt8-1", + "title": "Hampton vs East Molesey: the same terraced house, about 19% cheaper per m\u00b2", + "hook": "\u00a3120,060 less for an equivalent terraced house: same station, similar schools, ~2.23km apart", + "shocking_number": "19%", + "pricey": { + "sector": "TW12 3", + "name": "Hampton", + "label": "Hampton (TW12 3)", + "named": true, + "est_psqm": 7042, + "n": 2527 + }, + "twin": { + "sector": "KT8 1", + "name": "East Molesey", + "label": "East Molesey (KT8 1)", + "named": true, + "est_psqm": 5708, + "n": 1567 + }, + "stats": { + "gap_pct": 18.9, + "gap_per_sqm": 1334, + "gap_on_90sqm": 120060, + "gap_on_avg_home": 107053, + "dominant_type": "Terraced", + "build_year": 1979, + "good_secondary_catchments": 3.2, + "station_km": 1.27, + "distance_km": 2.23 + }, + "map_query": "lat=51.41616&lon=-0.37365&zoom=12.5&filter=Est.%20price%20per%20sqm:0:6000&filter=Good%2B%20secondary%20school%20catchments:1:11", + "map_url": "https://perfect-postcode.co.uk/?lat=51.41616&lon=-0.37365&zoom=12.5&filter=Est.%20price%20per%20sqm:0:6000&filter=Good%2B%20secondary%20school%20catchments:1:11", + "og_image": "https://perfect-postcode.co.uk/api/screenshot?og=1&lat=51.41616&lon=-0.37365&zoom=12.5&filter=Est.%20price%20per%20sqm:0:6000&filter=Good%2B%20secondary%20school%20catchments:1:11", + "methodology": "Postcode sectors (e.g. N10 3) compared on estimated \u00a3/m\u00b2 of floor space. A pair is only called a 'twin' when the two sectors share the dominant property type, build era (\u00b130y), good-school catchment provision, station access, deprivation/tenure, education, age and home size, so the price gap reflects a name premium, not a different kind of area. Estimates, not valuations; aggregated to sector, never address-level.", + "needs_name_check": false, + "attribution": "Contains HM Land Registry data \u00a9 Crown copyright and database right. Licensed under the Open Government Licence v3.0.", + "sources": "HM Land Registry \u00b7 EPC (DLUHC) \u00b7 Ofsted \u00b7 DfT \u00b7 ONS \u00b7 Police.uk" +} \ No newline at end of file diff --git a/analysis/out/findings/cheaper-twin__tw2-7-vs-tw3-2.json b/analysis/out/findings/cheaper-twin__tw2-7-vs-tw3-2.json new file mode 100644 index 0000000..fad8702 --- /dev/null +++ b/analysis/out/findings/cheaper-twin__tw2-7-vs-tw3-2.json @@ -0,0 +1,42 @@ +{ + "slug": "cheaper-twin/tw2-7-vs-tw3-2", + "type": "cheaper_twin", + "page_path": "/cheaper-twin/tw2-7-vs-tw3-2", + "title": "Twickenham vs Hounslow: the same semi-detached house, about 19% cheaper per m\u00b2", + "hook": "\u00a3121,590 less for an equivalent semi-detached house: same station, similar schools, ~1.0km apart", + "shocking_number": "19%", + "pricey": { + "sector": "TW2 7", + "name": "Twickenham", + "label": "Twickenham (TW2 7)", + "named": true, + "est_psqm": 6971, + "n": 3377 + }, + "twin": { + "sector": "TW3 2", + "name": "Hounslow", + "label": "Hounslow (TW3 2)", + "named": true, + "est_psqm": 5620, + "n": 2964 + }, + "stats": { + "gap_pct": 19.4, + "gap_per_sqm": 1351, + "gap_on_90sqm": 121590, + "gap_on_avg_home": 113821, + "dominant_type": "Semi-Detached", + "build_year": 1940, + "good_secondary_catchments": 5.4, + "station_km": 0.59, + "distance_km": 1.0 + }, + "map_query": "lat=51.45678&lon=-0.35702&zoom=12.5&filter=Est.%20price%20per%20sqm:0:5900&filter=Good%2B%20secondary%20school%20catchments:1:11", + "map_url": "https://perfect-postcode.co.uk/?lat=51.45678&lon=-0.35702&zoom=12.5&filter=Est.%20price%20per%20sqm:0:5900&filter=Good%2B%20secondary%20school%20catchments:1:11", + "og_image": "https://perfect-postcode.co.uk/api/screenshot?og=1&lat=51.45678&lon=-0.35702&zoom=12.5&filter=Est.%20price%20per%20sqm:0:5900&filter=Good%2B%20secondary%20school%20catchments:1:11", + "methodology": "Postcode sectors (e.g. N10 3) compared on estimated \u00a3/m\u00b2 of floor space. A pair is only called a 'twin' when the two sectors share the dominant property type, build era (\u00b130y), good-school catchment provision, station access, deprivation/tenure, education, age and home size, so the price gap reflects a name premium, not a different kind of area. Estimates, not valuations; aggregated to sector, never address-level.", + "needs_name_check": false, + "attribution": "Contains HM Land Registry data \u00a9 Crown copyright and database right. Licensed under the Open Government Licence v3.0.", + "sources": "HM Land Registry \u00b7 EPC (DLUHC) \u00b7 Ofsted \u00b7 DfT \u00b7 ONS \u00b7 Police.uk" +} \ No newline at end of file diff --git a/analysis/out/findings/cheaper-twin__w1j-7-vs-sw7-3.json b/analysis/out/findings/cheaper-twin__w1j-7-vs-sw7-3.json new file mode 100644 index 0000000..c04f3c9 --- /dev/null +++ b/analysis/out/findings/cheaper-twin__w1j-7-vs-sw7-3.json @@ -0,0 +1,42 @@ +{ + "slug": "cheaper-twin/w1j-7-vs-sw7-3", + "type": "cheaper_twin", + "page_path": "/cheaper-twin/w1j-7-vs-sw7-3", + "title": "W1J 7 vs SW7 3: the same flat, about 41% cheaper per m\u00b2", + "hook": "\u00a31,223,460 less for an equivalent flat: same station, similar schools, ~2.6km apart", + "shocking_number": "41%", + "pricey": { + "sector": "W1J 7", + "name": "Mayfair", + "label": "Mayfair (W1J 7)", + "named": true, + "est_psqm": 32986, + "n": 724 + }, + "twin": { + "sector": "SW7 3", + "name": null, + "label": "SW7 3", + "named": false, + "est_psqm": 19392, + "n": 2581 + }, + "stats": { + "gap_pct": 41.2, + "gap_per_sqm": 13594, + "gap_on_90sqm": 1223460, + "gap_on_avg_home": 1077324, + "dominant_type": "Flats/Maisonettes", + "build_year": 1914, + "good_secondary_catchments": 2.0, + "station_km": 0.3, + "distance_km": 2.6 + }, + "map_query": "lat=51.49856&lon=-0.16253&zoom=12.5&filter=Est.%20price%20per%20sqm:0:20400&filter=Good%2B%20secondary%20school%20catchments:1:11", + "map_url": "https://perfect-postcode.co.uk/?lat=51.49856&lon=-0.16253&zoom=12.5&filter=Est.%20price%20per%20sqm:0:20400&filter=Good%2B%20secondary%20school%20catchments:1:11", + "og_image": "https://perfect-postcode.co.uk/api/screenshot?og=1&lat=51.49856&lon=-0.16253&zoom=12.5&filter=Est.%20price%20per%20sqm:0:20400&filter=Good%2B%20secondary%20school%20catchments:1:11", + "methodology": "Postcode sectors (e.g. N10 3) compared on estimated \u00a3/m\u00b2 of floor space. A pair is only called a 'twin' when the two sectors share the dominant property type, build era (\u00b130y), good-school catchment provision, station access, deprivation/tenure, education, age and home size, so the price gap reflects a name premium, not a different kind of area. Estimates, not valuations; aggregated to sector, never address-level.", + "needs_name_check": true, + "attribution": "Contains HM Land Registry data \u00a9 Crown copyright and database right. Licensed under the Open Government Licence v3.0.", + "sources": "HM Land Registry \u00b7 EPC (DLUHC) \u00b7 Ofsted \u00b7 DfT \u00b7 ONS \u00b7 Police.uk" +} \ No newline at end of file diff --git a/analysis/out/findings/cheaper-twin__w1j-8-vs-sw1a-2.json b/analysis/out/findings/cheaper-twin__w1j-8-vs-sw1a-2.json new file mode 100644 index 0000000..a860b2c --- /dev/null +++ b/analysis/out/findings/cheaper-twin__w1j-8-vs-sw1a-2.json @@ -0,0 +1,42 @@ +{ + "slug": "cheaper-twin/w1j-8-vs-sw1a-2", + "type": "cheaper_twin", + "page_path": "/cheaper-twin/w1j-8-vs-sw1a-2", + "title": "W1J 8 vs SW1A 2: the same flat, about 37% cheaper per m\u00b2", + "hook": "\u00a3916,380 less for an equivalent flat: same station, similar schools, ~1.31km apart", + "shocking_number": "37%", + "pricey": { + "sector": "W1J 8", + "name": "Mayfair", + "label": "Mayfair (W1J 8)", + "named": true, + "est_psqm": 27270, + "n": 295 + }, + "twin": { + "sector": "SW1A 2", + "name": null, + "label": "SW1A 2", + "named": false, + "est_psqm": 17088, + "n": 261 + }, + "stats": { + "gap_pct": 37.3, + "gap_per_sqm": 10182, + "gap_on_90sqm": 916380, + "gap_on_avg_home": 1089474, + "dominant_type": "Flats/Maisonettes", + "build_year": 2000, + "good_secondary_catchments": 2.0, + "station_km": 0.18, + "distance_km": 1.31 + }, + "map_query": "lat=51.50663&lon=-0.13494&zoom=12.5&filter=Est.%20price%20per%20sqm:0:17900&filter=Good%2B%20secondary%20school%20catchments:1:11", + "map_url": "https://perfect-postcode.co.uk/?lat=51.50663&lon=-0.13494&zoom=12.5&filter=Est.%20price%20per%20sqm:0:17900&filter=Good%2B%20secondary%20school%20catchments:1:11", + "og_image": "https://perfect-postcode.co.uk/api/screenshot?og=1&lat=51.50663&lon=-0.13494&zoom=12.5&filter=Est.%20price%20per%20sqm:0:17900&filter=Good%2B%20secondary%20school%20catchments:1:11", + "methodology": "Postcode sectors (e.g. N10 3) compared on estimated \u00a3/m\u00b2 of floor space. A pair is only called a 'twin' when the two sectors share the dominant property type, build era (\u00b130y), good-school catchment provision, station access, deprivation/tenure, education, age and home size, so the price gap reflects a name premium, not a different kind of area. Estimates, not valuations; aggregated to sector, never address-level.", + "needs_name_check": true, + "attribution": "Contains HM Land Registry data \u00a9 Crown copyright and database right. Licensed under the Open Government Licence v3.0.", + "sources": "HM Land Registry \u00b7 EPC (DLUHC) \u00b7 Ofsted \u00b7 DfT \u00b7 ONS \u00b7 Police.uk" +} \ No newline at end of file diff --git a/analysis/out/findings/cheaper-twin__w1k-2-vs-sw1x-0.json b/analysis/out/findings/cheaper-twin__w1k-2-vs-sw1x-0.json new file mode 100644 index 0000000..441fc97 --- /dev/null +++ b/analysis/out/findings/cheaper-twin__w1k-2-vs-sw1x-0.json @@ -0,0 +1,42 @@ +{ + "slug": "cheaper-twin/w1k-2-vs-sw1x-0", + "type": "cheaper_twin", + "page_path": "/cheaper-twin/w1k-2-vs-sw1x-0", + "title": "W1K 2 vs SW1X 0: the same flat, about 32% cheaper per m\u00b2", + "hook": "\u00a3978,570 less for an equivalent flat: same station, similar schools, ~1.62km apart", + "shocking_number": "32%", + "pricey": { + "sector": "W1K 2", + "name": "Mayfair", + "label": "Mayfair (W1K 2)", + "named": true, + "est_psqm": 34362, + "n": 591 + }, + "twin": { + "sector": "SW1X 0", + "name": null, + "label": "SW1X 0", + "named": false, + "est_psqm": 23489, + "n": 1606 + }, + "stats": { + "gap_pct": 31.6, + "gap_per_sqm": 10873, + "gap_on_90sqm": 978570, + "gap_on_avg_home": 1293887, + "dominant_type": "Flats/Maisonettes", + "build_year": 1914, + "good_secondary_catchments": 2.0, + "station_km": 0.5, + "distance_km": 1.62 + }, + "map_query": "lat=51.50311&lon=-0.15673&zoom=12.5&filter=Est.%20price%20per%20sqm:0:24700&filter=Good%2B%20secondary%20school%20catchments:1:11", + "map_url": "https://perfect-postcode.co.uk/?lat=51.50311&lon=-0.15673&zoom=12.5&filter=Est.%20price%20per%20sqm:0:24700&filter=Good%2B%20secondary%20school%20catchments:1:11", + "og_image": "https://perfect-postcode.co.uk/api/screenshot?og=1&lat=51.50311&lon=-0.15673&zoom=12.5&filter=Est.%20price%20per%20sqm:0:24700&filter=Good%2B%20secondary%20school%20catchments:1:11", + "methodology": "Postcode sectors (e.g. N10 3) compared on estimated \u00a3/m\u00b2 of floor space. A pair is only called a 'twin' when the two sectors share the dominant property type, build era (\u00b130y), good-school catchment provision, station access, deprivation/tenure, education, age and home size, so the price gap reflects a name premium, not a different kind of area. Estimates, not valuations; aggregated to sector, never address-level.", + "needs_name_check": true, + "attribution": "Contains HM Land Registry data \u00a9 Crown copyright and database right. Licensed under the Open Government Licence v3.0.", + "sources": "HM Land Registry \u00b7 EPC (DLUHC) \u00b7 Ofsted \u00b7 DfT \u00b7 ONS \u00b7 Police.uk" +} \ No newline at end of file diff --git a/analysis/out/findings/cheaper-twin__w1u-4-vs-nw1-4.json b/analysis/out/findings/cheaper-twin__w1u-4-vs-nw1-4.json new file mode 100644 index 0000000..39b9e24 --- /dev/null +++ b/analysis/out/findings/cheaper-twin__w1u-4-vs-nw1-4.json @@ -0,0 +1,42 @@ +{ + "slug": "cheaper-twin/w1u-4-vs-nw1-4", + "type": "cheaper_twin", + "page_path": "/cheaper-twin/w1u-4-vs-nw1-4", + "title": "Marylebone vs Camden: the same flat, about 43% cheaper per m\u00b2", + "hook": "\u00a3942,480 less for an equivalent flat: same station, similar schools, ~0.97km apart", + "shocking_number": "43%", + "pricey": { + "sector": "W1U 4", + "name": "Marylebone", + "label": "Marylebone (W1U 4)", + "named": true, + "est_psqm": 24238, + "n": 984 + }, + "twin": { + "sector": "NW1 4", + "name": "Camden", + "label": "Camden (NW1 4)", + "named": true, + "est_psqm": 13766, + "n": 1340 + }, + "stats": { + "gap_pct": 43.2, + "gap_per_sqm": 10472, + "gap_on_90sqm": 942480, + "gap_on_avg_home": 759220, + "dominant_type": "Flats/Maisonettes", + "build_year": 1940, + "good_secondary_catchments": 1.0, + "station_km": 0.44, + "distance_km": 0.97 + }, + "map_query": "lat=51.5238&lon=-0.15091&zoom=12.5&filter=Est.%20price%20per%20sqm:0:14500&filter=Good%2B%20secondary%20school%20catchments:1:11", + "map_url": "https://perfect-postcode.co.uk/?lat=51.5238&lon=-0.15091&zoom=12.5&filter=Est.%20price%20per%20sqm:0:14500&filter=Good%2B%20secondary%20school%20catchments:1:11", + "og_image": "https://perfect-postcode.co.uk/api/screenshot?og=1&lat=51.5238&lon=-0.15091&zoom=12.5&filter=Est.%20price%20per%20sqm:0:14500&filter=Good%2B%20secondary%20school%20catchments:1:11", + "methodology": "Postcode sectors (e.g. N10 3) compared on estimated \u00a3/m\u00b2 of floor space. A pair is only called a 'twin' when the two sectors share the dominant property type, build era (\u00b130y), good-school catchment provision, station access, deprivation/tenure, education, age and home size, so the price gap reflects a name premium, not a different kind of area. Estimates, not valuations; aggregated to sector, never address-level.", + "needs_name_check": false, + "attribution": "Contains HM Land Registry data \u00a9 Crown copyright and database right. Licensed under the Open Government Licence v3.0.", + "sources": "HM Land Registry \u00b7 EPC (DLUHC) \u00b7 Ofsted \u00b7 DfT \u00b7 ONS \u00b7 Police.uk" +} \ No newline at end of file diff --git a/analysis/out/findings/cheaper-twin__wc2a-2-vs-ec2a-2.json b/analysis/out/findings/cheaper-twin__wc2a-2-vs-ec2a-2.json new file mode 100644 index 0000000..832b837 --- /dev/null +++ b/analysis/out/findings/cheaper-twin__wc2a-2-vs-ec2a-2.json @@ -0,0 +1,42 @@ +{ + "slug": "cheaper-twin/wc2a-2-vs-ec2a-2", + "type": "cheaper_twin", + "page_path": "/cheaper-twin/wc2a-2-vs-ec2a-2", + "title": "WC2A 2 vs EC2A 2: the same flat, about 43% cheaper per m\u00b2", + "hook": "\u00a3981,540 less for an equivalent flat: same station, similar schools, ~2.3km apart", + "shocking_number": "43%", + "pricey": { + "sector": "WC2A 2", + "name": null, + "label": "WC2A 2", + "named": false, + "est_psqm": 25482, + "n": 254 + }, + "twin": { + "sector": "EC2A 2", + "name": null, + "label": "EC2A 2", + "named": false, + "est_psqm": 14576, + "n": 772 + }, + "stats": { + "gap_pct": 42.8, + "gap_per_sqm": 10906, + "gap_on_90sqm": 981540, + "gap_on_avg_home": 834309, + "dominant_type": "Flats/Maisonettes", + "build_year": 2019, + "good_secondary_catchments": 2.0, + "station_km": 0.42, + "distance_km": 2.3 + }, + "map_query": "lat=51.51807&lon=-0.09837&zoom=12.5&filter=Est.%20price%20per%20sqm:0:15300&filter=Good%2B%20secondary%20school%20catchments:1:11", + "map_url": "https://perfect-postcode.co.uk/?lat=51.51807&lon=-0.09837&zoom=12.5&filter=Est.%20price%20per%20sqm:0:15300&filter=Good%2B%20secondary%20school%20catchments:1:11", + "og_image": "https://perfect-postcode.co.uk/api/screenshot?og=1&lat=51.51807&lon=-0.09837&zoom=12.5&filter=Est.%20price%20per%20sqm:0:15300&filter=Good%2B%20secondary%20school%20catchments:1:11", + "methodology": "Postcode sectors (e.g. N10 3) compared on estimated \u00a3/m\u00b2 of floor space. A pair is only called a 'twin' when the two sectors share the dominant property type, build era (\u00b130y), good-school catchment provision, station access, deprivation/tenure, education, age and home size, so the price gap reflects a name premium, not a different kind of area. Estimates, not valuations; aggregated to sector, never address-level.", + "needs_name_check": true, + "attribution": "Contains HM Land Registry data \u00a9 Crown copyright and database right. Licensed under the Open Government Licence v3.0.", + "sources": "HM Land Registry \u00b7 EPC (DLUHC) \u00b7 Ofsted \u00b7 DfT \u00b7 ONS \u00b7 Police.uk" +} \ No newline at end of file diff --git a/analysis/out/findings/square-metres-per-100k.json b/analysis/out/findings/square-metres-per-100k.json new file mode 100644 index 0000000..59f185a --- /dev/null +++ b/analysis/out/findings/square-metres-per-100k.json @@ -0,0 +1,28 @@ +{ + "slug": "square-metres-per-100k", + "type": "national_table", + "page_path": "/square-metres-per-100k", + "title": "How many square metres \u00a3100,000 buys across England", + "shocking_number": "152 m\u00b2 vs 3 m\u00b2", + "hook": "\u00a3100k buys ~152 m\u00b2 of floor space in BD21 3 but only ~3 m\u00b2 in Mayfair (W1K 2)", + "stats": { + "best": { + "sector": "BD21 3", + "est_psqm": 660, + "sqm_per_100k": 151.6, + "n": 1377 + }, + "dearest": { + "sector": "W1K 2", + "est_psqm": 34362, + "sqm_per_100k": 2.9, + "n": 591 + }, + "n_sectors": 7560 + }, + "map_query": "zoom=6&filter=Est.%20price%20per%20sqm:0:4000", + "methodology": "100000 \u00f7 median estimated \u00a3/m\u00b2, per England postcode sector with sufficient sales.", + "needs_name_check": true, + "attribution": "Contains HM Land Registry data \u00a9 Crown copyright and database right. Licensed under the Open Government Licence v3.0.", + "sources": "HM Land Registry \u00b7 EPC (DLUHC) \u00b7 Ofsted \u00b7 DfT \u00b7 ONS \u00b7 Police.uk" +} \ No newline at end of file diff --git a/analysis/out/video_scripts/INDEX.md b/analysis/out/video_scripts/INDEX.md new file mode 100644 index 0000000..fd9d145 --- /dev/null +++ b/analysis/out/video_scripts/INDEX.md @@ -0,0 +1,23 @@ +# Video kits: film one per 1–2 weeks + +Each kit is a complete, payoff-first faceless video you can screen-record off the live map. Pick one, open its Map URL, record, read the narration (human voice), export one clean cut + a 9:16 Short. + +**Priority order (relatable family-home twins first, since they convert better than prime London):** + +| Kit | Hook | File | +|-----|------|------| +| Beckenham → Croydon | 31% / £201,870 | `br3-3-vs-cr0-7.md` | +| Woodford Green → Barkingside | 26% / £164,070 | `ig8-7-vs-ig6-2.md` | +| SE28 8 → DA18 4 | 30% / £129,690 | `se28-8-vs-da18-4.md` | +| Twickenham → Hounslow | 19% / £121,590 | `tw2-7-vs-tw3-2.md` | +| Hampton → East Molesey | 19% / £120,060 | `tw12-3-vs-kt8-1.md` | +| Upminster → Hornchurch | 20% / £115,290 | `rm14-2-vs-rm12-5.md` | +| Stanmore → Kenton | 17% / £106,920 | `ha7-2-vs-ha3-0.md` | +| Childwall → Broadgreen | 30% / £106,740 | `l16-7-vs-l14-6.md` | +| Newton Heath → Harpurhey | 42% / £106,740 | `m40-5-vs-m9-4.md` | +| Mayfair → SW7 3 | 41% / £1,223,460 | `w1j-7-vs-sw7-3.md` | +| SW1X 8 → SW7 2 | 42% / £1,001,160 | `sw1x-8-vs-sw7-2.md` | +| WC2A 2 → EC2A 2 | 43% / £981,540 | `wc2a-2-vs-ec2a-2.md` | +| Mayfair → SW1X 0 | 32% / £978,570 | `w1k-2-vs-sw1x-0.md` | +| Marylebone → Camden | 43% / £942,480 | `w1u-4-vs-nw1-4.md` | +| Mayfair → SW1A 2 | 37% / £916,380 | `w1j-8-vs-sw1a-2.md` | \ No newline at end of file diff --git a/analysis/out/video_scripts/br3-3-vs-cr0-7.md b/analysis/out/video_scripts/br3-3-vs-cr0-7.md new file mode 100644 index 0000000..b81e560 --- /dev/null +++ b/analysis/out/video_scripts/br3-3-vs-cr0-7.md @@ -0,0 +1,79 @@ +# Video kit: Beckenham vs Croydon + +**Page:** https://perfect-postcode.co.uk/cheaper-twin/br3-3-vs-cr0-7 · **Format:** faceless screen-record, ~45–60s long + a 9:16 Short cut + +## 🎬 Map URL to record (open this, hit record) +`https://perfect-postcode.co.uk/?lat=51.38969&lon=-0.04244&zoom=12.5&filter=Est.%20price%20per%20sqm:0:5200&filter=Good%2B%20secondary%20school%20catchments:1:11` +*(filters are pre-applied so the value is on screen immediately)* + +## Hook (first 2 seconds, on screen + said) +**"£201,870 cheaper. Same station. Same schools."** + +## Shot list +| Time | Beat | What to show | On-screen | +|------|------|--------------|-----------| +| 0:00–0:06 | COLD OPEN: payoff first | Open on the map already showing both areas with the £/m² gap visible. Caption: '£201,870 cheaper'. Say the hook. | Land on the map URL below (filters pre-applied). | +| 0:06–0:18 | Same station | Pan/zoom to show both areas sit by the same line/station. Toggle the commute context if you want. | Caption: 'Same station.' | +| 0:18–0:28 | Same schools | Show the Good+ secondary catchment covering both. | Caption: 'Same school catchment.' | +| 0:28–0:38 | Same homes | Note the dominant type (terraced houses) and build era (~1940). | Caption: 'Same homes.' | +| 0:38–0:52 | THE REVEAL | Show the £/m² side by side: Beckenham £7,153 vs Croydon £4,910. | Caption: '31% less per m²'. | +| 0:52–1:00 | CTA | End on the map; invite them to find their own cheaper twin. | Caption: 'Free. No signup.' | + +## Narration (human voiceover, never raw TTS for a property audience) +> This is Beckenham. And this is Croydon, right next door. Same station. Same secondary school catchment. The same kind of home: terraced houses built around 1940. On every measure that moves price, they're twins. But watch the price per square metre. Beckenham: £7,153. Croydon: £4,910. That's 31% cheaper, about £201,870 on a typical 90-square-metre home, for the same life, one postcode over. You're not paying for the house. You're paying for the name. You can find the cheaper twin of any postcode in England on the map for free, no signup. + +## Captions (≤6 words, sound-off) +- Beckenham vs Croydon +- Same station. Same schools. +- £201,870 cheaper +- Same terraced, ~1940 +- 31% less per m² +- Find your cheaper twin, free + +## YouTube +**Title options:** +1. Beckenham vs Croydon: the same terraced house, about 31% cheaper per m² +2. Beckenham vs Croydon: same station, same schools, £201,870 cheaper +3. Why Croydon is the smart-money version of Beckenham (31% less per m²) + +**Thumbnail text:** big number `£201,870 cheaper` + the two names `Beckenham → Croydon` + +**Description (paste as-is):** +``` +https://perfect-postcode.co.uk/?lat=51.38969&lon=-0.04244&zoom=12.5&filter=Est.%20price%20per%20sqm:0:5200&filter=Good%2B%20secondary%20school%20catchments:1:11 + +Beckenham and Croydon share a station, a school catchment and the same era of housing, but Croydon costs about 31% less per square metre (£201,870 on a 90 m² home). I built a map that ranks every postcode in England by what each pound actually buys, from official open data (Land Registry, EPC, Ofsted, DfT, Police.uk). Find the cheaper twin of any area, free and with no signup, at https://perfect-postcode.co.uk. + +0:00 The two postcodes (Beckenham & Croydon) +0:08 Same station +0:18 Same school catchment +0:28 Same kind of home +0:38 The price-per-m² reveal +0:52 Find your own cheaper twin (free map) + +Data: Contains HM Land Registry data © Crown copyright and database right, OGL v3.0. Figures are estimates aggregated to postcode sector, not valuations. +``` + +## 9:16 Short (cut from the same recording) +First 3 seconds: the £/m² reveal (Beckenham £7,153 → Croydon £4,910) + caption '31% less'. End card: 'Find your cheaper twin, free, no signup.' + +## Optional auto-render spec (video/src/storyboard.ts AD_CONFIGS) +Add this as a `DemoAdStoryboardConfig` and run `video/render.sh --prod` (needs login creds + the live stack). Filter names must match live `/api/features` or preflight fails. +```json +{ + "name": "twin-br3-3-vs-cr0-7", + "city": "london", + "promptText": "Best value terraceds near Beckenham: same schools and station, lower price", + "initialFilters": { + "Est. price per sqm": [ + 0, + 5200 + ], + "Good+ secondary school catchments": [ + 1, + 11 + ] + }, + "outroLine": "Croydon: same life, 31% cheaper." +} +``` \ No newline at end of file diff --git a/analysis/out/video_scripts/ha7-2-vs-ha3-0.md b/analysis/out/video_scripts/ha7-2-vs-ha3-0.md new file mode 100644 index 0000000..d9fcd9e --- /dev/null +++ b/analysis/out/video_scripts/ha7-2-vs-ha3-0.md @@ -0,0 +1,79 @@ +# Video kit: Stanmore vs Kenton + +**Page:** https://perfect-postcode.co.uk/cheaper-twin/ha7-2-vs-ha3-0 · **Format:** faceless screen-record, ~45–60s long + a 9:16 Short cut + +## 🎬 Map URL to record (open this, hit record) +`https://perfect-postcode.co.uk/?lat=51.59199&lon=-0.3079&zoom=12.5&filter=Est.%20price%20per%20sqm:0:5900&filter=Good%2B%20secondary%20school%20catchments:1:11` +*(filters are pre-applied so the value is on screen immediately)* + +## Hook (first 2 seconds, on screen + said) +**"£106,920 cheaper. Same station. Same schools."** + +## Shot list +| Time | Beat | What to show | On-screen | +|------|------|--------------|-----------| +| 0:00–0:06 | COLD OPEN: payoff first | Open on the map already showing both areas with the £/m² gap visible. Caption: '£106,920 cheaper'. Say the hook. | Land on the map URL below (filters pre-applied). | +| 0:06–0:18 | Same station | Pan/zoom to show both areas sit by the same line/station. Toggle the commute context if you want. | Caption: 'Same station.' | +| 0:18–0:28 | Same schools | Show the Good+ secondary catchment covering both. | Caption: 'Same school catchment.' | +| 0:28–0:38 | Same homes | Note the dominant type (semi-detached houses) and build era (~1940). | Caption: 'Same homes.' | +| 0:38–0:52 | THE REVEAL | Show the £/m² side by side: Stanmore £6,834 vs Kenton £5,646. | Caption: '17% less per m²'. | +| 0:52–1:00 | CTA | End on the map; invite them to find their own cheaper twin. | Caption: 'Free. No signup.' | + +## Narration (human voiceover, never raw TTS for a property audience) +> This is Stanmore. And this is Kenton, right next door. Same station. Same secondary school catchment. The same kind of home: semi-detached houses built around 1940. On every measure that moves price, they're twins. But watch the price per square metre. Stanmore: £6,834. Kenton: £5,646. That's 17% cheaper, about £106,920 on a typical 90-square-metre home, for the same life, one postcode over. You're not paying for the house. You're paying for the name. You can find the cheaper twin of any postcode in England on the map for free, no signup. + +## Captions (≤6 words, sound-off) +- Stanmore vs Kenton +- Same station. Same schools. +- £106,920 cheaper +- Same semi-detached, ~1940 +- 17% less per m² +- Find your cheaper twin, free + +## YouTube +**Title options:** +1. Stanmore vs Kenton: the same semi-detached house, about 17% cheaper per m² +2. Stanmore vs Kenton: same station, same schools, £106,920 cheaper +3. Why Kenton is the smart-money version of Stanmore (17% less per m²) + +**Thumbnail text:** big number `£106,920 cheaper` + the two names `Stanmore → Kenton` + +**Description (paste as-is):** +``` +https://perfect-postcode.co.uk/?lat=51.59199&lon=-0.3079&zoom=12.5&filter=Est.%20price%20per%20sqm:0:5900&filter=Good%2B%20secondary%20school%20catchments:1:11 + +Stanmore and Kenton share a station, a school catchment and the same era of housing, but Kenton costs about 17% less per square metre (£106,920 on a 90 m² home). I built a map that ranks every postcode in England by what each pound actually buys, from official open data (Land Registry, EPC, Ofsted, DfT, Police.uk). Find the cheaper twin of any area, free and with no signup, at https://perfect-postcode.co.uk. + +0:00 The two postcodes (Stanmore & Kenton) +0:08 Same station +0:18 Same school catchment +0:28 Same kind of home +0:38 The price-per-m² reveal +0:52 Find your own cheaper twin (free map) + +Data: Contains HM Land Registry data © Crown copyright and database right, OGL v3.0. Figures are estimates aggregated to postcode sector, not valuations. +``` + +## 9:16 Short (cut from the same recording) +First 3 seconds: the £/m² reveal (Stanmore £6,834 → Kenton £5,646) + caption '17% less'. End card: 'Find your cheaper twin, free, no signup.' + +## Optional auto-render spec (video/src/storyboard.ts AD_CONFIGS) +Add this as a `DemoAdStoryboardConfig` and run `video/render.sh --prod` (needs login creds + the live stack). Filter names must match live `/api/features` or preflight fails. +```json +{ + "name": "twin-ha7-2-vs-ha3-0", + "city": "london", + "promptText": "Best value semi-detacheds near Stanmore: same schools and station, lower price", + "initialFilters": { + "Est. price per sqm": [ + 0, + 5900 + ], + "Good+ secondary school catchments": [ + 1, + 11 + ] + }, + "outroLine": "Kenton: same life, 17% cheaper." +} +``` \ No newline at end of file diff --git a/analysis/out/video_scripts/ig8-7-vs-ig6-2.md b/analysis/out/video_scripts/ig8-7-vs-ig6-2.md new file mode 100644 index 0000000..05f19c6 --- /dev/null +++ b/analysis/out/video_scripts/ig8-7-vs-ig6-2.md @@ -0,0 +1,79 @@ +# Video kit: Woodford Green vs Barkingside + +**Page:** https://perfect-postcode.co.uk/cheaper-twin/ig8-7-vs-ig6-2 · **Format:** faceless screen-record, ~45–60s long + a 9:16 Short cut + +## 🎬 Map URL to record (open this, hit record) +`https://perfect-postcode.co.uk/?lat=51.60238&lon=0.06063&zoom=12.5&filter=Est.%20price%20per%20sqm:0:5600&filter=Good%2B%20secondary%20school%20catchments:1:11` +*(filters are pre-applied so the value is on screen immediately)* + +## Hook (first 2 seconds, on screen + said) +**"£164,070 cheaper. Same station. Same schools."** + +## Shot list +| Time | Beat | What to show | On-screen | +|------|------|--------------|-----------| +| 0:00–0:06 | COLD OPEN: payoff first | Open on the map already showing both areas with the £/m² gap visible. Caption: '£164,070 cheaper'. Say the hook. | Land on the map URL below (filters pre-applied). | +| 0:06–0:18 | Same station | Pan/zoom to show both areas sit by the same line/station. Toggle the commute context if you want. | Caption: 'Same station.' | +| 0:18–0:28 | Same schools | Show the Good+ secondary catchment covering both. | Caption: 'Same school catchment.' | +| 0:28–0:38 | Same homes | Note the dominant type (terraced houses) and build era (~1958). | Caption: 'Same homes.' | +| 0:38–0:52 | THE REVEAL | Show the £/m² side by side: Woodford Green £7,148 vs Barkingside £5,325. | Caption: '26% less per m²'. | +| 0:52–1:00 | CTA | End on the map; invite them to find their own cheaper twin. | Caption: 'Free. No signup.' | + +## Narration (human voiceover, never raw TTS for a property audience) +> This is Woodford Green. And this is Barkingside, right next door. Same station. Same secondary school catchment. The same kind of home: terraced houses built around 1958. On every measure that moves price, they're twins. But watch the price per square metre. Woodford Green: £7,148. Barkingside: £5,325. That's 26% cheaper, about £164,070 on a typical 90-square-metre home, for the same life, one postcode over. You're not paying for the house. You're paying for the name. You can find the cheaper twin of any postcode in England on the map for free, no signup. + +## Captions (≤6 words, sound-off) +- Woodford Green vs Barkingside +- Same station. Same schools. +- £164,070 cheaper +- Same terraced, ~1958 +- 26% less per m² +- Find your cheaper twin, free + +## YouTube +**Title options:** +1. Woodford Green vs Barkingside: the same terraced house, about 26% cheaper per m² +2. Woodford Green vs Barkingside: same station, same schools, £164,070 cheaper +3. Why Barkingside is the smart-money version of Woodford Green (26% less per m²) + +**Thumbnail text:** big number `£164,070 cheaper` + the two names `Woodford Green → Barkingside` + +**Description (paste as-is):** +``` +https://perfect-postcode.co.uk/?lat=51.60238&lon=0.06063&zoom=12.5&filter=Est.%20price%20per%20sqm:0:5600&filter=Good%2B%20secondary%20school%20catchments:1:11 + +Woodford Green and Barkingside share a station, a school catchment and the same era of housing, but Barkingside costs about 26% less per square metre (£164,070 on a 90 m² home). I built a map that ranks every postcode in England by what each pound actually buys, from official open data (Land Registry, EPC, Ofsted, DfT, Police.uk). Find the cheaper twin of any area, free and with no signup, at https://perfect-postcode.co.uk. + +0:00 The two postcodes (Woodford Green & Barkingside) +0:08 Same station +0:18 Same school catchment +0:28 Same kind of home +0:38 The price-per-m² reveal +0:52 Find your own cheaper twin (free map) + +Data: Contains HM Land Registry data © Crown copyright and database right, OGL v3.0. Figures are estimates aggregated to postcode sector, not valuations. +``` + +## 9:16 Short (cut from the same recording) +First 3 seconds: the £/m² reveal (Woodford Green £7,148 → Barkingside £5,325) + caption '26% less'. End card: 'Find your cheaper twin, free, no signup.' + +## Optional auto-render spec (video/src/storyboard.ts AD_CONFIGS) +Add this as a `DemoAdStoryboardConfig` and run `video/render.sh --prod` (needs login creds + the live stack). Filter names must match live `/api/features` or preflight fails. +```json +{ + "name": "twin-ig8-7-vs-ig6-2", + "city": "london", + "promptText": "Best value terraceds near Woodford Green: same schools and station, lower price", + "initialFilters": { + "Est. price per sqm": [ + 0, + 5600 + ], + "Good+ secondary school catchments": [ + 1, + 11 + ] + }, + "outroLine": "Barkingside: same life, 26% cheaper." +} +``` \ No newline at end of file diff --git a/analysis/out/video_scripts/l16-7-vs-l14-6.md b/analysis/out/video_scripts/l16-7-vs-l14-6.md new file mode 100644 index 0000000..8625b77 --- /dev/null +++ b/analysis/out/video_scripts/l16-7-vs-l14-6.md @@ -0,0 +1,79 @@ +# Video kit: Childwall vs Broadgreen + +**Page:** https://perfect-postcode.co.uk/cheaper-twin/l16-7-vs-l14-6 · **Format:** faceless screen-record, ~45–60s long + a 9:16 Short cut + +## 🎬 Map URL to record (open this, hit record) +`https://perfect-postcode.co.uk/?lat=53.40344&lon=-2.88529&zoom=12.5&filter=Est.%20price%20per%20sqm:0:3000&filter=Good%2B%20secondary%20school%20catchments:1:11` +*(filters are pre-applied so the value is on screen immediately)* + +## Hook (first 2 seconds, on screen + said) +**"£106,740 cheaper. Same station. Same schools."** + +## Shot list +| Time | Beat | What to show | On-screen | +|------|------|--------------|-----------| +| 0:00–0:06 | COLD OPEN: payoff first | Open on the map already showing both areas with the £/m² gap visible. Caption: '£106,740 cheaper'. Say the hook. | Land on the map URL below (filters pre-applied). | +| 0:06–0:18 | Same station | Pan/zoom to show both areas sit by the same line/station. Toggle the commute context if you want. | Caption: 'Same station.' | +| 0:18–0:28 | Same schools | Show the Good+ secondary catchment covering both. | Caption: 'Same school catchment.' | +| 0:28–0:38 | Same homes | Note the dominant type (semi-detached houses) and build era (~1940). | Caption: 'Same homes.' | +| 0:38–0:52 | THE REVEAL | Show the £/m² side by side: Childwall £4,026 vs Broadgreen £2,840. | Caption: '30% less per m²'. | +| 0:52–1:00 | CTA | End on the map; invite them to find their own cheaper twin. | Caption: 'Free. No signup.' | + +## Narration (human voiceover, never raw TTS for a property audience) +> This is Childwall. And this is Broadgreen, right next door. Same station. Same secondary school catchment. The same kind of home: semi-detached houses built around 1940. On every measure that moves price, they're twins. But watch the price per square metre. Childwall: £4,026. Broadgreen: £2,840. That's 30% cheaper, about £106,740 on a typical 90-square-metre home, for the same life, one postcode over. You're not paying for the house. You're paying for the name. You can find the cheaper twin of any postcode in England on the map for free, no signup. + +## Captions (≤6 words, sound-off) +- Childwall vs Broadgreen +- Same station. Same schools. +- £106,740 cheaper +- Same semi-detached, ~1940 +- 30% less per m² +- Find your cheaper twin, free + +## YouTube +**Title options:** +1. Childwall vs Broadgreen: the same semi-detached house, about 30% cheaper per m² +2. Childwall vs Broadgreen: same station, same schools, £106,740 cheaper +3. Why Broadgreen is the smart-money version of Childwall (30% less per m²) + +**Thumbnail text:** big number `£106,740 cheaper` + the two names `Childwall → Broadgreen` + +**Description (paste as-is):** +``` +https://perfect-postcode.co.uk/?lat=53.40344&lon=-2.88529&zoom=12.5&filter=Est.%20price%20per%20sqm:0:3000&filter=Good%2B%20secondary%20school%20catchments:1:11 + +Childwall and Broadgreen share a station, a school catchment and the same era of housing, but Broadgreen costs about 30% less per square metre (£106,740 on a 90 m² home). I built a map that ranks every postcode in England by what each pound actually buys, from official open data (Land Registry, EPC, Ofsted, DfT, Police.uk). Find the cheaper twin of any area, free and with no signup, at https://perfect-postcode.co.uk. + +0:00 The two postcodes (Childwall & Broadgreen) +0:08 Same station +0:18 Same school catchment +0:28 Same kind of home +0:38 The price-per-m² reveal +0:52 Find your own cheaper twin (free map) + +Data: Contains HM Land Registry data © Crown copyright and database right, OGL v3.0. Figures are estimates aggregated to postcode sector, not valuations. +``` + +## 9:16 Short (cut from the same recording) +First 3 seconds: the £/m² reveal (Childwall £4,026 → Broadgreen £2,840) + caption '30% less'. End card: 'Find your cheaper twin, free, no signup.' + +## Optional auto-render spec (video/src/storyboard.ts AD_CONFIGS) +Add this as a `DemoAdStoryboardConfig` and run `video/render.sh --prod` (needs login creds + the live stack). Filter names must match live `/api/features` or preflight fails. +```json +{ + "name": "twin-l16-7-vs-l14-6", + "city": "london", + "promptText": "Best value semi-detacheds near Childwall: same schools and station, lower price", + "initialFilters": { + "Est. price per sqm": [ + 0, + 3000 + ], + "Good+ secondary school catchments": [ + 1, + 11 + ] + }, + "outroLine": "Broadgreen: same life, 30% cheaper." +} +``` \ No newline at end of file diff --git a/analysis/out/video_scripts/m40-5-vs-m9-4.md b/analysis/out/video_scripts/m40-5-vs-m9-4.md new file mode 100644 index 0000000..b7da930 --- /dev/null +++ b/analysis/out/video_scripts/m40-5-vs-m9-4.md @@ -0,0 +1,79 @@ +# Video kit: Newton Heath vs Harpurhey + +**Page:** https://perfect-postcode.co.uk/cheaper-twin/m40-5-vs-m9-4 · **Format:** faceless screen-record, ~45–60s long + a 9:16 Short cut + +## 🎬 Map URL to record (open this, hit record) +`https://perfect-postcode.co.uk/?lat=53.51293&lon=-2.19574&zoom=12.5&filter=Est.%20price%20per%20sqm:0:1700&filter=Good%2B%20secondary%20school%20catchments:1:11` +*(filters are pre-applied so the value is on screen immediately)* + +## Hook (first 2 seconds, on screen + said) +**"£106,740 cheaper. Same station. Same schools."** + +## Shot list +| Time | Beat | What to show | On-screen | +|------|------|--------------|-----------| +| 0:00–0:06 | COLD OPEN: payoff first | Open on the map already showing both areas with the £/m² gap visible. Caption: '£106,740 cheaper'. Say the hook. | Land on the map URL below (filters pre-applied). | +| 0:06–0:18 | Same station | Pan/zoom to show both areas sit by the same line/station. Toggle the commute context if you want. | Caption: 'Same station.' | +| 0:18–0:28 | Same schools | Show the Good+ secondary catchment covering both. | Caption: 'Same school catchment.' | +| 0:28–0:38 | Same homes | Note the dominant type (terraced houses) and build era (~1958). | Caption: 'Same homes.' | +| 0:38–0:52 | THE REVEAL | Show the £/m² side by side: Newton Heath £2,812 vs Harpurhey £1,626. | Caption: '42% less per m²'. | +| 0:52–1:00 | CTA | End on the map; invite them to find their own cheaper twin. | Caption: 'Free. No signup.' | + +## Narration (human voiceover, never raw TTS for a property audience) +> This is Newton Heath. And this is Harpurhey, right next door. Same station. Same secondary school catchment. The same kind of home: terraced houses built around 1958. On every measure that moves price, they're twins. But watch the price per square metre. Newton Heath: £2,812. Harpurhey: £1,626. That's 42% cheaper, about £106,740 on a typical 90-square-metre home, for the same life, one postcode over. You're not paying for the house. You're paying for the name. You can find the cheaper twin of any postcode in England on the map for free, no signup. + +## Captions (≤6 words, sound-off) +- Newton Heath vs Harpurhey +- Same station. Same schools. +- £106,740 cheaper +- Same terraced, ~1958 +- 42% less per m² +- Find your cheaper twin, free + +## YouTube +**Title options:** +1. Newton Heath vs Harpurhey: the same terraced house, about 42% cheaper per m² +2. Newton Heath vs Harpurhey: same station, same schools, £106,740 cheaper +3. Why Harpurhey is the smart-money version of Newton Heath (42% less per m²) + +**Thumbnail text:** big number `£106,740 cheaper` + the two names `Newton Heath → Harpurhey` + +**Description (paste as-is):** +``` +https://perfect-postcode.co.uk/?lat=53.51293&lon=-2.19574&zoom=12.5&filter=Est.%20price%20per%20sqm:0:1700&filter=Good%2B%20secondary%20school%20catchments:1:11 + +Newton Heath and Harpurhey share a station, a school catchment and the same era of housing, but Harpurhey costs about 42% less per square metre (£106,740 on a 90 m² home). I built a map that ranks every postcode in England by what each pound actually buys, from official open data (Land Registry, EPC, Ofsted, DfT, Police.uk). Find the cheaper twin of any area, free and with no signup, at https://perfect-postcode.co.uk. + +0:00 The two postcodes (Newton Heath & Harpurhey) +0:08 Same station +0:18 Same school catchment +0:28 Same kind of home +0:38 The price-per-m² reveal +0:52 Find your own cheaper twin (free map) + +Data: Contains HM Land Registry data © Crown copyright and database right, OGL v3.0. Figures are estimates aggregated to postcode sector, not valuations. +``` + +## 9:16 Short (cut from the same recording) +First 3 seconds: the £/m² reveal (Newton Heath £2,812 → Harpurhey £1,626) + caption '42% less'. End card: 'Find your cheaper twin, free, no signup.' + +## Optional auto-render spec (video/src/storyboard.ts AD_CONFIGS) +Add this as a `DemoAdStoryboardConfig` and run `video/render.sh --prod` (needs login creds + the live stack). Filter names must match live `/api/features` or preflight fails. +```json +{ + "name": "twin-m40-5-vs-m9-4", + "city": "manchester", + "promptText": "Best value terraceds near Newton Heath: same schools and station, lower price", + "initialFilters": { + "Est. price per sqm": [ + 0, + 1700 + ], + "Good+ secondary school catchments": [ + 1, + 11 + ] + }, + "outroLine": "Harpurhey: same life, 42% cheaper." +} +``` \ No newline at end of file diff --git a/analysis/out/video_scripts/rm14-2-vs-rm12-5.md b/analysis/out/video_scripts/rm14-2-vs-rm12-5.md new file mode 100644 index 0000000..4f5fcd6 --- /dev/null +++ b/analysis/out/video_scripts/rm14-2-vs-rm12-5.md @@ -0,0 +1,79 @@ +# Video kit: Upminster vs Hornchurch + +**Page:** https://perfect-postcode.co.uk/cheaper-twin/rm14-2-vs-rm12-5 · **Format:** faceless screen-record, ~45–60s long + a 9:16 Short cut + +## 🎬 Map URL to record (open this, hit record) +`https://perfect-postcode.co.uk/?lat=51.54892&lon=0.22193&zoom=12.5&filter=Est.%20price%20per%20sqm:0:5300&filter=Good%2B%20secondary%20school%20catchments:1:11` +*(filters are pre-applied so the value is on screen immediately)* + +## Hook (first 2 seconds, on screen + said) +**"£115,290 cheaper. Same station. Same schools."** + +## Shot list +| Time | Beat | What to show | On-screen | +|------|------|--------------|-----------| +| 0:00–0:06 | COLD OPEN: payoff first | Open on the map already showing both areas with the £/m² gap visible. Caption: '£115,290 cheaper'. Say the hook. | Land on the map URL below (filters pre-applied). | +| 0:06–0:18 | Same station | Pan/zoom to show both areas sit by the same line/station. Toggle the commute context if you want. | Caption: 'Same station.' | +| 0:18–0:28 | Same schools | Show the Good+ secondary catchment covering both. | Caption: 'Same school catchment.' | +| 0:28–0:38 | Same homes | Note the dominant type (semi-detached houses) and build era (~1940). | Caption: 'Same homes.' | +| 0:38–0:52 | THE REVEAL | Show the £/m² side by side: Upminster £6,360 vs Hornchurch £5,079. | Caption: '20% less per m²'. | +| 0:52–1:00 | CTA | End on the map; invite them to find their own cheaper twin. | Caption: 'Free. No signup.' | + +## Narration (human voiceover, never raw TTS for a property audience) +> This is Upminster. And this is Hornchurch, right next door. Same station. Same secondary school catchment. The same kind of home: semi-detached houses built around 1940. On every measure that moves price, they're twins. But watch the price per square metre. Upminster: £6,360. Hornchurch: £5,079. That's 20% cheaper, about £115,290 on a typical 90-square-metre home, for the same life, one postcode over. You're not paying for the house. You're paying for the name. You can find the cheaper twin of any postcode in England on the map for free, no signup. + +## Captions (≤6 words, sound-off) +- Upminster vs Hornchurch +- Same station. Same schools. +- £115,290 cheaper +- Same semi-detached, ~1940 +- 20% less per m² +- Find your cheaper twin, free + +## YouTube +**Title options:** +1. Upminster vs Hornchurch: the same semi-detached house, about 20% cheaper per m² +2. Upminster vs Hornchurch: same station, same schools, £115,290 cheaper +3. Why Hornchurch is the smart-money version of Upminster (20% less per m²) + +**Thumbnail text:** big number `£115,290 cheaper` + the two names `Upminster → Hornchurch` + +**Description (paste as-is):** +``` +https://perfect-postcode.co.uk/?lat=51.54892&lon=0.22193&zoom=12.5&filter=Est.%20price%20per%20sqm:0:5300&filter=Good%2B%20secondary%20school%20catchments:1:11 + +Upminster and Hornchurch share a station, a school catchment and the same era of housing, but Hornchurch costs about 20% less per square metre (£115,290 on a 90 m² home). I built a map that ranks every postcode in England by what each pound actually buys, from official open data (Land Registry, EPC, Ofsted, DfT, Police.uk). Find the cheaper twin of any area, free and with no signup, at https://perfect-postcode.co.uk. + +0:00 The two postcodes (Upminster & Hornchurch) +0:08 Same station +0:18 Same school catchment +0:28 Same kind of home +0:38 The price-per-m² reveal +0:52 Find your own cheaper twin (free map) + +Data: Contains HM Land Registry data © Crown copyright and database right, OGL v3.0. Figures are estimates aggregated to postcode sector, not valuations. +``` + +## 9:16 Short (cut from the same recording) +First 3 seconds: the £/m² reveal (Upminster £6,360 → Hornchurch £5,079) + caption '20% less'. End card: 'Find your cheaper twin, free, no signup.' + +## Optional auto-render spec (video/src/storyboard.ts AD_CONFIGS) +Add this as a `DemoAdStoryboardConfig` and run `video/render.sh --prod` (needs login creds + the live stack). Filter names must match live `/api/features` or preflight fails. +```json +{ + "name": "twin-rm14-2-vs-rm12-5", + "city": "london", + "promptText": "Best value semi-detacheds near Upminster: same schools and station, lower price", + "initialFilters": { + "Est. price per sqm": [ + 0, + 5300 + ], + "Good+ secondary school catchments": [ + 1, + 11 + ] + }, + "outroLine": "Hornchurch: same life, 20% cheaper." +} +``` \ No newline at end of file diff --git a/analysis/out/video_scripts/se28-8-vs-da18-4.md b/analysis/out/video_scripts/se28-8-vs-da18-4.md new file mode 100644 index 0000000..cac1f83 --- /dev/null +++ b/analysis/out/video_scripts/se28-8-vs-da18-4.md @@ -0,0 +1,79 @@ +# Video kit: SE28 8 vs DA18 4 + +**Page:** https://perfect-postcode.co.uk/cheaper-twin/se28-8-vs-da18-4 · **Format:** faceless screen-record, ~45–60s long + a 9:16 Short cut + +## 🎬 Map URL to record (open this, hit record) +`https://perfect-postcode.co.uk/?lat=51.50039&lon=0.12568&zoom=12.5&filter=Est.%20price%20per%20sqm:0:3600&filter=Good%2B%20secondary%20school%20catchments:1:11` +*(filters are pre-applied so the value is on screen immediately)* + +## Hook (first 2 seconds, on screen + said) +**"£129,690 cheaper. Same station. Same schools."** + +## Shot list +| Time | Beat | What to show | On-screen | +|------|------|--------------|-----------| +| 0:00–0:06 | COLD OPEN: payoff first | Open on the map already showing both areas with the £/m² gap visible. Caption: '£129,690 cheaper'. Say the hook. | Land on the map URL below (filters pre-applied). | +| 0:06–0:18 | Same station | Pan/zoom to show both areas sit by the same line/station. Toggle the commute context if you want. | Caption: 'Same station.' | +| 0:18–0:28 | Same schools | Show the Good+ secondary catchment covering both. | Caption: 'Same school catchment.' | +| 0:28–0:38 | Same homes | Note the dominant type (terraced houses) and build era (~1993). | Caption: 'Same homes.' | +| 0:38–0:52 | THE REVEAL | Show the £/m² side by side: SE28 8 £4,850 vs DA18 4 £3,409. | Caption: '30% less per m²'. | +| 0:52–1:00 | CTA | End on the map; invite them to find their own cheaper twin. | Caption: 'Free. No signup.' | + +## Narration (human voiceover, never raw TTS for a property audience) +> This is SE28 8. And this is DA18 4, right next door. Same station. Same secondary school catchment. The same kind of home: terraced houses built around 1993. On every measure that moves price, they're twins. But watch the price per square metre. SE28 8: £4,850. DA18 4: £3,409. That's 30% cheaper, about £129,690 on a typical 90-square-metre home, for the same life, one postcode over. You're not paying for the house. You're paying for the name. You can find the cheaper twin of any postcode in England on the map for free, no signup. + +## Captions (≤6 words, sound-off) +- SE28 8 vs DA18 4 +- Same station. Same schools. +- £129,690 cheaper +- Same terraced, ~1993 +- 30% less per m² +- Find your cheaper twin, free + +## YouTube +**Title options:** +1. SE28 8 vs DA18 4: the same terraced house, about 30% cheaper per m² +2. SE28 8 vs DA18 4: same station, same schools, £129,690 cheaper +3. Why DA18 4 is the smart-money version of SE28 8 (30% less per m²) + +**Thumbnail text:** big number `£129,690 cheaper` + the two names `SE28 8 → DA18 4` + +**Description (paste as-is):** +``` +https://perfect-postcode.co.uk/?lat=51.50039&lon=0.12568&zoom=12.5&filter=Est.%20price%20per%20sqm:0:3600&filter=Good%2B%20secondary%20school%20catchments:1:11 + +SE28 8 and DA18 4 share a station, a school catchment and the same era of housing, but DA18 4 costs about 30% less per square metre (£129,690 on a 90 m² home). I built a map that ranks every postcode in England by what each pound actually buys, from official open data (Land Registry, EPC, Ofsted, DfT, Police.uk). Find the cheaper twin of any area, free and with no signup, at https://perfect-postcode.co.uk. + +0:00 The two postcodes (SE28 8 & DA18 4) +0:08 Same station +0:18 Same school catchment +0:28 Same kind of home +0:38 The price-per-m² reveal +0:52 Find your own cheaper twin (free map) + +Data: Contains HM Land Registry data © Crown copyright and database right, OGL v3.0. Figures are estimates aggregated to postcode sector, not valuations. +``` + +## 9:16 Short (cut from the same recording) +First 3 seconds: the £/m² reveal (SE28 8 £4,850 → DA18 4 £3,409) + caption '30% less'. End card: 'Find your cheaper twin, free, no signup.' + +## Optional auto-render spec (video/src/storyboard.ts AD_CONFIGS) +Add this as a `DemoAdStoryboardConfig` and run `video/render.sh --prod` (needs login creds + the live stack). Filter names must match live `/api/features` or preflight fails. +```json +{ + "name": "twin-se28-8-vs-da18-4", + "city": "london", + "promptText": "Best value terraceds near SE28 8: same schools and station, lower price", + "initialFilters": { + "Est. price per sqm": [ + 0, + 3600 + ], + "Good+ secondary school catchments": [ + 1, + 11 + ] + }, + "outroLine": "DA18 4: same life, 30% cheaper." +} +``` \ No newline at end of file diff --git a/analysis/out/video_scripts/sw1x-8-vs-sw7-2.md b/analysis/out/video_scripts/sw1x-8-vs-sw7-2.md new file mode 100644 index 0000000..b80c729 --- /dev/null +++ b/analysis/out/video_scripts/sw1x-8-vs-sw7-2.md @@ -0,0 +1,79 @@ +# Video kit: SW1X 8 vs SW7 2 + +**Page:** https://perfect-postcode.co.uk/cheaper-twin/sw1x-8-vs-sw7-2 · **Format:** faceless screen-record, ~45–60s long + a 9:16 Short cut + +## 🎬 Map URL to record (open this, hit record) +`https://perfect-postcode.co.uk/?lat=51.49758&lon=-0.16439&zoom=12.5&filter=Est.%20price%20per%20sqm:0:16400&filter=Good%2B%20secondary%20school%20catchments:1:11` +*(filters are pre-applied so the value is on screen immediately)* + +## Hook (first 2 seconds, on screen + said) +**"£1,001,160 cheaper. Same station. Same schools."** + +## Shot list +| Time | Beat | What to show | On-screen | +|------|------|--------------|-----------| +| 0:00–0:06 | COLD OPEN: payoff first | Open on the map already showing both areas with the £/m² gap visible. Caption: '£1,001,160 cheaper'. Say the hook. | Land on the map URL below (filters pre-applied). | +| 0:06–0:18 | Same station | Pan/zoom to show both areas sit by the same line/station. Toggle the commute context if you want. | Caption: 'Same station.' | +| 0:18–0:28 | Same schools | Show the Good+ secondary catchment covering both. | Caption: 'Same school catchment.' | +| 0:28–0:38 | Same homes | Note the dominant type (flats) and build era (~1890). | Caption: 'Same homes.' | +| 0:38–0:52 | THE REVEAL | Show the £/m² side by side: SW1X 8 £26,735 vs SW7 2 £15,611. | Caption: '42% less per m²'. | +| 0:52–1:00 | CTA | End on the map; invite them to find their own cheaper twin. | Caption: 'Free. No signup.' | + +## Narration (human voiceover, never raw TTS for a property audience) +> This is SW1X 8. And this is SW7 2, right next door. Same station. Same secondary school catchment. The same kind of home: flats built around 1890. On every measure that moves price, they're twins. But watch the price per square metre. SW1X 8: £26,735. SW7 2: £15,611. That's 42% cheaper, about £1,001,160 on a typical 90-square-metre home, for the same life, one postcode over. You're not paying for the house. You're paying for the name. You can find the cheaper twin of any postcode in England on the map for free, no signup. + +## Captions (≤6 words, sound-off) +- SW1X 8 vs SW7 2 +- Same station. Same schools. +- £1,001,160 cheaper +- Same flats/maisonettes, ~1890 +- 42% less per m² +- Find your cheaper twin, free + +## YouTube +**Title options:** +1. SW1X 8 vs SW7 2: the same flat, about 42% cheaper per m² +2. SW1X 8 vs SW7 2: same station, same schools, £1,001,160 cheaper +3. Why SW7 2 is the smart-money version of SW1X 8 (42% less per m²) + +**Thumbnail text:** big number `£1,001,160 cheaper` + the two names `SW1X 8 → SW7 2` + +**Description (paste as-is):** +``` +https://perfect-postcode.co.uk/?lat=51.49758&lon=-0.16439&zoom=12.5&filter=Est.%20price%20per%20sqm:0:16400&filter=Good%2B%20secondary%20school%20catchments:1:11 + +SW1X 8 and SW7 2 share a station, a school catchment and the same era of housing, but SW7 2 costs about 42% less per square metre (£1,001,160 on a 90 m² home). I built a map that ranks every postcode in England by what each pound actually buys, from official open data (Land Registry, EPC, Ofsted, DfT, Police.uk). Find the cheaper twin of any area, free and with no signup, at https://perfect-postcode.co.uk. + +0:00 The two postcodes (SW1X 8 & SW7 2) +0:08 Same station +0:18 Same school catchment +0:28 Same kind of home +0:38 The price-per-m² reveal +0:52 Find your own cheaper twin (free map) + +Data: Contains HM Land Registry data © Crown copyright and database right, OGL v3.0. Figures are estimates aggregated to postcode sector, not valuations. +``` + +## 9:16 Short (cut from the same recording) +First 3 seconds: the £/m² reveal (SW1X 8 £26,735 → SW7 2 £15,611) + caption '42% less'. End card: 'Find your cheaper twin, free, no signup.' + +## Optional auto-render spec (video/src/storyboard.ts AD_CONFIGS) +Add this as a `DemoAdStoryboardConfig` and run `video/render.sh --prod` (needs login creds + the live stack). Filter names must match live `/api/features` or preflight fails. +```json +{ + "name": "twin-sw1x-8-vs-sw7-2", + "city": "london", + "promptText": "Best value flats/maisonettess near SW1X 8: same schools and station, lower price", + "initialFilters": { + "Est. price per sqm": [ + 0, + 16400 + ], + "Good+ secondary school catchments": [ + 1, + 11 + ] + }, + "outroLine": "SW7 2: same life, 42% cheaper." +} +``` \ No newline at end of file diff --git a/analysis/out/video_scripts/tw12-3-vs-kt8-1.md b/analysis/out/video_scripts/tw12-3-vs-kt8-1.md new file mode 100644 index 0000000..202558e --- /dev/null +++ b/analysis/out/video_scripts/tw12-3-vs-kt8-1.md @@ -0,0 +1,79 @@ +# Video kit: Hampton vs East Molesey + +**Page:** https://perfect-postcode.co.uk/cheaper-twin/tw12-3-vs-kt8-1 · **Format:** faceless screen-record, ~45–60s long + a 9:16 Short cut + +## 🎬 Map URL to record (open this, hit record) +`https://perfect-postcode.co.uk/?lat=51.41616&lon=-0.37365&zoom=12.5&filter=Est.%20price%20per%20sqm:0:6000&filter=Good%2B%20secondary%20school%20catchments:1:11` +*(filters are pre-applied so the value is on screen immediately)* + +## Hook (first 2 seconds, on screen + said) +**"£120,060 cheaper. Same station. Same schools."** + +## Shot list +| Time | Beat | What to show | On-screen | +|------|------|--------------|-----------| +| 0:00–0:06 | COLD OPEN: payoff first | Open on the map already showing both areas with the £/m² gap visible. Caption: '£120,060 cheaper'. Say the hook. | Land on the map URL below (filters pre-applied). | +| 0:06–0:18 | Same station | Pan/zoom to show both areas sit by the same line/station. Toggle the commute context if you want. | Caption: 'Same station.' | +| 0:18–0:28 | Same schools | Show the Good+ secondary catchment covering both. | Caption: 'Same school catchment.' | +| 0:28–0:38 | Same homes | Note the dominant type (terraced houses) and build era (~1979). | Caption: 'Same homes.' | +| 0:38–0:52 | THE REVEAL | Show the £/m² side by side: Hampton £7,042 vs East Molesey £5,708. | Caption: '19% less per m²'. | +| 0:52–1:00 | CTA | End on the map; invite them to find their own cheaper twin. | Caption: 'Free. No signup.' | + +## Narration (human voiceover, never raw TTS for a property audience) +> This is Hampton. And this is East Molesey, right next door. Same station. Same secondary school catchment. The same kind of home: terraced houses built around 1979. On every measure that moves price, they're twins. But watch the price per square metre. Hampton: £7,042. East Molesey: £5,708. That's 19% cheaper, about £120,060 on a typical 90-square-metre home, for the same life, one postcode over. You're not paying for the house. You're paying for the name. You can find the cheaper twin of any postcode in England on the map for free, no signup. + +## Captions (≤6 words, sound-off) +- Hampton vs East Molesey +- Same station. Same schools. +- £120,060 cheaper +- Same terraced, ~1979 +- 19% less per m² +- Find your cheaper twin, free + +## YouTube +**Title options:** +1. Hampton vs East Molesey: the same terraced house, about 19% cheaper per m² +2. Hampton vs East Molesey: same station, same schools, £120,060 cheaper +3. Why East Molesey is the smart-money version of Hampton (19% less per m²) + +**Thumbnail text:** big number `£120,060 cheaper` + the two names `Hampton → East Molesey` + +**Description (paste as-is):** +``` +https://perfect-postcode.co.uk/?lat=51.41616&lon=-0.37365&zoom=12.5&filter=Est.%20price%20per%20sqm:0:6000&filter=Good%2B%20secondary%20school%20catchments:1:11 + +Hampton and East Molesey share a station, a school catchment and the same era of housing, but East Molesey costs about 19% less per square metre (£120,060 on a 90 m² home). I built a map that ranks every postcode in England by what each pound actually buys, from official open data (Land Registry, EPC, Ofsted, DfT, Police.uk). Find the cheaper twin of any area, free and with no signup, at https://perfect-postcode.co.uk. + +0:00 The two postcodes (Hampton & East Molesey) +0:08 Same station +0:18 Same school catchment +0:28 Same kind of home +0:38 The price-per-m² reveal +0:52 Find your own cheaper twin (free map) + +Data: Contains HM Land Registry data © Crown copyright and database right, OGL v3.0. Figures are estimates aggregated to postcode sector, not valuations. +``` + +## 9:16 Short (cut from the same recording) +First 3 seconds: the £/m² reveal (Hampton £7,042 → East Molesey £5,708) + caption '19% less'. End card: 'Find your cheaper twin, free, no signup.' + +## Optional auto-render spec (video/src/storyboard.ts AD_CONFIGS) +Add this as a `DemoAdStoryboardConfig` and run `video/render.sh --prod` (needs login creds + the live stack). Filter names must match live `/api/features` or preflight fails. +```json +{ + "name": "twin-tw12-3-vs-kt8-1", + "city": "london", + "promptText": "Best value terraceds near Hampton: same schools and station, lower price", + "initialFilters": { + "Est. price per sqm": [ + 0, + 6000 + ], + "Good+ secondary school catchments": [ + 1, + 11 + ] + }, + "outroLine": "East Molesey: same life, 19% cheaper." +} +``` \ No newline at end of file diff --git a/analysis/out/video_scripts/tw2-7-vs-tw3-2.md b/analysis/out/video_scripts/tw2-7-vs-tw3-2.md new file mode 100644 index 0000000..597cc05 --- /dev/null +++ b/analysis/out/video_scripts/tw2-7-vs-tw3-2.md @@ -0,0 +1,79 @@ +# Video kit: Twickenham vs Hounslow + +**Page:** https://perfect-postcode.co.uk/cheaper-twin/tw2-7-vs-tw3-2 · **Format:** faceless screen-record, ~45–60s long + a 9:16 Short cut + +## 🎬 Map URL to record (open this, hit record) +`https://perfect-postcode.co.uk/?lat=51.45678&lon=-0.35702&zoom=12.5&filter=Est.%20price%20per%20sqm:0:5900&filter=Good%2B%20secondary%20school%20catchments:1:11` +*(filters are pre-applied so the value is on screen immediately)* + +## Hook (first 2 seconds, on screen + said) +**"£121,590 cheaper. Same station. Same schools."** + +## Shot list +| Time | Beat | What to show | On-screen | +|------|------|--------------|-----------| +| 0:00–0:06 | COLD OPEN: payoff first | Open on the map already showing both areas with the £/m² gap visible. Caption: '£121,590 cheaper'. Say the hook. | Land on the map URL below (filters pre-applied). | +| 0:06–0:18 | Same station | Pan/zoom to show both areas sit by the same line/station. Toggle the commute context if you want. | Caption: 'Same station.' | +| 0:18–0:28 | Same schools | Show the Good+ secondary catchment covering both. | Caption: 'Same school catchment.' | +| 0:28–0:38 | Same homes | Note the dominant type (semi-detached houses) and build era (~1940). | Caption: 'Same homes.' | +| 0:38–0:52 | THE REVEAL | Show the £/m² side by side: Twickenham £6,971 vs Hounslow £5,620. | Caption: '19% less per m²'. | +| 0:52–1:00 | CTA | End on the map; invite them to find their own cheaper twin. | Caption: 'Free. No signup.' | + +## Narration (human voiceover, never raw TTS for a property audience) +> This is Twickenham. And this is Hounslow, right next door. Same station. Same secondary school catchment. The same kind of home: semi-detached houses built around 1940. On every measure that moves price, they're twins. But watch the price per square metre. Twickenham: £6,971. Hounslow: £5,620. That's 19% cheaper, about £121,590 on a typical 90-square-metre home, for the same life, one postcode over. You're not paying for the house. You're paying for the name. You can find the cheaper twin of any postcode in England on the map for free, no signup. + +## Captions (≤6 words, sound-off) +- Twickenham vs Hounslow +- Same station. Same schools. +- £121,590 cheaper +- Same semi-detached, ~1940 +- 19% less per m² +- Find your cheaper twin, free + +## YouTube +**Title options:** +1. Twickenham vs Hounslow: the same semi-detached house, about 19% cheaper per m² +2. Twickenham vs Hounslow: same station, same schools, £121,590 cheaper +3. Why Hounslow is the smart-money version of Twickenham (19% less per m²) + +**Thumbnail text:** big number `£121,590 cheaper` + the two names `Twickenham → Hounslow` + +**Description (paste as-is):** +``` +https://perfect-postcode.co.uk/?lat=51.45678&lon=-0.35702&zoom=12.5&filter=Est.%20price%20per%20sqm:0:5900&filter=Good%2B%20secondary%20school%20catchments:1:11 + +Twickenham and Hounslow share a station, a school catchment and the same era of housing, but Hounslow costs about 19% less per square metre (£121,590 on a 90 m² home). I built a map that ranks every postcode in England by what each pound actually buys, from official open data (Land Registry, EPC, Ofsted, DfT, Police.uk). Find the cheaper twin of any area, free and with no signup, at https://perfect-postcode.co.uk. + +0:00 The two postcodes (Twickenham & Hounslow) +0:08 Same station +0:18 Same school catchment +0:28 Same kind of home +0:38 The price-per-m² reveal +0:52 Find your own cheaper twin (free map) + +Data: Contains HM Land Registry data © Crown copyright and database right, OGL v3.0. Figures are estimates aggregated to postcode sector, not valuations. +``` + +## 9:16 Short (cut from the same recording) +First 3 seconds: the £/m² reveal (Twickenham £6,971 → Hounslow £5,620) + caption '19% less'. End card: 'Find your cheaper twin, free, no signup.' + +## Optional auto-render spec (video/src/storyboard.ts AD_CONFIGS) +Add this as a `DemoAdStoryboardConfig` and run `video/render.sh --prod` (needs login creds + the live stack). Filter names must match live `/api/features` or preflight fails. +```json +{ + "name": "twin-tw2-7-vs-tw3-2", + "city": "london", + "promptText": "Best value semi-detacheds near Twickenham: same schools and station, lower price", + "initialFilters": { + "Est. price per sqm": [ + 0, + 5900 + ], + "Good+ secondary school catchments": [ + 1, + 11 + ] + }, + "outroLine": "Hounslow: same life, 19% cheaper." +} +``` \ No newline at end of file diff --git a/analysis/out/video_scripts/w1j-7-vs-sw7-3.md b/analysis/out/video_scripts/w1j-7-vs-sw7-3.md new file mode 100644 index 0000000..63271b3 --- /dev/null +++ b/analysis/out/video_scripts/w1j-7-vs-sw7-3.md @@ -0,0 +1,79 @@ +# Video kit: Mayfair vs SW7 3 + +**Page:** https://perfect-postcode.co.uk/cheaper-twin/w1j-7-vs-sw7-3 · **Format:** faceless screen-record, ~45–60s long + a 9:16 Short cut + +## 🎬 Map URL to record (open this, hit record) +`https://perfect-postcode.co.uk/?lat=51.49856&lon=-0.16253&zoom=12.5&filter=Est.%20price%20per%20sqm:0:20400&filter=Good%2B%20secondary%20school%20catchments:1:11` +*(filters are pre-applied so the value is on screen immediately)* + +## Hook (first 2 seconds, on screen + said) +**"£1,223,460 cheaper. Same station. Same schools."** + +## Shot list +| Time | Beat | What to show | On-screen | +|------|------|--------------|-----------| +| 0:00–0:06 | COLD OPEN: payoff first | Open on the map already showing both areas with the £/m² gap visible. Caption: '£1,223,460 cheaper'. Say the hook. | Land on the map URL below (filters pre-applied). | +| 0:06–0:18 | Same station | Pan/zoom to show both areas sit by the same line/station. Toggle the commute context if you want. | Caption: 'Same station.' | +| 0:18–0:28 | Same schools | Show the Good+ secondary catchment covering both. | Caption: 'Same school catchment.' | +| 0:28–0:38 | Same homes | Note the dominant type (flats) and build era (~1914). | Caption: 'Same homes.' | +| 0:38–0:52 | THE REVEAL | Show the £/m² side by side: Mayfair £32,986 vs SW7 3 £19,392. | Caption: '41% less per m²'. | +| 0:52–1:00 | CTA | End on the map; invite them to find their own cheaper twin. | Caption: 'Free. No signup.' | + +## Narration (human voiceover, never raw TTS for a property audience) +> This is Mayfair. And this is SW7 3, right next door. Same station. Same secondary school catchment. The same kind of home: flats built around 1914. On every measure that moves price, they're twins. But watch the price per square metre. Mayfair: £32,986. SW7 3: £19,392. That's 41% cheaper, about £1,223,460 on a typical 90-square-metre home, for the same life, one postcode over. You're not paying for the house. You're paying for the name. You can find the cheaper twin of any postcode in England on the map for free, no signup. + +## Captions (≤6 words, sound-off) +- Mayfair vs SW7 3 +- Same station. Same schools. +- £1,223,460 cheaper +- Same flats/maisonettes, ~1914 +- 41% less per m² +- Find your cheaper twin, free + +## YouTube +**Title options:** +1. W1J 7 vs SW7 3: the same flat, about 41% cheaper per m² +2. Mayfair vs SW7 3: same station, same schools, £1,223,460 cheaper +3. Why SW7 3 is the smart-money version of Mayfair (41% less per m²) + +**Thumbnail text:** big number `£1,223,460 cheaper` + the two names `Mayfair → SW7 3` + +**Description (paste as-is):** +``` +https://perfect-postcode.co.uk/?lat=51.49856&lon=-0.16253&zoom=12.5&filter=Est.%20price%20per%20sqm:0:20400&filter=Good%2B%20secondary%20school%20catchments:1:11 + +Mayfair and SW7 3 share a station, a school catchment and the same era of housing, but SW7 3 costs about 41% less per square metre (£1,223,460 on a 90 m² home). I built a map that ranks every postcode in England by what each pound actually buys, from official open data (Land Registry, EPC, Ofsted, DfT, Police.uk). Find the cheaper twin of any area, free and with no signup, at https://perfect-postcode.co.uk. + +0:00 The two postcodes (Mayfair & SW7 3) +0:08 Same station +0:18 Same school catchment +0:28 Same kind of home +0:38 The price-per-m² reveal +0:52 Find your own cheaper twin (free map) + +Data: Contains HM Land Registry data © Crown copyright and database right, OGL v3.0. Figures are estimates aggregated to postcode sector, not valuations. +``` + +## 9:16 Short (cut from the same recording) +First 3 seconds: the £/m² reveal (Mayfair £32,986 → SW7 3 £19,392) + caption '41% less'. End card: 'Find your cheaper twin, free, no signup.' + +## Optional auto-render spec (video/src/storyboard.ts AD_CONFIGS) +Add this as a `DemoAdStoryboardConfig` and run `video/render.sh --prod` (needs login creds + the live stack). Filter names must match live `/api/features` or preflight fails. +```json +{ + "name": "twin-w1j-7-vs-sw7-3", + "city": "london", + "promptText": "Best value flats/maisonettess near Mayfair: same schools and station, lower price", + "initialFilters": { + "Est. price per sqm": [ + 0, + 20400 + ], + "Good+ secondary school catchments": [ + 1, + 11 + ] + }, + "outroLine": "SW7 3: same life, 41% cheaper." +} +``` \ No newline at end of file diff --git a/analysis/out/video_scripts/w1j-8-vs-sw1a-2.md b/analysis/out/video_scripts/w1j-8-vs-sw1a-2.md new file mode 100644 index 0000000..caf33f1 --- /dev/null +++ b/analysis/out/video_scripts/w1j-8-vs-sw1a-2.md @@ -0,0 +1,79 @@ +# Video kit: Mayfair vs SW1A 2 + +**Page:** https://perfect-postcode.co.uk/cheaper-twin/w1j-8-vs-sw1a-2 · **Format:** faceless screen-record, ~45–60s long + a 9:16 Short cut + +## 🎬 Map URL to record (open this, hit record) +`https://perfect-postcode.co.uk/?lat=51.50663&lon=-0.13494&zoom=12.5&filter=Est.%20price%20per%20sqm:0:17900&filter=Good%2B%20secondary%20school%20catchments:1:11` +*(filters are pre-applied so the value is on screen immediately)* + +## Hook (first 2 seconds, on screen + said) +**"£916,380 cheaper. Same station. Same schools."** + +## Shot list +| Time | Beat | What to show | On-screen | +|------|------|--------------|-----------| +| 0:00–0:06 | COLD OPEN: payoff first | Open on the map already showing both areas with the £/m² gap visible. Caption: '£916,380 cheaper'. Say the hook. | Land on the map URL below (filters pre-applied). | +| 0:06–0:18 | Same station | Pan/zoom to show both areas sit by the same line/station. Toggle the commute context if you want. | Caption: 'Same station.' | +| 0:18–0:28 | Same schools | Show the Good+ secondary catchment covering both. | Caption: 'Same school catchment.' | +| 0:28–0:38 | Same homes | Note the dominant type (flats) and build era (~2000). | Caption: 'Same homes.' | +| 0:38–0:52 | THE REVEAL | Show the £/m² side by side: Mayfair £27,270 vs SW1A 2 £17,088. | Caption: '37% less per m²'. | +| 0:52–1:00 | CTA | End on the map; invite them to find their own cheaper twin. | Caption: 'Free. No signup.' | + +## Narration (human voiceover, never raw TTS for a property audience) +> This is Mayfair. And this is SW1A 2, right next door. Same station. Same secondary school catchment. The same kind of home: flats built around 2000. On every measure that moves price, they're twins. But watch the price per square metre. Mayfair: £27,270. SW1A 2: £17,088. That's 37% cheaper, about £916,380 on a typical 90-square-metre home, for the same life, one postcode over. You're not paying for the house. You're paying for the name. You can find the cheaper twin of any postcode in England on the map for free, no signup. + +## Captions (≤6 words, sound-off) +- Mayfair vs SW1A 2 +- Same station. Same schools. +- £916,380 cheaper +- Same flats/maisonettes, ~2000 +- 37% less per m² +- Find your cheaper twin, free + +## YouTube +**Title options:** +1. W1J 8 vs SW1A 2: the same flat, about 37% cheaper per m² +2. Mayfair vs SW1A 2: same station, same schools, £916,380 cheaper +3. Why SW1A 2 is the smart-money version of Mayfair (37% less per m²) + +**Thumbnail text:** big number `£916,380 cheaper` + the two names `Mayfair → SW1A 2` + +**Description (paste as-is):** +``` +https://perfect-postcode.co.uk/?lat=51.50663&lon=-0.13494&zoom=12.5&filter=Est.%20price%20per%20sqm:0:17900&filter=Good%2B%20secondary%20school%20catchments:1:11 + +Mayfair and SW1A 2 share a station, a school catchment and the same era of housing, but SW1A 2 costs about 37% less per square metre (£916,380 on a 90 m² home). I built a map that ranks every postcode in England by what each pound actually buys, from official open data (Land Registry, EPC, Ofsted, DfT, Police.uk). Find the cheaper twin of any area, free and with no signup, at https://perfect-postcode.co.uk. + +0:00 The two postcodes (Mayfair & SW1A 2) +0:08 Same station +0:18 Same school catchment +0:28 Same kind of home +0:38 The price-per-m² reveal +0:52 Find your own cheaper twin (free map) + +Data: Contains HM Land Registry data © Crown copyright and database right, OGL v3.0. Figures are estimates aggregated to postcode sector, not valuations. +``` + +## 9:16 Short (cut from the same recording) +First 3 seconds: the £/m² reveal (Mayfair £27,270 → SW1A 2 £17,088) + caption '37% less'. End card: 'Find your cheaper twin, free, no signup.' + +## Optional auto-render spec (video/src/storyboard.ts AD_CONFIGS) +Add this as a `DemoAdStoryboardConfig` and run `video/render.sh --prod` (needs login creds + the live stack). Filter names must match live `/api/features` or preflight fails. +```json +{ + "name": "twin-w1j-8-vs-sw1a-2", + "city": "london", + "promptText": "Best value flats/maisonettess near Mayfair: same schools and station, lower price", + "initialFilters": { + "Est. price per sqm": [ + 0, + 17900 + ], + "Good+ secondary school catchments": [ + 1, + 11 + ] + }, + "outroLine": "SW1A 2: same life, 37% cheaper." +} +``` \ No newline at end of file diff --git a/analysis/out/video_scripts/w1k-2-vs-sw1x-0.md b/analysis/out/video_scripts/w1k-2-vs-sw1x-0.md new file mode 100644 index 0000000..624b2fa --- /dev/null +++ b/analysis/out/video_scripts/w1k-2-vs-sw1x-0.md @@ -0,0 +1,79 @@ +# Video kit: Mayfair vs SW1X 0 + +**Page:** https://perfect-postcode.co.uk/cheaper-twin/w1k-2-vs-sw1x-0 · **Format:** faceless screen-record, ~45–60s long + a 9:16 Short cut + +## 🎬 Map URL to record (open this, hit record) +`https://perfect-postcode.co.uk/?lat=51.50311&lon=-0.15673&zoom=12.5&filter=Est.%20price%20per%20sqm:0:24700&filter=Good%2B%20secondary%20school%20catchments:1:11` +*(filters are pre-applied so the value is on screen immediately)* + +## Hook (first 2 seconds, on screen + said) +**"£978,570 cheaper. Same station. Same schools."** + +## Shot list +| Time | Beat | What to show | On-screen | +|------|------|--------------|-----------| +| 0:00–0:06 | COLD OPEN: payoff first | Open on the map already showing both areas with the £/m² gap visible. Caption: '£978,570 cheaper'. Say the hook. | Land on the map URL below (filters pre-applied). | +| 0:06–0:18 | Same station | Pan/zoom to show both areas sit by the same line/station. Toggle the commute context if you want. | Caption: 'Same station.' | +| 0:18–0:28 | Same schools | Show the Good+ secondary catchment covering both. | Caption: 'Same school catchment.' | +| 0:28–0:38 | Same homes | Note the dominant type (flats) and build era (~1914). | Caption: 'Same homes.' | +| 0:38–0:52 | THE REVEAL | Show the £/m² side by side: Mayfair £34,362 vs SW1X 0 £23,489. | Caption: '32% less per m²'. | +| 0:52–1:00 | CTA | End on the map; invite them to find their own cheaper twin. | Caption: 'Free. No signup.' | + +## Narration (human voiceover, never raw TTS for a property audience) +> This is Mayfair. And this is SW1X 0, right next door. Same station. Same secondary school catchment. The same kind of home: flats built around 1914. On every measure that moves price, they're twins. But watch the price per square metre. Mayfair: £34,362. SW1X 0: £23,489. That's 32% cheaper, about £978,570 on a typical 90-square-metre home, for the same life, one postcode over. You're not paying for the house. You're paying for the name. You can find the cheaper twin of any postcode in England on the map for free, no signup. + +## Captions (≤6 words, sound-off) +- Mayfair vs SW1X 0 +- Same station. Same schools. +- £978,570 cheaper +- Same flats/maisonettes, ~1914 +- 32% less per m² +- Find your cheaper twin, free + +## YouTube +**Title options:** +1. W1K 2 vs SW1X 0: the same flat, about 32% cheaper per m² +2. Mayfair vs SW1X 0: same station, same schools, £978,570 cheaper +3. Why SW1X 0 is the smart-money version of Mayfair (32% less per m²) + +**Thumbnail text:** big number `£978,570 cheaper` + the two names `Mayfair → SW1X 0` + +**Description (paste as-is):** +``` +https://perfect-postcode.co.uk/?lat=51.50311&lon=-0.15673&zoom=12.5&filter=Est.%20price%20per%20sqm:0:24700&filter=Good%2B%20secondary%20school%20catchments:1:11 + +Mayfair and SW1X 0 share a station, a school catchment and the same era of housing, but SW1X 0 costs about 32% less per square metre (£978,570 on a 90 m² home). I built a map that ranks every postcode in England by what each pound actually buys, from official open data (Land Registry, EPC, Ofsted, DfT, Police.uk). Find the cheaper twin of any area, free and with no signup, at https://perfect-postcode.co.uk. + +0:00 The two postcodes (Mayfair & SW1X 0) +0:08 Same station +0:18 Same school catchment +0:28 Same kind of home +0:38 The price-per-m² reveal +0:52 Find your own cheaper twin (free map) + +Data: Contains HM Land Registry data © Crown copyright and database right, OGL v3.0. Figures are estimates aggregated to postcode sector, not valuations. +``` + +## 9:16 Short (cut from the same recording) +First 3 seconds: the £/m² reveal (Mayfair £34,362 → SW1X 0 £23,489) + caption '32% less'. End card: 'Find your cheaper twin, free, no signup.' + +## Optional auto-render spec (video/src/storyboard.ts AD_CONFIGS) +Add this as a `DemoAdStoryboardConfig` and run `video/render.sh --prod` (needs login creds + the live stack). Filter names must match live `/api/features` or preflight fails. +```json +{ + "name": "twin-w1k-2-vs-sw1x-0", + "city": "london", + "promptText": "Best value flats/maisonettess near Mayfair: same schools and station, lower price", + "initialFilters": { + "Est. price per sqm": [ + 0, + 24700 + ], + "Good+ secondary school catchments": [ + 1, + 11 + ] + }, + "outroLine": "SW1X 0: same life, 32% cheaper." +} +``` \ No newline at end of file diff --git a/analysis/out/video_scripts/w1u-4-vs-nw1-4.md b/analysis/out/video_scripts/w1u-4-vs-nw1-4.md new file mode 100644 index 0000000..4c43934 --- /dev/null +++ b/analysis/out/video_scripts/w1u-4-vs-nw1-4.md @@ -0,0 +1,79 @@ +# Video kit: Marylebone vs Camden + +**Page:** https://perfect-postcode.co.uk/cheaper-twin/w1u-4-vs-nw1-4 · **Format:** faceless screen-record, ~45–60s long + a 9:16 Short cut + +## 🎬 Map URL to record (open this, hit record) +`https://perfect-postcode.co.uk/?lat=51.5238&lon=-0.15091&zoom=12.5&filter=Est.%20price%20per%20sqm:0:14500&filter=Good%2B%20secondary%20school%20catchments:1:11` +*(filters are pre-applied so the value is on screen immediately)* + +## Hook (first 2 seconds, on screen + said) +**"£942,480 cheaper. Same station. Same schools."** + +## Shot list +| Time | Beat | What to show | On-screen | +|------|------|--------------|-----------| +| 0:00–0:06 | COLD OPEN: payoff first | Open on the map already showing both areas with the £/m² gap visible. Caption: '£942,480 cheaper'. Say the hook. | Land on the map URL below (filters pre-applied). | +| 0:06–0:18 | Same station | Pan/zoom to show both areas sit by the same line/station. Toggle the commute context if you want. | Caption: 'Same station.' | +| 0:18–0:28 | Same schools | Show the Good+ secondary catchment covering both. | Caption: 'Same school catchment.' | +| 0:28–0:38 | Same homes | Note the dominant type (flats) and build era (~1940). | Caption: 'Same homes.' | +| 0:38–0:52 | THE REVEAL | Show the £/m² side by side: Marylebone £24,238 vs Camden £13,766. | Caption: '43% less per m²'. | +| 0:52–1:00 | CTA | End on the map; invite them to find their own cheaper twin. | Caption: 'Free. No signup.' | + +## Narration (human voiceover, never raw TTS for a property audience) +> This is Marylebone. And this is Camden, right next door. Same station. Same secondary school catchment. The same kind of home: flats built around 1940. On every measure that moves price, they're twins. But watch the price per square metre. Marylebone: £24,238. Camden: £13,766. That's 43% cheaper, about £942,480 on a typical 90-square-metre home, for the same life, one postcode over. You're not paying for the house. You're paying for the name. You can find the cheaper twin of any postcode in England on the map for free, no signup. + +## Captions (≤6 words, sound-off) +- Marylebone vs Camden +- Same station. Same schools. +- £942,480 cheaper +- Same flats/maisonettes, ~1940 +- 43% less per m² +- Find your cheaper twin, free + +## YouTube +**Title options:** +1. Marylebone vs Camden: the same flat, about 43% cheaper per m² +2. Marylebone vs Camden: same station, same schools, £942,480 cheaper +3. Why Camden is the smart-money version of Marylebone (43% less per m²) + +**Thumbnail text:** big number `£942,480 cheaper` + the two names `Marylebone → Camden` + +**Description (paste as-is):** +``` +https://perfect-postcode.co.uk/?lat=51.5238&lon=-0.15091&zoom=12.5&filter=Est.%20price%20per%20sqm:0:14500&filter=Good%2B%20secondary%20school%20catchments:1:11 + +Marylebone and Camden share a station, a school catchment and the same era of housing, but Camden costs about 43% less per square metre (£942,480 on a 90 m² home). I built a map that ranks every postcode in England by what each pound actually buys, from official open data (Land Registry, EPC, Ofsted, DfT, Police.uk). Find the cheaper twin of any area, free and with no signup, at https://perfect-postcode.co.uk. + +0:00 The two postcodes (Marylebone & Camden) +0:08 Same station +0:18 Same school catchment +0:28 Same kind of home +0:38 The price-per-m² reveal +0:52 Find your own cheaper twin (free map) + +Data: Contains HM Land Registry data © Crown copyright and database right, OGL v3.0. Figures are estimates aggregated to postcode sector, not valuations. +``` + +## 9:16 Short (cut from the same recording) +First 3 seconds: the £/m² reveal (Marylebone £24,238 → Camden £13,766) + caption '43% less'. End card: 'Find your cheaper twin, free, no signup.' + +## Optional auto-render spec (video/src/storyboard.ts AD_CONFIGS) +Add this as a `DemoAdStoryboardConfig` and run `video/render.sh --prod` (needs login creds + the live stack). Filter names must match live `/api/features` or preflight fails. +```json +{ + "name": "twin-w1u-4-vs-nw1-4", + "city": "london", + "promptText": "Best value flats/maisonettess near Marylebone: same schools and station, lower price", + "initialFilters": { + "Est. price per sqm": [ + 0, + 14500 + ], + "Good+ secondary school catchments": [ + 1, + 11 + ] + }, + "outroLine": "Camden: same life, 43% cheaper." +} +``` \ No newline at end of file diff --git a/analysis/out/video_scripts/wc2a-2-vs-ec2a-2.md b/analysis/out/video_scripts/wc2a-2-vs-ec2a-2.md new file mode 100644 index 0000000..462fd27 --- /dev/null +++ b/analysis/out/video_scripts/wc2a-2-vs-ec2a-2.md @@ -0,0 +1,79 @@ +# Video kit: WC2A 2 vs EC2A 2 + +**Page:** https://perfect-postcode.co.uk/cheaper-twin/wc2a-2-vs-ec2a-2 · **Format:** faceless screen-record, ~45–60s long + a 9:16 Short cut + +## 🎬 Map URL to record (open this, hit record) +`https://perfect-postcode.co.uk/?lat=51.51807&lon=-0.09837&zoom=12.5&filter=Est.%20price%20per%20sqm:0:15300&filter=Good%2B%20secondary%20school%20catchments:1:11` +*(filters are pre-applied so the value is on screen immediately)* + +## Hook (first 2 seconds, on screen + said) +**"£981,540 cheaper. Same station. Same schools."** + +## Shot list +| Time | Beat | What to show | On-screen | +|------|------|--------------|-----------| +| 0:00–0:06 | COLD OPEN: payoff first | Open on the map already showing both areas with the £/m² gap visible. Caption: '£981,540 cheaper'. Say the hook. | Land on the map URL below (filters pre-applied). | +| 0:06–0:18 | Same station | Pan/zoom to show both areas sit by the same line/station. Toggle the commute context if you want. | Caption: 'Same station.' | +| 0:18–0:28 | Same schools | Show the Good+ secondary catchment covering both. | Caption: 'Same school catchment.' | +| 0:28–0:38 | Same homes | Note the dominant type (flats) and build era (~2019). | Caption: 'Same homes.' | +| 0:38–0:52 | THE REVEAL | Show the £/m² side by side: WC2A 2 £25,482 vs EC2A 2 £14,576. | Caption: '43% less per m²'. | +| 0:52–1:00 | CTA | End on the map; invite them to find their own cheaper twin. | Caption: 'Free. No signup.' | + +## Narration (human voiceover, never raw TTS for a property audience) +> This is WC2A 2. And this is EC2A 2, right next door. Same station. Same secondary school catchment. The same kind of home: flats built around 2019. On every measure that moves price, they're twins. But watch the price per square metre. WC2A 2: £25,482. EC2A 2: £14,576. That's 43% cheaper, about £981,540 on a typical 90-square-metre home, for the same life, one postcode over. You're not paying for the house. You're paying for the name. You can find the cheaper twin of any postcode in England on the map for free, no signup. + +## Captions (≤6 words, sound-off) +- WC2A 2 vs EC2A 2 +- Same station. Same schools. +- £981,540 cheaper +- Same flats/maisonettes, ~2019 +- 43% less per m² +- Find your cheaper twin, free + +## YouTube +**Title options:** +1. WC2A 2 vs EC2A 2: the same flat, about 43% cheaper per m² +2. WC2A 2 vs EC2A 2: same station, same schools, £981,540 cheaper +3. Why EC2A 2 is the smart-money version of WC2A 2 (43% less per m²) + +**Thumbnail text:** big number `£981,540 cheaper` + the two names `WC2A 2 → EC2A 2` + +**Description (paste as-is):** +``` +https://perfect-postcode.co.uk/?lat=51.51807&lon=-0.09837&zoom=12.5&filter=Est.%20price%20per%20sqm:0:15300&filter=Good%2B%20secondary%20school%20catchments:1:11 + +WC2A 2 and EC2A 2 share a station, a school catchment and the same era of housing, but EC2A 2 costs about 43% less per square metre (£981,540 on a 90 m² home). I built a map that ranks every postcode in England by what each pound actually buys, from official open data (Land Registry, EPC, Ofsted, DfT, Police.uk). Find the cheaper twin of any area, free and with no signup, at https://perfect-postcode.co.uk. + +0:00 The two postcodes (WC2A 2 & EC2A 2) +0:08 Same station +0:18 Same school catchment +0:28 Same kind of home +0:38 The price-per-m² reveal +0:52 Find your own cheaper twin (free map) + +Data: Contains HM Land Registry data © Crown copyright and database right, OGL v3.0. Figures are estimates aggregated to postcode sector, not valuations. +``` + +## 9:16 Short (cut from the same recording) +First 3 seconds: the £/m² reveal (WC2A 2 £25,482 → EC2A 2 £14,576) + caption '43% less'. End card: 'Find your cheaper twin, free, no signup.' + +## Optional auto-render spec (video/src/storyboard.ts AD_CONFIGS) +Add this as a `DemoAdStoryboardConfig` and run `video/render.sh --prod` (needs login creds + the live stack). Filter names must match live `/api/features` or preflight fails. +```json +{ + "name": "twin-wc2a-2-vs-ec2a-2", + "city": "london", + "promptText": "Best value flats/maisonettess near WC2A 2: same schools and station, lower price", + "initialFilters": { + "Est. price per sqm": [ + 0, + 15300 + ], + "Good+ secondary school catchments": [ + 1, + 11 + ] + }, + "outroLine": "EC2A 2: same life, 43% cheaper." +} +``` \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 45825c3..a0bcbe4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -30,7 +30,7 @@ services: - cargo-home:/usr/local/cargo - cargo-target:/app/server-rs/target environment: - # Fallback only — the binary uses jemalloc as its global allocator + # Fallback only: the binary uses jemalloc as its global allocator # (tuned via a baked-in malloc_conf). Caps glibc to 2 arenas. MALLOC_ARENA_MAX: "2" # Dev only: spill the large property arrays (feature matrix + diff --git a/finder/README.md b/finder/README.md index 410d6cd..bea1384 100644 --- a/finder/README.md +++ b/finder/README.md @@ -1,9 +1,9 @@ -# Finder — property listing scraper +# Finder: property listing scraper Scrapes Greater-London sale listings from **Rightmove**, **OnTheMarket**, and **Zoopla**, recovers each property's true full postcode, and writes a single parquet (`data/online_listings_buy.parquet`) that the rest of the app consumes -(after a separate enrich step — see [Output](#output)). +(after a separate enrich step, see [Output](#output)). `main.py` is the only entry point; everything else is library code. @@ -12,7 +12,7 @@ parquet (`data/online_listings_buy.parquet`) that the rest of the app consumes ## How it works (and why it's careful about postcodes) Every portal's **search** API exposes only an *outcode*-level address (e.g. -`"…, London, SW9"`) plus map coordinates — never the full unit postcode. The +`"…, London, SW9"`) plus map coordinates, never the full unit postcode. The full postcode lives on each listing's **detail page**, so the scraper fetches detail pages to recover it, and only trusts a detail postcode when its outcode agrees with the coordinate-nearest postcode (so a stale/wrong value can never @@ -22,7 +22,7 @@ falls back to the coordinate-nearest postcode. See the module docstrings in Detail fetching is the dominant cost, so it is: -- **cached across runs** — `data/detail_cache/{source}.json` maps listing id → +- **cached across runs**: `data/detail_cache/{source}.json` maps listing id → recovered postcode; a re-run only fetches *newly-appeared* listings; - **fetched concurrently** for the HTTP portals (Rightmove, OnTheMarket), bounded by a shared global rate limiter so the VPN egress stays polite; @@ -51,7 +51,7 @@ Also required: the ARCGIS postcode parquet at `../property-data/arcgis_data.parq ## Running -### Docker Compose (recommended — the only way that does Zoopla) +### Docker Compose (recommended, the only way that does Zoopla) `finder/docker-compose.yml` brings up the scraper plus **FlareSolverr** (which solves Zoopla's Cloudflare challenge), both sharing `media_gluetun`'s netns. This @@ -106,18 +106,18 @@ GLUETUN_PROXY="" .venv/bin/python main.py --source onthemarket --outcodes SW9 \ | Flag | Default | Meaning | |------|---------|---------| | `--source rightmove,onthemarket` | `all` | Comma-separated portal(s): any of `rightmove`, `onthemarket`, `zoopla`, or `all`. | -| `--outcodes SW9,E14,BR1` | — | Specific outcodes (must be Greater-London-ish). Otherwise the full London set is loaded from ARCGIS. | -| `--limit-outcodes N` | — | Cap the number of outcodes (quick smoke). | -| `--max-properties-per-source N` | — | Stop each source after N transformed listings. | +| `--outcodes SW9,E14,BR1` | none | Specific outcodes (must be Greater-London-ish). Otherwise the full London set is loaded from ARCGIS. | +| `--limit-outcodes N` | none | Cap the number of outcodes (quick smoke). | +| `--max-properties-per-source N` | none | Stop each source after N transformed listings. | | `--output-dir DIR` | `data/` | Where the parquet (and `detail_cache/`) are written. | | `--test` | off | ~10 likely-London outcodes, ≤100 listings/source, writes to `data/test/`. | -> **Always pass `--output-dir /tmp/...` for testing** — the default `data/` holds +> **Always pass `--output-dir /tmp/...` for testing**: the default `data/` holds > the real listings the app consumes. ### Stopping a run -`Ctrl+C` (SIGINT) — or `docker stop` (SIGTERM) — triggers a **graceful +`Ctrl+C` (SIGINT), or `docker stop` (SIGTERM), triggers a **graceful shutdown**: every source stops at its next outcode boundary, in-flight delays and retry backoffs wake immediately, and the run still persists the detail caches and writes the listings collected so far before exiting (code `130`). @@ -147,7 +147,7 @@ A **separate enrich step** (outside `finder/`) turns that into `online_listings_buy_enriched.parquet`, which is what the Rust backend actually loads (`--actual-listings-path …/online_listings_buy_enriched.parquet` in the top-level `docker-compose.yml`). That enrich/scheduling pipeline is **not** -documented here — only the raw scrape is. +documented here. Only the raw scrape is documented. The top-level `docker-compose.yml` (Rust `server`, `frontend`, `pocketbase`, `screenshot`) is the **web app**; it is downstream of the scrape and is **not** diff --git a/finder/constants.py b/finder/constants.py index 66cc419..64924f5 100644 --- a/finder/constants.py +++ b/finder/constants.py @@ -26,14 +26,14 @@ RETRY_BASE_DELAY = 2.0 DETAIL_FETCH_CONCURRENCY = int(os.environ.get("DETAIL_FETCH_CONCURRENCY", "8")) REQUESTS_PER_SECOND = float(os.environ.get("REQUESTS_PER_SECOND", "10")) GRID_CELL_SIZE = 0.01 # degrees for postcode spatial index -MAX_BEDROOMS = 20 # sanity cap — values above this are almost certainly parsing errors +MAX_BEDROOMS = 20 # sanity cap: values above this are almost certainly parsing errors TYPEAHEAD_URL = "https://los.rightmove.co.uk/typeahead" SEARCH_URL = "https://www.rightmove.co.uk/api/property-search/listing/search" RIGHTMOVE_BASE = "https://www.rightmove.co.uk" # Detail page (plain HTTPS GET, no Cloudflare). Its window.__PAGE_MODEL embeds # propertyData.address.{outcode,incode}, which together form the property's TRUE -# full postcode — the search API only exposes the outcode. {id} is the numeric +# full postcode. The search API only exposes the outcode. {id} is the numeric # listing id from the search response. RIGHTMOVE_DETAIL_URL = "https://www.rightmove.co.uk/properties/{id}" @@ -53,7 +53,7 @@ RIGHTMOVE_MAX_DETAILS_PER_OUTCODE = 4000 # max detail-page fetches per outcode # keep on APPROXIMATE pins (new-builds/developments) where Rightmove # deliberately fuzzes the coordinates. Degrades safely: when `pinType` is absent # from the search payload, nothing is skipped (behaviour is unchanged), so this -# is only a speed-up to the extent the field is present — verify against a live +# is only a speed-up to the extent the field is present. Verify against a live # search response before relying on the saving. RIGHTMOVE_SKIP_DETAILS_FOR_ACCURATE_PINS = ( os.environ.get("RIGHTMOVE_SKIP_DETAILS_FOR_ACCURATE_PINS", "1") != "0" @@ -94,7 +94,7 @@ GLUETUN_API_KEY = "My8AbvnKhfyFdRhpTVfoTfa5DkAMmg8K" GLUETUN_MAX_ROTATIONS = 0 # max egress-IP rotations per Cloudflare challenge # Zoopla fetcher: "flaresolverr" (default) solves Cloudflare via the FlareSolverr -# sidecar (docker-compose.yml) and needs no display/VNC — verified to return the +# sidecar (docker-compose.yml) and needs no display/VNC, verified to return the # RSC flight stream with postcode + coordinates; "camoufox" drives a local # anti-fingerprint browser (needs an interactive solve on datacenter IPs). ZOOPLA_FETCHER = os.environ.get("ZOOPLA_FETCHER", "flaresolverr") @@ -214,3 +214,16 @@ PROPERTY_TYPE_MAP = { CHANNELS = [ {"channel": "BUY", "transactionType": "BUY", "sortType": "2"}, ] + +# A second search pass that restricts the BUY channel to new-build developments +# via Rightmove's `mustHave=newHome` filter, so new homes (which can rank low in +# the default resale sort) are captured thoroughly. The API still echoes +# `?channel=RES_BUY` in every listing URL regardless of this filter, so new +# builds are identified by the per-listing `development` flag in +# `transform_property`, which re-stamps the URL channel as RES_NEW. +NEW_HOMES_CHANNEL = { + "channel": "BUY", + "transactionType": "BUY", + "sortType": "2", + "extra_params": {"mustHave": "newHome"}, +} diff --git a/finder/flaresolverr.py b/finder/flaresolverr.py index dd91222..3d2c081 100644 --- a/finder/flaresolverr.py +++ b/finder/flaresolverr.py @@ -1,4 +1,4 @@ -"""FlareSolverr client — fetch Cloudflare-protected pages as rendered HTML. +"""FlareSolverr client: fetch Cloudflare-protected pages as rendered HTML. FlareSolverr (https://github.com/FlareSolverr/FlareSolverr) drives an undetected browser to pass Cloudflare's challenge and returns the fully @@ -6,7 +6,7 @@ rendered HTML. It runs as a sidecar service (see docker-compose.yml) sharing the Gluetun VPN network namespace, so its browser egresses through the VPN. Verified working against Zoopla's managed Turnstile on a datacenter VPN IP, -provided a reused session and a generous maxTimeout (~120s) — the first +provided a reused session and a generous maxTimeout (~120s): the first challenge solve is slow, subsequent requests on the warm session are fast. """ diff --git a/finder/main.py b/finder/main.py index f7773fb..92d3790 100644 --- a/finder/main.py +++ b/finder/main.py @@ -132,7 +132,7 @@ def main() -> int: configure_standalone_runtime() configure_logging() # Ctrl+C (and SIGTERM, e.g. `docker stop`) asks the scrapers to wind down - # gracefully — each source stops at its next outcode boundary and the run + # gracefully. Each source stops at its next outcode boundary and the run # still persists detail caches and writes the listings collected so far. shutdown.install_signal_handlers() diff --git a/finder/onthemarket.py b/finder/onthemarket.py index 8ccaf40..53d6643 100644 --- a/finder/onthemarket.py +++ b/finder/onthemarket.py @@ -1,4 +1,4 @@ -"""OnTheMarket (onthemarket.com) scraper — sale properties. +"""OnTheMarket (onthemarket.com) scraper: sale properties. OnTheMarket serves a Next.js app with the full search-results payload embedded as JSON in a `__NEXT_DATA__` script tag. No JS execution or browser needed: @@ -15,19 +15,19 @@ Postcodes --------- The search card exposes only an *outcode*-level address (e.g. "Padfield Road, London, SE5") and a map pin, so the old behaviour derived the postcode from the -nearest postcode to that pin — a guess that frequently lands on a neighbouring +nearest postcode to that pin, a guess that frequently lands on a neighbouring unit (the pin can sit on the wrong side of a street boundary). Each *detail* page (`/details/{id}/`) is a plain HTTPS GET whose `__NEXT_DATA__` embeds the property's analytics dataLayer at `props.initialReduxState.metadata.dataLayer`, which carries the property's own `postcode` (full unit postcode, e.g. "SE5 9AA") keyed to this listing by -`property-id`. Crucially this is NOT the agent's office postcode — that lives +`property-id`. Crucially this is NOT the agent's office postcode. That lives separately at `…property.agent.postcode` ("SE5 8RS" for the same listing) and is the classic trap when blindly scanning the page for a postcode. We read the dataLayer postcode, verify `property-id` matches the listing, and accept it only when its outcode agrees with the coordinate-nearest postcode (via -``resolve_listing_postcode``) — exactly the trust rule the other scrapers use. +``resolve_listing_postcode``), exactly the trust rule the other scrapers use. Measured over a sample of real listings this yields a trustworthy, usually exact-unit postcode for ~11/12 listings; the rest safely fall back to the coordinate-nearest postcode. @@ -68,8 +68,8 @@ from transform import ( log = logging.getLogger("rightmove") # Detail-page postcode recovery (see module docstring). When enabled, each -# listing's detail page is fetched so its analytics dataLayer postcode — the -# property's own full unit postcode — can replace the coordinate-nearest guess. +# listing's detail page is fetched so its analytics dataLayer postcode (the +# property's own full unit postcode) can replace the coordinate-nearest guess. # Bounded per outcode so a large outcode can't balloon into unbounded extra # HTTPS GETs. Kept at parity with the Rightmove/Zoopla detail caps (400) so a # typical outcode's listings all get their real postcode rather than a @@ -145,7 +145,7 @@ def _fetch_page_json(client: httpx.Client, outcode: str, page_num: int) -> dict if 300 <= resp.status_code < 400: log.debug( - "OnTheMarket %s page %d redirected (%d) — end of results", + "OnTheMarket %s page %d redirected (%d): end of results", outcode, page_num, resp.status_code, ) return None @@ -189,7 +189,7 @@ def parse_detail_postcode(html: str, listing_id: str | None = None) -> str | Non ``props.initialReduxState.metadata.dataLayer.postcode`` and is the property's own unit postcode (e.g. "SE5 9AA"). It is deliberately NOT the agent's office postcode, which sits separately at - ``…property.agent.postcode`` — the trap when scanning a detail page for "a" + ``…property.agent.postcode``, the trap when scanning a detail page for "a" postcode. When ``listing_id`` is given, the dataLayer's ``property-id`` must match it, guaranteeing we read this listing's postcode and not a stray one. @@ -235,7 +235,7 @@ def _fetch_detail_postcode( Results (including failures) are cached by listing id so a listing that reappears across overlapping outcode searches is fetched at most once. Plain - HTTPS GET — OnTheMarket detail pages have no Cloudflare challenge. Network / + HTTPS GET: OnTheMarket detail pages have no Cloudflare challenge. Network / parse errors degrade gracefully to None so the caller falls back to the coordinate-nearest postcode. Safe to call concurrently: distinct listing ids write distinct cache keys, and the shared RATE_LIMITER spaces the GETs. @@ -452,7 +452,7 @@ def _prime_detail_postcodes( ) -> None: """Fill ``_detail_postcode_cache`` for the listings that need a detail page. - Picks the fresh (uncached) listings — up to ``detail_cap`` per outcode — then + Picks the fresh (uncached) listings, up to ``detail_cap`` per outcode, then fetches their detail pages CONCURRENTLY, bounded by ``DETAIL_FETCH_CONCURRENCY`` (the shared RATE_LIMITER keeps the combined request rate polite). Cached listings cost neither a slot nor a GET. The diff --git a/frontend/src/components/map/FeatureBrowser.tsx b/frontend/src/components/map/FeatureBrowser.tsx index a6bf288..66e59dc 100644 --- a/frontend/src/components/map/FeatureBrowser.tsx +++ b/frontend/src/components/map/FeatureBrowser.tsx @@ -105,17 +105,12 @@ export default function FeatureBrowser({ return ( <> -
+
- {!search && ( -

- {t('filters.chooseFilters')} -

- )}
{mergedGrouped.map((group) => { diff --git a/frontend/src/components/map/filters/ActiveFiltersPanel.tsx b/frontend/src/components/map/filters/ActiveFiltersPanel.tsx index cf8bb81..a9e9b29 100644 --- a/frontend/src/components/map/filters/ActiveFiltersPanel.tsx +++ b/frontend/src/components/map/filters/ActiveFiltersPanel.tsx @@ -159,6 +159,26 @@ export function ActiveFiltersPanel({ {t('filters.clearAll')} )} + { + e.stopPropagation(); + onShowPhilosophy(); + }} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.stopPropagation(); + onShowPhilosophy(); + } + }} + className="flex text-warm-500 hover:text-teal-600 dark:text-warm-300 dark:hover:text-teal-300" + > + + -
- -
{enabledFeatureList.length === 0 && activeEntryCount === 0 && (

{t('filters.addFiltersHint')} diff --git a/frontend/src/components/map/filters/AddFilterPanel.tsx b/frontend/src/components/map/filters/AddFilterPanel.tsx index dfebf67..0bd1f66 100644 --- a/frontend/src/components/map/filters/AddFilterPanel.tsx +++ b/frontend/src/components/map/filters/AddFilterPanel.tsx @@ -167,7 +167,7 @@ export function AddFilterPanel({ {(!collapsed || !isLicensed) && (

{!collapsed && ( -
+
)} {!isLicensed && ( -
-

- {isLoggedIn ? t('filters.upgradePrompt') : t('filters.registerPrompt')} -

-

- {isLoggedIn ? t('filters.oneTimeLifetime') : t('filters.registerSubPrompt')} +

+

+ {isLoggedIn ? t('filters.upgradePrompt') : t('filters.registerPrompt')}{' '} + + {isLoggedIn ? t('filters.oneTimeLifetime') : t('filters.registerSubPrompt')} +

- - - - - -
)}
diff --git a/r5-java/run.sh b/r5-java/run.sh index 37a118c..15cbe51 100755 --- a/r5-java/run.sh +++ b/r5-java/run.sh @@ -7,7 +7,7 @@ set -euo pipefail # Uses full England OSM + 2 GTFS feeds (BODS buses, National Rail). # R5's TransportNetwork.fromDirectory() picks up all .osm.pbf and .zip files. # -# Uses each place as origin with all postcodes as destinations — R5 does one +# Uses each place as origin with all postcodes as destinations. R5 does one # routing computation per place, then reads off travel times to all postcodes. # For car/bicycle/walking this is symmetric (place->postcode = postcode->place). # @@ -27,7 +27,7 @@ THREADS=12 # The execution cgroup caps process memory at 48 GB (see /sys/fs/cgroup/memory.max); # the nominal "64 GB" host total is not all addressable to one process. 28g heap + # ~15g native overhead (DuckDB JNI, R5 mapdb, Kryo deserialize, RAPTOR scratch) -# leaves ~5g cgroup headroom — empirically the safe ceiling before SIGKILL. +# leaves ~5g cgroup headroom: empirically the safe ceiling before SIGKILL. # Under 32g also keeps CompressedOops on, halving R5's reference-heavy footprint. HEAP=28g NETWORK_DIR=property-data/r5-network @@ -137,7 +137,7 @@ fi if [ ! -f "$NETWORK_DIR/network.dat" ]; then BUILD_DIR="$NETWORK_DIR/build" - echo "--- No cached network — copying transit data to build dir ---" + echo "--- No cached network, copying transit data to build dir ---" mkdir -p "$BUILD_DIR" if [ ! -f "$OSM_PBF" ]; then echo "Error: OSM PBF not found at $OSM_PBF" diff --git a/r5-java/src/main/java/propertymap/App.java b/r5-java/src/main/java/propertymap/App.java index feb03ac..4d36cd4 100644 --- a/r5-java/src/main/java/propertymap/App.java +++ b/r5-java/src/main/java/propertymap/App.java @@ -47,7 +47,7 @@ import java.util.concurrent.atomic.AtomicInteger; * its own DuckDB connection. Routing tasks enqueue {@link WriteJob}s onto a * bounded {@link ArrayBlockingQueue}; if writes lag, the queue applies backpressure * to routing. - * - Within a mode, the latch counts down only after the write completes — so progress + * - Within a mode, the latch counts down only after the write completes, so progress * and the inter-mode barrier reflect fully-persisted work. */ public class App { @@ -253,8 +253,8 @@ public class App { // Recycle the DuckDB connection every CONN_RECYCLE_EVERY writes. Long-lived // in-memory DuckDB connections accumulate buffer pages / catalog state that // doesn't get fully released by DROP TABLE alone; close + reopen forces it. - // Set to 10 (vs 50) after leak testing showed +60MB/origin growth at 50 — - // tighter recycling keeps the per-connection working set bounded. + // Set to 10 (vs 50) after leak testing showed +60MB/origin growth at 50. + // Tighter recycling keeps the per-connection working set bounded. final int CONN_RECYCLE_EVERY = 10; DuckDBConnection conn; try { @@ -576,7 +576,7 @@ public class App { /** * Filter place indices to those near at least one England postcode. - * Uses a 0.1° grid (~11km cells) built from postcode locations — a place is kept + * Uses a 0.1° grid (~11km cells) built from postcode locations: a place is kept * if its grid cell or any adjacent cell contains an England postcode. */ private static Set filterEnglandPlaces( diff --git a/r5-java/src/main/java/propertymap/Parquet.java b/r5-java/src/main/java/propertymap/Parquet.java index 94fb6b7..fc48c13 100644 --- a/r5-java/src/main/java/propertymap/Parquet.java +++ b/r5-java/src/main/java/propertymap/Parquet.java @@ -122,7 +122,7 @@ public class Parquet { stmt.execute("COPY t TO '" + escapePath(tmp.toAbsolutePath().toString()) + "' (FORMAT PARQUET, COMPRESSION ZSTD, COMPRESSION_LEVEL 1)"); // Drop the populated table NOW so DuckDB releases its in-memory storage // for the next write. Without this, the previous origin's rows linger - // until the next call's DROP IF EXISTS — accumulating across writers. + // until the next call's DROP IF EXISTS, accumulating across writers. stmt.execute("DROP TABLE t"); } Files.move(tmp, outPath, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE); diff --git a/r5-java/src/main/java/propertymap/Router.java b/r5-java/src/main/java/propertymap/Router.java index 2e0630f..1a6eaca 100644 --- a/r5-java/src/main/java/propertymap/Router.java +++ b/r5-java/src/main/java/propertymap/Router.java @@ -114,13 +114,13 @@ public class Router { /** * Load or build the transport network with Kryo caching. - * The returned network is read-only after buildDistanceTables — safe for concurrent use. + * The returned network is read-only after buildDistanceTables, safe for concurrent use. * * The evictable LinkageCache is left small (32 entries) because non-transit modes * create one huge per-origin LinkedPointSet each (~1M dests for car @ 150km radius). * Caching 1024 such entries OOMs the heap. Transit tile linkages instead go into * the unevictable {@code linkageMap} via {@link #preloadTransitTileLinkages} after - * tiles are built — that map has no count limit and is checked first on lookup. + * tiles are built. That map has no count limit and is checked first on lookup. */ static TransportNetwork loadNetwork(String dataDir, String cacheDir) throws Exception { // Must be set BEFORE the TransportNetwork is deserialized, since its LinkageCache @@ -240,7 +240,7 @@ public class Router { int n = lats.length; int[] sorted = sortIndicesByLat(lats); - // Global lon span sets gridWidth — all tiles share the same horizontal extent + // Global lon span sets gridWidth: all tiles share the same horizontal extent // bound, so each tile is a horizontal band of postcodes. double minLon = Double.MAX_VALUE, maxLon = -Double.MAX_VALUE; for (double lon : lons) { @@ -390,7 +390,7 @@ public class Router { /** * Filter destination indices to those within a bounding box of maxRadiusKm from origin. - * Uses degree-based approximation — slightly overestimates at corners, which is fine. + * Uses degree-based approximation. Slightly overestimates at corners, which is fine. * Backed by STRtree: O(log n + k) per query instead of O(n) scan. */ @SuppressWarnings("unchecked") @@ -421,7 +421,7 @@ public class Router { double[] lats, double[] lons, int maxDestsPerChunk) { int n = lats.length; - // Sort indices by latitude for geographic chunking — primitive long sort to + // Sort indices by latitude for geographic chunking: primitive long sort to // avoid Integer[] autoboxing per origin (millions of Integer allocs at scale). // Pack: high 32 bits = lat as sortable int, low 32 bits = original index. int[] sorted = sortIndicesByLat(lats); diff --git a/server-rs/src/consts.rs b/server-rs/src/consts.rs index ef3ac69..ca569f7 100644 --- a/server-rs/src/consts.rs +++ b/server-rs/src/consts.rs @@ -33,13 +33,13 @@ pub const AI_FILTERS_WEEKLY_TOKEN_LIMIT: u64 = 10_000_000; pub const SERVICE_CALL_TIMEOUT: u64 = 120; /// Anonymous (logged-out) users may apply at most this many filters at a time. This -/// is the only gate on the demo: there is no region lock — they get the full +/// is the only gate on the demo: there is no region lock: they get the full /// dashboard everywhere, just capped on simultaneous filters. Enforced both /// client-side (UX) and server-side (defense-in-depth); must match the frontend /// constant `DEMO_MAX_FILTERS`. pub const DEMO_MAX_FILTERS: usize = 3; /// Registered but non-paying accounts get a higher filter allowance than anonymous -/// visitors — the incentive to create a free account. Paying/licensed users are +/// visitors, the incentive to create a free account. Paying/licensed users are /// uncapped. Must match the frontend constant `REGISTERED_MAX_FILTERS`. pub const REGISTERED_MAX_FILTERS: usize = 5; /// Sliding-window rate limit for unlicensed traffic to `/api/` endpoints, keyed by diff --git a/server-rs/src/features.rs b/server-rs/src/features.rs index ffea9a2..3df4bb5 100644 --- a/server-rs/src/features.rs +++ b/server-rs/src/features.rs @@ -70,7 +70,7 @@ pub struct FeatureGroup { /// Expand each crime type into its two filterable features: a 7-year and a /// 2-year window. Each is the average number of recorded incidents per year (the -/// raw, absolute count — no per-area or per-capita normalisation). The names must +/// raw, absolute count: no per-area or per-capita normalisation). The names must /// match the `"{type} (/yr, 7y|2y)"` columns written by `crime_spatial`. The /// per-incident records are NOT a feature (they are a display-only side table the /// server loads directly), so they never appear here and are not filterable. @@ -81,12 +81,12 @@ macro_rules! crime_features { name: concat!($base, " (/yr, 7y)"), bounds: Bounds::Percentile { low: 2.0, high: 98.0 }, step: 0.1, - description: concat!($blurb, " — average recorded incidents per year (last 7 years)"), + description: concat!($blurb, ": average recorded incidents per year (last 7 years)"), detail: concat!( $blurb, ", as the average number of recorded incidents per year, over the last \ 7 years. Counted from police.uk street-level crime points (anonymised, \ - snapped to nearby map points) that fall near the postcode boundary — \ + snapped to nearby map points) that fall near the postcode boundary: \ the raw, absolute count, with no per-area or per-capita adjustment. \ Computed over the months the local police force actually published; \ known force gaps (e.g. Greater Manchester since mid-2019) are excluded, \ @@ -102,11 +102,11 @@ macro_rules! crime_features { name: concat!($base, " (/yr, 2y)"), bounds: Bounds::Percentile { low: 2.0, high: 98.0 }, step: 0.1, - description: concat!($blurb, " — average recorded incidents per year (last 2 years)"), + description: concat!($blurb, ": average recorded incidents per year (last 2 years)"), detail: concat!( $blurb, ", as the average number of recorded incidents per year, over the last \ - 2 years — a more recent but noisier window than the 7-year figure. From \ + 2 years: a more recent but noisier window than the 7-year figure. From \ police.uk street-level crime points near the postcode boundary (the raw, \ absolute count), over the months the local force published (gaps \ excluded, not zeroed)." @@ -248,7 +248,7 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[ }, step: 1.0, description: "Maximum transport noise level near the postcode in decibels (Lden)", - detail: "Loudest of road, rail, or airport noise in decibels (Lden, a 24-hour day-evening-night weighted average) from Defra's Strategic Noise Mapping Round 4 (2022). Covers England only; rail noise dominates the value at ~120k postcodes and airport noise at ~4k. Modelled at 4m above ground on a 10m grid and sampled as the maximum 10m cell around the postcode representative point. Blank means no mapped data in the source (Wales, Scotland and areas away from major roads/railways/airports all return blank) — not necessarily quiet. Above ~55 dB is typically noticeable; above ~70 dB is considered harmful by the WHO.", + detail: "Loudest of road, rail, or airport noise in decibels (Lden, a 24-hour day-evening-night weighted average) from Defra's Strategic Noise Mapping Round 4 (2022). Covers England only; rail noise dominates the value at ~120k postcodes and airport noise at ~4k. Modelled at 4m above ground on a 10m grid and sampled as the maximum 10m cell around the postcode representative point. Blank means no mapped data in the source (Wales, Scotland and areas away from major roads/railways/airports all return blank). It does not necessarily mean the area is quiet. Above ~55 dB is typically noticeable; above ~70 dB is considered harmful by the WHO.", source: "noise", prefix: "", suffix: " dB", @@ -528,11 +528,11 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[ features: crime_features![ ( "Serious crime", - "Serious crime — violence, robbery, burglary and weapons possession — near the postcode" + "Serious crime (violence, robbery, burglary and weapons possession) near the postcode" ), ( "Minor crime", - "Lower-severity crime — anti-social behaviour, theft, criminal damage, drugs and public order — near the postcode" + "Lower-severity crime (anti-social behaviour, theft, criminal damage, drugs and public order) near the postcode" ), ( "Violence and sexual offences", @@ -638,7 +638,7 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[ bounds: Bounds::Fixed { min: 0.0, max: 100.0 }, step: 1.0, description: "Share of residents (16+) whose highest qualification is A-levels (Level 3)", - detail: "From the 2021 Census (TS067). Highest qualification is A-levels, AS-levels, T-levels, an advanced apprenticeship, or equivalent — typically studied after 16 and before a degree. The ONS calls this 'Level 3'.", + detail: "From the 2021 Census (TS067). Highest qualification is A-levels, AS-levels, T-levels, an advanced apprenticeship, or equivalent, typically studied after 16 and before a degree. The ONS calls this 'Level 3'.", source: "census-2021", prefix: "", suffix: "%", @@ -650,7 +650,7 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[ bounds: Bounds::Fixed { min: 0.0, max: 100.0 }, step: 1.0, description: "Share of residents (16+) with a degree-level or higher qualification", - detail: "From the 2021 Census (TS067). Highest qualification is degree level or above — a Bachelor's, Master's or PhD, foundation degree, HNC/HND, NVQ 4-5, or higher professional qualification. The census does not separate undergraduate from postgraduate degrees. The ONS calls this 'Level 4 or above'.", + detail: "From the 2021 Census (TS067). Highest qualification is degree level or above: a Bachelor's, Master's or PhD, foundation degree, HNC/HND, NVQ 4-5, or higher professional qualification. The census does not separate undergraduate from postgraduate degrees. The ONS calls this 'Level 4 or above'.", source: "census-2021", prefix: "", suffix: "%", @@ -662,7 +662,7 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[ bounds: Bounds::Fixed { min: 0.0, max: 100.0 }, step: 1.0, description: "Share of residents (16+) with other qualifications, including vocational or overseas ones", - detail: "From the 2021 Census (TS067). Highest qualification is classed as 'other' — vocational or professional qualifications not mapped to a UK level, and qualifications gained outside the UK.", + detail: "From the 2021 Census (TS067). Highest qualification is classed as 'other': vocational or professional qualifications not mapped to a UK level, and qualifications gained outside the UK.", source: "census-2021", prefix: "", suffix: "%", @@ -673,8 +673,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[ // shares sum to ~100% per neighbourhood (LSOA) and render as a // stacked composition (see STACKED_GROUPS["Neighbours"] in the // frontend), like the ethnicity, qualifications and vote-share bars. - // Unlike those — each folded into a single dropdown filter that - // selects one band — the three tenure shares are offered as + // Unlike those (each folded into a single dropdown filter that + // selects one band), the three tenure shares are offered as // individual filters, so users can target e.g. owner-occupier-heavy // areas. Feature::Numeric(FeatureConfig { @@ -713,6 +713,37 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[ raw: false, absolute: false, }), + // EPC-derived council-housing footprint for the neighbourhood (LSOA). + // Aggregated in the pipeline from the per-property "Former council + // house" flag and the latest certificate tenure. The denominator is + // ALL dwellings in the LSOA (homes with no EPC count as not council), + // so these are EPC-coverage-limited lower bounds, NOT a Census + // household share. These two plus the Census "% Social rent" back the + // frontend "Council housing" filter's Current / Ex / Both pill toggle. + Feature::Numeric(FeatureConfig { + name: "% Council housing", + bounds: Bounds::Fixed { min: 0.0, max: 100.0 }, + step: 1.0, + description: "Share of nearby homes ever recorded as council or social housing", + detail: "Estimated from EPC tenure records across the neighbourhood (LSOA): the share of homes whose Energy Performance Certificate history shows the property was council or social housing at some point, whether it is still social housing today or has since been sold (for example under Right to Buy). Homes with no EPC are counted as not council, so this is a lower bound that reflects EPC coverage and is not directly comparable to the Census social-rent share.", + source: "epc", + prefix: "", + suffix: "%", + raw: false, + absolute: false, + }), + Feature::Numeric(FeatureConfig { + name: "% Ex-council", + bounds: Bounds::Fixed { min: 0.0, max: 100.0 }, + step: 1.0, + description: "Share of nearby homes that were council housing but are no longer", + detail: "Estimated from EPC tenure records across the neighbourhood (LSOA): the share of homes once recorded as council or social housing whose most recent Energy Performance Certificate shows a different tenure, typically homes sold under Right to Buy. Homes with no EPC are counted as not council, so this is a lower bound that reflects EPC coverage and is not directly comparable to the Census social-rent share.", + source: "epc", + prefix: "", + suffix: "%", + raw: false, + absolute: false, + }), Feature::Numeric(FeatureConfig { name: "% White", bounds: Bounds::Fixed { diff --git a/server-rs/src/routes/ai_filters/prompt.rs b/server-rs/src/routes/ai_filters/prompt.rs index 3fbf8a2..8fda18a 100644 --- a/server-rs/src/routes/ai_filters/prompt.rs +++ b/server-rs/src/routes/ai_filters/prompt.rs @@ -23,7 +23,7 @@ pub fn build_system_prompt( - Leave out any filter the user did not mention. Empty arrays are fine.\n\ - Each numeric filter sets ONE bound only: \"min\" (at least this value) \ or \"max\" (at most this value). Never set two filters on the same feature.\n\ - - Use EXACT feature names from the list — spelling, capitalisation, and punctuation must match.\n\ + - Use EXACT feature names from the list: spelling, capitalisation, and punctuation must match.\n\ - \"cheap\" / \"affordable\" = lower price range. \"expensive\" = higher price range.\n\ - \"low crime\" / \"safe\" = low values on the Serious crime (/yr, 7y) and Minor crime (/yr, 7y) \ features (average recorded incidents per year near the postcode, last 7 years). Prefer these aggregates for broad \ @@ -39,7 +39,9 @@ pub fn build_system_prompt( % Other parties, or Voter turnout (%) when the user asks for political character.\n\ - Housing tenure is a normal filter in the Neighbours group. \"lots of homeowners\" / \ \"owner-occupied area\" = high % Owner occupied; \"rental area\" / \"lots of renters\" = \ - high % Private rent; \"social housing\" / \"council housing\" = high % Social rent.\n\ + high % Private rent; \"social housing\" / \"council housing\" = high % Social rent. \ + \"ex-council\" / \"former council\" / \"right to buy\" = high % Ex-council; \"council \ + estate\" character whether current or former = high % Council housing.\n\ - Education level is a normal filter in the Neighbours group. \"well-educated\" / \ \"graduate\" / \"professional\" / \"university-educated area\" = high % Degree or higher; \ \"few qualifications\" = high % No qualifications.\n\ @@ -88,7 +90,7 @@ pub fn build_system_prompt( \n\ When the user mentions a specific place, you MUST call the search_destinations \ tool to find the exact slug. Use the name and slug from the search results.\n\ - If search_destinations returns an empty array, the destination is not available — \ + If search_destinations returns an empty array, the destination is not available; \ mention it in \"notes\" (e.g. \"No travel data for: Gatwick Airport\") and do NOT \ include a travel_time_filter for it.\n\ \n\ diff --git a/server-rs/src/routes/ai_filters/usage.rs b/server-rs/src/routes/ai_filters/usage.rs index 73ddf40..d1a68d3 100644 --- a/server-rs/src/routes/ai_filters/usage.rs +++ b/server-rs/src/routes/ai_filters/usage.rs @@ -69,7 +69,7 @@ pub(super) async fn fetch_ai_usage( } /// Update the user's AI token usage in PocketBase. -/// Best-effort — logs warnings on failure but does not propagate errors. +/// Best-effort: logs warnings on failure but does not propagate errors. async fn update_ai_usage(state: &AppState, user_id: &str, tokens_used: u64, week: u64) { let token = match get_superuser_token(state).await { Ok(tk) => tk, diff --git a/server-rs/src/routes/stats.rs b/server-rs/src/routes/stats.rs index d1a210a..4904fa3 100644 --- a/server-rs/src/routes/stats.rs +++ b/server-rs/src/routes/stats.rs @@ -142,7 +142,7 @@ pub fn compute_feature_stats( if value.is_finite() { // Reject negatives, NaN-via-large-cast, and any out-of-range // index. A schema/data mismatch is a critical data-integrity - // bug — skip the row, count it, and surface as error so + // bug: skip the row, count it, and surface as error so // monitoring catches it. let len = value_counts.len(); let idx_ok = value >= 0.0 && (value as usize) < len; @@ -154,7 +154,7 @@ pub fn compute_feature_stats( feature = feature_names[fi].as_str(), value, max = len, - "Enum index out of bounds — data/schema mismatch" + "Enum index out of bounds: data/schema mismatch" ); } } @@ -296,7 +296,7 @@ pub fn compute_enum_feature_counts( /// Denominators are COVERAGE-AWARE: police.uk has multi-year publication gaps /// for whole forces (e.g. Greater Manchester from 2019-07), and the pipeline /// emits a `covered_years` calendar per postcode. A postcode only counts toward -/// a year's denominator if its force published that year — and only then does +/// a year's denominator if its force published that year, and only then does /// its missing bar mean a genuine zero. Years no selected postcode covers are /// omitted entirely (charted as gaps, not zeros). Postcodes without coverage /// info (legacy parquet without the column) count toward every year, restoring @@ -337,7 +337,7 @@ pub fn compute_crime_by_year( // A postcode with a row but no series for a given type had no recorded // incidents of that type: it contributes 0 to the sums, and its covered - // years still count in the denominator — a genuine zero. Uncovered + // years still count in the denominator: a genuine zero. Uncovered // years are excluded via the denominators instead. if let Some(series_list) = crime_by_year.series_by_postcode.get(postcode) { for series in series_list { @@ -575,7 +575,7 @@ mod tests { assert_eq!(all.get("Yes"), Some(&2)); assert_eq!(all.get("No"), Some(&1)); - // A filter-matching subset would yield a different tally — confirming + // A filter-matching subset would yield a different tally, confirming // the count is driven purely by the rows passed in (so callers can pass // the full area to make it filter-independent). let subset = compute_enum_feature_counts(&[0, 3], &data, 0).unwrap(); @@ -621,7 +621,7 @@ mod tests { let robbery = out.iter().find(|c| c.name == "Robbery (/yr, 7y)").unwrap(); assert_eq!(robbery.national, Some(6.0)); - // The outcode value was NaN — dropped to None; the sector value is finite. + // The outcode value was NaN, dropped to None; the sector value is finite. assert_eq!(robbery.outcode, None); assert_eq!(robbery.sector, Some(7.0)); } diff --git a/server-rs/src/routes/travel_destinations.rs b/server-rs/src/routes/travel_destinations.rs index df2fd4d..6e9f862 100644 --- a/server-rs/src/routes/travel_destinations.rs +++ b/server-rs/src/routes/travel_destinations.rs @@ -69,7 +69,7 @@ pub async fn get_travel_destinations( // Sort: type rank asc, population desc, name length asc matches.sort_unstable_by(|a, b| a.2.cmp(&b.2).then(b.3.cmp(&a.3)).then(a.4.cmp(&b.4))); - // Deduplicate by slug — multiple places can share a name/slug + // Deduplicate by slug: multiple places can share a name/slug // (e.g. "Richmond" as city + suburb), keep the best-ranked one let mut seen_slugs = FxHashSet::default(); matches.retain(|(_, slug, ..)| seen_slugs.insert(slug.clone())); diff --git a/server-rs/src/utils/grid_index.rs b/server-rs/src/utils/grid_index.rs index d03a087..3681255 100644 --- a/server-rs/src/utils/grid_index.rs +++ b/server-rs/src/utils/grid_index.rs @@ -139,7 +139,7 @@ impl GridIndex { } /// Count the number of row indices within the given bounds without allocating. - /// O(grid cells in bounds) — much cheaper than query() for threshold decisions. + /// O(grid cells in bounds): much cheaper than query() for threshold decisions. pub fn count_in_bounds(&self, south: f64, west: f64, north: f64, east: f64) -> usize { let Some((row_min, row_max, col_min, col_max)) = self.clamp_bounds(south, west, north, east) diff --git a/video/render.sh b/video/render.sh index 4e03036..e9812bd 100755 --- a/video/render.sh +++ b/video/render.sh @@ -8,10 +8,10 @@ # language variants there; this script renders every emitted slug. # # Two targets: -# local (default) — assumes the docker-compose stack on host.docker.internal, -# bootstraps a recorder admin user automatically. -# prod — points at https://perfect-postcode.co.uk and skips the -# bootstrap step; you supply real account credentials. +# local (default): assumes the docker-compose stack on host.docker.internal, +# bootstraps a recorder admin user automatically. +# prod: points at https://perfect-postcode.co.uk and skips the +# bootstrap step; you supply real account credentials. # # Usage: # ./render.sh # local stack, English homepage landscape + portrait @@ -27,10 +27,10 @@ # APP_URL=http://localhost:3001 ./render.sh # override frontend URL # # Cred env vars (read for both targets, but prod has no fallback defaults): -# LOGIN_EMAIL, LOGIN_PASSWORD — the dashboard account to record as -# (same email/password you'd type into -# the login modal) -# PB_ADMIN_EMAIL, PB_ADMIN_PASSWORD — PocketBase superuser, only used by +# LOGIN_EMAIL, LOGIN_PASSWORD: the dashboard account to record as +# (same email/password you'd type into +# the login modal) +# PB_ADMIN_EMAIL, PB_ADMIN_PASSWORD: PocketBase superuser, only used by # --target local to bootstrap the # recorder user; ignored on --prod @@ -80,7 +80,7 @@ else fi export PB_BOOTSTRAP_ADMIN export LOGIN_EMAIL LOGIN_PASSWORD -# Per-target storage state — switching targets must not reuse a stale token. +# Per-target storage state: switching targets must not reuse a stale token. # config.ts reads AUTH_STATE_FILE for AUTH_STATE_PATH. export AUTH_STATE_FILE="${AUTH_STATE_FILE:-auth.${TARGET}.json}" AUTH_TTL_HOURS="${AUTH_TTL_HOURS:-24}" # re-auth if cache older than this @@ -306,7 +306,7 @@ if [ "$DO_AUDIO" = "1" ]; then export UV_HTTP_TIMEOUT="${UV_HTTP_TIMEOUT:-600}" # Pull in the flash-attn prebuilt wheel (defined as the `gpu` extra) when # the host actually has a GPU. The wheel is bound to torch 2.6 + cu12 + - # cp312 — see tts/pyproject.toml. + # cp312. See tts/pyproject.toml. uv_sync_extras=() if command -v nvidia-smi >/dev/null 2>&1 && nvidia-smi -L >/dev/null 2>&1; then uv_sync_extras+=(--extra gpu) @@ -331,7 +331,7 @@ if [ "$DO_AUDIO" = "1" ]; then # Voice consistency: every ad in this set declares the same AD_VOICE # (instruct/seed/temperature/topP/referenceText). Even with seed-locked # VoiceDesign, independent invocations across processes can produce - # mildly different reference waveforms — different enough that a + # mildly different reference waveforms, different enough that a # listener notices the timbre shift across ads. To avoid that, we # mint the reference WAV ONCE (from the first storyboard) and reuse # it across the rest of the storyboards by copying _reference.wav + @@ -342,7 +342,7 @@ if [ "$DO_AUDIO" = "1" ]; then # We copy ONLY the reference, never the cue wavs or index.json. Copying # the whole audio dir (as an earlier version did) overwrote each later # storyboard's cached index.json with the FIRST storyboard's, which - # forced a full re-synth on every run — and in multi-voice sets (the + # forced a full re-synth on every run, and in multi-voice sets (the # localized homepage demos: en/de/zh/hi) it clobbered correct localized # audio. With a reference-only copy: same-voice sets reuse the reference # (meta matches); different-voice sets re-mint their own (meta mismatch), @@ -364,7 +364,7 @@ if [ "$DO_AUDIO" = "1" ]; then if [ -z "$shared_ref_wav" ] && [ -f "output/$sb/audio/_reference.wav" ]; then shared_ref_wav="output/$sb/audio/_reference.wav" shared_ref_meta="output/$sb/audio/_reference.meta.json" - say "Locked voice reference to $shared_ref_wav — reusing for the rest of the set" + say "Locked voice reference to $shared_ref_wav; reusing for the rest of the set" fi done fi @@ -422,7 +422,7 @@ for sb in "${STORYBOARDS[@]}"; do if [ "$DO_ENCODE" = "1" ] && [ "$DO_AUDIO" = "1" ]; then if [ ! -s "output/$sb/narration.json" ]; then - fail "[$sb] narration.json missing — recorder did not log cues" + fail "[$sb] narration.json missing: recorder did not log cues" fi say "[$sb] Muxing narration into output/$sb/recording.mp4" uv run --project tts python tts/mux.py --storyboard "$sb" --replace \ diff --git a/video/src/auth.ts b/video/src/auth.ts index 978f9bf..822f44c 100644 --- a/video/src/auth.ts +++ b/video/src/auth.ts @@ -8,7 +8,7 @@ import { ensureRecorderAdminUser } from './pb-admin.js'; * 1. Programmatic (preferred for CI / non-interactive runs): set * LOGIN_EMAIL and LOGIN_PASSWORD env vars (the same email/password you'd * type into the dashboard's login modal). We drive the actual login form - * in a headless browser — same path a real user takes, no knowledge of + * in a headless browser: the same path a real user takes, no knowledge of * the PocketBase REST endpoint required. * * 2. Interactive: no env vars, we open a headed browser, you log in by hand, @@ -49,7 +49,7 @@ async function programmatic() { { timeout: 15_000 } ); - // Skip the react-joyride product tour — its spotlight overlay intercepts + // Skip the react-joyride product tour: its spotlight overlay intercepts // pointer events and breaks the recording. await page.evaluate(() => { localStorage.setItem('tutorial_completed', '1'); }); diff --git a/video/src/browser.ts b/video/src/browser.ts index 4049743..ff8a97f 100644 --- a/video/src/browser.ts +++ b/video/src/browser.ts @@ -151,7 +151,7 @@ async function suppressDevServerNoise(context: BrowserContext) { [class*="webpack-error"], /* Recording-only: hide dashboard chrome that reads as noise/loading on camera (the "Finding the Perfect Postcode" AI-status CTA). The - data attribute is inert in production — only this injected rule + data attribute is inert in production: only this injected rule targets it, and only during a recording. */ [data-video-hide] { display: none !important; diff --git a/video/src/config.ts b/video/src/config.ts index 40fa4b1..1ac4a2b 100644 --- a/video/src/config.ts +++ b/video/src/config.ts @@ -7,7 +7,7 @@ function requiredEnv(name: string): string { } // Environment-only knobs. Per-storyboard tuning (aspect, fps, bitrate, -// voice, prompts, brand…) lives on the Storyboard object itself — see +// voice, prompts, brand…) lives on the Storyboard object itself. See // src/storyboard.ts. export const APP_URL = requiredEnv('APP_URL'); @@ -20,5 +20,5 @@ export const OUTPUT_DIR = 'output'; // Frames of head-room kept in front of sceneStart when trimming. Shared by // the video trim and the narration manifest so cue offsets line up with the -// trimmed timeline. Not tuned per storyboard — same lead-in for any cut. +// trimmed timeline. Not tuned per storyboard: same lead-in for any cut. export const LEAD_IN_S = 0.12; diff --git a/video/src/dashboard.ts b/video/src/dashboard.ts index 1c75f4d..1fe059b 100644 --- a/video/src/dashboard.ts +++ b/video/src/dashboard.ts @@ -102,7 +102,7 @@ export class DashboardRecorder { /** * Best-effort wait for tracked API traffic to go quiet (250ms of no * pending requests and no loading indicator). Unlike waitForStable this - * never throws — it simply returns at the deadline. Used before computing + * never throws: it simply returns at the deadline. Used before computing * hexagon click targets so the projection runs against the response for * the CURRENT viewport rather than one captured mid-animation. */ @@ -133,7 +133,7 @@ export class DashboardRecorder { // Empty responses don't replace the snapshot (parse* skips them), so a // snapshot whose bounds differ from the latest REQUEST means the current // view has zero matching features and we'd be projecting stale data. - // Surface that loudly — it almost always means the storyboard's filters + // Surface that loudly: it almost always means the storyboard's filters // emptied the area it zoomed into. const snapshotBounds = this.lastPostcodes?.bounds ?? this.lastHexagons?.bounds; if (snapshotBounds && this.lastRequestedMapBounds) { @@ -141,7 +141,7 @@ export class DashboardRecorder { if (snapKey !== this.lastRequestedMapBounds) { console.log( `[dashboard] WARNING: map snapshot is stale (snapshot bounds ${snapKey} ` + - `vs latest request ${this.lastRequestedMapBounds}) — the current view ` + + `vs latest request ${this.lastRequestedMapBounds}). The current view ` + `likely has no matching features; clicks may land on empty map.` ); } @@ -221,7 +221,7 @@ export class DashboardRecorder { } /** - * The pixel rect inside `mapBox` that's safe to click — i.e. not under + * The pixel rect inside `mapBox` that's safe to click, i.e. not under * the dashboard's left filters pane, right details pane, or (on mobile) * the floating MobileBottomSheet. We detect the sheet via the only * `section.rounded-t-2xl` in the DOM and treat its top as a hard diff --git a/video/src/dom.ts b/video/src/dom.ts index 403cbbd..3a6338e 100644 --- a/video/src/dom.ts +++ b/video/src/dom.ts @@ -6,8 +6,8 @@ import type { AdScene, AdScenePanel } from './script.js'; * native cursor is hidden so what the viewer sees is entirely our element. * * Design choice: the cursor listens to mousemove rather than being driven from - * the Node side. That keeps a single source of truth — Playwright's real mouse - * — and the visual is pure CSS, animated by the browser's compositor. + * the Node side. That keeps a single source of truth (Playwright's real mouse), + * and the visual is pure CSS, animated by the browser's compositor. */ export async function installCursor( page: Page, @@ -235,7 +235,7 @@ export async function installCursor( letter-spacing: -0.01em; color: #5eead4; } - /* Tighter outro for vertical 9:16 — the brand/url stack must fit + /* Tighter outro for vertical 9:16. The brand/url stack must fit comfortably inside the platform-safe centre column. */ body.__demo-aspect-vertical #__demo-outro-brand { font-size: 64px; } body.__demo-aspect-vertical #__demo-outro-tagline { font-size: 26px; max-width: 22ch; } @@ -270,7 +270,7 @@ export async function installCursor( -webkit-backdrop-filter: blur(3px) saturate(0.92); } /* - * Transparent mode: no scrim, no blur — the product stays fully + * Transparent mode: no scrim, no blur, so the product stays fully * visible behind a floating kicker + title. Used by mid-cue hook * stings ("Postcode polygraph") that should not occlude the demo. */ @@ -475,7 +475,7 @@ export async function installCursor( /* * Mobile (9x16) renders the dashboard at CSS 540x960 with captureScale 2. * That means the AdScene CSS is sizing against a 540px-wide viewport, so - * a 64px title is ~12% of viewport width per line — still big and bold, + * a 64px title is ~12% of viewport width per line, still big and bold, * but no longer overflows the available space the way 82px did on the * old 1080-wide ad config. Captions also re-anchor to the upper third * via body.__demo-aspect-vertical. @@ -601,7 +601,7 @@ export async function clearVignette(page: Page): Promise { * Tag the document body with the aspect class the caption / overlay CSS keys * off. Run once during recorder setup so every cue inherits the right * positioning without per-call overrides. The body class is the cheapest - * stable signal — the storyboard's `video.aspect` knows the truth and we + * stable signal. The storyboard's `video.aspect` knows the truth and we * surface it once into the DOM. */ export async function setAspectClass( @@ -741,7 +741,7 @@ export async function showAdScene(page: Page, scene: AdScene): Promise { img.className = '__ad-image'; // Skip crossOrigin so the request goes through as a vanilla // image fetch and CORS headers on the Unsplash CDN are not - // required. We never read pixels back out — display only. + // required. We never read pixels back out: display only. img.referrerPolicy = 'no-referrer'; img.onerror = () => img.remove(); img.src = src; @@ -872,7 +872,7 @@ export async function showOutro( * without dragging the cursor/caption/outro overlays along with it. * * Why a wrapper and not : a transformed ancestor establishes a new - * containing block for `position: fixed` descendants — meaning fixed + * containing block for `position: fixed` descendants, meaning fixed * overlays inside the transform get scaled too. By wrapping ONLY #root * and leaving the overlays as siblings of the wrapper, the cursor stays * at native size while the dashboard zooms behind it. diff --git a/video/src/preflight.ts b/video/src/preflight.ts index 7c9e1d2..507eff6 100644 --- a/video/src/preflight.ts +++ b/video/src/preflight.ts @@ -8,7 +8,7 @@ import { storyboards } from './storyboard.js'; * Emit per-storyboard narration scripts for the synth step. * * Synth (tts/synth.py) runs BEFORE recording, so it needs the full ordered - * narration list — text + per-cue gaps + voice config — without depending + * narration list (text + per-cue gaps + voice config) without depending * on Playwright, the dashboard, or auth. Walk each storyboard's cues, write * a flat manifest under `output//narration-script.json`, then write * an index manifest at `output/storyboards.json` so render.sh knows which @@ -18,11 +18,11 @@ import { storyboards } from './storyboard.js'; * matches storyboard cues to measured durations by index. */ // Em/en-dashes and ellipses make Qwen3-TTS produce dramatic pauses, sighs, -// or audible breaths — the captions still render the original (unicode-rich) +// or audible breaths. The captions still render the original (unicode-rich) // text from the storyboard; only the synth input is sanitised. function normalizeForTts(text: string): string { return text - .replace(/\s*[—–]\s*/g, ', ') + .replace(/\s*[\u2014\u2013]\s*/g, ', ') .replace(/…/g, '.') .replace(/\.{3,}/g, '.') .replace(/\s{2,}/g, ' ') @@ -39,7 +39,7 @@ function emitScript(storyboard: Storyboard): string { gapBeforeMs: cue.gapBeforeMs, })); - // The voice block is consumed by tts/synth.py — see _resolve_reference and + // The voice block is consumed by tts/synth.py. See _resolve_reference and // the cache check there for which fields invalidate cached audio. const manifest = { storyboard: storyboard.name, @@ -61,7 +61,7 @@ function emitScript(storyboard: Storyboard): string { /** * Validate every stubbed/initial filter name and travel-destination slug - * against the LIVE API. Wrong names don't error in the app — they silently + * against the LIVE API. Wrong names don't error in the app: they silently * no-op, the map never changes, and you only find out after a full render. * Fails hard on a mismatch; soft-warns if the API is unreachable (render.sh * has already health-checked it by the time preflight runs). @@ -69,7 +69,7 @@ function emitScript(storyboard: Storyboard): string { async function validateAgainstLiveApi(): Promise { const apiBase = process.env.API_URL ?? process.env.APP_URL; if (!apiBase) { - console.warn('[preflight] no API_URL/APP_URL set — skipping live filter validation'); + console.warn('[preflight] no API_URL/APP_URL set, skipping live filter validation'); return; } @@ -82,7 +82,7 @@ async function validateAgainstLiveApi(): Promise { }; featureNames = new Set(body.groups.flatMap((g) => g.features.map((f) => f.name))); } catch (err) { - console.warn(`[preflight] could not fetch ${apiBase}/api/features (${err}) — skipping validation`); + console.warn(`[preflight] could not fetch ${apiBase}/api/features (${err}), skipping validation`); return; } @@ -134,7 +134,7 @@ async function main(): Promise { for (const sb of storyboards) emitScript(sb); - // Index for shell loops — each entry has every field render.sh needs to + // Index for shell loops: each entry has every field render.sh needs to // address per-storyboard outputs without re-parsing the TS source. const index = { storyboards: storyboards.map((sb) => ({ diff --git a/video/src/probe.ts b/video/src/probe.ts index 5666172..88d65f1 100644 --- a/video/src/probe.ts +++ b/video/src/probe.ts @@ -4,7 +4,7 @@ import { viewportFor } from './script.js'; import { storyboards } from './storyboard.js'; async function main() { - // probe is a debug utility — pin it to the first storyboard's viewport. + // probe is a debug utility: pin it to the first storyboard's viewport. const viewport = viewportFor(storyboards[0].video); const browser = await chromium.launch({ headless: true }); const context = await browser.newContext({ diff --git a/video/src/runner.ts b/video/src/runner.ts index cd73221..298f330 100644 --- a/video/src/runner.ts +++ b/video/src/runner.ts @@ -115,7 +115,7 @@ async function runCue( `Trim a during step, lengthen the cue text, or move work into tail.` ); } - // Time the during block as a whole — individual steps may overrun their + // Time the during block as a whole: individual steps may overrun their // budgets, but what matters at the cue boundary is total wall-clock. const duringStart = Date.now(); for (const step of during) { @@ -393,7 +393,7 @@ async function runActivity(ctx: ScriptCtx, step: Activity): Promise { case 'dragSheet': { const sheet = ctx.page.locator('section[class*="rounded-t-2xl"]').first(); const sheetBox = await sheet.boundingBox().catch(() => null); - if (!sheetBox) return; // desktop layout — nothing to drag + if (!sheetBox) return; // desktop layout: nothing to drag const handle = ctx.page .locator('section[class*="rounded-t-2xl"] [class*="touch-none"]') .first(); @@ -427,7 +427,7 @@ async function runActivity(ctx: ScriptCtx, step: Activity): Promise { } case 'openFilterGroup': // Click is idempotent: if the group is already expanded, the click - // would collapse it — which we don't want. Detect via aria-expanded + // would collapse it, which we don't want. Detect via aria-expanded // (Radix Accordion sets it on the trigger) and skip the click when // the group is already open. await ctx.page.evaluate((selector) => { @@ -493,7 +493,7 @@ async function tryResolveTarget( /** * Load synth's measured cue durations. Falls back to a worst-case estimate - * if the manifest is missing — that path is only used for ``--no-audio`` + * if the manifest is missing: that path is only used for ``--no-audio`` * runs, where the visual flow needs to play even without speech to time * against. */ @@ -516,7 +516,7 @@ function loadSynthIndex(storyboard: Storyboard): SynthCue[] { }); } console.log( - `[runner] no ${path} found — using worst-case fallback durations (${FALLBACK_MS_PER_WORD}ms/word + ${FALLBACK_TAIL_BUFFER_MS}ms buffer). Audio will be missing.` + `[runner] no ${path} found. Using worst-case fallback durations (${FALLBACK_MS_PER_WORD}ms/word + ${FALLBACK_TAIL_BUFFER_MS}ms buffer). Audio will be missing.` ); return storyboard.cues.map((cue, cueIndex) => ({ cueIndex, diff --git a/video/src/script.ts b/video/src/script.ts index 93fe710..fd4b81a 100644 --- a/video/src/script.ts +++ b/video/src/script.ts @@ -4,14 +4,14 @@ import type { DashboardRecorder } from './dashboard.js'; /** * Public scripting API for the demo video. * - * The storyboard is a `Storyboard` — an ordered list of narration cues, each + * The storyboard is a `Storyboard`: an ordered list of narration cues, each * carrying the activities that play alongside it. Audio is generated FIRST * (one batched Qwen call so the voice stays consistent across cues); the * runner then reads the measured per-cue durations and slots `during` * activities inside each cue's audio window. * * Why cue-anchored: the audio drives pacing. Re-running synth produces a new - * set of measured durations and the storyboard self-aligns — you don't have + * set of measured durations and the storyboard self-aligns: you don't have * to retune activity numbers. Author intent stays declarative ("zoom + type * happen during this cue, dwell 4s after, then next cue starts"). */ @@ -62,7 +62,7 @@ export interface AdScene { items?: AdSceneItem[]; /** Optional single hero photo (URL) shown above the title. */ image?: string; - /** Optional [left, right] photos for split mode — used by "two streets apart" style ads. */ + /** Optional [left, right] photos for split mode, used by "two streets apart" style ads. */ images?: [string, string]; /** Optional caption shown under the image for attribution / context. */ imageCaption?: string; @@ -82,7 +82,7 @@ export type Target = /** * Resolved at runtime to the centre of a visible hexagon/postcode polygon, * picked from the dashboard's most recent map response. Robust to any zoom - * level — use this when the click MUST land on a polygon and a fixed pixel + * level. Use this when the click MUST land on a polygon and a fixed pixel * coordinate would risk landing on a road or river at deep zoom. */ | { kind: 'hexagon' } @@ -195,7 +195,7 @@ export type Activity = | { kind: 'dragSheet'; toHeightFrac: number; durationMs: number } /** * Click the header of a collapsible filter group (e.g. "Transport", - * "Schools") so the cards beneath it become visible. Idempotent — + * "Schools") so the cards beneath it become visible. Idempotent: * if the group is already open this is a no-op click. */ | { kind: 'openFilterGroup'; selector: string; durationMs: number } @@ -206,7 +206,7 @@ export type Activity = * A narration cue + the activities that play alongside it. * * text : the SPOKEN narration line (TTS input). Never rendered on - * screen — what's said must not also be shown. + * screen. What's said must not also be shown. * caption : optional SHORT on-screen chip (≤6 words) complementing the * narration. Distinct from `text` by design: visual hooks * ("You can't hear a photo") and stats live here, the story @@ -240,7 +240,7 @@ export interface VideoConfig { captureScale: number; /** * Optional CSS viewport override (in pixels). Lets a single storyboard - * record at a narrower CSS viewport than the aspect's default — e.g. + * record at a narrower CSS viewport than the aspect's default, e.g. * the recording-*-mobile cuts use 540x960 so Tailwind's `md:` * breakpoint (≥768px) doesn't match and every component picks its * mobile typography. Pair with `captureScale: 2` to keep text sharp. @@ -249,7 +249,7 @@ export interface VideoConfig { viewport?: { width: number; height: number }; /** * Visual style of the injected cursor. 'arrow' (default) renders the - * classic pointer — right for desktop demos. 'touch' renders a soft + * classic pointer, right for desktop demos. 'touch' renders a soft * fingertip dot, which reads as a phone gesture on 9:16 mobile cuts * (an arrow cursor on a phone-shaped video instantly breaks the * illusion that you're watching the mobile product). @@ -340,7 +340,7 @@ export interface TravelTimeFilter { * `post` runs once after the last cue's tail finishes. The cue list is what * gets handed to the synth step. * - * `name` doubles as the on-disk slug — outputs go to `output//` and + * `name` doubles as the on-disk slug: outputs go to `output//` and * publish as `.mp4` + `.jpg`. Keep names URL/path-safe. */ export interface Storyboard { @@ -358,7 +358,7 @@ export interface Storyboard { /** * Frontend viewport in CSS pixels. Defaults to the aspect's native size * (1920x1080 for 16x9, 1080x1920 for 9x16). A storyboard can opt into a - * narrower CSS viewport via `video.viewport` — e.g. recording-*-mobile + * narrower CSS viewport via `video.viewport`, e.g. recording-*-mobile * uses 540x960 so the frontend's Tailwind `md:` breakpoint doesn't match * and every component picks mobile typography/spacing. Pair the override * with `captureScale: 2` to keep text sharp at the smaller resolution. @@ -375,7 +375,7 @@ export function viewportFor(video: VideoConfig): { width: number; height: number * * Playwright's recordVideo captures the page at its CSS-pixel surface, so * passing a size larger than the viewport just letterboxes the content - * into the top-left of an empty frame — not a true high-DPR raster. + * into the top-left of an empty frame, not a true high-DPR raster. * Final-resolution upscale (e.g. mobile 540x960 → 1080x1920) is done in * render.sh's ffmpeg pass with `scale=...:flags=lanczos`, which gives a * sharp upscale because Chromium rasterises internally at DPR=captureScale. @@ -387,7 +387,7 @@ export function recordedSizeFor(video: VideoConfig): { width: number; height: nu /** * The final mp4's resolution (after the lanczos upscale pass in render.sh). * Storyboards drive their on-screen typography from CSS viewport sizes, but - * social platforms care about the file resolution — so we expose a + * social platforms care about the file resolution, so we expose a * separate getter for the published dimensions. */ export function publishedSizeFor(video: VideoConfig): { width: number; height: number } { diff --git a/video/src/storyboard.ts b/video/src/storyboard.ts index 9486f5b..e95c5fb 100644 --- a/video/src/storyboard.ts +++ b/video/src/storyboard.ts @@ -29,7 +29,7 @@ type FormFactor = 'desktop' | 'mobile'; * whenever the map is the story. * 4. Filter names MUST match live /api/features exactly (e.g. * "Serious crime (/yr, 7y)", "Distance to nearest amenity (Waitrose) - * (km)") — wrong names silently no-op and the map never changes. + * (km)"). Wrong names silently no-op and the map never changes. * preflight.ts validates every stubbed name against the live API. * * `name` doubles as the on-disk slug. The pipeline writes per-storyboard @@ -47,14 +47,14 @@ type FormFactor = 'desktop' | 'mobile'; // to modelled catchment counts ("Good+ primary school catchments"), which the // local stack already serves; prod still serves the older "…within 2km" names // until that deploy lands. Preflight fails loudly if these drift from -// whichever API render.sh is pointed at — flip them back if you render against +// whichever API render.sh is pointed at. Flip them back if you render against // prod before the catchment model deploys there. const SCHOOL_GOOD_PRIMARY = 'Good+ primary school catchments'; const SCHOOL_OUTSTANDING_PRIMARY = 'Outstanding primary school catchments'; // Cold-open lean-in on the AI card. Desktop only; kept moderate so the // map remains visible on the right (zoomTo clamps the pan so the app -// always covers the full frame — no backdrop voids). +// always covers the full frame: no backdrop voids). const AI_ZOOM_SCALE_DESKTOP = 1.45; const TT_CARD_SELECTOR = '[data-filter-name="tt_0"]'; @@ -62,7 +62,7 @@ const TT_SLIDER_MAX = 120; const TT_DRAG_FROM_MIN = 35; // 25 (not 20): tight enough that the drag visibly prunes the map, loose // enough that street-level central London keeps plenty of matching -// postcodes — at 20 the brief emptied the centre and the postcode tap had +// postcodes. At 20 the brief emptied the centre and the postcode tap had // nothing fresh to land on (the drawer then opened in its "filtered stats // are empty" fallback). const TT_DRAG_TO_MIN = 25; @@ -356,8 +356,8 @@ function createCues(locale: RecordingLocale, formFactor: FormFactor): Storyboard { kind: 'cursorScale', scale: isMobile ? 1 : 1.4, durationMs: 200 }, { // Zoom AROUND the strongest visible match (hex()) so the - // destination area is guaranteed to contain matching postcodes — - // a fixed pixel target can dive into a part of town the filters + // destination area is guaranteed to contain matching postcodes. + // A fixed pixel target can dive into a part of town the filters // just emptied. Settled so the next cue's hex() click projects // against the postcode response for the FINAL viewport. kind: 'mapZoom', @@ -424,7 +424,7 @@ function createCues(locale: RecordingLocale, formFactor: FormFactor): Storyboard // The drawer's close-button aria-label is localized // (mobileDrawer.closeDrawer), so use the per-locale string. // clickIfVisible keeps a label mismatch from crashing the - // take — worst case the drawer lingers behind the zoom-out. + // take. Worst case, the drawer lingers behind the zoom-out. kind: 'clickIfVisible', target: el(`button[aria-label="${copy.closeDrawerLabel}"]`), durationMs: 650, @@ -442,7 +442,7 @@ function createCues(locale: RecordingLocale, formFactor: FormFactor): Storyboard { kind: 'zoomReset', durationMs: 800 }, { // IfVisible: the export button only renders on ≥1024px-wide - // dashboards with a licensed user — a missing button must not + // dashboards with a licensed user. A missing button must not // crash the take, just skip the ripple. kind: 'clickIfVisible', target: el(`button[title="${copy.exportButtonTitle}"]`), @@ -510,13 +510,13 @@ function buildVideoConfig(formFactor: FormFactor): VideoConfig { outputFps: 50, minDurationS: 10, maxDurationS: 75, - // Street-level zoom with the sheet collapsed — the map is the frame. + // Street-level zoom with the sheet collapsed: the map is the frame. posterTimeS: 25, }; } return { aspect: '16x9', - // Native 1920x1080. NOTE: don't be tempted by a narrower CSS viewport — + // Native 1920x1080. NOTE: don't be tempted by a narrower CSS viewport: // the dashboard header switches to tablet sidebar nav between 768 and // 1023px and the Export button (cue 7) disappears. captureScale: 1, @@ -528,7 +528,7 @@ function buildVideoConfig(formFactor: FormFactor): VideoConfig { // Right-pane inspection: London map, filters applied, data pane // FULLY populated (Street View + price history). 31s caught the pane // mid-load on prod (empty white + spinner); 35s lands a few seconds into - // the "open" cue once the evidence has rendered — the strongest preview. + // the "open" cue once the evidence has rendered: the strongest preview. posterTimeS: 35, }; } @@ -545,7 +545,7 @@ function createRecordingStoryboard( // Both form factors open at zoom 12 so the cold-open frame lands on a // densely-populated inner-London slice rather than the low-density outer // edges. Desktop was 11.5, but the opening lean-in panned the visible map - // toward the sparse NW suburbs (Wembley/Ealing) — a soft first frame for + // toward the sparse NW suburbs (Wembley/Ealing), a soft first frame for // the hero. 12 keeps central London (rich default-density colouring) in // shot from t=0. const initialZoom = 12; @@ -586,10 +586,10 @@ function createRecordingStoryboard( // £600k (not £315k like the old Manchester cut): central London is // pricier, and at £315k the price+crime combo emptied the inner // boroughs. At £600k ~51k postcodes pass in-frame, dropping to ~10k - // once the commute is dragged to 25 min — a visible prune that still + // once the commute is dragged to 25 min, a visible prune that still // leaves the zoom something to land on (verified via /api/filter-counts). 'Estimated current price': [0, 600000], - // Loose enough to keep the central-London map richly populated — a + // Loose enough to keep the central-London map richly populated. A // cap of 20 emptied the city centre and left the zoom with nothing // to land on. 'Serious crime (/yr, 7y)': [0, 40], @@ -646,7 +646,7 @@ const DEMO_STORYBOARDS: Storyboard[] = RECORDING_LOCALES.flatMap((locale) => // the story, and a fingertip-style cursor sells the gestures as touch. // --------------------------------------------------------------------------- -type CityKey = 'manchester' | 'birmingham' | 'bristol' | 'london' | 'leeds'; +type CityKey = 'manchester' | 'birmingham' | 'bristol' | 'london' | 'leeds' | 'liverpool'; interface DemoAdCueConfig { text: string; @@ -675,9 +675,11 @@ interface DemoAdStoryboardConfig { travelTimeFilters?: TravelTimeFilter[]; posterTimeS?: number; initialZoom?: number; + /** Override the city centre (e.g. to frame a specific cheaper-twin pair). */ + center?: { lat: number; lon: number }; /** Plausible per-city "{{count}} properties match" total for the AI summary. */ matchCount?: number; - /** Activities to run before the cue loop starts (silent — keep short). */ + /** Activities to run before the cue loop starts (silent, keep short). */ prePrime?: Activity[]; /** Spoken line during the outro card. Must NOT repeat the card's text. */ outroLine: string; @@ -692,7 +694,7 @@ const AD_VIDEO: VideoConfig = { webmBitrate: '4M', outputFps: 50, minDurationS: 8, - // Generous upper bound — ads with a deep mapZoom drift a second or two + // Generous upper bound: ads with a deep mapZoom drift a second or two // past their declared budgets while tiles load. maxDurationS: 35, posterTimeS: 5, @@ -734,6 +736,7 @@ const CITY_VIEWS: Record = bristol: { lat: 51.3245, lon: -2.5879, zoom: 11.3 }, london: { lat: 51.4272, lon: -0.1276, zoom: 10.4 }, leeds: { lat: 53.7308, lon: -1.5491, zoom: 11.0 }, + liverpool: { lat: 53.4084, lon: -2.9916, zoom: 11.2 }, }; // -- small helpers used by the per-ad cue lists ------------------------------- @@ -782,7 +785,7 @@ const mapZoomIn = (durationMs = 1500, steps = 5): Activity => ({ }); /** * Tap the centre of the highest-priority visible postcode polygon from the - * latest map response (robust to zoom drift — a fixed pixel target at deep + * latest map response (robust to zoom drift: a fixed pixel target at deep * zoom can land on a road or river and the drawer never opens). */ const tapHex = (durationMs = 1000): Activity => ({ @@ -814,9 +817,12 @@ function createDemoAdStoryboard(ad: DemoAdStoryboardConfig): Storyboard { ad.promptText ?? 'Flat under £350k, good commute, good schools, lower crime, quieter streets'; + const cityView = CITY_VIEWS[ad.city]; const initialMapView = { - ...CITY_VIEWS[ad.city], - zoom: ad.initialZoom ?? CITY_VIEWS[ad.city].zoom, + ...cityView, + lat: ad.center?.lat ?? cityView.lat, + lon: ad.center?.lon ?? cityView.lon, + zoom: ad.initialZoom ?? cityView.zoom, }; return { @@ -877,7 +883,7 @@ function createDemoAdStoryboard(ad: DemoAdStoryboardConfig): Storyboard { const AD_CONFIGS: DemoAdStoryboardConfig[] = [ // ------------------------------------------------------------------- - // 01 — The hero feature: the whole brief in one sentence. + // 01. The hero feature: the whole brief in one sentence. // Cold open ON the typing; map reveal as the payoff. // ------------------------------------------------------------------- { @@ -922,7 +928,7 @@ const AD_CONFIGS: DemoAdStoryboardConfig[] = [ }, // ------------------------------------------------------------------- - // 02 — The commute slider. Cold open on a travel-time-coloured map + // 02. The commute slider. Cold open on a travel-time-coloured map // (filter + colour applied in pre), then the 60→20 drag is the story. // ------------------------------------------------------------------- { @@ -973,8 +979,8 @@ const AD_CONFIGS: DemoAdStoryboardConfig[] = [ }, // ------------------------------------------------------------------- - // 03 — Tap a postcode, read its file. The drawer (sold prices, Street - // View, schools, crime) is the wow — most viewers don't know this exists. + // 03. Tap a postcode, read its file. The drawer (sold prices, Street + // View, schools, crime) is the wow. Most viewers don't know this exists. // ------------------------------------------------------------------- { name: 'ad-03-postcode-files', @@ -1029,7 +1035,7 @@ const AD_CONFIGS: DemoAdStoryboardConfig[] = [ }, // ------------------------------------------------------------------- - // 04 — Noise. The strongest single line in the set; the product proves it. + // 04. Noise. The strongest single line in the set; the product proves it. // ------------------------------------------------------------------- { name: 'ad-04-quiet-streets', @@ -1066,7 +1072,7 @@ const AD_CONFIGS: DemoAdStoryboardConfig[] = [ }, // ------------------------------------------------------------------- - // 05 — Families / the school run. Leeds for non-London variety. + // 05. Families / the school run. Leeds for non-London variety. // ------------------------------------------------------------------- { name: 'ad-05-school-run', @@ -1106,7 +1112,7 @@ const AD_CONFIGS: DemoAdStoryboardConfig[] = [ }, // ------------------------------------------------------------------- - // 06 — Lifestyle amenities. The Waitrose line is the hook; tube + park + // 06. Lifestyle amenities. The Waitrose line is the hook; tube + park // make it practical. Names match the live amenity-distance features. // ------------------------------------------------------------------- { @@ -1145,7 +1151,7 @@ const AD_CONFIGS: DemoAdStoryboardConfig[] = [ }, // ------------------------------------------------------------------- - // 07 — Renters. Every tool is for buyers; rent is a live feature here. + // 07. Renters. Every tool is for buyers; rent is a live feature here. // ------------------------------------------------------------------- { name: 'ad-07-renters-map', @@ -1185,7 +1191,7 @@ const AD_CONFIGS: DemoAdStoryboardConfig[] = [ }, // ------------------------------------------------------------------- - // 08 — Value. The cost of overpaying for a name vs the one-off fee. + // 08. Value. The cost of overpaying for a name vs the one-off fee. // ------------------------------------------------------------------- { name: 'ad-08-value', @@ -1228,6 +1234,187 @@ const AD_CONFIGS: DemoAdStoryboardConfig[] = [ const AD_STORYBOARDS = AD_CONFIGS.map(createDemoAdStoryboard); +// --------------------------------------------------------------------------- +// Cheaper-twin spots: pair-centred value reveals generated from the +// analysis/ twin index. Filter names verified against live /api/features. +// Render with: VIDEO_STORYBOARD_SET=twins ./render.sh --prod +// --------------------------------------------------------------------------- +const TWIN_AD_CONFIGS: DemoAdStoryboardConfig[] = [ + { + name: 'twin-beckenham-croydon', + matchCount: 1840, + city: 'london', + center: { lat: 51.38969, lon: -0.04244 }, + initialZoom: 12.2, + promptText: 'Good schools, best value per square metre near Beckenham and Croydon', + filters: { + 'Est. price per sqm': [0, 5200], + 'Good+ secondary school catchments': [1, 11], + }, + posterTimeS: 7, + outroLine: "Beckenham's cheaper twin is on this map. Find yours, free.", + cues: [ + { + text: 'Beckenham and Croydon sit side by side: same trains, same school catchment.', + caption: 'Same station. Same schools.', + during: [ + typeAct('Good schools, best value per square metre near Beckenham and Croydon', 2600), + ], + tail: [wait(150)], + }, + { + text: 'Rank them by what each pound of floor space actually buys.', + caption: 'Ranked by £ per m²', + during: [submitSettled(1400)], + tail: [sheetDown(800), wait(250)], + }, + { + text: 'One postcode over, the same home quietly costs about a third less.', + caption: 'The cheaper twin', + during: [mapZoomIn(1400, 3)], + tail: [wait(400)], + }, + ], + }, + { + name: 'twin-ha7-2-vs-ha3-0', + matchCount: 1620, + city: 'london', + center: { lat: 51.59199, lon: -0.3079 }, + initialZoom: 12.0, + promptText: 'Good schools, best value per square metre near Stanmore and Kenton', + filters: { + 'Est. price per sqm': [0, 5900], + 'Good+ secondary school catchments': [1, 11], + }, + posterTimeS: 7, + outroLine: "Stanmore's cheaper twin is on this map. Find yours, free.", + cues: [ + { + text: 'Stanmore and Kenton sit right next door, with the same schools and transport links.', + caption: 'Same area. Same schools.', + during: [typeAct('Good schools, best value per square metre near Stanmore and Kenton', 2600)], + tail: [wait(150)], + }, + { + text: 'Rank every postcode by what each pound of floor space actually buys.', + caption: 'Ranked by £ per m²', + during: [submitSettled(1400)], + tail: [sheetDown(800), wait(250)], + }, + { + text: 'One postcode over, the same home quietly costs about a sixth less.', + caption: 'The cheaper twin', + during: [mapZoomIn(1400, 3)], + tail: [wait(400)], + }, + ], + }, + { + name: 'twin-m40-5-vs-m9-4', + matchCount: 2480, + city: 'manchester', + center: { lat: 53.51293, lon: -2.19574 }, + initialZoom: 12.0, + promptText: 'Good schools, best value per square metre near Newton Heath and Harpurhey', + filters: { + 'Est. price per sqm': [0, 1700], + 'Good+ secondary school catchments': [1, 11], + }, + posterTimeS: 7, + outroLine: "Newton Heath's cheaper twin is on this map. Find yours, free.", + cues: [ + { + text: 'Newton Heath and Harpurhey sit right next door, with the same schools and transport links.', + caption: 'Same area. Same schools.', + during: [typeAct('Good schools, best value per square metre near Newton Heath and Harpurhey', 2600)], + tail: [wait(150)], + }, + { + text: 'Rank every postcode by what each pound of floor space actually buys.', + caption: 'Ranked by £ per m²', + during: [submitSettled(1400)], + tail: [sheetDown(800), wait(250)], + }, + { + text: 'One postcode over, the same home quietly costs over a third less.', + caption: 'The cheaper twin', + during: [mapZoomIn(1400, 3)], + tail: [wait(400)], + }, + ], + }, + { + name: 'twin-l16-7-vs-l14-6', + matchCount: 1740, + city: 'liverpool', + center: { lat: 53.40344, lon: -2.88529 }, + initialZoom: 12.0, + promptText: 'Good schools, best value per square metre near Childwall and Broadgreen', + filters: { + 'Est. price per sqm': [0, 3000], + 'Good+ secondary school catchments': [1, 11], + }, + posterTimeS: 7, + outroLine: "Childwall's cheaper twin is on this map. Find yours, free.", + cues: [ + { + text: 'Childwall and Broadgreen sit right next door, with the same schools and transport links.', + caption: 'Same area. Same schools.', + during: [typeAct('Good schools, best value per square metre near Childwall and Broadgreen', 2600)], + tail: [wait(150)], + }, + { + text: 'Rank every postcode by what each pound of floor space actually buys.', + caption: 'Ranked by £ per m²', + during: [submitSettled(1400)], + tail: [sheetDown(800), wait(250)], + }, + { + text: 'One postcode over, the same home quietly costs about a third less.', + caption: 'The cheaper twin', + during: [mapZoomIn(1400, 3)], + tail: [wait(400)], + }, + ], + }, + { + name: 'twin-rm14-2-vs-rm12-5', + matchCount: 1980, + city: 'london', + center: { lat: 51.54892, lon: 0.22193 }, + initialZoom: 12.0, + promptText: 'Good schools, best value per square metre near Upminster and Hornchurch', + filters: { + 'Est. price per sqm': [0, 5300], + 'Good+ secondary school catchments': [1, 11], + }, + posterTimeS: 7, + outroLine: "Upminster's cheaper twin is on this map. Find yours, free.", + cues: [ + { + text: 'Upminster and Hornchurch sit right next door, with the same schools and transport links.', + caption: 'Same area. Same schools.', + during: [typeAct('Good schools, best value per square metre near Upminster and Hornchurch', 2600)], + tail: [wait(150)], + }, + { + text: 'Rank every postcode by what each pound of floor space actually buys.', + caption: 'Ranked by £ per m²', + during: [submitSettled(1400)], + tail: [sheetDown(800), wait(250)], + }, + { + text: 'One postcode over, the same home quietly costs about a fifth less.', + caption: 'The cheaper twin', + during: [mapZoomIn(1400, 3)], + tail: [wait(400)], + }, + ], + }, +]; +const TWIN_AD_STORYBOARDS = TWIN_AD_CONFIGS.map(createDemoAdStoryboard); + const STORYBOARD_SET = process.env.VIDEO_STORYBOARD_SET ?? 'homepage-en'; export const storyboards: Storyboard[] = (() => { @@ -1238,6 +1425,8 @@ export const storyboards: Storyboard[] = (() => { return DEMO_STORYBOARDS; case 'all': return [...AD_STORYBOARDS, ...DEMO_STORYBOARDS]; + case 'twins': + return TWIN_AD_STORYBOARDS; case 'ads': default: return AD_STORYBOARDS; diff --git a/video/tts/mux.py b/video/tts/mux.py index c7aeb95..bfd9f54 100644 --- a/video/tts/mux.py +++ b/video/tts/mux.py @@ -2,9 +2,9 @@ Reads two manifests inside ``output//``: -* ``audio/index.json`` (synth output) — per-cue WAV filename + measured +* ``audio/index.json`` (synth output): per-cue WAV filename + measured duration. Generated BEFORE recording in one batched Qwen3-TTS call. -* ``narration.json`` (recorder output) — per-cue ``videoTimeMs`` against +* ``narration.json`` (recorder output): per-cue ``videoTimeMs`` against the trimmed video. Generated DURING recording. Joins them by ``cueIndex`` (index in the cue list, 1:1 between manifests), @@ -115,7 +115,7 @@ def main() -> int: } ) - # Refuse to mux overlapping cues — amix would silently mash voices on top + # Refuse to mux overlapping cues: amix would silently mash voices on top # of each other. Sort by start so the order matches what we'll actually # play, then check that each cue ends before the next one starts. ordered = sorted(items, key=lambda it: it["videoTimeMs"]) diff --git a/video/tts/pyproject.toml b/video/tts/pyproject.toml index 639a98f..7abfc78 100644 --- a/video/tts/pyproject.toml +++ b/video/tts/pyproject.toml @@ -7,8 +7,8 @@ dependencies = [ "qwen-tts>=0.1.1", # Host driver is CUDA 12.4 (see `nvidia-smi`). torch 2.7+ dropped cu124 # wheels, so we cap below that and pull the cu124 build from PyTorch's - # own index (configured below). torchaudio must match torch's CUDA build - # — the PyPI default ships a CUDA 13 binary that fails to load + # own index (configured below). torchaudio must match torch's CUDA build: + # the PyPI default ships a CUDA 13 binary that fails to load # libcudart.so.13 on this host. "torch>=2.5,<2.7", "torchaudio>=2.5,<2.7", @@ -20,7 +20,7 @@ dependencies = [ # Flash-attention prebuilt wheel matched to torch 2.6 + cu12 + cp312, old # CXX ABI (PyTorch's cu124 wheel reports compiled_with_cxx11_abi() == False # and only exports the old-ABI c10::Error constructor). Pinned to -# 2.7.4.post1 because 2.8.x's torch2.6/abiFALSE wheels were mislabelled — +# 2.7.4.post1 because 2.8.x's torch2.6/abiFALSE wheels were mislabelled: # they ship new-ABI symbols and fail to import. Building from source needs # nvcc which isn't on the host. Enable via `uv sync --extra gpu`; render.sh # does this automatically when nvidia-smi reports a GPU. diff --git a/video/tts/synth.py b/video/tts/synth.py index 5b11f61..2bd17fd 100644 --- a/video/tts/synth.py +++ b/video/tts/synth.py @@ -2,7 +2,7 @@ Reads ``output//narration-script.json`` (emitted by ``dist/preflight.js``) and runs ``Qwen3TTSModel.generate_voice_design`` with -all cue texts as a single batched list — that way every cue shares the same +all cue texts as a single batched list: that way every cue shares the same model state, which keeps prosody and timbre consistent across cues. Per-cue WAVs and an index manifest go to ``output//audio/`` for the recording step (which reads measured cue durations) and the mux step (which @@ -16,7 +16,7 @@ production runs. We use the VoiceDesign sibling of CustomVoice because it accepts a free-form voice persona (British accent, narrator register, "no laughter") via the ``instruct`` parameter. CustomVoice's preset speakers are all American or -non-English, and its ``instruct`` is documented for emotion only — it +non-English, and its ``instruct`` is documented for emotion only. It ignored accent directives and bled non-speech tokens (laughter, sighs) between cues. @@ -43,7 +43,7 @@ from qwen_tts import Qwen3TTSModel # Two checkpoints: the design model mints the reference clip in the desired # persona; the clone model conditions every cue on that reference's x-vector. -# Neither CustomVoice nor VoiceDesign support generate_voice_clone — only the +# Neither CustomVoice nor VoiceDesign support generate_voice_clone. Only the # Base checkpoint does. DEFAULT_DESIGN_MODEL = "Qwen/Qwen3-TTS-12Hz-1.7B-VoiceDesign" DEFAULT_CLONE_MODEL = "Qwen/Qwen3-TTS-12Hz-1.7B-Base" @@ -69,8 +69,8 @@ def _file_sha256(path: Path) -> str: """Content hash of a file, used to pin cached cues to a reference WAV. The cue cache keys off the *settings* that produced the reference - (instruct/seed/…), but a re-mint of VoiceDesign — or render.sh copying a - different storyboard's reference into this audio dir — can swap the actual + (instruct/seed/…), but a re-mint of VoiceDesign (or render.sh copying a + different storyboard's reference into this audio dir) can swap the actual reference waveform out from under those settings. Cloning some cues from reference A and others from reference B yields two audibly different speakers in one video. Hashing the bytes of the reference that was @@ -328,7 +328,7 @@ def _resolve_reference( sf.write(str(ref_wav_path), ref_audio, ref_sr) ref_meta_path.write_text(json.dumps(ref_meta, indent=2)) - # Free the design model before loading the clone model — both are 1.7B, + # Free the design model before loading the clone model: both are 1.7B, # we don't want them resident at the same time. del design_model if torch.cuda.is_available(): @@ -357,7 +357,7 @@ def main() -> int: voice = script.get("voice") if not voice: print( - f"[synth] {script_path} has no `voice` block — re-run preflight.", + f"[synth] {script_path} has no `voice` block. Re-run preflight.", file=sys.stderr, ) return 1 @@ -386,7 +386,7 @@ def main() -> int: # (or the user supplies one via --reference-audio). # 2. Base + generate_voice_clone(x_vector_only_mode=True) conditions # every cue on the reference's speaker embedding. - # Without (2), batched generation drifts timbre across cues — a persona + # Without (2), batched generation drifts timbre across cues: a persona # prompt anchors style but not identity, so each batch item picks its # own voice. The reference WAV is cached so subsequent runs only load # the clone model (saves ~20s + 3.4 GB of disk download); when it is @@ -398,13 +398,13 @@ def main() -> int: # Pin the cue cache to this exact reference waveform. A re-mint (or a # reference copied in from another storyboard by render.sh) changes these # bytes; without this gate the cue cache would keep cloned-from-the-old- - # reference WAVs alongside freshly regenerated ones — two voices in one + # reference WAVs alongside freshly regenerated ones: two voices in one # video. See _file_sha256. reference_hash = _file_sha256(ref_wav_path) index_path = audio_dir / "index.json" - # Skip generation when the existing audio matches the script — same cue + # Skip generation when the existing audio matches the script: same cue # texts and same gapBeforeMs values in the same order, AND same synth # settings (instruct/language/reference text + reference-WAV hash/model/ # seed/temperature/top_p). Saves ~30s of GPU time when iterating on @@ -424,7 +424,7 @@ def main() -> int: top_p, ): print( - f"[synth] [{args.storyboard}] cached audio matches the current script — skipping generation", + f"[synth] [{args.storyboard}] cached audio matches the current script: skipping generation", flush=True, ) return 0 From 909e24190701764a2814778a51780bc7ba144567 Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Fri, 3 Jul 2026 18:01:10 +0100 Subject: [PATCH 2/9] ok --- .gitignore | 1 + Makefile.data | 4 +- finder/http_client.py | 2 +- finder/postcode_cache.py | 4 +- finder/rightmove.py | 24 ++-- finder/scraper.py | 33 ++++- finder/shutdown.py | 6 +- finder/storage.py | 4 +- finder/test_http_client.py | 2 +- finder/test_main.py | 2 +- finder/test_onthemarket.py | 8 +- finder/test_rightmove_channels.py | 33 +++++ frontend/src/App.tsx | 24 +++- frontend/src/components/home/HomeFinalCta.tsx | 5 +- frontend/src/index-html.test.ts | 17 +++ frontend/src/index.css | 4 +- pipeline/utils/fuzzy_join.py | 6 +- pipeline/utils/normalize.py | 2 +- pyproject.toml | 1 + screenshot/src/screenshot.ts | 18 +-- server-rs/src/aggregation.rs | 4 +- server-rs/src/checkout_sessions/stripe.rs | 2 +- server-rs/src/checkout_sessions/tests.rs | 6 +- server-rs/src/data/area_crime_averages.rs | 6 +- server-rs/src/data/crime_by_year.rs | 8 +- server-rs/src/data/crime_records.rs | 10 +- server-rs/src/data/developments.rs | 2 +- server-rs/src/data/places.rs | 2 +- server-rs/src/data/poi.rs | 4 +- server-rs/src/data/postcode_population.rs | 2 +- server-rs/src/data/property/loading.rs | 21 ++- server-rs/src/data/property/mod.rs | 3 + server-rs/src/data/property/stats.rs | 12 +- server-rs/src/features.rs | 23 +-- server-rs/src/language.rs | 6 + server-rs/src/main.rs | 36 ++++- server-rs/src/metrics.rs | 67 ++++++++- server-rs/src/og_middleware.rs | 135 +++++++++++++++++- server-rs/src/parsing/fields.rs | 2 +- server-rs/src/parsing/filters.rs | 2 +- server-rs/src/pocketbase.rs | 14 +- server-rs/src/ratelimit.rs | 8 +- server-rs/src/routes/ai_filters/handler.rs | 6 +- server-rs/src/routes/ai_filters/parsing.rs | 13 +- server-rs/src/routes/crime_records.rs | 2 +- server-rs/src/routes/hexagon_stats.rs | 8 +- server-rs/src/routes/hexagons.rs | 6 +- server-rs/src/routes/pb_proxy.rs | 6 +- server-rs/src/routes/places.rs | 8 +- server-rs/src/routes/postcodes.rs | 15 ++ server-rs/src/routes/shorten.rs | 2 + server-rs/src/state.rs | 2 +- server-rs/src/utils.rs | 2 +- uv.lock | 80 +++++++++++ video/src/storyboard.ts | 92 ++---------- 55 files changed, 594 insertions(+), 223 deletions(-) create mode 100644 finder/test_rightmove_channels.py create mode 100644 frontend/src/index-html.test.ts diff --git a/.gitignore b/.gitignore index 964aaa8..00b8193 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,4 @@ r5-java/tmp property-data property-data-snapshot property-data-snapshot2 +video/.audit* diff --git a/Makefile.data b/Makefile.data index d576639..1fd989e 100644 --- a/Makefile.data +++ b/Makefile.data @@ -229,7 +229,7 @@ $(SATELLITE_HIGHRES_TILES): $(PMTILES_BIN) pipeline/download/satellite_highres.p docker build -t $(GDAL_ECW_IMAGE) docker/gdal-ecw uv run python -m pipeline.download.satellite_highres --output $@ --pmtiles-bin $(PMTILES_BIN) --pmtiles-version $(PMTILES_VERSION) --gdal-image $(GDAL_ECW_IMAGE) $(SATELLITE_HIGHRES_ARGS) -# EPC requires manual registration — fail with instructions +# EPC requires manual registration. Fail with instructions $(EPC): @echo "" @echo "=== EPC dataset not found ===" @@ -409,7 +409,7 @@ $(TREE_DENSITY_PC): $(FR_TOW) $(NFI) $(ARCGIS) $(TREE_DENSITY_DEPS) --arcgis $(ARCGIS) \ --output-postcodes $(TREE_DENSITY_PC) -# Postcode boundaries require manual generation — fail with instructions +# Postcode boundaries require manual generation. Fail with instructions $(PC_BOUNDARIES): @echo "" @echo "=== Postcode boundaries not found ===" diff --git a/finder/http_client.py b/finder/http_client.py index 635371f..d1cb76d 100644 --- a/finder/http_client.py +++ b/finder/http_client.py @@ -23,7 +23,7 @@ class RateLimiter: Detail-page fetches run concurrently across many worker threads (and across providers), but a single shared limiter caps their COMBINED rate so the VPN egress IP stays polite. Each ``acquire()`` reserves the next free time slot - under a lock, then sleeps (outside the lock) until that slot — so N threads + under a lock, then sleeps (outside the lock) until that slot, so N threads calling concurrently are spaced ``1/rate_per_second`` apart rather than all firing at once. ``rate_per_second <= 0`` disables limiting.""" diff --git a/finder/postcode_cache.py b/finder/postcode_cache.py index 272333e..7a621e5 100644 --- a/finder/postcode_cache.py +++ b/finder/postcode_cache.py @@ -2,7 +2,7 @@ Each portal recovers a listing's true postcode (Rightmove/OnTheMarket) or full geo dict (Zoopla) from its detail page. That value never changes for a given -listing id, yet the in-memory caches are discarded at the end of every run — so +listing id, yet the in-memory caches are discarded at the end of every run, so each run re-fetches every listing's detail page from scratch. Persisting the cache to disk means a steady-state run only fetches NEWLY-appeared listings, typically a small fraction of the market, which is the single biggest saving @@ -27,7 +27,7 @@ log = logging.getLogger("rightmove") def load_cache(path: str | Path) -> dict: """Load a persisted detail cache. Returns ``{}`` when absent or unreadable. - A corrupt or non-object file is treated as empty rather than fatal — a bad + A corrupt or non-object file is treated as empty rather than fatal: a bad cache must never block a scrape; the worst case is re-fetching details.""" p = Path(path) if not p.exists(): diff --git a/finder/rightmove.py b/finder/rightmove.py index cabfdd9..68eb724 100644 --- a/finder/rightmove.py +++ b/finder/rightmove.py @@ -36,7 +36,7 @@ _MAX_INDEX = 1008 # --------------------------------------------------------------------------- # # The search API (_paginate) only returns an outcode-level `displayAddress` -# (e.g. "Akerman Road, Brixton, London, SW9") — never the full postcode. Each +# (e.g. "Akerman Road, Brixton, London, SW9"), never the full postcode. Each # listing's detail page, however, embeds the property's OWN full postcode in a # `window.__PAGE_MODEL` script as `propertyData.address.{outcode, incode}` # (e.g. outcode "SW9" + incode "0HD" → "SW9 0HD"), independently corroborated by @@ -51,8 +51,8 @@ _MAX_INDEX = 1008 # __PAGE_MODEL is a "devalue"-style flattened object graph: its `data` field is # a JSON STRING holding a flat array where every integer inside a container is # an index reference into that same array (so the graph can dedupe). We -# brace-match the (large, deeply-nested) object literal — a non-greedy regex -# cannot — then rehydrate the reference graph before reading the address. +# brace-match the (large, deeply-nested) object literal (a non-greedy regex +# cannot), then rehydrate the reference graph before reading the address. _PAGE_MODEL_RE = re.compile(r"window\.__PAGE_MODEL\s*=\s*") @@ -128,7 +128,7 @@ def parse_detail_postcode(html: str) -> str | None: Pure and network-free so it is unit-testable: callers pass the page HTML. Reads ``propertyData.address.outcode`` + ``.incode`` from window.__PAGE_MODEL and returns a normalised full postcode (e.g. "SW9 0HD"), or None when the - page has no parseable address (the property location wrapper can be empty — + page has no parseable address (the property location wrapper can be empty; the caller then keeps the coordinate fallback). The returned outcode is re-validated against the joined postcode so a malformed incode is dropped. """ @@ -193,7 +193,7 @@ def _fetch_detail_postcode(client: httpx.Client, property_id: str) -> str | None """GET a listing detail page and return its true full postcode (or None). Results (including failures) are cached by listing id. The detail page is a - plain HTML GET — no Cloudflare, unlike Zoopla — so a single httpx call + plain HTML GET (no Cloudflare, unlike Zoopla), so a single httpx call suffices; any error degrades gracefully to the coordinate fallback. Safe to call concurrently: distinct listing ids write distinct cache keys, and the shared RATE_LIMITER spaces the GETs.""" @@ -245,7 +245,7 @@ def _needs_detail_fetch(prop: dict) -> bool: Skips listings the search already pins precisely: an "ACCURATE_POINT" ``pinType`` means rooftop-exact coordinates, so the coordinate-nearest postcode is trustworthy and the detail page would only confirm it. Listings - with an approximate pin — or no ``pinType`` field at all — still get fetched, + with an approximate pin (or no ``pinType`` field at all) still get fetched, so this degrades safely to the previous behaviour when the search payload omits ``pinType``.""" if not RIGHTMOVE_SKIP_DETAILS_FOR_ACCURATE_PINS: @@ -262,8 +262,8 @@ def _prime_detail_postcodes( ) -> None: """Fill ``_detail_postcode_cache`` for the listings that need a detail page. - Picks the fresh (uncached, not-skipped) listings — up to ``detail_cap`` per - outcode — then fetches their detail pages CONCURRENTLY, bounded by + Picks the fresh (uncached, not-skipped) listings, up to ``detail_cap`` per + outcode, then fetches their detail pages CONCURRENTLY, bounded by ``DETAIL_FETCH_CONCURRENCY`` (the shared RATE_LIMITER keeps the combined request rate polite). Cached listings cost neither a slot nor a GET. The worklist is deduplicated, so distinct ids write distinct cache keys and the @@ -305,8 +305,8 @@ def _collect_search_props( ) -> tuple[list[dict], int]: """Paginate the search API for one outcode+channel, collecting raw results. - Returns ``(raw_props, result_count)``. Pagination stays serial — each page - reveals the next — but is cheap relative to detail fetching, and the + Returns ``(raw_props, result_count)``. Pagination stays serial (each page + reveals the next) but is cheap relative to detail fetching, and the RATE_LIMITER spaces the page GETs. Collection stops at ``max_properties`` raw listings, the end of results, or Rightmove's ``_MAX_INDEX`` page cap.""" raw_props: list[dict] = [] @@ -324,6 +324,8 @@ def _collect_search_props( "channel": channel_cfg["channel"], "transactionType": channel_cfg["transactionType"], } + # Optional per-channel filters, e.g. `mustHave=newHome` for the new-homes pass. + params.update(channel_cfg.get("extra_params", {})) data = fetch_with_retry(client, SEARCH_URL, params) if not data: log.warning( @@ -372,7 +374,7 @@ def _paginate( ) -> tuple[list[dict], int]: """Collect search results, recover true postcodes, and transform them. - Search pages are paginated serially; then — when ``fetch_details`` is set — + Search pages are paginated serially; then, when ``fetch_details`` is set, up to ``detail_cap`` listings per outcode have their detail page fetched CONCURRENTLY for the property's TRUE full postcode (see ``parse_detail_postcode``), with listings the search already pins precisely diff --git a/finder/scraper.py b/finder/scraper.py index 7e4e358..6130de8 100644 --- a/finder/scraper.py +++ b/finder/scraper.py @@ -14,6 +14,7 @@ import polars as pl from constants import ( ARCGIS_PATH, CHANNELS, + NEW_HOMES_CHANNEL, DATA_DIR, DELAY_BETWEEN_OUTCODES, LONDON_OUTCODE_PREFIXES, @@ -368,6 +369,34 @@ def _scrape_rightmove( except Exception as exc: _record_error(errors, "rightmove", outcode, exc) + # Second pass: new-build developments (mustHave=newHome). These can + # rank low in the default resale sort, so a dedicated pass ensures + # they are captured; transform_property stamps their URL as RES_NEW. + # Overlap with the resale pass is removed by id in _merge_properties. + # Skipped if a stop was requested mid-outcode (don't start a new pass). + remaining = _source_remaining( + results, "rightmove", max_properties_per_source + ) + if not shutdown.stop_requested() and remaining != 0: + try: + new_props = rightmove_search_outcode( + client, + outcode_id, + outcode, + NEW_HOMES_CHANNEL, + pc_index, + max_properties=remaining, + ) + added_new = _store_properties( + results, + "rightmove", + new_props, + max_properties_per_source, + ) + log.info("Rightmove %s new-homes: +%d", outcode, added_new) + except Exception as exc: + _record_error(errors, "rightmove", outcode, exc) + shutdown.sleep(DELAY_BETWEEN_OUTCODES) finally: client.close() @@ -378,7 +407,7 @@ class OutcodeTimeout(BaseException): Inherits BaseException (not Exception) so the SIGALRM-triggered raise can't be silently swallowed by any of the broad `except Exception:` handlers - inside zoopla.py — the signal may fire at any bytecode boundary, including + inside zoopla.py: the signal may fire at any bytecode boundary, including inside those handlers.""" @@ -431,7 +460,7 @@ def _wall_clock_timeout(seconds: int, label: str): Interrupts a hung Playwright IPC by delivering SIGALRM to the main thread; socket waits return EINTR and the handler raises into the caller. The - browser is presumed unhealthy afterwards — caller must relaunch it.""" + browser is presumed unhealthy afterwards. Caller must relaunch it.""" if seconds <= 0: yield return diff --git a/finder/shutdown.py b/finder/shutdown.py index beed235..59f1358 100644 --- a/finder/shutdown.py +++ b/finder/shutdown.py @@ -4,8 +4,8 @@ A single :class:`threading.Event` is set the first time the process receives SIGINT (Ctrl+C) or SIGTERM. Every scrape loop polls :func:`stop_requested` at its outcode/page boundaries and every blocking delay goes through :func:`sleep`, which wakes the instant a stop is requested. So Ctrl+C makes each source stop -*starting* new work and unwind through its normal ``finally`` blocks — detail -caches are persisted and whatever has been collected so far is still written — +*starting* new work and unwind through its normal ``finally`` blocks (detail +caches are persisted and whatever has been collected so far is still written) instead of hanging until the worker threads happen to finish (the orchestrator's ``ThreadPoolExecutor`` used to block the exit waiting on them) or losing the run outright. @@ -34,7 +34,7 @@ def request_stop() -> None: def reset() -> None: - """Clear the flag — for tests and repeated in-process runs.""" + """Clear the flag (for tests and repeated in-process runs).""" _STOP.clear() diff --git a/finder/storage.py b/finder/storage.py index 13008eb..a7b051a 100644 --- a/finder/storage.py +++ b/finder/storage.py @@ -16,7 +16,7 @@ def write_parquet(properties: list[dict], path: Path) -> None: log.warning("No properties to write to %s", path) return - # Sanitize bedroom/bathroom counts — values above MAX_BEDROOMS are + # Sanitize bedroom/bathroom counts: values above MAX_BEDROOMS are # almost certainly prices or other numeric fields mis-parsed as bedrooms. bad_count = 0 for p in properties: @@ -91,7 +91,7 @@ def write_parquet(properties: list[dict], path: Path) -> None: else: listing_dates.append(None) - # Zero prices indicate parsing failures or POA/auction listings — treat as null + # Zero prices indicate parsing failures or POA/auction listings: treat as null asking_prices = [p["price"] if p["price"] > 0 else None for p in properties] listing_statuses = ["For sale"] * len(properties) diff --git a/finder/test_http_client.py b/finder/test_http_client.py index 11e31ad..c1ce432 100644 --- a/finder/test_http_client.py +++ b/finder/test_http_client.py @@ -73,7 +73,7 @@ def test_zero_rate_disables_limiting(monkeypatch): def test_concurrent_acquires_are_all_spaced(monkeypatch): # Real clock, tiny rate: N threads hitting acquire() at once must be # serialised so the total wall time is at least (N-1) * interval. - rl = RateLimiter(200) # 5ms interval — fast but measurable + rl = RateLimiter(200) # 5ms interval, fast but measurable barrier = threading.Barrier(8) def worker(): diff --git a/finder/test_main.py b/finder/test_main.py index 0e14393..326dcd5 100644 --- a/finder/test_main.py +++ b/finder/test_main.py @@ -6,7 +6,7 @@ import main # --------------------------------------------------------------------------- -# selected_sources — comma-separated --source values +# selected_sources: comma-separated --source values # --------------------------------------------------------------------------- diff --git a/finder/test_onthemarket.py b/finder/test_onthemarket.py index 4e0b3dd..aa71b84 100644 --- a/finder/test_onthemarket.py +++ b/finder/test_onthemarket.py @@ -5,7 +5,7 @@ or None), so these tests use a trimmed but faithful copy of a real OnTheMarket detail page's `__NEXT_DATA__` payload. The fixture mirrors the live structure: the property's own postcode lives in the analytics dataLayer (`props.initialReduxState.metadata.dataLayer.postcode`) while the agent's office -postcode sits separately under `…property.agent.postcode` — the trap we must not +postcode sits separately under `…property.agent.postcode`, the trap we must not fall into. """ @@ -49,7 +49,7 @@ def _detail_html( "property": { "displayAddress": "Padfield Road, London, SE5", "location": {"lon": -0.100233, "lat": 51.466129}, - # The agent block carries the AGENT'S office postcode — the + # The agent block carries the AGENT'S office postcode, the # trap. parse_detail_postcode must not return this. "agent": { "address": "29 Denmark Hill, Camberwell\nLondon\nSE5 8RS", @@ -125,7 +125,7 @@ def test_parse_handles_missing_datalayer(): # --------------------------------------------------------------------------- -# transform_property — detail postcode wiring + trust rule +# transform_property: detail postcode wiring + trust rule # --------------------------------------------------------------------------- @@ -176,7 +176,7 @@ def test_transform_without_detail_postcode_uses_coordinates(): def test_transform_detail_postcode_via_search_address_outcode(): # When the card address already carries a full postcode that agrees with the # coordinates, the existing "address" source still wins absent a detail - # postcode — detail recovery never regresses that path. + # postcode. Detail recovery never regresses that path. raw = dict(_RAW_LISTING, address="Padfield Road, London, SE5 1AA") index = _StubIndex("SE5 1AA") out = transform_property(raw, index, detail_postcode=None) diff --git a/finder/test_rightmove_channels.py b/finder/test_rightmove_channels.py new file mode 100644 index 0000000..3ba9770 --- /dev/null +++ b/finder/test_rightmove_channels.py @@ -0,0 +1,33 @@ +"""Tests for the new-homes search pass (mustHave=newHome) channel wiring.""" + +import rightmove +from constants import CHANNELS, NEW_HOMES_CHANNEL +from rightmove import _collect_search_props + + +def _capture_params(monkeypatch): + captured: list[dict] = [] + + def fake_fetch(client, url, params): + captured.append(dict(params)) + return {"properties": [], "resultCount": "0"} + + monkeypatch.setattr(rightmove, "fetch_with_retry", fake_fetch) + return captured + + +def test_new_homes_channel_sends_must_have_new_home(monkeypatch): + captured = _capture_params(monkeypatch) + _collect_search_props(None, "749", "E14", NEW_HOMES_CHANNEL) + assert captured, "no search request was issued" + assert captured[0].get("mustHave") == "newHome" + # New homes are still requested on the BUY channel; only the filter differs. + assert captured[0]["channel"] == "BUY" + assert captured[0]["transactionType"] == "BUY" + + +def test_resale_channel_sends_no_extra_filters(monkeypatch): + captured = _capture_params(monkeypatch) + _collect_search_props(None, "749", "E14", CHANNELS[0]) + assert captured, "no search request was issued" + assert "mustHave" not in captured[0] diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index d87c5f1..efe20a8 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -17,7 +17,7 @@ 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 { DEFAULT_DEMO_FILTERS, INITIAL_VIEW_STATE } from './lib/consts'; import { useTheme } from './hooks/useTheme'; import { useIsMobile } from './hooks/useIsMobile'; import { useAuth } from './hooks/useAuth'; @@ -86,7 +86,7 @@ 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. + // Storage unavailable (private mode/quota). Session restore is best-effort. } } @@ -234,6 +234,16 @@ export default function App() { return params.get('og') === '1'; }, []); + // Funnel fix: pre-seed high-intent filters on a cold (empty) map open so first-time visitors + // immediately see value and are one filter from the demo cap. Deep links (OG screenshots, the + // SEO landing-page CTAs) already carry filters, so they're left as-is. See DEFAULT_DEMO_FILTERS. + const initialMapFilters = useMemo(() => { + if (isScreenshotMode || isOgMode) return mapUrlState.filters; + return Object.keys(mapUrlState.filters).length === 0 + ? { ...DEFAULT_DEMO_FILTERS } + : mapUrlState.filters; + }, [mapUrlState.filters, isScreenshotMode, isOgMode]); + const [features, setFeatures] = useState([]); const [poiCategoryGroups, setPOICategoryGroups] = useState([]); const [initialLoading, setInitialLoading] = useState(true); @@ -253,7 +263,7 @@ export default function App() { // Restore from history state (e.g. popstate) if (window.history.state?.page) return window.history.state.page; - // Unknown path — track as 404 + // Unknown path: track as 404 if (window.location.pathname !== '/') { trackEvent('404', { path: window.location.pathname }); } @@ -267,6 +277,7 @@ export default function App() { user, loading: authLoading, error: authError, + errorAction: authErrorAction, login, register, loginWithOAuth, @@ -346,7 +357,7 @@ export default function App() { async function refreshOnStartup() { if (!returnedFromCheckout) { // Refresh auth on startup to pick up server-side subscription changes, - // but only when a token exists — logged-out visitors would just 401. + // but only when a token exists. Logged-out visitors would just 401. if (pb.authStore.token) { refreshAuthRef.current().catch(() => {}); } @@ -517,7 +528,7 @@ export default function App() { activePageRef.current = activePage; }, [activePage]); - // Refs for the initial history.replaceState seed below — the popstate effect runs + // Refs for the initial history.replaceState seed below. The popstate effect runs // mount-only, but it needs to read the *initial* page/hash/inviteCode values once. const initialPageRef = useRef(activePage); const initialRouteHashRef = useRef(routeHash); @@ -736,7 +747,7 @@ export default function App() { key={dashboardRouteKey} features={features} poiCategoryGroups={poiCategoryGroups} - initialFilters={mapUrlState.filters} + initialFilters={initialMapFilters} initialViewState={initialViewState} initialPOICategories={mapUrlState.poiCategories} initialOverlays={mapUrlState.overlays} @@ -793,6 +804,7 @@ export default function App() { onForgotPassword={requestPasswordReset} loading={authLoading} error={authError} + errorAction={authErrorAction} onClearError={clearError} initialTab={authModalTab} valuePropKey={authModalValuePropKey ?? undefined} diff --git a/frontend/src/components/home/HomeFinalCta.tsx b/frontend/src/components/home/HomeFinalCta.tsx index e114b10..aa2ed46 100644 --- a/frontend/src/components/home/HomeFinalCta.tsx +++ b/frontend/src/components/home/HomeFinalCta.tsx @@ -5,7 +5,7 @@ const HOME_SECTION_HEADING_CLASS = 'text-2xl md:text-3xl font-bold text-navy-950 dark:text-warm-100'; const HOME_BODY_CLASS = 'text-base leading-relaxed text-warm-600 dark:text-warm-400'; const HOME_PRIMARY_BUTTON_CLASS = - 'border border-[#d27a11] bg-[#f09a22] text-navy-950 rounded-lg font-semibold hover:bg-[#df8614] transition-colors text-base shadow-lg shadow-[#7a3905]/25 text-center'; + 'border border-[#d27a11] bg-[#f09a22] text-navy-950 rounded-lg font-semibold hover:bg-[#df8614] transition-colors text-base shadow-lg shadow-[#7a3905]/25 text-center focus:outline-none focus-visible:ring-4 focus-visible:ring-teal-300/80'; export default function HomeFinalCta({ onOpenDashboard, @@ -31,6 +31,9 @@ export default function HomeFinalCta({ > {t('home.exploreTheMap')} +

+ {t('home.guaranteeNote')} +

); } diff --git a/frontend/src/index-html.test.ts b/frontend/src/index-html.test.ts new file mode 100644 index 0000000..4bd700c --- /dev/null +++ b/frontend/src/index-html.test.ts @@ -0,0 +1,17 @@ +import { readFileSync } from 'fs'; +import { resolve } from 'path'; +import { describe, it, expect } from 'vitest'; + +describe('index.html viewport', () => { + // vitest runs with cwd at the frontend package root. + const html = readFileSync(resolve(process.cwd(), 'src/index.html'), 'utf-8'); + it('allows pinch-zoom (WCAG 1.4.4)', () => { + const m = html.match(/'; initial-value: 0deg; diff --git a/pipeline/utils/fuzzy_join.py b/pipeline/utils/fuzzy_join.py index 9b0a4e9..42e80e2 100644 --- a/pipeline/utils/fuzzy_join.py +++ b/pipeline/utils/fuzzy_join.py @@ -113,7 +113,7 @@ def fuzzy_join_on_postcode( ``_match_score``) null. This turns "price-paid LEFT JOIN epc" into a union that also surfaces EPC-only dwellings (an energy certificate but no Land Registry sale). It assumes ``right`` is already unique on its match key - (address + postcode) — as the deduped EPC frame is — so each unmatched right + (address + postcode), as the deduped EPC frame is, so each unmatched right row appears once. """ @@ -308,7 +308,7 @@ def _numbers_compatible(a: str, b: str) -> bool: Equality, not subset: subset logic let "188 GREAT NORTH WAY" absorb "FLAT 1 188 GREAT NORTH WAY" ({188} is a subset of {1, 188}), attaching a - single flat's EPC facts to the whole building — tens of thousands of + single flat's EPC facts to the whole building: tens of thousands of wrong-property matches. Likewise digit-only tokens made "8A" and "8B" both look like {8} and match each other (and plain "8"), and ungated letter flats let "FLAT D 39 X ST" cross-match "FLAT F 39 X ST" at ~96. @@ -332,7 +332,7 @@ def _admissible_variants( """Variants of ``primary`` that are safe to score against the other side. A variant may only ADD or DROP whole tokens relative to the primary (one - word multiset must contain the other) — never substitute, so a register + word multiset must contain the other), never substitute, so a register row whose address lines disagree with the combined address can't smuggle in a different street. The number gate runs on the primary addresses only, so the added/dropped tokens must additionally carry no digits diff --git a/pipeline/utils/normalize.py b/pipeline/utils/normalize.py index 4032e3d..fa77724 100644 --- a/pipeline/utils/normalize.py +++ b/pipeline/utils/normalize.py @@ -12,7 +12,7 @@ import re import polars as pl # One character outside [a-z0-9 ]. Callers lowercase first; each offending -# character becomes a single space (runs are NOT merged here — callers apply +# character becomes a single space (runs are NOT merged here; callers apply # word-level rules and then collapse_whitespace). _NON_ALNUM_LOWER_RE = re.compile(r"[^a-z0-9 ]") diff --git a/pyproject.toml b/pyproject.toml index bc10371..9f2639e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,6 +26,7 @@ dependencies = [ "pillow>=12.0.0", "folium>=0.20.0", "pyogrio>=0.12.1", + "google-auth>=2.55.1", ] [tool.uv] diff --git a/screenshot/src/screenshot.ts b/screenshot/src/screenshot.ts index e58af40..c5d8a23 100644 --- a/screenshot/src/screenshot.ts +++ b/screenshot/src/screenshot.ts @@ -71,8 +71,8 @@ function getBrowserArgs(): string[] { '--disable-gpu', '--use-gl=swiftshader', // Run the GPU (SwiftShader) work in the browser process to eliminate - // IPC overhead between browser and GPU processes — significant win - // when all GL calls are CPU-bound anyway + // IPC overhead between browser and GPU processes (a significant win + // when all GL calls are CPU-bound anyway) '--in-process-gpu', ]; } @@ -96,7 +96,7 @@ async function ensureContext(): Promise { deviceScaleFactor: 1, }); - // Set up response caching at context level — all pages share the cache. + // Set up response caching at context level: all pages share the cache. // This means tiles fetched during pre-warm or a previous screenshot are // served instantly for subsequent screenshots. await sharedContext.route('**/*', async (route: Route) => { @@ -108,7 +108,7 @@ async function ensureContext(): Promise { return; } - // Cache hit — fulfill from memory, zero network + // Cache hit: fulfill from memory, zero network const cached = networkCache.get(url); if (cached) { await route.fulfill({ @@ -119,7 +119,7 @@ async function ensureContext(): Promise { return; } - // Cache miss — fetch, cache, fulfill + // Cache miss: fetch, cache, fulfill try { const response = await route.fetch(); const body = await response.body(); @@ -190,7 +190,7 @@ async function releasePage(page: Page): Promise { /** * Pre-warm the browser and populate the network cache by loading the app once. * Subsequent screenshots benefit from cached JS/CSS bundles, map tiles, - * and V8 compiled bytecode — eliminating cold-start latency. + * and V8 compiled bytecode, eliminating cold-start latency. * * Retries with exponential backoff if the frontend isn't up yet (common during * docker-compose startup where the frontend may still be installing/building). @@ -214,7 +214,7 @@ export async function initialize(appUrl: string): Promise { timeout: READY_TIMEOUT, }); } catch { - // Non-fatal — cache will still have JS/CSS/tiles from the partial load + // Non-fatal: cache will still have JS/CSS/tiles from the partial load } console.log(`Pre-warm complete. Cache: ${networkCache.stats()}`); await page.close().catch(() => { }); @@ -256,7 +256,7 @@ export async function takeScreenshot(url: string, authHeader?: string): Promise< // Inject Authorization header on API requests so the headless browser // is authenticated (required for licensed users outside the free zone). // Page-level routes take precedence over the context-level cache route, - // so only /api/ requests are affected — static assets still use the cache. + // so only /api/ requests are affected. Static assets still use the cache. if (authHeader) { await page.route('**/api/**', async (route) => { const headers = { ...route.request().headers(), authorization: authHeader }; @@ -297,7 +297,7 @@ export async function takeScreenshot(url: string, authHeader?: string): Promise< // Buffer for SwiftShader to finish rendering the WebGL frame after // __screenshot_ready fires. The frontend uses double-rAF before signaling, - // so one paint cycle has already completed — this is extra safety for + // so one paint cycle has already completed. This is extra safety for // compositor staging and any residual tile/layer rendering. await page.waitForTimeout(RENDER_BUFFER_MS); diff --git a/server-rs/src/aggregation.rs b/server-rs/src/aggregation.rs index 3d7bb36..808a3f7 100644 --- a/server-rs/src/aggregation.rs +++ b/server-rs/src/aggregation.rs @@ -104,7 +104,7 @@ impl Aggregator { } /// Add a row using row-major feature_data layout (quantized u16). - /// feature_data[row * num_features + feat_idx] — all features for one row + /// feature_data[row * num_features + feat_idx]: all features for one row /// are contiguous, so this reads a single cache line per ~16 features. #[inline] pub fn add_row( @@ -131,7 +131,7 @@ impl Aggregator { } } // Enum distribution: single branch per row (not per feature). - // Uses raw u16 directly — enum features are stored as u16 indices. + // Uses raw u16 directly: enum features are stored as u16 indices. if let Some(ref mut ed) = self.enum_dist { let raw = row_slice[ed.feat_idx]; if raw != NAN_U16 { diff --git a/server-rs/src/checkout_sessions/stripe.rs b/server-rs/src/checkout_sessions/stripe.rs index 9546b35..f635b4c 100644 --- a/server-rs/src/checkout_sessions/stripe.rs +++ b/server-rs/src/checkout_sessions/stripe.rs @@ -12,7 +12,7 @@ use super::{ ensure_success_ref, is_safe_stripe_session_id, CHECKOUT_CURRENCY, REFERRAL_DISCOUNT_PERCENT, }; -const CHECKOUT_PRODUCT_NAME: &str = "Perfect Postcodes Lifetime License"; +const CHECKOUT_PRODUCT_NAME: &str = "Perfect Postcode: Lifetime Licence"; /// Fetch a Stripe coupon and ensure its `percent_off` matches the expected /// referral discount AND that it has no `amount_off` override. This blocks a diff --git a/server-rs/src/checkout_sessions/tests.rs b/server-rs/src/checkout_sessions/tests.rs index fa76662..27a9d63 100644 --- a/server-rs/src/checkout_sessions/tests.rs +++ b/server-rs/src/checkout_sessions/tests.rs @@ -81,7 +81,7 @@ impl MockPocketBase { .unwrap_or_default() } - /// Number of PATCHes that set the user's subscription to "licensed" — + /// Number of PATCHes that set the user's subscription to "licensed", /// i.e. how many times a license was granted. fn license_grant_count(&self, user_id: &str) -> usize { let path = format!("/api/collections/users/records/{user_id}"); @@ -414,7 +414,9 @@ async fn redeem_invite(env: &TestEnv, user: PocketBaseUser, code: &str) -> Respo post_redeem_invite( State(env.shared.clone()), Extension(OptionalUser(Some(user))), - Json(serde_json::from_value(json!({ "code": code })).expect("redeem request deserializes")), + Ok(Json( + serde_json::from_value(json!({ "code": code })).expect("redeem request deserializes"), + )), ) .await } diff --git a/server-rs/src/data/area_crime_averages.rs b/server-rs/src/data/area_crime_averages.rs index b3a82e7..79e0e00 100644 --- a/server-rs/src/data/area_crime_averages.rs +++ b/server-rs/src/data/area_crime_averages.rs @@ -9,7 +9,7 @@ //! These averages are precomputed by the data pipeline //! (`pipeline/transform/area_crime_averages.py`) and loaded here from a side //! parquet. Crime figures are constant within a postcode, so the pipeline -//! property-weights each postcode's value by its property count — keeping these +//! property-weights each postcode's value by its property count, keeping these //! averages on the same property-weighted basis as the per-selection mean, so the //! four numbers (this area / sector / outcode / nation) are directly comparable. @@ -41,7 +41,7 @@ pub struct AreaCrimeAverages { pub crime_types: Vec, /// National mean headline count per crime type (index-aligned with /// `crime_types`). An EXACT property-weighted mean over every postcode, so it - /// shares a basis with `by_outcode`/`by_sector` and the per-selection mean — + /// shares a basis with `by_outcode`/`by_sector` and the per-selection mean, /// unlike the histogram-bin national average, which is biased upward for the /// right-skewed crime counts. `NaN` where no postcode has data. pub national: Vec, @@ -53,7 +53,7 @@ pub struct AreaCrimeAverages { } impl AreaCrimeAverages { - /// Empty table — used only by the test-only `AppState` builder (the real + /// Empty table, used only by the test-only `AppState` builder (the real /// server always loads the precomputed parquet). #[cfg(test)] pub fn empty() -> Self { diff --git a/server-rs/src/data/crime_by_year.rs b/server-rs/src/data/crime_by_year.rs index 73a96f4..8080265 100644 --- a/server-rs/src/data/crime_by_year.rs +++ b/server-rs/src/data/crime_by_year.rs @@ -1,6 +1,6 @@ //! Per-postcode per-crime-type per-year crime counts, loaded from a side //! parquet and used by the right pane to plot crime-over-time. Filtering is not -//! supported — this data is display-only. +//! supported: this data is display-only. use std::path::Path; @@ -21,7 +21,7 @@ pub const BY_YEAR_SUFFIX: &str = " (by year)"; /// months}]` of the years the postcode's home force published enough months. /// police.uk has multi-year publication gaps for whole forces (e.g. Greater /// Manchester 2019-07 onwards), and a missing year is *no data*, not zero -/// crime — consumers must exclude uncovered (postcode, year)s instead of +/// crime. Consumers must exclude uncovered (postcode, year)s instead of /// charting them as zeros. pub const COVERAGE_COLUMN: &str = "covered_years"; @@ -47,7 +47,7 @@ pub struct CrimeByYearData { pub series_by_postcode: FxHashMap>, /// Postcode → years its police force actually published data for (from /// the `covered_years` column). An EMPTY vec means the postcode's crime - /// picture is unknown (force gap / unusable geometry) — it must not count + /// picture is unknown (force gap / unusable geometry). It must not count /// toward any year. A postcode ABSENT from this map (legacy parquet /// without the column) is treated as covered for every year. pub covered_years_by_postcode: FxHashMap>, @@ -181,7 +181,7 @@ impl CrimeByYearData { // Force-coverage calendar (optional column: legacy parquets predate it; // their postcodes are treated as fully covered). A row with an empty - // list is meaningful — zero covered years — so it IS inserted. + // list is meaningful (zero covered years), so it IS inserted. let mut covered_years_by_postcode: FxHashMap> = FxHashMap::default(); if let Ok(col) = df.column(COVERAGE_COLUMN) { let list_ca = col diff --git a/server-rs/src/data/crime_records.rs b/server-rs/src/data/crime_records.rs index 3aa99d3..b616f85 100644 --- a/server-rs/src/data/crime_records.rs +++ b/server-rs/src/data/crime_records.rs @@ -1,7 +1,7 @@ //! Individual police.uk crime records (last 7 years) backing the right pane's //! "individual crimes" list and the `/api/crime-records` endpoint. //! -//! This table is enormous — ~500M rows, because each incident is replicated to +//! This table is enormous, ~500M rows, because each incident is replicated to //! every postcode whose buffer covers it (see [`gather`](CrimeRecords::gather)), //! so it is NOT held as a `Vec`: each field is a flat columnar //! [`SpillVec`] (mmap-backed and kernel-reclaimable when `--spill-dir` is set), @@ -112,7 +112,7 @@ impl CrimeRecords { /// Record indices across `postcodes`, newest first, optionally restricted to /// months `>= since_month`. These are exactly the incidents counted for the - /// selected postcodes — for a single postcode that is its precise incident + /// selected postcodes. For a single postcode that is its precise incident /// list; for a multi-postcode selection a boundary incident counted for /// several postcodes appears once per postcode, matching the count. We do not /// de-duplicate because police.uk snaps many genuinely distinct incidents @@ -173,7 +173,7 @@ impl CrimeRecords { } // Columns that, when spilling, are written straight into mmap-backed files - // as we stream — so the ~9GB of columnar data never lands on the heap. + // as we stream, so the ~9GB of columnar data never lands on the heap. let mut month = SpillVecBuilder::::with_len(n, spill_dir, "crime_month")?; let mut ctype = SpillVecBuilder::::with_len(n, spill_dir, "crime_ctype")?; let mut outcome = SpillVecBuilder::::with_len(n, spill_dir, "crime_outcome")?; @@ -439,7 +439,7 @@ mod tests { } /// The CSR per-postcode index and the column builders must compose correctly - /// across streaming chunk boundaries — including a postcode run split between + /// across streaming chunk boundaries, including a postcode run split between /// two chunks. Forces `chunk_rows = 2` over the 4-row fixture so AA1 1AA's /// three records straddle the boundary (rows 0,1 in chunk 0; row 2 in chunk 1) /// and is exercised both heap-backed (no spill) and mmap-backed (spill). @@ -542,7 +542,7 @@ mod tests { // ceiling still proves we never materialise the whole file. assert!( hwm_after - hwm_before < 20_480.0, - "peak RSS grew by {:.0} MiB during load — streaming/spill not bounding memory", + "peak RSS grew by {:.0} MiB during load: streaming/spill not bounding memory", hwm_after - hwm_before ); diff --git a/server-rs/src/data/developments.rs b/server-rs/src/data/developments.rs index 95c97f5..961a794 100644 --- a/server-rs/src/data/developments.rs +++ b/server-rs/src/data/developments.rs @@ -13,7 +13,7 @@ const GRID_CELL_SIZE: f32 = 0.01; /// A single planned/pipeline development site (one brownfield-register entry or /// one Homes England land-disposal site). These are *sites*, not properties: /// they carry a coordinate, an estimate of the number of new dwellings, and the -/// planning status — the forward-looking "where new homes are coming" signal. +/// planning status, the forward-looking "where new homes are coming" signal. #[derive(Serialize, Clone)] pub struct DevelopmentSite { pub lat: f32, diff --git a/server-rs/src/data/places.rs b/server-rs/src/data/places.rs index 2fcf248..1b68e67 100644 --- a/server-rs/src/data/places.rs +++ b/server-rs/src/data/places.rs @@ -772,7 +772,7 @@ mod tests { #[test] fn normalize_search_text_elides_every_apostrophe_variant() { // Whatever glyph the keyboard / autocorrect / paste produced for the apostrophe, the - // normalized form must be identical — otherwise "King's Cross" tokenizes as `king s cross` + // normalized form must be identical. Otherwise "King's Cross" tokenizes as `king s cross` // and matching breaks. See is_apostrophe for the full set. let expected = "kings cross"; for q in [ diff --git a/server-rs/src/data/poi.rs b/server-rs/src/data/poi.rs index 4f2dc23..80e2c16 100644 --- a/server-rs/src/data/poi.rs +++ b/server-rs/src/data/poi.rs @@ -360,7 +360,7 @@ fn build_school_meta( ) -> anyhow::Result<(Vec, Vec)> { let phase = extract_optional_str_col(df, "school_phase")?; if phase.is_none() { - // POI parquet predates the school metadata extension — record an empty + // POI parquet predates the school metadata extension. Record an empty // table and a sentinel-filled index, so callers transparently see None. return Ok((vec![u32::MAX; row_count], Vec::new())); } @@ -404,7 +404,7 @@ fn build_school_meta( let type_group_val = fetch_str(&type_group, row); let type_val = fetch_str(&r#type, row); // type_group is present for every GIAS row, so use it as the sentinel - // for "this POI is a school" — matches the pipeline guarantee. + // for "this POI is a school", matching the pipeline guarantee. if type_group_val.is_none() && type_val.is_none() { continue; } diff --git a/server-rs/src/data/postcode_population.rs b/server-rs/src/data/postcode_population.rs index 1913b3f..acaaf01 100644 --- a/server-rs/src/data/postcode_population.rs +++ b/server-rs/src/data/postcode_population.rs @@ -21,7 +21,7 @@ pub struct PostcodePopulation { } impl PostcodePopulation { - /// Empty table — used in tests and when no --population-path is supplied. + /// Empty table, used in tests and when no --population-path is supplied. pub fn empty() -> Self { Self { by_postcode: FxHashMap::default(), diff --git a/server-rs/src/data/property/loading.rs b/server-rs/src/data/property/loading.rs index 0872ab2..22659ca 100644 --- a/server-rs/src/data/property/loading.rs +++ b/server-rs/src/data/property/loading.rs @@ -163,7 +163,7 @@ fn validate_no_england_rows_missing_coordinates( impl PropertyData { /// Load the property data. When `spill` is `Some(dir)`, the large flat arrays /// (the feature matrix and the address-search index) are written to anonymous - /// files in `dir` and memory-mapped read-only instead of held on the heap — + /// files in `dir` and memory-mapped read-only instead of held on the heap: /// the `--spill-dir` dev flag, which lets a low-memory box page them from disk. pub fn load( properties_path: &Path, @@ -221,7 +221,7 @@ impl PropertyData { let mut poi_metrics = PostcodePoiMetrics::from_postcode_df(&postcode_df, poi_metric_names)?; // Load properties.parquet and join with postcode data lazily so the - // wide combined frame is never fully materialized — projection is + // wide combined frame is never fully materialized: projection is // pushed down into the join, keeping peak memory bounded. tracing::info!("Loading properties from {:?}", properties_path); let properties_path = PlRefPath::try_from_path(properties_path) @@ -424,7 +424,7 @@ impl PropertyData { // Compute quantization parameters from feature stats (numeric features). // For features with Fixed bounds, use those bounds so the full configured range - // is representable — the histogram refinement can narrow min/max to exclude + // is representable. The histogram refinement can narrow min/max to exclude // "outliers" that are actually valid data (e.g. ethnicity percentages). // For Percentile-bounded features, use the (possibly refined) histogram range // so extreme outliers don't destroy precision for the main distribution. @@ -665,6 +665,9 @@ impl PropertyData { let prices = structs .field_by_name("price") .context("Missing 'price' field in historical_prices struct")?; + // Per-sale new-build flag. Optional: older parquet predates + // it, so a missing field degrades gracefully to `false`. + let is_new_flags = structs.field_by_name("is_new").ok(); let mut row_prices = Vec::new(); for idx in 0..inner.len() { @@ -680,10 +683,16 @@ impl PropertyData { let AnyValue::Int64(price_i64) = price else { bail!("historical_prices.price is not Int64 at row {old_row}, got {price:?}"); }; + let is_new = is_new_flags + .as_ref() + .and_then(|flags| flags.get(idx).ok()) + .map(|value| matches!(value, AnyValue::Boolean(true))) + .unwrap_or(false); row_prices.push(HistoricalPrice { year: year_i32, month: month_u8, price: price_i64, + is_new, }); } if !row_prices.is_empty() { @@ -789,8 +798,8 @@ impl PropertyData { .context("Required numeric column 'Last known price' not configured")?; // Build contiguous address buffer and address search index (permuted). - // Each row's posting lists cover BOTH address spellings we hold — the - // price-paid form and the EPC form — so a property is findable by either; + // Each row's posting lists cover BOTH address spellings we hold (the + // price-paid form and the EPC form), so a property is findable by either; // the display address prefers the price-paid form, falling back to the EPC // form for never-sold (EPC-only) dwellings whose price-paid form is null. tracing::info!("Building interned strings"); @@ -1070,7 +1079,7 @@ impl PropertyData { // Spill the remaining large flat arrays (the address-search index) to disk // when configured. The posting-list hashmaps and interners stay on the heap - // — they aren't flat arrays, and they're a small fraction of the footprint. + // (they aren't flat arrays, and they're a small fraction of the footprint). let address_buffer = SpillVec::maybe_spill(address_buffer.into_bytes(), spill, "address_buffer")?; let address_offsets = SpillVec::maybe_spill(address_offsets, spill, "address_offsets")?; diff --git a/server-rs/src/data/property/mod.rs b/server-rs/src/data/property/mod.rs index 8a3d20f..98cec2e 100644 --- a/server-rs/src/data/property/mod.rs +++ b/server-rs/src/data/property/mod.rs @@ -39,6 +39,9 @@ pub struct HistoricalPrice { pub year: i32, pub month: u8, pub price: i64, + /// Whether this specific sale was a new-build transfer (PPD `old_new == "Y"`). + /// Defaults to `false` for older parquet that predates the per-sale flag. + pub is_new: bool, } /// A point on the property's tenure timeline: the year a certificate first diff --git a/server-rs/src/data/property/stats.rs b/server-rs/src/data/property/stats.rs index bdce42b..9c138cb 100644 --- a/server-rs/src/data/property/stats.rs +++ b/server-rs/src/data/property/stats.rs @@ -9,9 +9,9 @@ use crate::consts::HISTOGRAM_BINS; use crate::features::Bounds; /// Histogram with outlier buckets at the edges. -/// - Bin 0: [min, p1) — low outliers -/// - Bins 1 to n-2: [p1, p99) — main distribution, evenly divided -/// - Bin n-1: [p99, max] — high outliers +/// - Bin 0: [min, p1): low outliers +/// - Bins 1 to n-2: [p1, p99): main distribution, evenly divided +/// - Bin n-1: [p99, max]: high outliers #[derive(Serialize, Clone)] pub struct Histogram { pub min: f32, @@ -218,9 +218,9 @@ pub fn compute_feature_stats(vals: &[f32], bounds: &Bounds, integer_bins: bool) }; // Build final histogram with outlier bins at edges: - // - Bin 0: [min, p1) — low outliers - // - Bins 1 to n-2: [p1, p99) — main distribution, evenly divided - // - Bin n-1: [p99, max] — high outliers + // - Bin 0: [min, p1): low outliers + // - Bins 1 to n-2: [p1, p99): main distribution, evenly divided + // - Bin n-1: [p99, max]: high outliers let mut counts = vec![0u64; num_bins]; let middle_bins = num_bins.saturating_sub(2); let middle_width = if middle_bins > 0 && p99 > p1 { diff --git a/server-rs/src/features.rs b/server-rs/src/features.rs index 3df4bb5..496c4de 100644 --- a/server-rs/src/features.rs +++ b/server-rs/src/features.rs @@ -713,19 +713,20 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[ raw: false, absolute: false, }), - // EPC-derived council-housing footprint for the neighbourhood (LSOA). - // Aggregated in the pipeline from the per-property "Former council - // house" flag and the latest certificate tenure. The denominator is - // ALL dwellings in the LSOA (homes with no EPC count as not council), - // so these are EPC-coverage-limited lower bounds, NOT a Census - // household share. These two plus the Census "% Social rent" back the - // frontend "Council housing" filter's Current / Ex / Both pill toggle. + // EPC-derived council-housing footprint per postcode. Aggregated in + // the pipeline from the per-property "Former council house" flag and + // the latest certificate tenure. The denominator is ALL dwellings in + // the postcode (homes with no EPC count as not council), so these are + // coarse, EPC-coverage-limited lower bounds, NOT a Census household + // share (and finer-grained than the LSOA-level Census "% Social rent", + // which is the filter's "Current" pill). These two plus "% Social + // rent" back the "Council housing" filter's Current / Ex / Both toggle. Feature::Numeric(FeatureConfig { name: "% Council housing", bounds: Bounds::Fixed { min: 0.0, max: 100.0 }, step: 1.0, - description: "Share of nearby homes ever recorded as council or social housing", - detail: "Estimated from EPC tenure records across the neighbourhood (LSOA): the share of homes whose Energy Performance Certificate history shows the property was council or social housing at some point, whether it is still social housing today or has since been sold (for example under Right to Buy). Homes with no EPC are counted as not council, so this is a lower bound that reflects EPC coverage and is not directly comparable to the Census social-rent share.", + description: "Share of homes in the postcode ever recorded as council or social housing", + detail: "Estimated from EPC tenure records within the postcode: the share of its homes whose Energy Performance Certificate history shows the property was council or social housing at some point, whether it is still social housing today or has since been sold (for example under Right to Buy). Homes with no EPC are counted as not council, and a postcode holds few homes, so this is a coarse lower bound that reflects EPC coverage and is not directly comparable to the Census social-rent share.", source: "epc", prefix: "", suffix: "%", @@ -736,8 +737,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[ name: "% Ex-council", bounds: Bounds::Fixed { min: 0.0, max: 100.0 }, step: 1.0, - description: "Share of nearby homes that were council housing but are no longer", - detail: "Estimated from EPC tenure records across the neighbourhood (LSOA): the share of homes once recorded as council or social housing whose most recent Energy Performance Certificate shows a different tenure, typically homes sold under Right to Buy. Homes with no EPC are counted as not council, so this is a lower bound that reflects EPC coverage and is not directly comparable to the Census social-rent share.", + description: "Share of homes in the postcode that were council housing but are no longer", + detail: "Estimated from EPC tenure records within the postcode: the share of its homes once recorded as council or social housing whose most recent Energy Performance Certificate shows a different tenure, typically homes sold under Right to Buy. Homes with no EPC are counted as not council, and a postcode holds few homes, so this is a coarse lower bound that reflects EPC coverage and is not directly comparable to the Census social-rent share.", source: "epc", prefix: "", suffix: "%", diff --git a/server-rs/src/language.rs b/server-rs/src/language.rs index 81ead9b..31de375 100644 --- a/server-rs/src/language.rs +++ b/server-rs/src/language.rs @@ -2,6 +2,12 @@ pub const DEFAULT_LANGUAGE: &str = "en"; const SUPPORTED_LANGUAGES: &[&str] = &["en", "fr", "de", "zh", "hi", "hu"]; +/// The set of language codes the frontend can render, used e.g. to emit +/// `hreflang` alternates for indexable pages. +pub fn supported_languages() -> &'static [&'static str] { + SUPPORTED_LANGUAGES +} + pub fn supported_language(value: &str) -> Option<&'static str> { let value = value.trim().to_ascii_lowercase(); if value.is_empty() || value == "*" { diff --git a/server-rs/src/main.rs b/server-rs/src/main.rs index ed23e46..ae09e38 100644 --- a/server-rs/src/main.rs +++ b/server-rs/src/main.rs @@ -8,6 +8,7 @@ mod checkout_sessions; mod consts; mod data; mod features; +mod generated_data_pages; mod language; mod metrics; mod og_middleware; @@ -124,6 +125,30 @@ async fn static_cache_headers( response } +/// Add baseline security headers to every response. These are deliberately the +/// low-risk, broadly-compatible set: a Content-Security-Policy and +/// Permissions-Policy are intentionally left out because this app loads many +/// cross-origin resources (maplibre/deck.gl, Stripe, Google Street View, +/// analytics, the error sink) and a mis-scoped policy would break the map or +/// checkout. They should be added later as report-only first. +async fn security_headers( + request: axum::extract::Request, + next: middleware::Next, +) -> axum::response::Response { + let mut response = next.run(request).await; + let headers = response.headers_mut(); + headers + .entry(header::X_CONTENT_TYPE_OPTIONS) + .or_insert(HeaderValue::from_static("nosniff")); + headers + .entry(header::X_FRAME_OPTIONS) + .or_insert(HeaderValue::from_static("SAMEORIGIN")); + headers + .entry(header::REFERRER_POLICY) + .or_insert(HeaderValue::from_static("strict-origin-when-cross-origin")); + response +} + #[cfg(target_os = "linux")] fn resident_memory_kib() -> Option { let status = std::fs::read_to_string("/proc/self/status").ok()?; @@ -998,7 +1023,7 @@ async fn main() -> anyhow::Result<()> { "/pb/{*rest}", any(routes::proxy_to_pocketbase).layer(ConcurrencyLimitLayer::new(10)), ) - // Tile routes use a different state type — kept as closures + // Tile routes use a different state type, kept as closures .route( "/api/tiles/{z}/{x}/{y}", get(move |path| routes::get_tile(axum::extract::State(reader_tile.clone()), path)) @@ -1087,7 +1112,9 @@ async fn main() -> anyhow::Result<()> { .route("/health", get(|| async { "ok" })) .route( "/metrics", - get(move |connect_info| metrics::metrics_handler(metrics_handle.clone(), connect_info)), + get(move |headers, connect_info| { + metrics::metrics_handler(metrics_handle.clone(), headers, connect_info) + }), ) .with_state(shared.clone()); @@ -1112,6 +1139,7 @@ async fn main() -> anyhow::Result<()> { }, )) .layer(middleware::from_fn(static_cache_headers)) + .layer(middleware::from_fn(security_headers)) .layer(middleware::from_fn(capture_server_error_responses)) .layer(cors) .layer(CompressionLayer::new().zstd(true).gzip(true)) @@ -1125,8 +1153,8 @@ async fn main() -> anyhow::Result<()> { ); // NOTE: we deliberately do NOT mlockall() here. Locking MCL_CURRENT|MCL_FUTURE - // pinned the allocator's entire mapped heap — including jemalloc's freed/dirty - // pages — resident and non-reclaimable, inflating RSS from the ~10GB working + // pinned the allocator's entire mapped heap, including jemalloc's freed/dirty + // pages, resident and non-reclaimable, inflating RSS from the ~10GB working // set to ~40GB and defeating the allocator's page-return entirely. The hot // working set stays resident naturally; freed pages are returned to the OS. diff --git a/server-rs/src/metrics.rs b/server-rs/src/metrics.rs index 448888d..a717862 100644 --- a/server-rs/src/metrics.rs +++ b/server-rs/src/metrics.rs @@ -1,6 +1,6 @@ use axum::body::Body; use axum::extract::{ConnectInfo, Request}; -use axum::http::StatusCode; +use axum::http::{HeaderMap, StatusCode}; use axum::middleware::Next; use axum::response::{IntoResponse, Response}; use metrics::{counter, gauge, histogram}; @@ -119,7 +119,7 @@ fn normalize_path(path: &str) -> String { if path.starts_with("/assets/") { return "/assets/:file".to_string(); } - // Known application routes and API endpoints — keep as-is + // Known application routes and API endpoints: keep as-is if path.starts_with("/api/") || matches!( path, @@ -147,11 +147,20 @@ fn normalize_path(path: &str) -> String { /// Handler for the /metrics endpoint. Only accepts requests from peers on the /// same private network (loopback, RFC1918, or IPv6 unique/link-local). +/// +/// The TCP peer is always the reverse proxy (a private container IP), so the +/// real client IP is taken from the proxy-set `X-Real-IP` / `X-Forwarded-For` +/// headers when present; a public client reaching this endpoint through the +/// public domain is therefore rejected. Direct in-network connections (e.g. the +/// Prometheus scraper hitting the container directly) carry no such header and +/// fall back to the trusted TCP peer address. pub async fn metrics_handler( handle: PrometheusHandle, + headers: HeaderMap, ConnectInfo(peer): ConnectInfo, ) -> Response { - if !is_same_network(peer.ip()) { + let client_ip = client_ip_from_headers(&headers).unwrap_or_else(|| peer.ip()); + if !is_same_network(client_ip) { return StatusCode::FORBIDDEN.into_response(); } @@ -167,6 +176,27 @@ pub async fn metrics_handler( } } +/// Resolve the real client IP from trusted reverse-proxy headers. nginx sets +/// `X-Real-IP` to the post-`real_ip` client address and overwrites any +/// client-supplied value, so when present it is authoritative; the left-most +/// `X-Forwarded-For` hop is used as a fallback. Returns `None` when neither +/// header is set, so a direct in-network connection uses its TCP peer address. +fn client_ip_from_headers(headers: &HeaderMap) -> Option { + if let Some(value) = headers.get("x-real-ip").and_then(|v| v.to_str().ok()) { + if let Ok(ip) = value.trim().parse::() { + return Some(ip); + } + } + if let Some(value) = headers.get("x-forwarded-for").and_then(|v| v.to_str().ok()) { + if let Some(first) = value.split(',').next() { + if let Ok(ip) = first.trim().parse::() { + return Some(ip); + } + } + } + None +} + fn is_same_network(ip: IpAddr) -> bool { match ip { IpAddr::V4(v4) => v4.is_loopback() || v4.is_private() || v4.is_link_local(), @@ -216,3 +246,34 @@ pub fn record_data_stats(property_count: usize, poi_count: usize, postcode_count gauge!("data_pois_loaded").set(poi_count as f64); gauge!("data_postcodes_loaded").set(postcode_count as f64); } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn x_real_ip_is_authoritative_and_public_ip_is_rejected() { + let mut headers = HeaderMap::new(); + headers.insert("x-real-ip", "203.0.113.7".parse().unwrap()); + let ip = client_ip_from_headers(&headers).expect("resolves from x-real-ip"); + assert_eq!(ip, "203.0.113.7".parse::().unwrap()); + assert!(!is_same_network(ip), "a public client must be rejected"); + } + + #[test] + fn missing_proxy_headers_fall_back_to_peer() { + // No X-Real-IP / X-Forwarded-For → caller uses the TCP peer (e.g. an + // in-network Prometheus scraper hitting the container directly). + assert_eq!(client_ip_from_headers(&HeaderMap::new()), None); + } + + #[test] + fn forwarded_for_uses_first_hop() { + let mut headers = HeaderMap::new(); + headers.insert("x-forwarded-for", "198.51.100.4, 10.0.0.1".parse().unwrap()); + assert_eq!( + client_ip_from_headers(&headers), + Some("198.51.100.4".parse::().unwrap()) + ); + } +} diff --git a/server-rs/src/og_middleware.rs b/server-rs/src/og_middleware.rs index f755c76..719bd33 100644 --- a/server-rs/src/og_middleware.rs +++ b/server-rs/src/og_middleware.rs @@ -7,7 +7,9 @@ use axum::middleware::Next; use axum::response::Response; use tracing::warn; -use crate::language::{language_from_accept_language, query_string_with_language}; +use crate::language::{ + language_from_accept_language, query_string_with_language, supported_languages, +}; use crate::state::AppState; const OG_PLACEHOLDER: &str = @@ -133,6 +135,18 @@ fn seo_page_for_path(path: &str) -> Option { description: "Learn how Perfect Postcode treats saved searches, account data and property research workflows with privacy and security in mind.", indexable: true, }), + "/terms" => Some(SeoPage { + canonical_path: "/terms", + 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.", + indexable: true, + }), + "/privacy" => Some(SeoPage { + canonical_path: "/privacy", + 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.", + indexable: true, + }), "/dashboard" => Some(SeoPage { canonical_path: "/dashboard", title: "Perfect Postcode dashboard", @@ -163,7 +177,14 @@ fn seo_page_for_path(path: &str) -> Option { description: "Accept your invitation to explore property prices, energy ratings, crime stats, school ratings, and more across England.", indexable: false, }), - _ => None, + // Generated growth pages (cheaper-twin / value-index landing pages). Without this they + // would 404 (should_return_404 keys off seo_page_for_path); see analysis/build_pages.py. + _ => crate::generated_data_pages::data_page(path).map(|d| SeoPage { + canonical_path: d.path, + title: d.title, + description: d.description, + indexable: true, + }), } } @@ -206,12 +227,46 @@ fn not_found_response(public_url: &str, path: &str) -> Response { Page not found - Perfect Postcode +
+
Perfect Postcode

Page not found

-

The requested path was not found: {path_e}

-

Go to Perfect Postcode

+

We couldn't find {path_e}.

+

It may have moved, or the link might be incomplete.

+Back to Perfect Postcode
"# @@ -234,7 +289,13 @@ fn route_seo_tags( ) -> String { let path_e = escape_attr(path); let query_e = escape_attr(query_string); - let screenshot_query_string = query_string_with_language(query_string, language); + // Generated data pages have a clean URL with no query, but their OG card must frame the finding's + // map view rather than the default whole-England map. Use the registry's screenshot query. + let screenshot_query_base = match crate::generated_data_pages::data_page(trim_trailing_slash(path)) { + Some(dp) if !dp.screenshot_query.is_empty() => dp.screenshot_query, + _ => query_string, + }; + let screenshot_query_string = query_string_with_language(screenshot_query_base, language); let screenshot_query_e = escape_attr(&screenshot_query_string); let public_url_e = escape_attr(public_url.trim_end_matches('/')); let canonical_path_e = escape_attr(page.canonical_path); @@ -266,9 +327,26 @@ fn route_seo_tags( "noindex,follow" }; + // Emit hreflang alternates for indexable pages so search engines can serve + // the right localized variant (the SPA switches language via ?lang=). + let hreflang = if page.indexable { + let mut tags = String::new(); + for lang in supported_languages() { + tags.push_str(&format!( + "\n " + )); + } + tags.push_str(&format!( + "\n " + )); + tags + } else { + String::new() + }; + format!( r#" - + {hreflang} @@ -406,3 +484,48 @@ pub async fn og_middleware(request: Request, next: Next) -> Response { parts.headers.remove(header::CONTENT_LENGTH); Response::from_parts(parts, Body::from(html)) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn legal_pages_resolve_and_are_not_404() { + for path in ["/terms", "/privacy", "/terms/", "/privacy/"] { + assert!( + seo_page_for_path(path).is_some(), + "{path} should resolve to an SEO page (legal pages must render, not 404)" + ); + assert!( + !should_return_404(path), + "{path} must not return a server 404" + ); + } + } + + #[test] + fn unknown_paths_still_404() { + assert!(should_return_404("/definitely-not-a-real-page")); + } + + #[test] + fn indexable_pages_emit_hreflang() { + let page = seo_page_for_path("/terms").expect("terms page"); + let tags = route_seo_tags(&page, "/terms", "", "https://perfect-postcode.co.uk", "en"); + assert!(tags.contains(r#"hreflang="x-default""#)); + assert!(tags.contains(r#"hreflang="de""#)); + } + + #[test] + fn noindex_pages_omit_hreflang() { + let page = seo_page_for_path("/dashboard").expect("dashboard page"); + let tags = route_seo_tags( + &page, + "/dashboard", + "", + "https://perfect-postcode.co.uk", + "en", + ); + assert!(!tags.contains("hreflang")); + } +} diff --git a/server-rs/src/parsing/fields.rs b/server-rs/src/parsing/fields.rs index 0fd8bb5..aa4ccc1 100644 --- a/server-rs/src/parsing/fields.rs +++ b/server-rs/src/parsing/fields.rs @@ -5,7 +5,7 @@ use rustc_hash::FxHashMap; /// Parse an optional `?fields=` query param into feature indices for selective aggregation. /// Returns `None` if fields param is absent (all features included). -/// Returns `Some(vec![])` if fields is present but empty (no features — count only). +/// Returns `Some(vec![])` if fields is present but empty (no features, count only). /// Returns `Some(indices)` for named fields. Errors on unknown field names. pub fn parse_field_indices( fields: Option<&str>, diff --git a/server-rs/src/parsing/filters.rs b/server-rs/src/parsing/filters.rs index 24661d1..a43720c 100644 --- a/server-rs/src/parsing/filters.rs +++ b/server-rs/src/parsing/filters.rs @@ -610,7 +610,7 @@ mod tests { fn selectivity_sort_handles_saturating_sub() { // Even if a ParsedFilter is constructed directly with min_u16 > max_u16 // (bypassing parse_filters validation), the sort must not wrap to a - // huge u16 — saturating_sub clamps to 0. + // huge u16: saturating_sub clamps to 0. let mut filters = [ ParsedFilter { feat_idx: 0, diff --git a/server-rs/src/pocketbase.rs b/server-rs/src/pocketbase.rs index e21b19e..6608841 100644 --- a/server-rs/src/pocketbase.rs +++ b/server-rs/src/pocketbase.rs @@ -28,7 +28,7 @@ impl SuperuserTokenCache { /// Get a cached superuser token, or authenticate fresh if expired/missing. pub async fn get_superuser_token(state: &AppState) -> anyhow::Result { - // Check cache first (read lock — cheap, non-blocking for other readers) + // Check cache first (read lock: cheap, non-blocking for other readers) { let cached = state.superuser_token_cache.token.read(); if let Some((token, created)) = cached.as_ref() { @@ -38,7 +38,7 @@ pub async fn get_superuser_token(state: &AppState) -> anyhow::Result { } } - // Cache miss or expired — fetch a fresh token + // Cache miss or expired: fetch a fresh token let pb_url = state.pocketbase_url.trim_end_matches('/'); let token = auth_superuser( &state.http_client, @@ -861,7 +861,7 @@ async fn ensure_saved_searches_rules( } /// Ensure the `saved_searches` collection has a `screenshot` file field. -/// This field was added after the initial collection schema — existing deployments +/// This field was added after the initial collection schema. Existing deployments /// need it patched in so the frontend can attach screenshot JPEGs to saved searches. async fn ensure_screenshot_field( client: &Client, @@ -971,7 +971,7 @@ async fn ensure_notes_field( } /// Ensure a collection has `created` and `updated` autodate fields. -/// PocketBase 0.23+ no longer adds these automatically — they must be explicit. +/// PocketBase 0.23+ no longer adds these automatically: they must be explicit. async fn ensure_autodate_fields( client: &Client, base_url: &str, @@ -1419,7 +1419,7 @@ pub async fn ensure_oauth_providers( { Some(idx) => &mut providers[idx], None => { - info!("Google provider not found — adding it"); + info!("Google provider not found, adding it"); providers.push(serde_json::json!({"name": "google"})); providers.last_mut().expect("just pushed") } @@ -1504,7 +1504,7 @@ async fn poll_pocketbase_counts(state: &AppState) { } /// Insert a record into the `location_logs` collection. -/// Best-effort — logs warnings on failure but does not propagate errors. +/// Best-effort: logs warnings on failure but does not propagate errors. pub async fn log_user_location( state: &AppState, user_id: &str, @@ -1546,7 +1546,7 @@ pub async fn log_user_location( } /// Insert a record into the `ai_query_logs` collection. -/// Best-effort — logs warnings on failure but does not propagate errors. +/// Best-effort: logs warnings on failure but does not propagate errors. #[allow(clippy::too_many_arguments)] pub async fn log_ai_query( state: &AppState, diff --git a/server-rs/src/ratelimit.rs b/server-rs/src/ratelimit.rs index b4c7d85..3d7e60e 100644 --- a/server-rs/src/ratelimit.rs +++ b/server-rs/src/ratelimit.rs @@ -32,7 +32,7 @@ use crate::state::AppState; /// Extract the client IP, preferring reverse-proxy/CDN headers (we sit behind one /// in production) and falling back to the socket peer. Client-supplied and -/// spoofable — only used as a coarse rate-limit key for anonymous visitors. +/// spoofable: only used as a coarse rate-limit key for anonymous visitors. fn client_ip(headers: &HeaderMap, peer: Option) -> Option { let from_header = |name: &str, take_first: bool| -> Option { let raw = headers.get(name)?.to_str().ok()?; @@ -72,7 +72,7 @@ impl DemoRateLimiter { // Bound memory: when the table grows large, drop keys with no recent hits. // If even that can't get us back under the cap (e.g. a spoofed-IP flood // minting a fresh key per request), clear the table outright. That resets - // everyone's window — acceptable under attack — and keeps both the size and + // everyone's window (acceptable under attack) and keeps both the size and // the cost of this scan bounded (it then won't re-run for ~MAX_KEYS inserts, // instead of scanning O(n) on every request once full). if map.len() > DEMO_RATE_LIMIT_MAX_KEYS { @@ -197,7 +197,7 @@ pub async fn demo_guard_middleware(req: Request, next: Next) -> Response { if let Some(query) = req.uri().query() { if filter_count(query) > filter_limit { let message = if user.is_some() { - format!("Free accounts are limited to {filter_limit} filters at a time — upgrade for unlimited") + format!("Free accounts are limited to {filter_limit} filters at a time. Upgrade for unlimited") } else { format!("Create a free account for up to {REGISTERED_MAX_FILTERS} filters (currently limited to {filter_limit})") }; @@ -228,7 +228,7 @@ pub async fn demo_guard_middleware(req: Request, next: Next) -> Response { StatusCode::TOO_MANY_REQUESTS, axum::Json(json!({ "error": "rate_limited", - "message": "Too many requests — slow down, or sign in for full access", + "message": "Too many requests. Slow down, or sign in for full access", })), ) .into_response(); diff --git a/server-rs/src/routes/ai_filters/handler.rs b/server-rs/src/routes/ai_filters/handler.rs index 090bdab..c1aa629 100644 --- a/server-rs/src/routes/ai_filters/handler.rs +++ b/server-rs/src/routes/ai_filters/handler.rs @@ -241,11 +241,11 @@ pub async fn post_ai_filters( }] })); - // Continue the loop — model will process the results + // Continue the loop: model will process the results continue; } - // Model returned text — extract and parse as JSON + // Model returned text: extract and parse as JSON let text = parts .iter() .find_map(|part| part.get("text").and_then(|t| t.as_str())) @@ -330,7 +330,7 @@ pub async fn post_ai_filters( let total_rows = state.data.lat.len(); info!( attempt = refinement_attempts, - "0 matches out of {total_rows} — asking AI to relax filters" + "0 matches out of {total_rows}: asking AI to relax filters" ); if refinement_attempts > MAX_REFINEMENTS { diff --git a/server-rs/src/routes/ai_filters/parsing.rs b/server-rs/src/routes/ai_filters/parsing.rs index 55a45a4..28c2f18 100644 --- a/server-rs/src/routes/ai_filters/parsing.rs +++ b/server-rs/src/routes/ai_filters/parsing.rs @@ -78,6 +78,7 @@ fn backend_filter_name(name: &str) -> Option { "Ethnicities:", "Qualifications:", "Tenure:", + "Council housing:", "Amenity distance:", "Closest transport option:", "Amenities within 2km:", @@ -241,7 +242,7 @@ pub(super) fn validate_and_convert(raw: &Value, features: &FeaturesResponse) -> } } - // Process numeric filters — each sets one bound (min or max). + // Process numeric filters: each sets one bound (min or max). // The unset side uses the true data min/max (from histogram), not // the slider bounds (percentile-based), so a "max" filter for crime // produces [0, value] rather than [2nd-percentile, value]. @@ -371,6 +372,16 @@ mod tests { canonical_filter_name("Tenure:%25%20Owner%20occupied:0"), "% Owner occupied" ); + // The "Council housing" pill toggle re-points to the Census social-rent + // column for "current" and to these EPC columns for "ex" / "both". + assert_eq!( + canonical_filter_name("Council housing:%25%20Ex-council:0"), + "% Ex-council" + ); + assert_eq!( + canonical_filter_name("Council housing:%25%20Council%20housing:1"), + "% Council housing" + ); assert_eq!( canonical_filter_name("Serious crime:Serious%20crime%20(%2Fyr%2C%207y):0"), "Serious crime (/yr, 7y)" diff --git a/server-rs/src/routes/crime_records.rs b/server-rs/src/routes/crime_records.rs index 3d698f3..3509753 100644 --- a/server-rs/src/routes/crime_records.rs +++ b/server-rs/src/routes/crime_records.rs @@ -1,4 +1,4 @@ -//! `GET /api/crime-records` — the individual police.uk crimes (last 7 years) +//! `GET /api/crime-records`: the individual police.uk crimes (last 7 years) //! behind a selected hexagon or postcode, paginated. Display-only and //! independent of the property filters, like the population figure: the records //! are an attribute of the area, not of the filter-matching subset. diff --git a/server-rs/src/routes/hexagon_stats.rs b/server-rs/src/routes/hexagon_stats.rs index b617884..ac6b47b 100644 --- a/server-rs/src/routes/hexagon_stats.rs +++ b/server-rs/src/routes/hexagon_stats.rs @@ -84,7 +84,7 @@ pub struct CrimeYearStats { pub struct CrimeAreaAverage { /// Full crime-feature name (e.g. "Burglary (/yr, 7y)"). pub name: String, - /// Exact national mean count — the frontend prefers this over the + /// Exact national mean count. The frontend prefers this over the /// histogram-bin national average for crime so all four numbers in the row /// share one estimator. #[serde(skip_serializing_if = "Option::is_none")] @@ -163,7 +163,7 @@ pub struct HexagonStatsResponse { #[serde(skip_serializing_if = "Vec::is_empty")] pub crime_area_averages: Vec, /// Total individual crime records (last 7 years) across the distinct - /// postcodes in this selection — the count behind the "individual crimes" + /// postcodes in this selection: the count behind the "individual crimes" /// list. Filter-independent, like `population`. #[serde(skip_serializing_if = "Option::is_none")] pub crime_total_records: Option, @@ -627,7 +627,7 @@ pub async fn get_hexagon_stats( }); row.map(|row| state.data.postcode(row).to_string()) } else { - // No journey destination requested — use geographic center + // No journey destination requested. Use geographic center let closest_row = matching_rows .iter() .copied() @@ -693,7 +693,7 @@ pub async fn get_hexagon_stats( ); // Distinct postcodes covered by the hexagon, taken over `area_rows` (all - // properties in the cell), not the filter-matching subset — population and + // properties in the cell), not the filter-matching subset: population and // the crime-records count are attributes of the area, independent of the // active filters (like the council-house count). let mut seen: HashSet<&str> = HashSet::new(); diff --git a/server-rs/src/routes/hexagons.rs b/server-rs/src/routes/hexagons.rs index e21aacd..9364d1a 100644 --- a/server-rs/src/routes/hexagons.rs +++ b/server-rs/src/routes/hexagons.rs @@ -73,7 +73,7 @@ pub struct HexagonParams { /// Build feature maps from aggregated cell data, filtering to only cells whose /// center is within the query bounds (expanded by a resolution-dependent buffer). /// This is much cheaper than the previous approach of computing full cell boundaries -/// (6 vertices per cell) — just 4 float comparisons per cell. +/// (6 vertices per cell): just 4 float comparisons per cell. #[allow(clippy::too_many_arguments)] fn build_feature_maps( groups: &FxHashMap, @@ -129,7 +129,7 @@ fn build_feature_maps( continue; }; - // Center is already needed for lat/lon output — reuse for bounds check + // Center is already needed for lat/lon output: reuse for bounds check let center: h3o::LatLng = cell.into(); let lat = center.lat(); let lng = center.lng(); @@ -341,7 +341,7 @@ pub async fn get_hexagons( .map(|_| FxHashMap::default()) .collect(); - // O(grid cells) count — no allocation. Used for parallel threshold decision. + // O(grid cells) count: no allocation. Used for parallel threshold decision. let row_count = state.grid.count_in_bounds(south, west, north, east); let t_grid = t0.elapsed(); diff --git a/server-rs/src/routes/pb_proxy.rs b/server-rs/src/routes/pb_proxy.rs index c654cef..3fe206f 100644 --- a/server-rs/src/routes/pb_proxy.rs +++ b/server-rs/src/routes/pb_proxy.rs @@ -22,7 +22,7 @@ fn is_allowed_pb_path(path: &str) -> bool { ) { return true; } - // Prefix-allowed paths. The trailing slash is intentional — without it, + // Prefix-allowed paths. The trailing slash is intentional. Without it, // `/api/collections/users` (the schema endpoint) would match. const ALLOWED_PREFIXES: &[&str] = &[ "/api/collections/users/", @@ -34,7 +34,7 @@ fn is_allowed_pb_path(path: &str) -> bool { .any(|prefix| path.starts_with(prefix)) } -/// Dedicated HTTP client for proxying — does not follow redirects so 3xx +/// Dedicated HTTP client for proxying: does not follow redirects so 3xx /// responses are passed through to the browser (needed for OAuth flows). /// No client-wide timeout because SSE (Server-Sent Events) connections used /// by PocketBase realtime/OAuth2 are long-lived streams; non-realtime @@ -132,7 +132,7 @@ pub async fn proxy_to_pocketbase( // Stream the response body instead of buffering it entirely. // This is critical for SSE (Server-Sent Events) used by PocketBase's - // realtime system and OAuth2 flow — buffering would hang forever + // realtime system and OAuth2 flow: buffering would hang forever // since SSE responses never complete. let body = Body::from_stream(upstream.bytes_stream()); response.body(body).unwrap_or_else(|err| { diff --git a/server-rs/src/routes/places.rs b/server-rs/src/routes/places.rs index 491a854..953ae6d 100644 --- a/server-rs/src/routes/places.rs +++ b/server-rs/src/routes/places.rs @@ -160,7 +160,7 @@ fn is_postcode_fragmentish(token: &str) -> bool { /// Peel a trailing geographic refinement (outcode, or outcode + sector digit) off the query. /// "camden nw1" → ("camden", Some("NW1")); the core matches the place, the refinement biases -/// ranking and drives the outcode/postcode lists — instead of breaking the match entirely. +/// ranking and drives the outcode/postcode lists, instead of breaking the match entirely. fn split_geographic_refinement(query: &str) -> (String, Option) { let words: Vec<&str> = query.split_whitespace().collect(); if words.len() < 2 { @@ -320,7 +320,7 @@ pub async fn get_places( let bias_center = viewport.or_else(|| refinement_outcode.map(|idx| od.centroids[idx])); // ---- Places: candidate rows from the inverted token index, then exact/prefix/token-AND - // scoring — bounded by matched candidates, not the ~1M-row corpus. Fuzzy fallback uses the + // scoring: bounded by matched candidates, not the ~1M-row corpus. Fuzzy fallback uses the // (small) trigram index over fuzzy-eligible rows only. let mut place_results: Vec<(f32, PlaceResult)> = Vec::new(); let mut matched_place_idx: FxHashSet = FxHashSet::default(); @@ -400,7 +400,7 @@ pub async fn get_places( }; if mode_filter.is_none() { if let Some(idx) = refinement_outcode { - // A refinement ("camden nw1") resolves to exactly one outcode — no NW10/NW11 noise. + // A refinement ("camden nw1") resolves to exactly one outcode: no NW10/NW11 noise. push_outcode(&mut place_results, idx, 980.0); } else if looks_like_postcode_prefix(&query) { // A bare postcode-prefix query ("e1") lists matching outcodes (e1, e10, e11, ...). @@ -597,7 +597,7 @@ mod tests { // A reordered multi-word query still matches via token-AND. assert!(base("manchester piccadilly", "piccadilly manchester").is_some()); // Pure infix substrings no longer match (candidates are token-based): "ford" must not - // surface "Stratford" — that was the old population-dominated noise. + // surface "Stratford". That was the old population-dominated noise. assert!(base("stratford", "ford").is_none()); // Appended noise that matches nothing yields no match (the route strips postcodes first). assert!(base("camden", "camden zzzz").is_none()); diff --git a/server-rs/src/routes/postcodes.rs b/server-rs/src/routes/postcodes.rs index fc48f34..911b73c 100644 --- a/server-rs/src/routes/postcodes.rs +++ b/server-rs/src/routes/postcodes.rs @@ -56,6 +56,21 @@ pub async fn get_postcodes( let (south, west, north, east) = require_bounds(params.bounds).map_err(IntoResponse::into_response)?; + // Guard against unbounded GeoJSON payloads: postcodes are only ever requested + // by the client at high zoom (POSTCODE_ZOOM_THRESHOLD = 13.5, viewport area + // well under 0.1 deg²), so any request spanning a large area is a bug or an + // abuse attempt. Without this cap a ~200-byte request could pull hundreds of + // MB of full-resolution polygons for the whole country. + const MAX_BOUNDS_AREA_DEG2: f64 = 1.0; + let bounds_area = (north - south).abs() * (east - west).abs(); + if bounds_area > MAX_BOUNDS_AREA_DEG2 { + return Err(( + StatusCode::BAD_REQUEST, + "Requested area is too large to load individual postcodes; zoom in.".to_string(), + ) + .into_response()); + } + let quant = state.data.quant_ref(); let poi_quant = state.data.poi_metrics.quant_ref(); let (parsed_filters, parsed_enum_filters, parsed_poi_filters) = parse_filters_with_poi( diff --git a/server-rs/src/routes/shorten.rs b/server-rs/src/routes/shorten.rs index 6ac8c7e..1fa7c4b 100644 --- a/server-rs/src/routes/shorten.rs +++ b/server-rs/src/routes/shorten.rs @@ -124,6 +124,7 @@ fn is_allowed_param_key(key: &str) -> bool { | "ethnicity" | "qualification" | "tenure" + | "council" | "amenityDistance" | "transportDistance" | "amenityCount2km" @@ -504,6 +505,7 @@ mod tests { let query = "crimeSeverity=Serious%3A0%3A5\ &qualification=Degree%3A20%3A80\ &tenure=Owner%3A30%3A90\ + &council=Ex%3A10%3A90\ &crimeType=burglary\ &colorOpacity=60"; let params = sanitized_query_params(query).unwrap(); diff --git a/server-rs/src/state.rs b/server-rs/src/state.rs index 2a04d0c..861e67e 100644 --- a/server-rs/src/state.rs +++ b/server-rs/src/state.rs @@ -55,7 +55,7 @@ pub struct AppState { /// served by the `/api/crime-records` endpoint and counted in stats. pub crime_records: Arc, /// Per-unit-postcode usual-resident headcounts (Census 2021), shown in the - /// right pane. Display-only — never filterable. Empty when no data is loaded. + /// right pane. Display-only. Never filterable. Empty when no data is loaded. pub population: Arc, /// Precomputed per-outcode and per-postcode-sector average crime counts, /// shown in the right pane alongside the national average for each metric. diff --git a/server-rs/src/utils.rs b/server-rs/src/utils.rs index a28db3c..7482a5d 100644 --- a/server-rs/src/utils.rs +++ b/server-rs/src/utils.rs @@ -23,7 +23,7 @@ pub fn normalize_postcode(raw: &str) -> String { /// Outward code (outcode) of a normalized postcode: the part before the space. /// e.g. "E14 2DG" → Some("E14"). Returns `None` when the input has no space -/// (already-short or malformed — `normalize_postcode` only inserts a space for +/// (already-short or malformed: `normalize_postcode` only inserts a space for /// inputs of length ≥ 5). pub fn postcode_outcode(postcode: &str) -> Option<&str> { postcode.split_once(' ').map(|(outward, _)| outward) diff --git a/uv.lock b/uv.lock index fcc460a..7dd132e 100644 --- a/uv.lock +++ b/uv.lock @@ -314,6 +314,50 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3c/b2/6d913d4d04e14379de429057cd169e5e00f6c2af3bb13e1710bcbdb5da12/contourpy-1.3.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:fd6ec6be509c787f1caf6b247f0b1ca598bef13f4ddeaa126b7658215529ba0f", size = 1391027, upload-time = "2025-07-26T12:02:47.09Z" }, ] +[[package]] +name = "cryptography" +version = "49.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "python_full_version < '3.14' and platform_python_implementation != 'PyPy' and sys_platform == 'linux'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1f/99/d1c90d6041656cc6ee229dc99cd67fd0cd5aec3c5f7d72fffc27cc750054/cryptography-49.0.0.tar.gz", hash = "sha256:f89660a348f4f78a92366240a61404e337586ef7f5909a2fef59ca88ef505493", size = 854345, upload-time = "2026-06-12T20:02:30.512Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/09/41/3797cfaf69cae04a13ee78ebd83f0678d9c02b4779d21ce24445326f1a69/cryptography-49.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:36d1709f992593689b45bda411498d62c6e365f2ca00b84657d4dadd24de16db", size = 4692978, upload-time = "2026-06-12T20:01:21.305Z" }, + { url = "https://files.pythonhosted.org/packages/e6/8b/43011f7ebe515a8aa20d61f290a326cd890c2e738e16e59eaff8d9c3a412/cryptography-49.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0e959b578856a3924bc0cbb710fc12c387b9412a951389f3ca61704a9e25f325", size = 4716422, upload-time = "2026-06-12T20:01:48.566Z" }, + { url = "https://files.pythonhosted.org/packages/4a/91/01ce7303a4579e6d3a6abef01bd322848e9ea7a219adcabc5048b9033571/cryptography-49.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:53ecee2e23f7169b6117e99fc8a944e5e50f79e69758a83b52a00cb98ab2b2d2", size = 4700503, upload-time = "2026-06-12T20:02:47.091Z" }, + { url = "https://files.pythonhosted.org/packages/62/99/a2c95cf8293f07491e9e27c20cc4dcd18176d944e674679adeb1d0173fd6/cryptography-49.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2eda353d8a27bcbcaa4cbed18994a74ab4d19a2ca897db188ea269ab9b71419b", size = 5309779, upload-time = "2026-06-12T20:02:08.987Z" }, + { url = "https://files.pythonhosted.org/packages/20/2c/0622f20ff02b2ef32558733443805dc82fd4c275be01b2d19d14676f3a1b/cryptography-49.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2afe9051da7ae7bd5905da5a949280c7d2bb75682e188f650a9d0f2756b834c6", size = 4749683, upload-time = "2026-06-12T20:02:03.335Z" }, + { url = "https://files.pythonhosted.org/packages/a3/5b/c5246635d5fd3b64e0d45ae10e99fd32fe9676a79915ccfe5a61ba9af1a5/cryptography-49.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:0b82e28ee398a386f0807bba7884d30f25218855690f45115831bcce5d90822c", size = 4337874, upload-time = "2026-06-12T20:02:54.323Z" }, + { url = "https://files.pythonhosted.org/packages/6d/88/05563c7fe2e914e87d1a536d06fe83e66b4e1d95cb593e05aea375531da8/cryptography-49.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:ccac2bfebc306b862133e3bb71f3f6ee8bb525240089b2d952e4144b3a6d5da7", size = 4700283, upload-time = "2026-06-12T20:01:34.822Z" }, + { url = "https://files.pythonhosted.org/packages/c4/b6/d7696e4e890d6ae1469935164c9e5215c557671cb78d6e3f458ccceaa632/cryptography-49.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:d0527ce944105f257f605a827d6ebead966c752038b6e8656abb9c5edee6fc68", size = 5265844, upload-time = "2026-06-12T20:01:24.09Z" }, + { url = "https://files.pythonhosted.org/packages/a9/3c/f3ad17eecc1a57b0ba236dc01f90e783c51f4a2f35f64777cc4f47a184b2/cryptography-49.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:cbc77da8c523d5abd028635ba850a6966fcee2c82e2bf65a41d1d8afe0f98be9", size = 4749290, upload-time = "2026-06-12T20:01:30.848Z" }, + { url = "https://files.pythonhosted.org/packages/4f/01/339573cf1023163a400b0b5d16f6d507de413b9f60be6fd1b77feeaf6737/cryptography-49.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b87e65d263b3e5d3bb92a57e2a6638e2f31110fa7aa890c7b2dbba42248d0a3f", size = 4834612, upload-time = "2026-06-12T20:01:29.246Z" }, + { url = "https://files.pythonhosted.org/packages/71/fd/577302e213a1be9468f92d1afef66fcf1ef83d516819d9992ca547f592bd/cryptography-49.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:66ec79c3904820572d7e987abdf304281f141d37ad9a489b8e97066e7b9b6459", size = 4980804, upload-time = "2026-06-12T20:01:42.853Z" }, + { url = "https://files.pythonhosted.org/packages/86/12/c48a424f38db03027be9f7ed5c7dc5de9933dbee992865f98b13727a009d/cryptography-49.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:196ecd6a36e4e9aa10270393bb98d8df88fccee0bf1e5128b91ae4eb4375896d", size = 4678835, upload-time = "2026-06-12T20:02:48.743Z" }, + { url = "https://files.pythonhosted.org/packages/68/28/8a3ad4653662c93fc44dc4e5d8fd374c25c42e07b34bbfbadf49cf57a5a8/cryptography-49.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7abcee80084cda3f7691f3eb1ce480d8df49cec637b429aa35986c1de71738aa", size = 4697239, upload-time = "2026-06-12T20:02:56.03Z" }, + { url = "https://files.pythonhosted.org/packages/a8/b2/2193fc74f81aee4f9b62733133b73b5176718932ed8f2e4b03fa040480a6/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:4ae387c9cb68ea569ca17e490d66d8142b81c3cc814bf179974b7d146e490bbb", size = 4685593, upload-time = "2026-06-12T20:02:50.666Z" }, + { url = "https://files.pythonhosted.org/packages/47/f1/1d3eaa243bfc5de4a187b22aa8c048b3e4980bfbe830ac46e6bac2e66947/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:f37d847238971164fdbc68ade6f6574aecc9c0af714190e2083429ff68f4ce9d", size = 5289961, upload-time = "2026-06-12T20:01:46.468Z" }, + { url = "https://files.pythonhosted.org/packages/58/39/2d51306721330c486495853eda1c567880ff036de15a14c4b74f399934af/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:c2bc30226390d60ea19d9f82b19db005fe0452154a23c1c410c12ea801e43561", size = 4731145, upload-time = "2026-06-12T20:02:16.832Z" }, + { url = "https://files.pythonhosted.org/packages/17/50/983e838c7fd0d87fd8c969bcdd328edaf5f756e38df5281637424c155873/cryptography-49.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:07cab27cc7b7e0fd28e5e26bb9eeedde5c135c868b46de4a27845abe94af6122", size = 4321719, upload-time = "2026-06-12T20:02:52.611Z" }, + { url = "https://files.pythonhosted.org/packages/a7/f5/8f571d7e27c55bce9f76f026143bcb1e040a4233149ecca0bea5fa5dd5f7/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:b20133d204d2bb56ba047642199603876c872026ca53e79c35b83772ab2cc505", size = 4685209, upload-time = "2026-06-12T20:02:07.282Z" }, + { url = "https://files.pythonhosted.org/packages/e7/84/0e27016a6fc5a0886f797018b26aa42f40c09a82332bff77822a451deaaa/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b970c6da94d5bb18629db453d14f2a1300f6bf59b61e9b82377931ef95504866", size = 5246285, upload-time = "2026-06-12T20:01:32.439Z" }, + { url = "https://files.pythonhosted.org/packages/11/2d/5e1fb307cb5931881516b464c98774b3f2c36b5d4bb9a2830253cf553cad/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d8ecde755e2e91bf773fc94e8c9d730cd7f2007004cb492263a794ec3899a1c8", size = 4730441, upload-time = "2026-06-12T20:02:01.469Z" }, + { url = "https://files.pythonhosted.org/packages/e4/c0/bff5a02ee731d207d6a1ed51732549d8c53d2bc8da1d10ec6f2844201d68/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e3fb64c420688e5319ae25113a354015abbd8dffbfbc41781a1ea66fc7622ac3", size = 4815869, upload-time = "2026-06-12T20:01:36.574Z" }, + { url = "https://files.pythonhosted.org/packages/b9/26/814681d14248d95d73d5c3eea0c39a94eb8302df966f670a2c60de90974b/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32703d93296f5c1f4b53349ad3a250c2cae0fdecd3a3dd5d47e616d8d616af27", size = 4960948, upload-time = "2026-06-12T20:02:18.688Z" }, + { url = "https://files.pythonhosted.org/packages/3d/df/40577043ca124e17012f408ddddaeb213b856336ac82ddb3bc915f39e29f/cryptography-49.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f78ff2c9ed8dc2d036b0f4d640e22522213d047c1b14e61205a7e55c80a494d4", size = 4692429, upload-time = "2026-06-12T20:01:53.628Z" }, + { url = "https://files.pythonhosted.org/packages/2c/99/2d13299eb3dd27b02dcfaafcc91d6b5cb3329f7cbd6d8f51921acd566c1a/cryptography-49.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:35b151772baff2c74cba7fa290ceaff4c3b11c0c881eb93eb5dbc05a7cfbba18", size = 4700968, upload-time = "2026-06-12T20:02:45.383Z" }, + { url = "https://files.pythonhosted.org/packages/a5/4d/9c0cd02f95e2602dd5e563da149ee0830abef3537be8b34dc56281ebe27a/cryptography-49.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:0f21641cf4b30fca7aee061ced0ec7ad7b073518088b7c9969a297c0ae796c69", size = 4697758, upload-time = "2026-06-12T20:01:41.13Z" }, + { url = "https://files.pythonhosted.org/packages/24/01/186c825898477d77e2324d5360fefe622ff1d8d1963ec0554e2cada8ec77/cryptography-49.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9e82dcc8e56052715fb18b2429e3bca4823b1629136a2084fc45a9a5cecb9b64", size = 5298863, upload-time = "2026-06-12T20:02:24.579Z" }, + { url = "https://files.pythonhosted.org/packages/b8/7b/62cbbab75d0659865bf0273790031544a0b16c8072d258f9428dcd8190dc/cryptography-49.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:6f2debedf9ca60cf1d5bd466475638af5130f89965605cd818484d19987d3a21", size = 4735983, upload-time = "2026-06-12T20:01:50.14Z" }, + { url = "https://files.pythonhosted.org/packages/6c/72/3e798c064bc39e471008075d0f9bc9daf77a80879c092e4a8e170c585ed4/cryptography-49.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:8c25ceb16df5b9435f3f6a9829204985b0e0cbee3b48aacd432c7d2c850b44d9", size = 4334173, upload-time = "2026-06-12T20:01:44.743Z" }, + { url = "https://files.pythonhosted.org/packages/f0/ee/6fca21d1ac73e06f8bef71940abfd4d2f6472b4bca284d770f32bd4086f6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:28d8b15e6275f12c8a207dc309dfa957903c927d08d0cc937ee3f63f200693cc", size = 4697298, upload-time = "2026-06-12T20:02:20.918Z" }, + { url = "https://files.pythonhosted.org/packages/67/d0/a5fcd3515f0bae49a7b6d0413cc1bdccdcc1fc0047037a0d480642cdc5d6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6fc361c34fb6aac015ce19435876635e5c6d21db31998b0920f675f131e043b8", size = 5254338, upload-time = "2026-06-12T20:02:22.737Z" }, + { url = "https://files.pythonhosted.org/packages/a0/84/84fe36f19caf857d61cb7fc9c63035a47ffabd84ea12d1d393148efa3615/cryptography-49.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2400ef9c9e2299a25614eb1dea3db54a69b1349efd043bfac9c67630d136df36", size = 4735650, upload-time = "2026-06-12T20:02:41.389Z" }, + { url = "https://files.pythonhosted.org/packages/6c/a0/db537264e234f7273a73ec020873d6d6b39dfd8a53db78b550ca8320440e/cryptography-49.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:67e1d20ad9ef3a563c59ef22e7a8a0b8210bd26604369ea4a30a7c66aefe504e", size = 4834820, upload-time = "2026-06-12T20:01:51.847Z" }, + { url = "https://files.pythonhosted.org/packages/93/77/8df9eb486495979bccecd1062e2eaf435250e84437040295b57d09048b0b/cryptography-49.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:42b0684e0e40cf26122427802486f6d93aea593612603a94fbf260c7eb1e9c1b", size = 4967968, upload-time = "2026-06-12T20:02:12.524Z" }, +] + [[package]] name = "cycler" version = "0.12.1" @@ -450,6 +494,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cf/58/8acf1b3e91c58313ce5cb67df61001fc9dcd21be4fadb76c1a2d540e09ed/fqdn-1.5.1-py3-none-any.whl", hash = "sha256:3a179af3761e4df6eb2e026ff9e1a3033d3587bf980a0b1b2e1e5d08d7358014", size = 9121, upload-time = "2021-03-11T07:16:28.351Z" }, ] +[[package]] +name = "google-auth" +version = "2.55.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography", marker = "python_full_version < '3.14' and sys_platform == 'linux'" }, + { name = "pyasn1-modules", marker = "python_full_version < '3.14' and sys_platform == 'linux'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/6f/f3f4ac177c67bbee8fe8e88f2ab4f36af88c44a096e165c5217accf6e5d3/google_auth-2.55.1.tar.gz", hash = "sha256:fb2d9b730f2c9b8d326ec8d7222f21aef2ead15bf0513793d6442485d87af0a1", size = 349527, upload-time = "2026-06-25T23:39:27.182Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/1d/f6d3ca1ad0725f2e08a1c6915640748a52de2e66596160a4d53b010cccf0/google_auth-2.55.1-py3-none-any.whl", hash = "sha256:eada68dfd52b3b81191827601e2a0c3fa12540c818534b630ddc5355769c3995", size = 252349, upload-time = "2026-06-25T23:38:52.946Z" }, +] + [[package]] name = "h11" version = "0.16.0" @@ -1369,6 +1426,7 @@ source = { virtual = "." } dependencies = [ { name = "fastexcel", marker = "python_full_version < '3.14' and sys_platform == 'linux'" }, { name = "folium", marker = "python_full_version < '3.14' and sys_platform == 'linux'" }, + { name = "google-auth", marker = "python_full_version < '3.14' and sys_platform == 'linux'" }, { name = "httpx", extra = ["socks"], marker = "python_full_version < '3.14' and sys_platform == 'linux'" }, { name = "ipywidgets", marker = "python_full_version < '3.14' and sys_platform == 'linux'" }, { name = "jupyter", marker = "python_full_version < '3.14' and sys_platform == 'linux'" }, @@ -1401,6 +1459,7 @@ dev = [ requires-dist = [ { name = "fastexcel", specifier = ">=0.19.0" }, { name = "folium", specifier = ">=0.20.0" }, + { name = "google-auth", specifier = ">=2.55.1" }, { name = "httpx", extras = ["socks"], specifier = ">=0.28.1" }, { name = "ipywidgets", specifier = ">=8.0.0" }, { name = "jupyter", specifier = ">=1.0.0" }, @@ -1491,6 +1550,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/39/07/e4e2d568cb57543d84482f61e510732820cddb0f47c4bb7df629abfed852/pyarrow-23.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:14de7d48052cf4b0ed174533eafa3cfe0711b8076ad70bede32cf59f744f0d7c", size = 50603979, upload-time = "2026-01-18T16:19:26.717Z" }, ] +[[package]] +name = "pyasn1" +version = "0.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5c/5f/6583902b6f79b399c9c40674ac384fd9cd77805f9e6205075f828ef11fb2/pyasn1-0.6.3.tar.gz", hash = "sha256:697a8ecd6d98891189184ca1fa05d1bb00e2f84b5977c481452050549c8a72cf", size = 148685, upload-time = "2026-03-17T01:06:53.382Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/a0/7d793dce3fa811fe047d6ae2431c672364b462850c6235ae306c0efd025f/pyasn1-0.6.3-py3-none-any.whl", hash = "sha256:a80184d120f0864a52a073acc6fc642847d0be408e7c7252f31390c0f4eadcde", size = 83997, upload-time = "2026-03-17T01:06:52.036Z" }, +] + +[[package]] +name = "pyasn1-modules" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyasn1", marker = "python_full_version < '3.14' and sys_platform == 'linux'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e9/e6/78ebbb10a8c8e4b61a59249394a4a594c1a7af95593dc933a349c8d00964/pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6", size = 307892, upload-time = "2025-03-28T02:41:22.17Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/47/8d/d529b5d697919ba8c11ad626e835d4039be708a35b0d22de83a269a6682c/pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a", size = 181259, upload-time = "2025-03-28T02:41:19.028Z" }, +] + [[package]] name = "pycparser" version = "3.0" diff --git a/video/src/storyboard.ts b/video/src/storyboard.ts index e95c5fb..25a51d4 100644 --- a/video/src/storyboard.ts +++ b/video/src/storyboard.ts @@ -1244,14 +1244,14 @@ const TWIN_AD_CONFIGS: DemoAdStoryboardConfig[] = [ name: 'twin-beckenham-croydon', matchCount: 1840, city: 'london', - center: { lat: 51.38969, lon: -0.04244 }, + center: { lat: 51.36969, lon: -0.04244 }, initialZoom: 12.2, promptText: 'Good schools, best value per square metre near Beckenham and Croydon', filters: { 'Est. price per sqm': [0, 5200], 'Good+ secondary school catchments': [1, 11], }, - posterTimeS: 7, + posterTimeS: 13, outroLine: "Beckenham's cheaper twin is on this map. Find yours, free.", cues: [ { @@ -1271,8 +1271,8 @@ const TWIN_AD_CONFIGS: DemoAdStoryboardConfig[] = [ { text: 'One postcode over, the same home quietly costs about a third less.', caption: 'The cheaper twin', - during: [mapZoomIn(1400, 3)], - tail: [wait(400)], + during: [mapZoomIn(1400, 2)], + tail: [wait(900)], }, ], }, @@ -1280,14 +1280,14 @@ const TWIN_AD_CONFIGS: DemoAdStoryboardConfig[] = [ name: 'twin-ha7-2-vs-ha3-0', matchCount: 1620, city: 'london', - center: { lat: 51.59199, lon: -0.3079 }, + center: { lat: 51.57199, lon: -0.3079 }, initialZoom: 12.0, promptText: 'Good schools, best value per square metre near Stanmore and Kenton', filters: { 'Est. price per sqm': [0, 5900], 'Good+ secondary school catchments': [1, 11], }, - posterTimeS: 7, + posterTimeS: 13, outroLine: "Stanmore's cheaper twin is on this map. Find yours, free.", cues: [ { @@ -1305,42 +1305,8 @@ const TWIN_AD_CONFIGS: DemoAdStoryboardConfig[] = [ { text: 'One postcode over, the same home quietly costs about a sixth less.', caption: 'The cheaper twin', - during: [mapZoomIn(1400, 3)], - tail: [wait(400)], - }, - ], - }, - { - name: 'twin-m40-5-vs-m9-4', - matchCount: 2480, - city: 'manchester', - center: { lat: 53.51293, lon: -2.19574 }, - initialZoom: 12.0, - promptText: 'Good schools, best value per square metre near Newton Heath and Harpurhey', - filters: { - 'Est. price per sqm': [0, 1700], - 'Good+ secondary school catchments': [1, 11], - }, - posterTimeS: 7, - outroLine: "Newton Heath's cheaper twin is on this map. Find yours, free.", - cues: [ - { - text: 'Newton Heath and Harpurhey sit right next door, with the same schools and transport links.', - caption: 'Same area. Same schools.', - during: [typeAct('Good schools, best value per square metre near Newton Heath and Harpurhey', 2600)], - tail: [wait(150)], - }, - { - text: 'Rank every postcode by what each pound of floor space actually buys.', - caption: 'Ranked by £ per m²', - during: [submitSettled(1400)], - tail: [sheetDown(800), wait(250)], - }, - { - text: 'One postcode over, the same home quietly costs over a third less.', - caption: 'The cheaper twin', - during: [mapZoomIn(1400, 3)], - tail: [wait(400)], + during: [mapZoomIn(1400, 2)], + tail: [wait(900)], }, ], }, @@ -1348,14 +1314,14 @@ const TWIN_AD_CONFIGS: DemoAdStoryboardConfig[] = [ name: 'twin-l16-7-vs-l14-6', matchCount: 1740, city: 'liverpool', - center: { lat: 53.40344, lon: -2.88529 }, + center: { lat: 53.38344, lon: -2.88529 }, initialZoom: 12.0, promptText: 'Good schools, best value per square metre near Childwall and Broadgreen', filters: { 'Est. price per sqm': [0, 3000], 'Good+ secondary school catchments': [1, 11], }, - posterTimeS: 7, + posterTimeS: 13, outroLine: "Childwall's cheaper twin is on this map. Find yours, free.", cues: [ { @@ -1373,42 +1339,8 @@ const TWIN_AD_CONFIGS: DemoAdStoryboardConfig[] = [ { text: 'One postcode over, the same home quietly costs about a third less.', caption: 'The cheaper twin', - during: [mapZoomIn(1400, 3)], - tail: [wait(400)], - }, - ], - }, - { - name: 'twin-rm14-2-vs-rm12-5', - matchCount: 1980, - city: 'london', - center: { lat: 51.54892, lon: 0.22193 }, - initialZoom: 12.0, - promptText: 'Good schools, best value per square metre near Upminster and Hornchurch', - filters: { - 'Est. price per sqm': [0, 5300], - 'Good+ secondary school catchments': [1, 11], - }, - posterTimeS: 7, - outroLine: "Upminster's cheaper twin is on this map. Find yours, free.", - cues: [ - { - text: 'Upminster and Hornchurch sit right next door, with the same schools and transport links.', - caption: 'Same area. Same schools.', - during: [typeAct('Good schools, best value per square metre near Upminster and Hornchurch', 2600)], - tail: [wait(150)], - }, - { - text: 'Rank every postcode by what each pound of floor space actually buys.', - caption: 'Ranked by £ per m²', - during: [submitSettled(1400)], - tail: [sheetDown(800), wait(250)], - }, - { - text: 'One postcode over, the same home quietly costs about a fifth less.', - caption: 'The cheaper twin', - during: [mapZoomIn(1400, 3)], - tail: [wait(400)], + during: [mapZoomIn(1400, 2)], + tail: [wait(900)], }, ], }, From 1ee796b2829ae19981e3156b7cf77966e90b3622 Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Fri, 3 Jul 2026 18:28:56 +0100 Subject: [PATCH 3/9] . --- finder/test_rightmove_concurrency.py | 6 +- finder/test_scraper_concurrency.py | 6 +- finder/test_transform.py | 31 +++++ finder/test_zoopla.py | 4 +- finder/transform.py | 25 +++- finder/zoopla_flaresolverr.py | 2 +- .../src/components/home/ProductShowcase.tsx | 114 ++++++++++++------ frontend/src/types.ts | 3 +- pipeline/download/median_age.py | 4 +- pipeline/download/naptan.py | 4 +- pipeline/download/noise.py | 18 +-- pipeline/download/os_greenspace.py | 4 +- pipeline/download/places.py | 2 +- pipeline/download/rental_prices.py | 4 +- pipeline/download/tenure.py | 6 +- pipeline/download/test_ethnicity.py | 2 +- pipeline/download/test_noise.py | 2 +- pipeline/download/transit_network.py | 8 +- .../transform/postcode_boundaries/geometry.py | 10 +- .../transform/postcode_boundaries/output.py | 12 +- .../postcode_boundaries/process_oa.py | 8 +- .../transform/postcode_boundaries/uprn.py | 4 +- pipeline/transform/price_estimation/index.py | 2 +- pipeline/transform/price_estimation/utils.py | 2 +- pipeline/transform/test_join_epc_pp.py | 47 ++++++-- pipeline/transform/test_transform_poi.py | 2 +- pipeline/transform/transform_poi.py | 26 ++-- pipeline/transform/tree_density.py | 12 +- pipeline/utils/test_poi_counts.py | 6 +- 29 files changed, 250 insertions(+), 126 deletions(-) diff --git a/finder/test_rightmove_concurrency.py b/finder/test_rightmove_concurrency.py index cadc3b1..3ef3671 100644 --- a/finder/test_rightmove_concurrency.py +++ b/finder/test_rightmove_concurrency.py @@ -14,7 +14,7 @@ def _prop(pid, pin=None): # --------------------------------------------------------------------------- -# _needs_detail_fetch — accurate-pin skip +# _needs_detail_fetch: accurate-pin skip # --------------------------------------------------------------------------- @@ -32,7 +32,7 @@ def test_needs_detail_fetch_disabled_always_fetches(monkeypatch): # --------------------------------------------------------------------------- -# _prime_detail_postcodes — worklist selection + concurrent fetch +# _prime_detail_postcodes: worklist selection + concurrent fetch # --------------------------------------------------------------------------- @@ -97,7 +97,7 @@ def test_prime_is_a_noop_when_disabled_or_cap_zero(monkeypatch): # --------------------------------------------------------------------------- -# _paginate — end-to-end (network stubbed): accurate pins fall back to +# _paginate end-to-end (network stubbed): accurate pins fall back to # coordinates, approximate pins use the detail postcode. # --------------------------------------------------------------------------- diff --git a/finder/test_scraper_concurrency.py b/finder/test_scraper_concurrency.py index 6f9bbe3..24df360 100644 --- a/finder/test_scraper_concurrency.py +++ b/finder/test_scraper_concurrency.py @@ -9,7 +9,7 @@ import zoopla # --------------------------------------------------------------------------- -# _run_sources — Zoopla inline, others in threads, failures isolated +# _run_sources: Zoopla inline, others in threads, failures isolated # --------------------------------------------------------------------------- @@ -85,13 +85,13 @@ def test_seed_and_save_detail_caches_round_trip(tmp_path): def test_seed_detail_caches_tolerates_missing_files(tmp_path): rightmove._detail_postcode_cache.clear() - # No file written yet — seeding must not raise and must leave cache empty. + # No file written yet: seeding must not raise and must leave cache empty. scraper._seed_detail_caches(["rightmove"], tmp_path) assert rightmove._detail_postcode_cache == {} # --------------------------------------------------------------------------- -# run_scrape — full orchestration wiring (sources stubbed, no network) +# run_scrape: full orchestration wiring (sources stubbed, no network) # --------------------------------------------------------------------------- diff --git a/finder/test_transform.py b/finder/test_transform.py index 1da0a7e..1750fca 100644 --- a/finder/test_transform.py +++ b/finder/test_transform.py @@ -1,4 +1,5 @@ from transform import ( + build_listing_url, build_register_address, clean_listing_address, extract_full_postcode, @@ -173,3 +174,33 @@ def test_rightmove_transform_without_detail_keeps_coordinate_logic() -> None: assert result is not None assert result["Postcode"] == "SW9 7AA" assert result["Postcode source"] == "coordinates" + + +def test_build_listing_url_stamps_channel_from_new_build_flag() -> None: + # Resale gets RES_BUY; new builds get RES_NEW. + assert build_listing_url("/properties/200", False) == ( + "https://www.rightmove.co.uk/properties/200#/?channel=RES_BUY" + ) + assert build_listing_url("/properties/200", True) == ( + "https://www.rightmove.co.uk/properties/200#/?channel=RES_NEW" + ) + # An existing channel/fragment on the source URL is stripped and re-stamped. + assert build_listing_url("/properties/200#/?channel=RES_BUY", True) == ( + "https://www.rightmove.co.uk/properties/200#/?channel=RES_NEW" + ) + # Missing URL stays empty. + assert build_listing_url("", True) == "" + + +def test_rightmove_transform_tags_new_builds_res_new() -> None: + # The Rightmove search response marks new-build developments with + # development=True; transform_property must stamp the listing URL RES_NEW. + new_build = {**_rightmove_prop(), "development": True} + result = transform_property(new_build, "SW9", StubPostcodeIndex("SW9 7AA")) + assert result is not None + assert result["Listing URL"].endswith("#/?channel=RES_NEW") + + # Ordinary resale (development absent/false) stays RES_BUY. + resale = transform_property(_rightmove_prop(), "SW9", StubPostcodeIndex("SW9 7AA")) + assert resale is not None + assert resale["Listing URL"].endswith("#/?channel=RES_BUY") diff --git a/finder/test_zoopla.py b/finder/test_zoopla.py index 228e21b..f7a993f 100644 --- a/finder/test_zoopla.py +++ b/finder/test_zoopla.py @@ -103,7 +103,7 @@ def test_parse_detail_geo_merges_location_uprn_with_address_full_address() -> No def test_parse_detail_geo_does_not_borrow_comparable_full_address() -> None: # The only `address` twin on the page belongs to a different uprn (a # comparable listing). With a uprn to match on, an unrelated twin is never - # borrowed — full_address stays None rather than grabbing the wrong street. + # borrowed: full_address stays None rather than grabbing the wrong street. html = ( '"location":{"outcode":"NR29",' '"coordinates":{"latitude":52.716014,"longitude":1.614495},' @@ -185,7 +185,7 @@ def test_parse_detail_geo_returns_none_for_garbage() -> None: assert parse_detail_geo("no data here") is None assert parse_detail_geo("") is None # Coordinates that are not inside a property location/address wrapper (e.g. - # only an unwrapped POI) yield nothing — safe degradation to the outcode. + # only an unwrapped POI) yield nothing, safe degradation to the outcode. assert parse_detail_geo('"name":"X","coordinates":{"latitude":51.5,"longitude":-0.1}') is None diff --git a/finder/transform.py b/finder/transform.py index 49986ba..2a05dd4 100644 --- a/finder/transform.py +++ b/finder/transform.py @@ -149,7 +149,7 @@ def map_property_type(sub_type: str | None) -> str: return "Terraced" if "house" in lower or "cottage" in lower: return "Detached" - log.warning("Unknown propertySubType: %r — mapping to Other", sub_type) + log.warning("Unknown propertySubType: %r, mapping to Other", sub_type) return "Other" @@ -267,7 +267,7 @@ def build_register_address( property's own number or name (e.g. Zoopla detail pages expose ``propertyNumberOrName`` = "12" or "Martham Mill"), prepend it so the address carries the house identifier that the EPC/Price-Paid register addresses also - use — turning a fuzzy street match into a near-exact one. Falls back to the + use, turning a fuzzy street match into a near-exact one. Falls back to the plain cleaned address when no number/name is available. """ cleaned = clean_listing_address(raw_address) @@ -282,6 +282,23 @@ def build_register_address( return f"{number_or_name}, {cleaned}" if cleaned else number_or_name +def build_listing_url(property_url: str | None, is_new_build: bool) -> str: + """Build the canonical Rightmove listing URL with an explicit channel marker. + + The search API always echoes ``?channel=RES_BUY`` in ``propertyUrl`` even for + new-build developments (the request channel stays BUY), so the channel is + re-stamped here from the per-listing ``development`` flag: ``RES_NEW`` for new + builds, ``RES_BUY`` for ordinary resale. The map UI reads this marker to split + new vs non-new listings. Any channel/fragment already on ``propertyUrl`` is + stripped first so the result is deterministic. + """ + if not property_url: + return "" + base = property_url.split("#", 1)[0].split("?", 1)[0] + channel = "RES_NEW" if is_new_build else "RES_BUY" + return f"{RIGHTMOVE_BASE}{base}#/?channel={channel}" + + def transform_property( prop: dict, outcode: str, @@ -337,7 +354,7 @@ def transform_property( inferred_postcode = pc_index.nearest(lat, lng) if not inferred_postcode: - log.debug("No England postcode for property at %.4f, %.4f — skipping", lat, lng) + log.debug("No England postcode for property at %.4f, %.4f; skipping", lat, lng) return None raw_address = prop.get("displayAddress", "") or "" extracted_postcode = extract_full_postcode(raw_address) @@ -391,7 +408,7 @@ def transform_property( "price_frequency": "", "Price qualifier": price_qualifier, "Total floor area (sqm)": parse_display_size(prop.get("displaySize")), - "Listing URL": RIGHTMOVE_BASE + property_url if property_url else "", + "Listing URL": build_listing_url(property_url, bool(prop.get("development"))), "Listing features": key_features, "first_visible_date": prop.get("firstVisibleDate", ""), } diff --git a/finder/zoopla_flaresolverr.py b/finder/zoopla_flaresolverr.py index f3e6860..c268d83 100644 --- a/finder/zoopla_flaresolverr.py +++ b/finder/zoopla_flaresolverr.py @@ -1,7 +1,7 @@ """Zoopla scraping via FlareSolverr (no browser/VNC needed). FlareSolverr solves Zoopla's Cloudflare and returns the rendered HTML, which -still contains the React Server Components flight stream — so the existing pure +still contains the React Server Components flight stream, so the existing pure parsers work unchanged: - the search page yields the outcode's listing detail URLs, and - each detail page's flight stream carries the property's location object diff --git a/frontend/src/components/home/ProductShowcase.tsx b/frontend/src/components/home/ProductShowcase.tsx index 4273c72..5bfe64a 100644 --- a/frontend/src/components/home/ProductShowcase.tsx +++ b/frontend/src/components/home/ProductShowcase.tsx @@ -12,6 +12,7 @@ import type { TFunction } from 'i18next'; import { useTranslation } from 'react-i18next'; import { cellToLatLng, polygonToCells } from 'h3-js'; import PriceHistoryChart from '../map/PriceHistoryChart'; +import { MapErrorBoundary } from '../map/MapErrorBoundary'; import StackedBarChart from '../map/StackedBarChart'; import JourneyInstructions, { type JourneyInstructionPreset } from '../map/JourneyInstructions'; import { DualHistogram } from '../map/DualHistogram'; @@ -214,10 +215,13 @@ const SHOWCASE_MAP_START_VIEW: ViewState = { bearing: 0, }; +// End the Match fly-in over Greater London so the "match" step lands on the same +// place the Inspect (SW5 9AA) and Scout (SW5/SE22/N4) steps then drill into. The +// single-search narrative used to break by zooming to Birmingham then showing London. const SHOWCASE_MAP_END_VIEW: ViewState = { - longitude: -1.89, - latitude: 52.49, - zoom: 8.72, + longitude: -0.12, + latitude: 51.51, + zoom: 9.05, pitch: 0, bearing: 0, }; @@ -260,7 +264,12 @@ function buildShowcaseMapData(): HexagonData[] { } const SHOWCASE_MAP_DATA = buildShowcaseMapData(); -const SHOWCASE_MAP_TOTAL_COUNT = SHOWCASE_MAP_DATA.reduce((sum, item) => sum + item.count, 0); +// Count only the hexes around Greater London so the Match card's figure matches the +// area it has zoomed to, instead of mislabelling the England-wide total as one city. +const SHOWCASE_LONDON_COUNT = SHOWCASE_MAP_DATA.reduce((sum, item) => { + const nearLondon = Math.abs(item.lat - 51.5072) < 0.55 && Math.abs(item.lon + 0.1276) < 0.85; + return nearLondon ? sum + item.count : sum; +}, 0); const EMPTY_SHOWCASE_POSTCODES: PostcodeFeature[] = []; const EMPTY_SHOWCASE_POIS: POI[] = []; @@ -601,37 +610,42 @@ function EnglandHexMapScreen({ isActive }: { isActive: boolean }) { return (
{shouldRenderMap && ( - + // The full deck.gl/maplibre map sits above the fold; contain any WebGL + // context-loss crash to this pane (and auto-recover) instead of letting it + // bubble to the top-level boundary and blank the hero. + + + )}
-
Birmingham
+
{t('home.showcaseStep2Region')}
{t('home.showcaseMatchingHomes', { - value: SHOWCASE_MAP_TOTAL_COUNT.toLocaleString(), + value: SHOWCASE_LONDON_COUNT.toLocaleString(), })}
@@ -789,19 +803,22 @@ function ScoutScreen({ isActive }: { isActive: boolean }) { postcode: 'SW5 9AA', score: '94%', commute: t('home.showcaseMinutes', { count: 23 }), - price: '£492k', + perSqm: '£8,200', + delta: '−16%', }, { postcode: 'SE22 8EF', score: '91%', commute: t('home.showcaseMinutes', { count: 28 }), - price: '£518k', + perSqm: '£5,900', + delta: '−24%', }, { postcode: 'N4 2AB', score: '88%', commute: t('home.showcaseMinutes', { count: 31 }), - price: '£476k', + perSqm: '£6,400', + delta: '−21%', }, ]; @@ -934,7 +951,12 @@ function ScoutScreen({ isActive }: { isActive: boolean }) {
{row.commute}
-
{row.price}
+
+
{row.perSqm}
+
+ {row.delta} +
+
))}
@@ -1031,6 +1053,7 @@ export default function ProductShowcase({ className = '' }: ProductShowcaseProps const [isStagePaused, setIsStagePaused] = useState(false); const [hasStarted, setHasStarted] = useState(false); const [canPauseOnHover, setCanPauseOnHover] = useState(false); + const [prefersReducedMotion, setPrefersReducedMotion] = useState(false); const showcaseRef = useRef(null); const inspectUserScrolledRef = useRef(false); @@ -1101,6 +1124,27 @@ export default function ProductShowcase({ className = '' }: ProductShowcaseProps return () => mediaQuery.removeEventListener('change', updateCanPause); }, []); + useEffect(() => { + if (typeof window.matchMedia !== 'function') return; + const mediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)'); + const update = () => setPrefersReducedMotion(mediaQuery.matches); + update(); + mediaQuery.addEventListener('change', update); + return () => mediaQuery.removeEventListener('change', update); + }, []); + + // Under prefers-reduced-motion the progress-bar animation is disabled in CSS, so + // its onAnimationEnd (which normally advances the carousel) never fires and the + // demo would freeze on step 1. Drive the advance from a timer in that case. + useEffect(() => { + if (!prefersReducedMotion || !isProgressRunning) return; + const timer = window.setTimeout( + () => setActiveStep((step) => (step + 1) % SHOWCASE_STEP_COUNT), + activeStepIntervalMs + ); + return () => window.clearTimeout(timer); + }, [prefersReducedMotion, isProgressRunning, activeStep, activeStepIntervalMs]); + const pauseForHover = () => { if (canPauseOnHover) setIsStagePaused(true); }; diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 0db2755..b476a72 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -235,6 +235,7 @@ export interface HistoricalPrice { year: number; month: number; price: number; + is_new: boolean; } export interface Property { @@ -386,7 +387,7 @@ export interface HexagonStatsResponse { * sector, shown alongside the national average for each crime metric. */ crime_area_averages?: CrimeAreaAverage[]; /** Total individual crime records (last 7 years) across the selection's - * postcodes — the count behind the "individual crimes" list. */ + * postcodes, the count behind the "individual crimes" list. */ crime_total_records?: number; central_postcode?: string; /** Total usual residents (ONS Census 2021) across the postcodes in this diff --git a/pipeline/download/median_age.py b/pipeline/download/median_age.py index 922caff..23f866c 100644 --- a/pipeline/download/median_age.py +++ b/pipeline/download/median_age.py @@ -3,7 +3,7 @@ Downloads five-year age band counts (TS007A) from the NOMIS API, then computes the median age per LSOA using linear interpolation within the median class. -Source: NOMIS (ONS Census 2021 — TS007A dataset, NM_2020_1) +Source: NOMIS (ONS Census 2021, TS007A dataset, NM_2020_1) License: Open Government Licence v3.0 """ @@ -20,7 +20,7 @@ from pipeline.utils import ENGLAND_LSOA_COUNT_2021, download_nomis_csv BASE_URL = "https://www.nomisweb.co.uk/api/v01/dataset/NM_2020_1.data.csv?date=latest&geography=TYPE151&c2021_age_19=1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18&measures=20100&select=GEOGRAPHY_CODE,C2021_AGE_19_NAME,OBS_VALUE" # Five-year age bands in order, with lower bounds for interpolation. -# The last band (85+) is open-ended — we treat it as 85-89 for median purposes. +# The last band (85+) is open-ended. We treat it as 85-89 for median purposes. AGE_BANDS = [ (0, 5), # Aged 0 to 4 years (5, 5), # Aged 5 to 9 years diff --git a/pipeline/download/naptan.py b/pipeline/download/naptan.py index 5a21aab..10054ed 100644 --- a/pipeline/download/naptan.py +++ b/pipeline/download/naptan.py @@ -28,7 +28,7 @@ LONDON_UNDERGROUND_ATCO_PATTERN = r"(?i)^\d{3}[0G]ZZLU" # The Docklands Light Railway uses the analogous "ZZDL" system code (e.g. # "9400ZZDLBEC" for Beckton). Like ZZLU it is unique to one network, so a # TMU/MET stop carrying a ZZDL code is reclassified from the tram/metro family -# to its own "DLR station" category — restoring DLR to the train/tube station +# to its own "DLR station" category, restoring DLR to the train/tube station # list and giving it the DLR roundel rather than a generic tram icon. LONDON_DLR_ATCO_PATTERN = r"(?i)^\d{3}[0G]ZZDL" @@ -337,7 +337,7 @@ class StationAccumulator: # A single node is never both (ZZLU vs ZZDL), but a co-located # interchange (Bank, Stratford, Canning Town, West Ham) merges its LU # and DLR halves into one group carrying both flags; Tube is checked - # first so these resolve to "Tube station" — their primary identity — + # first so these resolve to "Tube station" (their primary identity), # leaving "DLR station" for the DLR-only stops the fix targets. if self.category == TRAM_METRO_CATEGORY and self.is_lu: return TUBE_STATION_CATEGORY diff --git a/pipeline/download/noise.py b/pipeline/download/noise.py index 55e7c27..c065951 100644 --- a/pipeline/download/noise.py +++ b/pipeline/download/noise.py @@ -85,24 +85,24 @@ RESOLUTION = NATIVE_RESOLUTION # Defra encodes TRUE "no data" with this sentinel (NOT 0.0). A 0.0 cell that is # otherwise inside the raster means "modelled below the lowest reporting band", -# i.e. genuinely quiet — see noise_overlay_tiles.py:167. +# i.e. genuinely quiet (see noise_overlay_tiles.py:167). NOISE_NODATA_SENTINEL = np.float32(-96.0) # Lowest modelled Defra Lden reporting band (dB). Verified against the actual # rasters: the minimum positive in-coverage value is 40.0 dB with NO values in -# (0, 40) — below the band, cells are encoded as 0.0 (genuinely quiet). We floor +# (0, 40). Below the band, cells are encoded as 0.0 (genuinely quiet). We floor # in-coverage cells to 40.0 so a below-band 0.0 surfaces as "we know it's quiet" # (~40 dB) instead of collapsing to null ("we don't know"), WITHOUT inflating the # ~35% of genuine 40-44.99 dB readings that a 45.0 floor would wrongly bump to 45. # NB: 45.0 is the overlay's lowest *paint* stop (noise_overlay_tiles. -# NOISE_COLOR_STOPS[0]) — a rendering threshold, not the data's reporting floor. +# NOISE_COLOR_STOPS[0]), a rendering threshold, not the data's reporting floor. NOISE_QUIET_FLOOR_DB = np.float32(40.0) # Sample noise at the postcode representative point itself (no neighbourhood # window). A 50m MAX-of-window grabbed the single loudest 10m cell within ~1.2 ha # of every postcode; because Defra road contours hug every modelled road and # representative points sit on/near streets, that inflated postcode noise by -# roughly +9 dB (log scale) — making ~94% of England read >=55 dB Lden and +# roughly +9 dB (log scale), making ~94% of England read >=55 dB Lden and # collapsing the metric's discrimination at the quiet end. Radius 0 -> # filter_size 1 -> the maximum_filter is skipped and each postcode reads the # 10m cell it actually sits in. @@ -110,8 +110,8 @@ POSTCODE_NOISE_RADIUS_M = 0 # Adjacent download tiles overlap by the sampling radius so every postcode's # sampling footprint is fully contained in at least one tile. With point -# sampling (radius 0) this is 0 — a representative point falls inside exactly -# one tile — but the relationship is kept so any future non-zero radius keeps +# sampling (radius 0) this is 0 (a representative point falls inside exactly +# one tile), but the relationship is kept so any future non-zero radius keeps # its window seam-safe. TILE_OVERLAP_M = POSTCODE_NOISE_RADIUS_M @@ -273,7 +273,7 @@ def _download_tile( NoGeoTiffError, httpx.HTTPStatusError, # TransportError is the superset of TimeoutException, ConnectError, - # ReadError and ProtocolError — including RemoteProtocolError, raised + # ReadError and ProtocolError, including RemoteProtocolError, raised # when the WCS server closes the connection mid-stream ("incomplete # chunked read"). All are transient; retry/split rather than letting # one flaky tile crash the whole raster download. @@ -446,7 +446,7 @@ def sample_noise_at_postcodes( # Defra rasters encode TRUE nodata as the -96.0 sentinel (and # occasionally non-finite / dataset.nodata); genuinely quiet ground # below the model's lowest reporting band is encoded as 0.0. Only - # the former is "we don't know" — the latter is a real "we know it's + # the former is "we don't know". The latter is a real "we know it's # quiet" reading and must not collapse to null. So treat ONLY true # nodata as -inf (it never wins a max and never counts as coverage), # and clamp every in-coverage cell up to NOISE_QUIET_FLOOR_DB so a @@ -555,7 +555,7 @@ def main() -> None: if not tile_paths: print( - f"[{label}] WARNING: No tiles downloaded — column will be all null" + f"[{label}] WARNING: No tiles downloaded; column will be all null" ) series = pl.Series(col_name, [None] * len(lat), dtype=pl.Float32) else: diff --git a/pipeline/download/os_greenspace.py b/pipeline/download/os_greenspace.py index 59e2768..d64ed65 100644 --- a/pipeline/download/os_greenspace.py +++ b/pipeline/download/os_greenspace.py @@ -7,7 +7,7 @@ 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 +distance calculations: a property next to Hyde Park won't show 400m just 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. @@ -190,7 +190,7 @@ def download_greenspace(output: Path) -> None: print(f"Reading {site_shps[0].name} for function types...") site_funcs = _read_site_functions(site_shps[0]) - # Step 2: Read access points (primary — park entrances) + # Step 2: Read access points (primary: park entrances) print(f"Reading {access_shps[0].name}...") ap_lats, ap_lngs, ap_cats, ap_site_ids = _read_access_points( access_shps[0], site_funcs diff --git a/pipeline/download/places.py b/pipeline/download/places.py index 2c547a5..03a0e53 100644 --- a/pipeline/download/places.py +++ b/pipeline/download/places.py @@ -930,7 +930,7 @@ def main() -> None: df.write_parquet(args.output) print(f"Saved to {args.output}") else: - print("No places found — skipping output") + print("No places found, skipping output") if __name__ == "__main__": diff --git a/pipeline/download/rental_prices.py b/pipeline/download/rental_prices.py index 739bded..e200141 100644 --- a/pipeline/download/rental_prices.py +++ b/pipeline/download/rental_prices.py @@ -35,7 +35,7 @@ def _data_rows(df: pl.DataFrame) -> pl.DataFrame: The preamble length varies (title, optional "This worksheet contains..." note, then the header row starting with "Time period"), so locate the - header by content instead of counting rows — a fixed slice leaves the + header by content instead of counting rows: a fixed slice leaves the header in the data whenever ONS adds or removes a note line. """ header_marker = ( @@ -78,7 +78,7 @@ def _latest_rents_long(df: pl.DataFrame) -> pl.DataFrame: print(f"LAs in latest month: {df.height}") # Melt to long format: one row per area x bedroom count. - # PIPR has no Studio category — one-bed rent used as proxy for bedrooms=0. + # PIPR has no Studio category: one-bed rent used as proxy for bedrooms=0. frames = [] for col, bedrooms in [ ("rent_1bed", 0), # Studio (proxy) diff --git a/pipeline/download/tenure.py b/pipeline/download/tenure.py index 73da82f..49b999f 100644 --- a/pipeline/download/tenure.py +++ b/pipeline/download/tenure.py @@ -2,8 +2,8 @@ Downloads the household-tenure breakdown (TS054, classification C2021_TENURE_9) from the NOMIS API at LSOA 2021 granularity and folds the 8 detailed leaf -categories into our 3 output buckets — Owner occupied / Social rent / -Private rent — emitting one row per LSOA with the percentage of households in +categories into our 3 output buckets (Owner occupied / Social rent / +Private rent), emitting one row per LSOA with the percentage of households in each. The three buckets sum to 100%, so downstream they render as a composition/ratio (like the ethnicity and qualifications stacked bars) AND each percentage is independently filterable. @@ -19,7 +19,7 @@ NOTE this table counts HOUSEHOLDS (not usual residents). The join key downstream (merge.py) is `lsoa21`, the same key used for ethnicity, qualifications, median age, and IoD. -Source: NOMIS (ONS Census 2021 — TS054 dataset, NM_2072_1) +Source: NOMIS (ONS Census 2021, TS054 dataset, NM_2072_1) License: Open Government Licence v3.0 """ diff --git a/pipeline/download/test_ethnicity.py b/pipeline/download/test_ethnicity.py index 63f913f..5dcc0e2 100644 --- a/pipeline/download/test_ethnicity.py +++ b/pipeline/download/test_ethnicity.py @@ -72,7 +72,7 @@ def test_ethnicity_routes_chinese_to_east_and_other_asian_to_se(): def test_ethnicity_percentages_independent_per_lsoa(): - """Two LSOAs get independent profiles — the LSOA granularity is the point.""" + """Two LSOAs get independent profiles: the LSOA granularity is the point.""" df = pl.concat( [ pl.DataFrame( diff --git a/pipeline/download/test_noise.py b/pipeline/download/test_noise.py index d075590..f9e11b7 100644 --- a/pipeline/download/test_noise.py +++ b/pipeline/download/test_noise.py @@ -281,7 +281,7 @@ def test_sample_noise_preserves_genuine_reading_above_quiet_floor( # The lowest Defra reporting band is 40.0 dB; genuine readings populate # [40, ~80]. A genuine in-coverage reading at or just above the floor must be - # PRESERVED, not clamped UP to the floor — only true-quiet 0.0 is floored. A + # PRESERVED, not clamped UP to the floor. Only true-quiet 0.0 is floored. A # quiet floor set too high (e.g. 45) would inflate the ~35% of real 40-44.99 # dB readings; this pins that they survive unchanged. floor = float(noise.NOISE_QUIET_FLOOR_DB) diff --git a/pipeline/download/transit_network.py b/pipeline/download/transit_network.py index fa858b7..f373beb 100644 --- a/pipeline/download/transit_network.py +++ b/pipeline/download/transit_network.py @@ -50,7 +50,7 @@ ENGLAND_PBF_URL = ( "https://download.geofabrik.de/europe/united-kingdom/england-latest.osm.pbf" ) -# Bus Open Data Service — pre-converted GTFS covering all England bus/tram/ferry +# 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/" # National Rail Open Data API @@ -597,7 +597,7 @@ def validate_gtfs_feed( 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} — " + 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" ) @@ -697,7 +697,7 @@ def _iter_stop_time_trips(lines, trip_id_idx: int): dtd2mysql currently writes rows grouped by trip and ordered by stop_sequence, but neither is guaranteed by GTFS. Grouping is verified (a trip_id reappearing later raises instead of silently scrambling trips); - within-trip order is NOT assumed — callers sort each group by its original + within-trip order is NOT assumed: callers sort each group by its original stop_sequence. """ current_trip: str | None = None @@ -1175,7 +1175,7 @@ def main() -> None: cif = download_national_rail_cif(raw_dir) if cif is None: raise RuntimeError( - "National Rail timetable was not downloaded — set " + "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 " diff --git a/pipeline/transform/postcode_boundaries/geometry.py b/pipeline/transform/postcode_boundaries/geometry.py index 3fd85a4..4172f48 100644 --- a/pipeline/transform/postcode_boundaries/geometry.py +++ b/pipeline/transform/postcode_boundaries/geometry.py @@ -1,8 +1,8 @@ """Robust GEOS overlay helpers. Overlay operations (union, difference, intersection) can raise a -``GEOSException`` — most often ``TopologyException: side location conflict``, -``Ring edge missing``, or ``found non-noded intersection`` — on geometries that +``GEOSException``, most often ``TopologyException: side location conflict``, +``Ring edge missing``, or ``found non-noded intersection``, on geometries that contain near-coincident or near-degenerate edges, or that are individually invalid. The robust remedy is a *fixed-precision* overlay: GEOS's OverlayNG engine, handed a grid size, nodes every edge onto that grid and finishes where @@ -14,8 +14,8 @@ learned the hard way from a crash: 1. **Never precision-reduce with the default mode.** ``set_precision``'s default ``valid_output`` (and ``keep_collapsed``) mode runs its *own* noding pass that re-raises the very ``side location conflict`` we are trying to escape. We push - the grid into the overlay via the ``grid_size`` argument instead — where - OverlayNG nodes robustly — and only ever call ``set_precision`` in + the grid into the overlay via the ``grid_size`` argument instead (where + OverlayNG nodes robustly) and only ever call ``set_precision`` in ``pointwise`` mode (pure coordinate rounding, which cannot raise). 2. **Validate first.** ``make_valid`` repairs the self-intersections (bow-ties, pinches) that make GEOS choke, so the overlay starts from an OGC-valid shape. @@ -40,7 +40,7 @@ from shapely import GEOSException, make_valid, set_precision from shapely.geometry import Polygon from shapely.ops import unary_union -# 0.1 mm in metres — well below MIN_GEOM_AREA (0.01 m^2) and survey resolution. +# 0.1 mm in metres: well below MIN_GEOM_AREA (0.01 m^2) and survey resolution. _SNAP_GRID = 1e-4 _EMPTY = Polygon() diff --git a/pipeline/transform/postcode_boundaries/output.py b/pipeline/transform/postcode_boundaries/output.py index 4d5aa58..0356459 100644 --- a/pipeline/transform/postcode_boundaries/output.py +++ b/pipeline/transform/postcode_boundaries/output.py @@ -120,7 +120,7 @@ def _is_pointlike(geom_bng) -> bool: def _rescue_footprint(geom_bng) -> dict | None: """Fatten a degenerate BNG geometry into a representable footprint and snap. - A POINTLIKE input (a point, or a near-zero-area/short-perimeter polygon — the + A POINTLIKE input (a point, or a near-zero-area/short-perimeter polygon, the signature of a tower-block postcode whose UPRNs all share one coordinate) gets a building-scale buffer so it is not reduced to an invisible sub-metre dot; thin slivers that still carry length keep the minimal buffer. @@ -263,7 +263,7 @@ def merge_fragments( # Close tiny gaps between adjacent OA boundary edges (float mismatches). # The closing can erode a tiny MultiPolygon (e.g. a postcode with only a # sliver fragment) to nothing, which would leave the postcode with no - # geometry at all — keep the un-closed shape if that happens. + # geometry at all. Keep the un-closed shape if that happens. if combined.geom_type == "MultiPolygon": closed = combined.buffer(5.0).buffer(-5.0) if not closed.is_valid: @@ -308,7 +308,7 @@ def _polygonal(geom): return None # Both callers run on WGS84-degree output geometry, so the robustness # fallback snaps on the 1e-6° grid (~0.11 m), not geometry.py's metre - # default — a coarse metre grid would obliterate a degree-scale shape. + # default. A coarse metre grid would obliterate a degree-scale shape. merged = safe_union(polys, grid=_OUTPUT_PRECISION_DEG) return merged if not merged.is_empty else None return None @@ -324,7 +324,7 @@ def _resolve_overlaps( containment (a postcode fully enclosed by another). Each postcode is trimmed by the union of its higher-priority overlapping neighbours, where **priority = ascending area**: a smaller postcode wins contested ground. That single rule - handles both cases correctly — an enclosed postcode is always smaller than its + handles both cases correctly: an enclosed postcode is always smaller than its container, so it keeps its area while the container gets a hole (a `overlaps` query alone would miss containment entirely). Run last, on the final output geometries, so nothing re-introduces overlap afterwards. A postcode that would @@ -348,7 +348,7 @@ def _resolve_overlaps( arr = np.array(geoms, dtype=object) pairs: set[tuple[int, int]] = set() # "overlaps" gives partial overlaps; "contains" gives containment (which - # "overlaps" excludes) — together they cover every 2-D overlap without the + # "overlaps" excludes). Together they cover every 2-D overlap without the # edge-touch explosion a plain "intersects" query would add. for predicate in ("overlaps", "contains"): qsrc, qtgt = tree.query(arr, predicate=predicate) @@ -577,7 +577,7 @@ def _grid_footprint(geom): pass can shave a small (e.g. co-located, non-geographic) postcode down to a sub-grid sliver that disappears when snapped to output precision. Rather than drop it, place a minimal valid footprint at its location. The tiny overlap - this re-creates with the neighbour that trimmed it is harmless — the output + this re-creates with the neighbour that trimmed it is harmless: the output partition is best-effort, a missing boundary is a hard validation failure. """ try: diff --git a/pipeline/transform/postcode_boundaries/process_oa.py b/pipeline/transform/postcode_boundaries/process_oa.py index 4404560..50555d6 100644 --- a/pipeline/transform/postcode_boundaries/process_oa.py +++ b/pipeline/transform/postcode_boundaries/process_oa.py @@ -11,7 +11,7 @@ from .voronoi import compute_voronoi_regions MIN_GEOM_AREA = 0.01 # Minimal footprint (BNG metres) for a postcode whose UPRN seed wins no area in a -# crowded multi-postcode OA — its Voronoi cell ∩ remaining collapses below +# crowded multi-postcode OA: its Voronoi cell ∩ remaining collapses below # MIN_GEOM_AREA, or its seed sits inside an INSPIRE parcel wholly claimed by a # co-located postcode. Every *active* postcode must keep a boundary # (validate_outputs is zero-tolerance), so it gets a small disc at its true seed @@ -77,7 +77,7 @@ def process_oa( fragments.append((pc, merged)) # Every postcode with a UPRN seed in this OA must keep at least a minimal - # footprint — in a dense OA (a block of flats with hundreds of distinct + # footprint: in a dense OA (a block of flats with hundreds of distinct # postcodes) a single-seed postcode's cell can collapse below MIN_GEOM_AREA or # be fully absorbed by a co-located postcode's INSPIRE parcel, producing no # fragment, and an active postcode must never be dropped. @@ -142,7 +142,7 @@ def _claim_inspire_parcels( # UPRNs from a single postcode goes wholly to that postcode. A parcel shared # by several postcodes (a block of flats spanning postcodes, or overlapping # parcel data) is split between them via a sub-Voronoi over their own UPRNs - # clipped to the parcel — so EVERY contained postcode keeps part of the + # clipped to the parcel, so EVERY contained postcode keeps part of the # parcel. A bare majority vote would hand the whole parcel to one winner and # leave the losers' UPRNs trapped inside claimed land, dropping them from # both this claim and the `remaining` polygon handed to Voronoi downstream. @@ -312,7 +312,7 @@ def _extract_polygonal(geom) -> Polygon | MultiPolygon | None: return polys[0] # Union (not bare MultiPolygon construction): make_valid can emit # overlapping polygonal parts, and a MultiPolygon of overlapping parts is - # invalid — it double-counts area and makes the next `.difference()` raise + # invalid: it double-counts area and makes the next `.difference()` raise # a TopologyException that aborts the OA (and, in parallel mode, the # worker). safe_union merges them into a valid geometry. merged = safe_union(polys) diff --git a/pipeline/transform/postcode_boundaries/uprn.py b/pipeline/transform/postcode_boundaries/uprn.py index d7d19b8..6c99e94 100644 --- a/pipeline/transform/postcode_boundaries/uprn.py +++ b/pipeline/transform/postcode_boundaries/uprn.py @@ -82,7 +82,7 @@ def load_uprns( # Remap terminated postcodes to their nearest active successor. The # successor generally lives in a DIFFERENT OA (and at different grid # coordinates), so the remapped point must adopt the successor's - # authoritative OA/coords — keeping the terminated postcode's original + # authoritative OA/coords. Keeping the terminated postcode's original # OA would seed the successor into an OA it doesn't belong to, splitting # its boundary across OAs. Genuine (non-remapped) UPRN rows keep their # own OA, since a live postcode can legitimately span several OAs. @@ -127,7 +127,7 @@ def load_uprns( uprns.sort("OA21CD").sink_parquet(tmp_path) release_memory() - # Read the sorted data — only one copy in memory (~2GB) + # Read the sorted data: only one copy in memory (~2GB) df = pl.read_parquet(tmp_path) tmp_path.unlink() n = len(df) diff --git a/pipeline/transform/price_estimation/index.py b/pipeline/transform/price_estimation/index.py index 0994939..65230e9 100644 --- a/pipeline/transform/price_estimation/index.py +++ b/pipeline/transform/price_estimation/index.py @@ -4,7 +4,7 @@ Stratified by property type and postcode sector, with IRLS Huber regression, hierarchical shrinkage (sector → district → area → national → hedonic), and KD-tree spatial smoothing for sparse sectors. -Output: price_index.parquet — sector x type_group x year -> log_index +Output: price_index.parquet (sector x type_group x year -> log_index) """ import argparse diff --git a/pipeline/transform/price_estimation/utils.py b/pipeline/transform/price_estimation/utils.py index e551e17..e0e73ba 100644 --- a/pipeline/transform/price_estimation/utils.py +++ b/pipeline/transform/price_estimation/utils.py @@ -23,7 +23,7 @@ ESTIMATE_COLUMNS = ["Estimated current price", "Est. price per sqm"] # Natural join key from estimates back onto properties: postcode plus the # coalesced register/EPC address. This is unique and non-null on the deduped # dwelling universe (see property_base._dedupe_collapsed_properties), so it maps -# estimates 1:1 onto properties regardless of row order — estimates are computed +# estimates 1:1 onto properties regardless of row order. Estimates are computed # from a separate price_inputs.parquet, so a positional key would not line up. JOIN_ADDRESS = "_join_address" JOIN_KEYS = ["Postcode", JOIN_ADDRESS] diff --git a/pipeline/transform/test_join_epc_pp.py b/pipeline/transform/test_join_epc_pp.py index 6438caf..8940495 100644 --- a/pipeline/transform/test_join_epc_pp.py +++ b/pipeline/transform/test_join_epc_pp.py @@ -315,7 +315,7 @@ def test_run_tenure_history_tracks_rent_owner_transitions(tmp_path: Path): def test_run_tenure_history_empty_when_always_owner_occupied(tmp_path: Path): # A property only ever observed as owner-occupied has no tenure change worth - # surfacing — the timeline column is null (no events), not a noisy baseline. + # surfacing: the timeline column is null (no events), not a noisy baseline. zip_path = tmp_path / "domestic-csv.zip" _write_epc_zip( zip_path, @@ -339,6 +339,37 @@ def test_run_tenure_history_empty_when_always_owner_occupied(tmp_path: Path): assert df.get_column("tenure_history").to_list() == [None] +def test_run_latest_tenure_status_reflects_most_recent_certificate(tmp_path: Path): + # Two certificates for one dwelling: an older social-rented cert and a newer + # owner-occupied one. The published latest_tenure_status must carry the + # LATEST certificate's normalized tenure ("Owner-occupied"), while + # was_council_house stays "Yes" because the dwelling was social at some + # point. This also confirms latest_tenure_status reaches the epc_pp parquet. + zip_path = tmp_path / "domestic-csv.zip" + _write_epc_zip( + zip_path, + [ + _row(inspection_date="2016-04-01", tenure="Rented (social)"), + _row(inspection_date="2024-04-01", tenure="owner-occupied"), + ], + ) + + price_paid_path = tmp_path / "price-paid.parquet" + _price_paid_frame(prices=[250_000], dates=[date(2024, 2, 3)]).write_parquet( + price_paid_path + ) + + output_path = tmp_path / "epc-pp.parquet" + _run(zip_path, price_paid_path, output_path, tmp_path) + + df = pl.read_parquet(output_path) + + assert df.height == 1 + assert df.select("latest_tenure_status", "was_council_house").to_dicts() == [ + {"latest_tenure_status": "Owner-occupied", "was_council_house": "Yes"} + ] + + def test_run_dedup_prefers_valid_dated_cert_over_garbled_date(tmp_path: Path): # Two certificates for the same property. The cert with the garbled, # unparseable inspection_date must NOT be chosen as "latest": a string sort @@ -565,7 +596,7 @@ def test_run_new_build_keeps_early_first_transfer_when_sub_min_price(tmp_path: P price_paid_path = tmp_path / "price-paid.parquet" pl.DataFrame( { - # 5_000 is below MIN_PRICE (10_000) — a nominal/junk transfer that + # 5_000 is below MIN_PRICE (10_000), a nominal/junk transfer that # must still anchor the construction year but stay out of the price # aggregations. "price": [5_000, 300_000], @@ -603,7 +634,7 @@ def test_run_caps_band_year_at_first_transfer_year(tmp_path: Path): # lands AFTER its first Land Registry sale (1998). A dwelling cannot have # been built after it was first sold, so the published build year must be # capped at the first transfer year (1998), not the later band estimate. - # It stays flagged as an estimate (approximate=1) — it is still EPC-derived. + # It stays flagged as an estimate (approximate=1). It is still EPC-derived. zip_path = tmp_path / "domestic-csv.zip" _write_epc_zip( zip_path, [_row(construction_age_band="England and Wales: 2003 onwards")] @@ -627,7 +658,7 @@ def test_run_caps_band_year_at_first_transfer_year(tmp_path: Path): def test_run_keeps_band_year_when_earlier_than_first_transfer(tmp_path: Path): # The common case: the EPC band (1950-1966 -> 1958) predates the first - # recorded sale (2020). The cap must NOT fire — the band estimate stands. + # recorded sale (2020). The cap must NOT fire. The band estimate stands. zip_path = tmp_path / "domestic-csv.zip" _write_epc_zip(zip_path) @@ -649,7 +680,7 @@ def test_run_keeps_band_year_when_earlier_than_first_transfer(tmp_path: Path): def test_run_keeps_sale_above_lowered_min_price(tmp_path: Path): # A genuine cheap sale of 30_000 sits between the OLD floor (50k) and the # NEW floor (10k): it must now be RETAINED in the price aggregations. This - # pins the 50k->10k change — it fails on the pre-fix 50k floor (where 30k was + # pins the 50k->10k change: it fails on the pre-fix 50k floor (where 30k was # excluded, giving historical_prices length 1 / latest_price 250_000). zip_path = tmp_path / "domestic-csv.zip" with zipfile.ZipFile(zip_path, "w", compression=zipfile.ZIP_DEFLATED) as archive: @@ -748,8 +779,8 @@ def test_run_collapses_duplicate_transactions(tmp_path: Path): # The duplicated 250_000 sale collapses to one entry; two distinct sales. assert df.get_column("historical_prices").to_list() == [ [ - {"year": 2020, "month": 2, "price": 200_000}, - {"year": 2024, "month": 2, "price": 250_000}, + {"year": 2020, "month": 2, "price": 200_000, "is_new": False}, + {"year": 2024, "month": 2, "price": 250_000, "is_new": False}, ] ] assert df.get_column("latest_price").to_list() == [250_000] @@ -777,7 +808,7 @@ def test_run_excludes_implausible_price_jump_but_keeps_property(tmp_path: Path): assert df.height == 1 assert df.get_column("latest_price").to_list() == [140_000] assert df.get_column("historical_prices").to_list() == [ - [{"year": 2016, "month": 6, "price": 140_000}] + [{"year": 2016, "month": 6, "price": 140_000, "is_new": False}] ] diff --git a/pipeline/transform/test_transform_poi.py b/pipeline/transform/test_transform_poi.py index 11bb3a4..7b5399f 100644 --- a/pipeline/transform/test_transform_poi.py +++ b/pipeline/transform/test_transform_poi.py @@ -593,7 +593,7 @@ def test_transform_grocery_dedup_drops_only_grocery_aspect(tmp_path): # The _write_transform_inputs fixture seeds 5 GEOLYTIX "Tesco" points at # (51.52, -0.14). An OSM object colocated there carrying "Tesco" in its name # is the same physical store, so its Convenience Store (Groceries) row is a - # duplicate and must be dropped — but its NON-grocery aspect (a Post Office + # duplicate and must be dropped, but its NON-grocery aspect (a Post Office # sharing the same OSM id) must survive. An independent shop away from the # GEOLYTIX point keeps its grocery row. raw = pl.DataFrame( diff --git a/pipeline/transform/transform_poi.py b/pipeline/transform/transform_poi.py index 4fae0a9..dac448f 100644 --- a/pipeline/transform/transform_poi.py +++ b/pipeline/transform/transform_poi.py @@ -34,7 +34,7 @@ DROP_CATEGORIES = { "emergency/water_tank", "leisure/bleachers", "leisure/schoolyard", - # Park "furniture" / incidental features — not parks; they massively + # Park "furniture" / incidental features, not parks; they massively # inflated the Park count (picnic_table ~15k, outdoor_seating ~5.8k). "leisure/bandstand", "leisure/bird_hide", @@ -222,7 +222,7 @@ DROP_CATEGORIES = { "public_transport/entrance", "public_transport/station", "public_transport/stop_position", - # Education amenities — schools come from GIAS instead. OSM coverage for + # Education amenities. Schools come from GIAS instead. OSM coverage for # tertiary education, tutoring, and childcare is too noisy/incomplete to be # useful on a property-search map. "amenity/school", @@ -398,7 +398,7 @@ _CATEGORIES: list[tuple[str, str, str, list[str]]] = [ "tourism/theme_park", # bicycle_rental/boat_rental/marina/slipway used to live here and # made up ~46% of the bucket (cycle-hire docks, boat ramps); they - # are infrastructure, not entertainment venues — see DROP_CATEGORIES. + # are infrastructure, not entertainment venues. See DROP_CATEGORIES. "leisure/hackerspace", "leisure/yes", ], @@ -722,7 +722,7 @@ _CATEGORIES: list[tuple[str, str, str, list[str]]] = [ [ "leisure/fitness_centre", # leisure/fitness_station (outdoor pull-up bars / trim-trail - # apparatus, ~2.5k) is not a gym — see DROP_CATEGORIES. + # apparatus, ~2.5k) is not a gym. See DROP_CATEGORIES. "amenity/dojo", "amenity/dancing_school", ], @@ -849,8 +849,8 @@ _CATEGORIES: list[tuple[str, str, str, list[str]]] = [ "healthcare/pharmacy", "shop/chemist", # healthcare/alternative, shop/herbalist and shop/health (homeopaths, - # herbalists, generic "health" shops) are not dispensing pharmacies - # — see DROP_CATEGORIES. + # herbalists, generic "health" shops) are not dispensing pharmacies. + # See DROP_CATEGORIES. ], ), # "Hospital & Clinic" used to be one bucket; an actual hospital and a small @@ -878,7 +878,7 @@ _CATEGORIES: list[tuple[str, str, str, list[str]]] = [ "healthcare/laboratory", "healthcare/rehabilitation", "healthcare/vaccination_centre", - # healthcare/yes (untyped junk rows) is dropped — see DROP_CATEGORIES. + # healthcare/yes (untyped junk rows) is dropped. See DROP_CATEGORIES. ], ), ( @@ -950,7 +950,7 @@ _CATEGORIES: list[tuple[str, str, str, list[str]]] = [ [ "tourism/gallery", # tourism/artwork (statues, murals, village signs) was 93% of this - # bucket and is not a visitable gallery — see DROP_CATEGORIES. + # bucket and is not a visitable gallery. See DROP_CATEGORIES. ], ), ( @@ -1420,7 +1420,7 @@ def _school_icon_category_expr() -> pl.Expr: # GIAS phase mixes casing ("Middle deemed Primary" vs "Middle deemed # primary") so we normalise before matching. phase = pl.col("phase").str.to_lowercase() - # gias._format_age_range emits three shapes: "" (em-dash), + # gias._format_age_range emits three shapes: "" (en-dash), # "up to " (high-only) and "+" (low-only). Extract the leading # integer as low and the trailing integer as high, then suppress the wrong # end for the one-sided shapes so they don't collapse to a single bound. @@ -1477,7 +1477,7 @@ def _load_ofsted_ratings(ofsted_path: Path) -> pl.LazyFrame: the conventional Ofsted labels; when there is no usable graded result (null/"Not judged", e.g. schools last seen under the post-2024 ungraded report-card framework) we fall back to "Ungraded inspection overall outcome" - so genuinely good/outstanding schools aren't dropped — mirroring + so genuinely good/outstanding schools aren't dropped, mirroring school_catchments.classify_good_plus_schools. Remaining nulls drop out.""" grade_col = pl.col("Latest OEIF overall effectiveness") # See school_catchments: the ungraded outcome carries "School remains Good"/ @@ -1682,7 +1682,7 @@ def osm_groceries_colocated_with_geolytix( An OSM Groceries row is a duplicate when a GEOLYTIX point lies within ``radius_m`` metres AND that point's brand tokens (its ``category``, e.g. - "Tesco", "Co-op", "M&S") are all present in the OSM row's name — i.e. the + "Tesco", "Co-op", "M&S") are all present in the OSM row's name, i.e. the same physical branded store. Short brands like "M&S" match via _GROCERY_SHORT_BRAND_TOKENS; brands that still tokenise to set() are kept. @@ -1703,7 +1703,7 @@ def osm_groceries_colocated_with_geolytix( osm_ids = osm_groceries["id"].to_list() osm_name_tokens = [_significant_tokens(n) for n in osm_groceries["name"].to_list()] - # Equirectangular projection to metres around the shared mean latitude — at + # Equirectangular projection to metres around the shared mean latitude. At # England's scale this is accurate to well under the dedup radius. mean_lat = float(np.mean(np.concatenate([glx_lat, osm_lat]))) cos_lat = float(np.cos(np.radians(mean_lat))) @@ -1860,7 +1860,7 @@ def transform( ) # Scope the drop to the Groceries group: a single OSM object can also # carry a non-grocery aspect (e.g. a convenience store that is also a - # Post Office), which must survive — only its duplicate grocery row goes. + # Post Office), which must survive. Only its duplicate grocery row goes. lf = lf.filter( ~((pl.col("group") == "Groceries") & pl.col("id").is_in(duplicate_ids)) ) diff --git a/pipeline/transform/tree_density.py b/pipeline/transform/tree_density.py index 75ba03e..8eb7275 100644 --- a/pipeline/transform/tree_density.py +++ b/pipeline/transform/tree_density.py @@ -45,7 +45,7 @@ POSTCODE_DENSITY_PERCENTILE_COL = "Tree canopy density percentile within {radius POSTCODE_AREA_COL = "Tree canopy area within {radius}m (sqm)" POSTCODE_HEIGHT_COL = "Mean TOW height within {radius}m (m)" -# National Forest Inventory (NFI) woodland — the geometric complement of TOW. +# National Forest Inventory (NFI) woodland: the geometric complement of TOW. # NFI ships as a zipped shapefile of woodland parcels (>=0.5 ha) in EPSG:27700. # Field names are from the NFI Woodland England 2022 release; re-check on bumps. NFI_CATEGORY_COL = "CATEGORY" @@ -263,7 +263,7 @@ def _postcode_buffers( return circles, shapely.STRtree(circles) -# 0.1 mm in the BNG working CRS (EPSG:27700) — far below survey resolution; the +# 0.1 mm in the BNG working CRS (EPSG:27700), far below survey resolution; the # same grid the postcode_boundaries overlay uses. _OVERLAY_GRID_M = 1e-4 @@ -274,13 +274,13 @@ def _robust_intersection_area(a: np.ndarray, b: np.ndarray) -> np.ndarray: External Forest Research TOW/NFI polygons are occasionally invalid (self-intersections), and a single bad polygon makes the batched ``shapely.intersection`` raise ``TopologyException: side location conflict``, - aborting the whole run. The fast path is the raw batched overlay — unchanged, - full-speed, when the data is clean — and only a failure triggers repair. + aborting the whole run. The fast path is the raw batched overlay (unchanged, + full-speed, when the data is clean), and only a failure triggers repair. The repair deliberately uses a *plain* overlay rather than the fixed-precision (``grid_size``) one: ``make_valid`` can emit a mixed-dimension ``GeometryCollection`` (a polygon plus a dangling line), which OverlayNG - rejects with ``Overlay input is mixed-dimension`` — whereas a plain overlay + rejects with ``Overlay input is mixed-dimension``, whereas a plain overlay accepts it, and its non-polygonal debris has zero area and is dropped by the ``clipped_area > 0`` filter downstream anyway. A final pointwise coordinate snap (which never raises) collapses the near-coincident edges behind any @@ -496,7 +496,7 @@ def _finalize_metrics( if over_count: print( f" note: {over_count:,} postcode(s) exceeded 100% raw canopy and were " - "capped — indicates overlapping TOW/NFI canopy within the buffer" + "capped, indicating overlapping TOW/NFI canopy within the buffer" ) mean_height = np.divide( diff --git a/pipeline/utils/test_poi_counts.py b/pipeline/utils/test_poi_counts.py index 59d34c2..008c37d 100644 --- a/pipeline/utils/test_poi_counts.py +++ b/pipeline/utils/test_poi_counts.py @@ -92,7 +92,7 @@ def test_custom_radius(pois): } ) - # 0.01 km = 10m — only the POI at the exact same location should match + # 0.01 km = 10m: only the POI at the exact same location should match result = count_pois_per_postcode(postcodes, pois, groups=POI_GROUPS, radius_km=0.01) # The Restaurant at (51.5074, -0.1278) is at distance 0 assert result["restaurants_0km"][0] >= 1 @@ -135,9 +135,9 @@ def test_min_distance_finds_nearest(postcodes, pois): assert len(result) == 2 ec1a = result.filter(pl.col("postcode") == "EC1A 1BB") - # Rail station is at (51.5073, -0.1277), postcode at (51.5074, -0.1278) — very close + # Rail station is at (51.5073, -0.1277), postcode at (51.5074, -0.1278), very close assert ec1a["train_tube_nearest_km"][0] < 0.05 # within 50m - # Restaurant is co-located — distance ~0 + # Restaurant is co-located: distance ~0 assert ec1a["restaurants_nearest_km"][0] < 0.01 # Far-away postcode should still get the global nearest distance. From ab688243d725a0a3e8642ad179fefa467a6d1460 Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Fri, 3 Jul 2026 18:39:34 +0100 Subject: [PATCH 4/9] .. --- .../src/components/account/AccountPage.tsx | 68 +++++++++-- .../src/components/invite/InvitePage.test.tsx | 63 +++++++++++ frontend/src/components/invite/InvitePage.tsx | 19 +++- .../src/components/landing/SeoContentPage.tsx | 22 ---- frontend/src/components/map/HoverCard.tsx | 3 + .../src/components/map/LocationSearch.tsx | 2 +- .../src/components/map/MapErrorBoundary.tsx | 8 +- frontend/src/components/map/MobileDrawer.tsx | 2 +- .../src/components/map/NumberLine.test.ts | 4 +- frontend/src/components/map/NumberLine.tsx | 2 +- .../components/map/map-page/derivedState.ts | 2 + .../components/map/map-page/lazyComponents.ts | 1 + pipeline/check_school_cutoffs.py | 4 +- pipeline/check_travel_times.py | 6 +- pipeline/download/broadband.py | 2 +- pipeline/download/census_population.py | 4 +- pipeline/download/crime.py | 2 +- pipeline/download/development_sites.py | 4 +- pipeline/download/education.py | 6 +- pipeline/download/election_results.py | 2 +- pipeline/download/england_boundary.py | 2 +- pipeline/download/ethnicity.py | 2 +- pipeline/download/gias.py | 8 +- pipeline/download/greenspace_water.py | 6 +- pipeline/download/lsoa_children.py | 2 +- pipeline/download/lsoa_population.py | 2 +- pipeline/download/test_tenure.py | 2 +- pipeline/download/test_transit_network.py | 2 +- pipeline/transform/area_crime_averages.py | 8 +- pipeline/transform/crime.py | 4 +- pipeline/transform/crime_spatial.py | 2 +- pipeline/transform/join_epc_pp.py | 33 ++++-- pipeline/transform/merge.py | 106 +++++++++++++----- pipeline/transform/poi_proximity.py | 6 +- server-rs/src/main.rs | 7 +- server-rs/src/routes/invites.rs | 24 ++-- 36 files changed, 307 insertions(+), 135 deletions(-) create mode 100644 frontend/src/components/invite/InvitePage.test.tsx diff --git a/frontend/src/components/account/AccountPage.tsx b/frontend/src/components/account/AccountPage.tsx index 3e19e44..ab0a10a 100644 --- a/frontend/src/components/account/AccountPage.tsx +++ b/frontend/src/components/account/AccountPage.tsx @@ -20,6 +20,7 @@ import { BookmarkIcon } from '../ui/icons/BookmarkIcon'; import { TrashIcon } from '../ui/icons/TrashIcon'; import { CloseIcon } from '../ui/icons/CloseIcon'; import { useLicense } from '../../hooks/useLicense'; +import { useModalA11y } from '../../hooks/useModalA11y'; function PageLayout({ children }: { children: React.ReactNode }) { return ( @@ -43,17 +44,43 @@ function DeleteDialog({ onConfirm: () => void; }) { const { t } = useTranslation(); + const dialogRef = useModalA11y(); + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') onCancel(); + }; + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + }, [onCancel]); + return ( -
-
+
+