This commit is contained in:
Andras Schmelczer 2026-07-03 18:01:10 +01:00
parent c2070693fb
commit 909e241907
55 changed files with 594 additions and 223 deletions

1
.gitignore vendored
View file

@ -28,3 +28,4 @@ r5-java/tmp
property-data
property-data-snapshot
property-data-snapshot2
video/.audit*

View file

@ -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 ==="

View file

@ -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."""

View file

@ -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():

View file

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

View file

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

View file

@ -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()

View file

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

View file

@ -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():

View file

@ -6,7 +6,7 @@ import main
# ---------------------------------------------------------------------------
# selected_sources comma-separated --source values
# selected_sources: comma-separated --source values
# ---------------------------------------------------------------------------

View file

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

View file

@ -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]

View file

@ -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<FeatureMeta[]>([]);
const [poiCategoryGroups, setPOICategoryGroups] = useState<POICategoryGroup[]>([]);
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}

View file

@ -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')}
</button>
<p className="mt-4 text-sm font-medium text-warm-600 dark:text-warm-400">
{t('home.guaranteeNote')}
</p>
</div>
);
}

View file

@ -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(/<meta name="viewport" content="([^"]*)"/);
expect(m).not.toBeNull();
const content = m![1];
expect(content).not.toMatch(/maximum-scale/);
expect(content).not.toMatch(/user-scalable\s*=\s*no/);
expect(content).toContain('width=device-width');
expect(content).toContain('initial-scale=1');
});
});

View file

@ -456,7 +456,7 @@ h3 {
}
}
/* MapLibre scale control — dark mode */
/* MapLibre scale control (dark mode) */
.dark .maplibregl-ctrl-scale {
border-color: #d6d3d1;
color: #d6d3d1;
@ -467,7 +467,7 @@ h3 {
bottom: calc(var(--map-mobile-bottom-inset, 0px) + 0.25rem) !important;
}
/* Highlight flash for newly added filter cards animated green stroke. */
/* Highlight flash for newly added filter cards: animated green stroke. */
@property --filter-highlight-angle {
syntax: '<angle>';
initial-value: 0deg;

View file

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

View file

@ -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 ]")

View file

@ -26,6 +26,7 @@ dependencies = [
"pillow>=12.0.0",
"folium>=0.20.0",
"pyogrio>=0.12.1",
"google-auth>=2.55.1",
]
[tool.uv]

View file

@ -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<BrowserContext> {
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<BrowserContext> {
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<BrowserContext> {
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<void> {
/**
* 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<void> {
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);

View file

@ -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 {

View file

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

View file

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

View file

@ -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<String>,
/// 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<f32>,
@ -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 {

View file

@ -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<String, Vec<PostcodeCrimeSeries>>,
/// 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<String, Vec<i32>>,
@ -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<String, Vec<i32>> = FxHashMap::default();
if let Ok(col) = df.column(COVERAGE_COLUMN) {
let list_ca = col

View file

@ -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<struct>`: 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::<u32>::with_len(n, spill_dir, "crime_month")?;
let mut ctype = SpillVecBuilder::<u8>::with_len(n, spill_dir, "crime_ctype")?;
let mut outcome = SpillVecBuilder::<u8>::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
);

View file

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

View file

@ -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 [

View file

@ -360,7 +360,7 @@ fn build_school_meta(
) -> anyhow::Result<(Vec<u32>, Vec<SchoolMetadata>)> {
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;
}

View file

@ -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(),

View file

@ -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")?;

View file

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

View file

@ -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 {

View file

@ -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: "%",

View file

@ -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 == "*" {

View file

@ -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<u64> {
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.

View file

@ -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<SocketAddr>,
) -> 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<IpAddr> {
if let Some(value) = headers.get("x-real-ip").and_then(|v| v.to_str().ok()) {
if let Ok(ip) = value.trim().parse::<IpAddr>() {
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::<IpAddr>() {
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::<IpAddr>().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::<IpAddr>().unwrap())
);
}
}

View file

@ -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<SeoPage> {
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<SeoPage> {
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 {
<title>Page not found - Perfect Postcode</title>
<meta name="description" content="This Perfect Postcode page could not be found." />
<link rel="canonical" href="{public_url_e}/" />
<style>
:root {{ color-scheme: dark; }}
* {{ box-sizing: border-box; }}
body {{
margin: 0;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: #0b1220;
color: #e7ecf3;
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
text-align: center;
padding: 24px;
}}
main {{ max-width: 32rem; }}
.brand {{ font-weight: 700; letter-spacing: .01em; color: #2dd4bf; font-size: 1.1rem; margin-bottom: 1.5rem; }}
h1 {{ font-size: 2rem; margin: 0 0 .5rem; }}
p {{ color: #9fb0c3; line-height: 1.6; margin: .5rem 0; }}
code {{ color: #cbd5e1; background: rgba(148,163,184,.15); padding: .1rem .35rem; border-radius: .25rem; word-break: break-all; }}
a.cta {{
display: inline-block;
margin-top: 1.5rem;
padding: .65rem 1.25rem;
border-radius: .5rem;
background: #2dd4bf;
color: #0b1220;
font-weight: 600;
text-decoration: none;
}}
a.cta:hover {{ background: #5eead4; }}
</style>
</head>
<body>
<main>
<div class="brand">Perfect Postcode</div>
<h1>Page not found</h1>
<p>The requested path was not found: {path_e}</p>
<p><a href="{public_url_e}/">Go to Perfect Postcode</a></p>
<p>We couldn&#39;t find <code>{path_e}</code>.</p>
<p>It may have moved, or the link might be incomplete.</p>
<a class="cta" href="{public_url_e}/">Back to Perfect Postcode</a>
</main>
</body>
</html>"#
@ -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 <link rel=\"alternate\" hreflang=\"{lang}\" href=\"{public_url_e}{canonical_path_e}?lang={lang}\" />"
));
}
tags.push_str(&format!(
"\n <link rel=\"alternate\" hreflang=\"x-default\" href=\"{public_url_e}{canonical_path_e}\" />"
));
tags
} else {
String::new()
};
format!(
r#"<meta name="robots" content="{robots}" />
<link rel="canonical" href="{canonical_url}" />
<link rel="canonical" href="{canonical_url}" />{hreflang}
<meta property="og:title" content="{title_e}" />
<meta property="og:description" content="{description_e}" />
<meta property="og:type" content="website" />
@ -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"));
}
}

View file

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

View file

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

View file

@ -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<String> {
// 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<String> {
}
}
// 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,

View file

@ -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<IpAddr>) -> Option<IpAddr> {
let from_header = |name: &str, take_first: bool| -> Option<IpAddr> {
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();

View file

@ -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 {

View file

@ -78,6 +78,7 @@ fn backend_filter_name(name: &str) -> Option<String> {
"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)"

View file

@ -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.

View file

@ -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<CrimeAreaAverage>,
/// 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<u32>,
@ -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();

View file

@ -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<u64, Aggregator>,
@ -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();

View file

@ -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| {

View file

@ -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<String>) {
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<usize> = 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());

View file

@ -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(

View file

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

View file

@ -55,7 +55,7 @@ pub struct AppState {
/// served by the `/api/crime-records` endpoint and counted in stats.
pub crime_records: Arc<CrimeRecords>,
/// 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<PostcodePopulation>,
/// Precomputed per-outcode and per-postcode-sector average crime counts,
/// shown in the right pane alongside the national average for each metric.

View file

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

80
uv.lock generated
View file

@ -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"

View file

@ -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)],
},
],
},