Compare commits

...

9 commits

Author SHA1 Message Date
05a1f316e1 More
Some checks failed
CI / Check (push) Failing after 2m14s
Build and publish Docker image / build-and-push (push) Failing after 2m38s
2026-05-04 17:21:26 +01:00
cd34ee693f No logs 2026-05-04 16:19:28 +01:00
90c47afe17 these too 2026-05-04 16:19:15 +01:00
d4dde21ad2 Codex changes 2026-05-04 16:19:09 +01:00
0bae902e08 Bigger 2026-05-04 13:13:17 +01:00
6077a17a83 no moneyback 2026-04-10 08:10:26 +01:00
14db8b4a05 Change pricing 2026-04-09 23:10:40 +01:00
a98c54c5b8 Remove winning party 2026-04-09 21:58:24 +01:00
8614acdfae Rerun prepare script 2026-04-06 11:13:52 +01:00
104 changed files with 8013 additions and 5631 deletions

View file

@ -11,8 +11,8 @@ concurrency:
cancel-in-progress: true
jobs:
python:
name: Python (lint + test)
check:
name: Check
runs-on: docker
steps:
- uses: actions/checkout@v4
@ -20,70 +20,27 @@ jobs:
- name: Install uv
run: curl -LsSf https://astral.sh/uv/install.sh | sh
- name: Install dependencies
- name: Install Python dependencies
run: uv sync
- name: Ruff check
run: uv run ruff check .
- name: Deptry (unused dependencies)
run: uv run deptry .
- name: Tests
run: |
uv run pytest pipeline/utils/test_haversine.py
uv run pytest pipeline/utils/test_poi_counts.py
uv run pytest pipeline/transform/postcode_boundaries/test_postcode_boundaries.py
frontend:
name: Frontend (lint + typecheck)
runs-on: docker
defaults:
run:
working-directory: frontend
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- name: Install dependencies
- name: Install frontend dependencies
working-directory: frontend
run: npm ci
- name: ESLint
run: npm run lint
- name: Prettier check
run: npm run format:check
- name: TypeScript typecheck
run: npm run typecheck
rust:
name: Rust (lint + test)
runs-on: docker
defaults:
run:
working-directory: server-rs
steps:
- uses: actions/checkout@v4
- name: Install screenshot service dependencies
working-directory: screenshot
run: npm ci
- uses: https://github.com/dtolnay/rust-toolchain@stable
with:
components: clippy, rustfmt
- name: Clippy
run: cargo clippy -- -D warnings
- name: Format check
run: cargo fmt --check
- name: Install cargo-machete
run: cargo install cargo-machete
- name: Unused dependencies check
run: cargo machete
- name: Tests
run: cargo test
- name: Run checks
run: ./check.sh

View file

@ -1,94 +0,0 @@
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
python:
name: Python (lint + test)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v4
with:
enable-cache: true
- name: Install dependencies
run: uv sync
- name: Ruff check
run: uv run ruff check .
- name: Deptry (unused dependencies)
run: uv run deptry .
- name: Tests
run: |
uv run pytest pipeline/utils/test_haversine.py
uv run pytest pipeline/utils/test_poi_counts.py
uv run pytest pipeline/transform/postcode_boundaries/test_postcode_boundaries.py
frontend:
name: Frontend (lint + typecheck)
runs-on: ubuntu-latest
defaults:
run:
working-directory: frontend
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
cache-dependency-path: frontend/package-lock.json
- name: Install dependencies
run: npm ci
- name: ESLint
run: npm run lint
- name: Prettier check
run: npm run format:check
- name: TypeScript typecheck
run: npm run typecheck
rust:
name: Rust (lint + test)
runs-on: ubuntu-latest
defaults:
run:
working-directory: server-rs
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
with:
workspaces: server-rs
- name: Clippy
run: cargo clippy -- -D warnings
- name: Format check
run: cargo fmt --check
- name: Install cargo-machete
run: cargo install cargo-machete
- name: Unused dependencies check
run: cargo machete
- name: Tests
run: cargo test

View file

@ -1,72 +0,0 @@
name: Build and publish Docker image
on:
push:
branches: [main]
tags: ["v*"]
workflow_dispatch:
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
build-and-push:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up uv
uses: astral-sh/setup-uv@v4
- name: Download map assets (fonts, sprites, twemoji)
run: uv run python -m pipeline.download.map_assets --output frontend/public/assets
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata (tags, labels)
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=sha
type=raw,value=latest,enable={{is_default_branch}}
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Build and push screenshot service
uses: docker/build-push-action@v6
with:
context: ./screenshot
push: true
tags: |
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-screenshot:latest
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-screenshot:sha-${{ github.sha }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha,scope=screenshot
cache-to: type=gha,mode=max,scope=screenshot

430
CLAUDE.md
View file

@ -1,430 +0,0 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
NEVER EVER RUN GIT COMMANDS!!
## Project Overview
Property Map is a full-stack geospatial application for visualizing UK property data on an interactive map. It combines Land Registry price-paid data, EPC energy certificates, postcode geolocation, TFL journey times, Index of Deprivation scores, crime statistics, ethnicity data, broadband speeds, school ratings, road noise, and OpenStreetMap POIs into a single wide parquet file, then serves aggregated H3 hexagon statistics and POI data via a Rust backend.
## Commands
All commands use [Task](https://taskfile.dev) runner. Python uses `uv run`. Frontend uses `npm run` from `frontend/`.
```bash
# Development servers
task dev:server # Rust backend on :8001 (cargo run --release)
task dev:frontend # Webpack dev server on :3001 (proxies /api to :8001)
# Data pipeline (uses Make, not Task — see Makefile.data)
make -f Makefile.data prepare # Build properties.parquet (merge + price estimation)
make -f Makefile.data merge # Just the merge step (no price estimation)
# Assets
task download:map-assets # Download font glyphs + twemoji PNGs into frontend/public/assets/
# Quality
task lint # Lint all: Python (ruff) + TypeScript (ESLint+Prettier) + Rust (clippy+fmt)
task format # Auto-fix formatting for all languages
task test # Python tests (fuzzy join, haversine, POI counts)
task check # Full validation: lint + build + test
# Building
task build:frontend # TypeScript typecheck + webpack production build
task build:server # cargo build --release (NOTE: dir is wrong in Taskfile, run from server-rs/)
# Granular lint/format
task lint:python # uv run ruff check .
task lint:frontend # eslint + prettier --check
task lint:rust # cargo clippy -- -D warnings && cargo fmt --check
task format:python # ruff check --fix && ruff format
task format:frontend # eslint --fix + prettier --write
task format:rust # cargo fmt --all
```
Running individual tests:
```bash
uv run pytest pipeline/utils/test_haversine.py # Single test file
uv run pytest pipeline/utils/test_haversine.py -k "test_name" # Single test
```
## Architecture
### Data Flow
```
Raw sources → [Download scripts] → data/*.parquet
→ [Fuzzy join EPC ↔ Price-Paid] → epc_pp.parquet
→ [Merge all datasets] → properties.parquet
→ [Price estimation] → properties.parquet (augmented with estimated prices)
→ [Rust server loads into memory + precomputes H3 + spatial grid]
→ [Frontend renders deck.gl H3HexagonLayer over MapLibre GL]
```
### Data Pipeline (`pipeline/`)
Python + Polars. Orchestrated by `Makefile.data` (Make DAG with sentinel files like `.merge_done`, `.prices_done`). Two phases:
1. **Download** (`pipeline/download/`) — Each script fetches one raw dataset into `data/`
2. **Transform** (`pipeline/transform/`) — Joins and derives features:
- `join_epc_pp.py` — Fuzzy-joins EPC ↔ price-paid by address within postcode buckets
- `merge.py`**Main pipeline**: joins all datasets → `properties.parquet` with human-readable column names
- `price_estimation/` — Post-merge step: adds "Estimated current price" and "Est. price per sqm" columns to `properties.parquet`. Uses repeat-sales price index + kNN spatial blending. Requires `price_index.parquet` (built by `price_estimation/index.py`). Run via `make -f Makefile.data prepare` (the `merge` target alone skips this).
- `transform_poi.py` — Filters POIs, maps to friendly names + emoji (exhaustive category validation)
- `poi_proximity.py` — Counts POIs within 2km per postcode using 0.05° spatial grid
- `crime.py` — Aggregates crime CSVs into yearly averages by LSOA
**Critical: column renaming in `merge.py`** — The pipeline renames columns from snake_case to human-readable names before writing `properties.parquet`. The Rust server and frontend use **only** these human-readable names — there are no fallbacks to snake_case. Key renames:
- `pp_address``Address per Property Register`
- `postcode``Postcode`
- `latest_price``Last known price`
- `duration``Leasehold/Freehold`
- `total_floor_area``Total floor area (sqm)`
- `current_energy_rating``Current energy rating`
The server requires these exact column names at startup (will error if missing). See the full rename map in `merge.py`.
### Backend (`server-rs/`)
Rust + Axum. Loads parquet into memory at startup.
**Structure** (uses Rust 2018 module style — `foo.rs` + `foo/` directory, not `foo/mod.rs`):
- `data.rs` + `data/` — Property and POI data loading
- `parsing.rs` + `parsing/` — Filter parsing and bounds parsing
- `routes.rs` + `routes/` — One file per endpoint. `properties.rs` exports shared `build_property()` used by both hexagon and postcode property endpoints
- `utils.rs` + `utils/` — GridIndex, hashing, interned columns
- `consts.rs` — Key constants (histogram bins, H3 range, max enum cardinality, excluded columns)
**API endpoints:**
- `GET /api/features` — Feature metadata with histograms and 2nd/98th percentiles
- `GET /api/hexagons?resolution=&bounds=&filters=&fields=&enum_dist=` — H3 aggregates (min/max per feature per hex), AABB-filtered to bounds. Optional `enum_dist=FeatureName` adds `dist_FeatureName: [count_per_value...]` arrays for pie chart visualization.
- `GET /api/postcodes?bounds=&filters=&fields=&enum_dist=` — Postcode polygon aggregates, AABB-filtered to bounds. Same `enum_dist` support as hexagons.
- `GET /api/postcode/:postcode` — Single postcode lookup (centroid + polygon)
- `GET /api/hexagon-properties?h3=&resolution=&filters=&limit=&offset=` — Paginated properties within a hexagon
- `GET /api/postcode-properties?postcode=&filters=&limit=&offset=` — Paginated properties within a postcode
- `GET /api/pois?bounds=&categories=` — POIs by bounds (max 5000)
- `GET /api/poi-categories` — Available POI category names
Serves `frontend/dist/` as static fallback in production **only** when `--dist` is explicitly provided. When `--dist` is set, the server panics at startup if `index.html` is unreadable. When omitted (dev mode), static serving and OG injection are disabled.
**Data representation (unified model):**
- All features (numeric and enum): row-major flat `Vec<f32>`, NaN = null
- Enum features: stored as f32 indices (0.0, 1.0, 2.0...) with `enum_values: FxHashMap<usize, Vec<String>>` mapping feature index → string values. Raw u16 indices are used directly for distribution counting (no dequantization needed for enums).
- Enum distribution: `Aggregator` optionally tracks per-value counts via `EnumDist` struct (configured by `EnumDistConfig`). Emitted as `dist_FeatureName: [count_val0, count_val1, ...]` in hex/postcode responses when `enum_dist` param is set.
- String fields (address, postcode): interned/packed for memory efficiency
- All CLI args are required (no hidden defaults). Optional services use `Option<String>`: `r5_url` (travel time disabled when None), `pocketbase_admin_email`/`password` (collection auto-creation skipped when None). Required config like `gemini_model` and `public_url` must be explicitly provided via env or CLI.
### Frontend (`frontend/`)
React 18 + TypeScript. deck.gl `H3HexagonLayer` over MapLibre GL. TailwindCSS. No state management library — pure React hooks.
**Architecture:**
- `App.tsx` — Minimal router: loads features/POI categories, handles page navigation. Page type is `'home' | 'dashboard' | 'learn' | 'pricing' | 'account' | 'saved' | 'invites' | 'invite'`. Auth-required pages (`account`, `saved`, `invites`) redirect to home with login modal when unauthenticated. `pageToPath()` / `pathToPage()` map between Page values and URL paths.
- `AccountPage.tsx` — Exports three separate page components: `SavedPage` (`/saved` — saved searches + saved properties with sub-tabs), `InvitesPage` (`/invites` — invite link generation + history), and `AccountPage` (default export, `/account` — email, subscription, newsletter, support). Note: `'invite'` (singular, `/invite/:code`) is the invite *redemption* flow — distinct from `'invites'` (plural, `/invites`) which is the invite *management* page.
- `MapPage.tsx` — Dashboard layout: composes map + left/right panes, uses custom hooks for all logic
- Custom hooks in `hooks/` encapsulate stateful logic:
- `useMapData` — Hexagon/postcode fetching, bounds, loading state, color range calculation
- `useFilters` — Filter state and handlers (add/remove/change/drag/pin)
- `useHexagonSelection` — Selection state, area stats, properties fetching (supports both hexagons and postcodes)
- `usePOIData` — POI fetching with debounce
- `usePaneResize` — Reusable pane resize handlers
- `useTheme` — Theme state with localStorage persistence
- `useUrlSync` — URL state synchronization
**Key patterns:**
- URL encodes view/filters/POI categories/active tab as query params for shareable links. Only the current format is supported — no legacy parameter parsing (old `v=`, `f=`, or tab abbreviations are not handled). `tmode` is always serialized when travel time is active (no implicit default); parsing throws if `tmode` is missing when `dest` is present.
- AbortControllers cancel in-flight requests on new queries (150ms debounce)
- Zoom → H3 resolution defined in `consts.ts` `ZOOM_TO_RESOLUTION_THRESHOLDS`: `<7.5→5, <9.5→6, <10.5→8, <12→9, ≥12→10`
- `POSTCODE_ZOOM_THRESHOLD = 15`: below 15 shows H3 hexagons, at/above 15 shows postcode polygons
- Viewport bounds computed via `getBoundsFromViewState()` in `map-utils.ts` — uses Web Mercator math with **TILE_SIZE=512** (MapLibre/deck.gl convention, NOT 256)
- Properties pane uses feature names from API response (human-readable), not hardcoded field names
- Proxy: dev server on :3001 proxies `/api` to :8001; also handles VS Code `/proxy/PORT` patterns
- **Nav links must be `<a>` tags, not `<button>`**: All page navigation items in `Header.tsx` and `MobileMenu.tsx` use `<a href={PAGE_PATHS[page]}>` with an `onClick` that calls `e.preventDefault()` + client-side navigation for normal clicks, but lets CMD/Ctrl+click fall through to open in a new tab. `PAGE_PATHS` is exported from `Header.tsx`. Pattern: `if (e.metaKey || e.ctrlKey || e.shiftKey || e.button !== 0) return;` before `preventDefault()`.
- **Portal outside-click handlers must check both refs**: When a dropdown uses `createPortal(content, document.body)`, the portal DOM is outside the trigger's container. An outside-click handler using `container.contains(e.target)` will treat clicks on portal content as "outside" and close the dropdown. On mobile this breaks selection entirely because the native `mousedown` listener on `document` preempts React's synthetic event on the portal content. Fix: add a separate ref to the portal content and check both in the handler (`!containerRef.current.contains(target) && !dropdownRef.current?.contains(target)`). See `DestinationDropdown.tsx` for the pattern.
**Shared UI Components (`frontend/src/components/ui/`):**
- `icons/` — One file per icon (CloseIcon, InfoIcon, EyeIcon, PlusIcon, ChevronIcon, FilterIcon, LightbulbIcon, DownloadIcon, MapPinIcon, CheckIcon, ClipboardIcon, SunIcon, MoonIcon, SpinnerIcon). All accept `className` prop. **Never inline SVGs** — always extract to this folder.
- `IconButton.tsx` — Reusable icon button wrapper with consistent hover states. Accepts `active` prop for teal highlight.
- `SearchInput.tsx` — Styled search input with dark mode support. Used in Filters, POIPane, PropertiesPane.
- `PaneHeader.tsx` — Reusable pane header with title, optional subtitle, info button, and close button.
- `SelectionButtons.tsx` — "All" / "None" selection buttons for checkbox lists.
- `TabButton.tsx` — Tab button with active state styling. Used in right pane tabs.
- `EmptyState.tsx` — Empty state display with icon, title, description. Also exports `PaneEmptyState` for centered pane messages.
- `CheckboxList.tsx` — Checkbox list with toggle logic. Variants for array and Set-based selection.
**Shared Components (`frontend/src/components/`):**
- `FeatureInfoPopup.tsx` — Popup showing feature name, description, detail, and "View data source" link.
- `FeatureIcons.tsx``FeatureActions` component combining eye/info/add/remove icons for feature rows.
**Shared Utilities (`frontend/src/lib/`):**
- `api.ts``apiUrl(endpoint, params?)` builds API URLs. `logNonAbortError(label, err)` and `isAbortError(err)` for error handling.
- `features.ts``groupFeaturesByCategory(features)` groups FeatureMeta[] by their `group` field.
- `format.ts``formatNumber(value, decimals)` for number formatting. `calculateHistogramMean(histogram)` for weighted mean calculation.
- `property-fields.ts``getNum(property, key)` for getting a single numeric property value. Takes exactly one key — no fallback names.
- `PieHexExtension.ts` — deck.gl `LayerExtension` that turns polygon fills into hexagonal pie charts. Injects GLSL that computes angle from fragment position to centroid, picks slice color from ENUM_PALETTE. See "deck.gl LayerExtension patterns" below.
When adding new UI, prefer using these shared components over inline implementations to maintain consistency.
**When to extract vs inline:**
- Extract to `hooks/`: Stateful logic with useState/useEffect/useCallback that can be named as a cohesive unit (e.g., `useFilters`, `useMapData`). If a component has 5+ related state variables and handlers, extract them to a hook.
- Extract to page component: Layout + hook composition for a major view (e.g., `MapPage` composes `useMapData` + `useFilters` + child components). Keep App.tsx focused on routing.
- Extract to `ui/` component: Repeated 3+ times with same styling (buttons, inputs, icons)
- Extract to `lib/`: Pure functions used across components (formatting, calculations, lookups)
- Keep inline: One-off UI specific to a single component
**deck.gl LayerExtension patterns (CRITICAL — hard-won knowledge):**
Creating custom `LayerExtension`s that add per-instance attributes to CompositeLayer sublayers (H3HexagonLayer, PolygonLayer, GeoJsonLayer) requires following the exact canonical pattern. Getting any part wrong silently fails (attributes read as zero).
1. **`static defaultProps` with `type: 'accessor'`** — This is what tells `LayerExtension.getSubLayerProps()` to wrap accessors via `getSubLayerAccessor()`, which unwraps `__source.object` to reach the original data item through CompositeLayer sublayer chains. Without this, accessors receive `undefined` or binary data objects instead of the original data.
2. **`stepMode: 'dynamic'`** instead of `addInstanced()` — Use `am.add({...})` with `stepMode: 'dynamic'`, not `am.addInstanced({...})`. Dynamic step mode handles per-instance counting automatically for variable-geometry layers like SolidPolygonLayer.
3. **`isEnabled(layer)` must guard all hooks** — Check in `getShaders()` and `initializeState()`. For polygon fills, use `layer.id.endsWith('-fill')` to skip PathLayer (stroke) sublayers.
4. **Change layer ID when extensions change** — deck.gl recycles layers with the same ID. If you conditionally add/remove extensions, use a different layer ID (e.g., `'h3-hexagons-pie'` vs `'h3-hexagons'`) to force full teardown/rebuild. Otherwise `initializeState` never re-runs and attributes are never populated.
5. **Include `data` in updateTriggers for extension accessors** — When API data changes (e.g., new response with `dist_` fields), `colorTrigger` may not change. Include the `data` array reference in the extension accessor updateTriggers so the attribute manager re-runs the accessors on fresh data.
6. **FragmentGeometry only has `uv`** — In deck.gl v9's fragment shader, `geometry.position` does NOT exist. The `VertexGeometry` struct has `position`, `worldPosition`, `normal`, etc., but `FragmentGeometry` only has `uv`. To get fragment position in the FS, capture `geometry.position.xy` in the VS into a custom varying.
7. **Binary attribute overrides go in `data.attributes`** — In deck.gl v9, `props.instanceFoo` is rejected with "has been removed". Use `data.attributes.instanceFoo` instead. But for extensions using the accessor pattern above, this isn't needed.
8. **`getSubLayerProps` only forwards whitelisted props** — Custom props (binary buffers, accessors) set on a CompositeLayer are NOT automatically forwarded to sublayers. The `defaultProps` + `getSubLayerProps()` mechanism in step 1 is the ONLY reliable way to get extension data through the chain.
See `PieHexExtension.ts` for a working example and `DataFilterExtension` / `FillStyleExtension` in `@deck.gl/extensions` for reference implementations.
**Component size guideline:** If a component exceeds ~300 lines, look for extraction opportunities. Large components are usually doing too much — split into hooks (for logic) and child components (for UI sections).
**Naming conventions:**
- UI components: PascalCase, noun-based (`TabButton`, `EmptyState`)
- Utilities: camelCase verb-based (`formatNumber`, `calculateHistogramMean`)
## Frontend Design Guide (STRICT — must be followed for all UI changes)
The frontend uses Tailwind's `darkMode: 'class'` strategy. The `dark` class is toggled on `<html>`. Every visible element must have both light and dark styles. **Never add a light-only color class without its `dark:` counterpart.** Run `task build:frontend` after any UI change to verify.
### Theme System
- **State**: `App.tsx` owns a `theme` state (`'light' | 'dark' | 'system'`), persisted in `localStorage` under the key `theme`, default `'system'`.
- **Effective theme**: When `'system'`, resolved via `window.matchMedia('(prefers-color-scheme: dark)')`. A `change` listener re-renders on OS preference flip.
- **Toggle cycle**: light → dark → system → light. Three-way, not binary.
- **Flash prevention**: `index.html` contains an inline `<script>` that applies the `dark` class before first paint. If the localStorage/matchMedia logic in that script changes, update it to match `App.tsx`.
- **Prop plumbing**: `effectiveTheme` (`'light' | 'dark'`) is passed as a prop to `<Map>` and `<HomePage>`. Components that need the resolved theme must receive it as a prop — do not read localStorage or matchMedia inside child components.
### Color Token Reference
Every UI element must use the correct token from this table. Do not invent new pairings.
| Role | Light class | Dark class | Hex (dark) |
|------|------------|------------|------------|
| **Page / pane background** | `bg-warm-50` or `bg-white` | `dark:bg-warm-900` | #1c1917 |
| **Card / elevated surface** | `bg-white` | `dark:bg-warm-800` | #292524 |
| **Inset / recessed surface** | `bg-warm-100` or `bg-warm-50` | `dark:bg-warm-800` | #292524 |
| **Input / select background** | `bg-white` | `dark:bg-warm-800` or `dark:bg-warm-900` | |
| **Primary border** | `border-warm-200` | `dark:border-warm-700` | #44403c |
| **Subtle border (dividers)** | `border-warm-100` | `dark:border-warm-800` | #292524 |
| **Primary text (headings)** | `text-navy-950` or implicit dark | `dark:text-warm-100` | #f5f5f4 |
| **Body text** | `text-warm-700` | `dark:text-warm-300` | #d6d3d1 |
| **Secondary text (labels, hints)** | `text-warm-500` or `text-warm-600` | `dark:text-warm-400` | #a8a29e |
| **Disabled / placeholder text** | `text-warm-400` / `placeholder-warm-400` | `dark:text-warm-500` / `dark:placeholder-warm-500` | #78716c |
| **Accent text (links, actions)** | `text-teal-600` | `dark:text-teal-400` | #1de4c3 |
| **Accent hover text** | `hover:text-teal-800` | `dark:hover:text-teal-300` | #51f7d9 |
| **Accent background (highlights)** | `bg-teal-50` | `dark:bg-teal-900/30` | |
| **Active ring / focus ring** | `ring-teal-400` | same — works in both | |
| **Price / key metric text** | `text-teal-700` | `dark:text-teal-400` | |
| **Remove / close button** | `text-warm-400 hover:text-warm-700` | `dark:hover:text-warm-300` | |
| **Checkbox accent** | `accent-teal-600` | same — works in both | |
| **Header (unchanged both modes)** | `bg-navy-900 text-white` | same | |
### Mapping Rules for Specific Contexts
**Sidebars (Filters, POIPane, PropertiesPane, right-pane tabs):**
- Container: `bg-white dark:bg-warm-900`
- Inner cards / dropdown menus: `bg-white dark:bg-warm-800`
- Borders: `border-warm-200 dark:border-warm-700`
- Tab text (active): add `dark:text-warm-100`
- Tab text (inactive): `text-warm-600 dark:text-warm-400`
**Map overlays (PostcodeSearch, MapLegend, POI popup, loading indicator):**
- Background: `bg-white dark:bg-warm-800`
- Text: `dark:text-warm-200`
- Semi-transparent variants: use `/90` opacity suffix (e.g. `dark:bg-warm-800/90`)
- Deck.gl tooltip (inline styles, not Tailwind): use `#292524` bg / `#e7e5e4` text / `rgba(0,0,0,0.5)` shadow in dark.
- Deck.gl postcode labels (RGB arrays): `[220,220,220,220]` text / `[30,30,30,200]` outline in dark; inverse in light.
**Map basemaps:**
- Self-hosted Protomaps tiles served from PMTiles via `/api/tiles/{z}/{x}/{y}`
- Style built by `@protomaps/basemaps` library with `namedFlavor(theme)` for light/dark
- Font glyphs and twemoji PNGs served locally from `frontend/public/assets/` (no external CDN deps at runtime)
- `CopyWebpackPlugin` copies `frontend/public/``dist/` on build; Rust `ServeDir` fallback serves them in prod
- Download assets with `task download:map-assets` (script: `pipeline/download/map_assets.py`)
**HomePage (landing page):**
- Page bg: `bg-warm-50 dark:bg-warm-900`
- Cards: `bg-white dark:bg-warm-800` with `border-warm-200 dark:border-warm-700`
- Backdrop-blur panels: use `/60` or `/40` opacity on both `bg-warm-50` and `dark:bg-warm-900`
- HexCanvas: reads `isDark` ref; uses dimmer fill (`#058172`) and stroke (`#0a665b`) at 60% opacity multiplier.
- All headings: `dark:text-warm-100`. All body: `dark:text-warm-300` or `dark:text-warm-400`.
**DataSourcesPage:**
- Same card pattern as above. Footer is already dark (`bg-navy-900`) — no changes needed.
- License badges: `bg-warm-100 dark:bg-warm-700 text-warm-600 dark:text-warm-300`
- Links: `text-teal-600 dark:text-teal-400`
**DataSources floating button (on map):**
- `bg-white/90 dark:bg-warm-800/90` with `text-teal-600 dark:text-teal-400`
### Rules for New Components
1. **Every `bg-white` needs `dark:bg-warm-800` or `dark:bg-warm-900`.** Pane-level = warm-900, card-level = warm-800.
2. **Every `border-warm-200` needs `dark:border-warm-700`.**
3. **Every `text-warm-*` needs a `dark:text-warm-*` counterpart.** Follow the token table — don't guess.
4. **Every `text-teal-600` needs `dark:text-teal-400`.** Every `hover:text-teal-800` needs `dark:hover:text-teal-300`.
5. **Every `bg-teal-50` needs `dark:bg-teal-900/30`.**
6. **Every `hover:bg-warm-50` needs `dark:hover:bg-warm-700` or `dark:hover:bg-warm-800`.**
7. **Inputs and selects**: always add `dark:bg-warm-800 dark:text-warm-200 dark:border-warm-700`. Placeholders get `dark:placeholder-warm-500`.
8. **Checkboxes**: always include `accent-teal-600 rounded`.
9. **Do not use Tailwind `dark:` classes inside deck.gl layers or canvas code.** Use the `theme` prop / ref and conditional JS values.
10. **Do not add `transition-*` classes for theme switching.** The global CSS rule in `index.css` handles transitions for `background-color`, `border-color`, and `color` on all standard HTML elements. Adding per-element transition classes will conflict.
11. **Never hardcode hex colors in JSX `style=` props for themed elements** (except deck.gl tooltip and canvas, which can't use Tailwind). Use the Tailwind classes from the token table instead.
12. **The header (`bg-navy-900`) is identical in both themes.** Do not add dark variants to it.
### Verification Checklist (for any UI PR)
- [ ] `task build:frontend` passes with no errors
- [ ] Every new `bg-*`, `text-*`, `border-*` class has a `dark:` counterpart (search your diff)
- [ ] Toggle through all three modes (light → dark → system) with no flash
- [ ] Map basemap switches when theme changes
- [ ] Sidebars, dropdowns, and popups are readable in both modes
- [ ] HomePage and DataSourcesPage adapt correctly
## Internationalization (i18n) — MANDATORY
All user-visible text in the frontend MUST be translated. The build will fail if any language file is missing keys. Supported languages: English, French, German, Hungarian, Chinese.
### Architecture
```
frontend/src/i18n/
index.ts # i18next init, language detection, SUPPORTED_LANGUAGES
i18next.d.ts # Module augmentation — makes t() type-safe
server.ts # ts() for server-derived values, re-exports tsDesc()
descriptions.ts # Feature description translations (separate from locale files)
locales/
en.ts # English (source of truth, as const)
fr.ts / de.ts / hu.ts / zh.ts # Each typed as Translations = DeepStringify<typeof en>
```
**Three translation mechanisms:**
1. **`t('key')`** — UI strings (buttons, labels, headings). Type-safe: `t('typo')` is a compile error.
2. **`ts(value)`** — Server-derived values (feature names, group names, enum values, POI categories). Looks up `server.${value}` in the locale file, falls back to English.
3. **`tsDesc(featureName, englishFallback)`** — Feature descriptions. English comes from the server (single source of truth); other languages from `descriptions.ts`. Keyed by feature name, not description text.
### Adding a new UI string
1. Add the key to `en.ts` in the appropriate section
2. The build will immediately fail for all other locale files — add translations to each
3. Use `t('section.keyName')` in the component
### Adding a new language
1. Create `locales/xx.ts` importing `Translations` from `./en` — TypeScript enforces every key exists
2. Add a `xx` section to `descriptions.ts` for feature descriptions
3. Register in `index.ts`: import, add to `SUPPORTED_LANGUAGES` (with flag emoji) and `resources`
### Translating server-derived values (feature names, POI categories, etc.)
When a new feature is added in `features.rs`:
- Its name should be added to the `server` section of **all** locale files (keyed by the English name)
- Its description should be added to `descriptions.ts` for each non-English language
- English descriptions come from the server — do NOT duplicate them in `en.ts`
### Rules
- **Every `bg-*`, `text-*` class still needs `dark:` counterpart** (i18n doesn't change the design system)
- **URLs always contain English feature names**`ts()` only wraps display, never data keys or URL params
- **Never use dynamic key construction with `t()`** — it breaks TypeScript checking. Use `ts()` for runtime lookups or `tDynamic()` from `index.ts`
- **`useTranslatedModes()`** hook provides translated travel mode labels — don't use `MODE_LABELS` for display
- **Format utilities** (`formatRelativeTime`, `formatDuration`, `summarizeParams`) are already i18n-aware — they import `i18n` directly since they're not React components
## Coding Preferences
- **No backwards compatibility, no silent fallbacks**: Never add fallback codepaths for old data formats, legacy URL parameters, or alternate field names. Never silently swallow errors — always error loudly (return an error, panic, or at minimum log). If something is wrong, the code should fail visibly. One canonical name per field, one format per API, one way to do things. Specific patterns:
- Use `Option<String>` for truly optional config, never `default_value = ""` with `.is_empty()` checks
- Use `expect()` not `unwrap_or(0.0)` when a value is logically guaranteed to be present
- Return error responses on upstream failures, never silently drop results
- Don't add `#[serde(default)]` on `Option<T>` fields — serde already defaults them to `None`
- Required query params should be non-Option types so serde rejects missing params with 400 automatically
- **Unified data models over special-casing**: Prefer storing different data types uniformly (e.g., enums as f32 indices alongside numeric features) rather than maintaining separate code paths
- **Terse tests**: Test what matters in as few tests as possible — don't overcomplicate with excessive setup or edge cases that don't add value
- **Extract and organize**: Group related utilities into proper modules (e.g., `utils/`, `parsing/`) rather than leaving helpers scattered
- **Inline module tests**: Place `#[cfg(test)] mod tests { }` at the bottom of each module file rather than in separate test files
- **Decompose large React components**: Extract stateful logic into custom hooks (`useXxx`), extract page layouts into page components. App.tsx should only handle routing and initial data loading. Each hook should encapsulate one cohesive concern (e.g., `useFilters` owns filter state + all filter handlers).
## Rust Code Style (server-rs)
Follow these conventions in all Rust code:
1. **Module style**: Use Rust 2018 module naming — `foo.rs` + `foo/` directory, NOT `foo/mod.rs`
2. **Imports over inline paths**: Import items at the top of the file, don't use `crate::` inline in code
```rust
// Good
use crate::utils::generate_priorities;
let p = generate_priorities(n);
// Bad
let p = crate::utils::generate_priorities(n);
```
3. **Tracing macros**: Import and use short form, not fully qualified
```rust
// Good
use tracing::{info, warn};
info!("message");
// Bad
tracing::info!("message");
```
4. **JSON serialization**: Use `serde_json` with `#[derive(Serialize)]` structs, not manual string building
5. **Precompute at startup**: For static/rarely-changing responses, compute once at startup and store in `AppState`
6. **Unique placeholders**: When injecting content into HTML, use distinctive markers like `__PERFECT_POSTCODES_OG_TAGS__` that won't accidentally match other content
## Key Implementation Details
- **Spatial sort**: Rows sorted by 0.01° grid cell at load time for cache-friendly sequential access
- **Row-major layout**: `feature_data[row * num_features + feat_idx]` — all features (numeric and enum) for one property are contiguous
- **H3 precomputation**: Resolutions 412 computed in parallel (rayon) at startup
- **Histogram percentiles without sorting**: O(n) two-pass algorithm — build histogram, interpolate percentiles
- **Startup precomputation**: Static responses (like `/api/features`) are computed once at startup and cached in `AppState`
- **POI transform validation**: Fails if any OSM category is unmapped — guarantees exhaustive coverage
- **Fuzzy join**: Groups by postcode, uses `thefuzz.token_sort_ratio` with numeric token compatibility, greedy assignment from highest score
- **Filter parsing is strict**: `parse_filters()` returns `Result` — malformed entries, unknown feature names, and unparseable numbers all return 400 Bad Request. No silent skipping of invalid filters.
- **Data loading is strict**: `extract_string_col` and `lookup_enum_value` take a single column name (no fallback names). H3 precomputation panics on invalid coordinates. All configured features (defined in `features.rs`) must exist in the data — the server panics at startup if any are missing (no NaN placeholders). This means all pipeline steps must be complete before starting the server.
- **Travel time is strict**: `mode` param is required (400) when `destination` is set — no silent default to "car". R5 failures return 502 Bad Gateway, not silent omission. `r5_url` is `Option<String>` — returns 503 if travel time requested without R5 configured.
- **Filter bounds format**: `south,west,north,east` (not standard bbox order)
- **Server-side AABB filtering**: Both `/api/hexagons` and `/api/postcodes` filter results by bounding-box intersection with query bounds. Hexagons use `h3_cell_bounds()` (h3o returns degrees, not radians). Postcodes compute polygon AABB from vertices. See `bounds_intersect()` in `parsing/bounds.rs`.
- **Postcode row matching**: Both `postcode-stats` and `postcode-properties` use the same pattern: look up centroid from `postcode_data`, search `GridIndex` within `POSTCODE_SEARCH_OFFSET` (0.02°) of centroid, then exact string match on `state.data.postcode(row)`. Simpler than hexagon matching (no H3 cell computation needed).
- **GridIndex returns slightly more than requested**: The 0.01° grid cells mean properties up to ~1km outside the viewport may be returned. The AABB filter in the route handlers catches these extras.
- **POI proximity**: Uses 0.05° grid (~5km cells) to reduce candidates before haversine distance check
- **OG tag injection**: Uses `<meta name="x-og-placeholder" content="__PERFECT_POSTCODES_OG_TAGS__"/>` placeholder in HTML, replaced at runtime by middleware
- **Enum distribution (pie charts)**: When `enum_dist=FeatureName` is set on `/api/hexagons` or `/api/postcodes`, each cell includes `dist_FeatureName: [count_for_val0, count_for_val1, ...]`. The `Aggregator` struct has optional `EnumDist` that counts raw u16 enum indices per cell. `parse_enum_dist()` in `parsing/fields.rs` validates the feature name and confirms it's an enum. On the frontend, `PieHexExtension` (LayerExtension) injects GLSL into SolidPolygonLayer's fragment shader: computes angle from fragment position to hex centroid (passed as `instancePieCenter` varying), picks slice color from ENUM_PALETTE. `useMapData` adds the `enum_dist` query param when `viewFeatureIsEnum` is true.
- **Dev invite code**: The code `devdevdevdev` is recognized as a valid admin invite in dev mode only (`state.index_html.is_none()`, i.e., `--dist` not passed). Both `get_invite` and `post_redeem_invite` short-circuit for this code, returning a fake valid admin invite / no-op "licensed" response without hitting PocketBase. Preview at `http://localhost:3001/invite/devdevdevdev`.
## Rust Performance Patterns (server-rs)
**Lookup optimization:**
- `AppState.feature_name_to_index: FxHashMap<String, usize>` for O(1) feature lookups (used in filter parsing, field selection)
- Never use `.position()` on feature_names in hot paths — always use the prebuilt HashMap
- Enum filters use `FxHashSet<u32>` (f32 bits) for O(1) contains checks instead of `Vec::contains`
**Hot loop patterns:**
- Hoist conditional branches outside loops when possible (e.g., `if has_selective` check moved outside aggregation loop in hexagons.rs)
- Use `into_par_iter()` for file I/O (postcode GeoJSON loading) and CPU-bound startup work (H3 precomputation)
**Cardinality counting:**
- Use `FxHashSet` with `f32::to_bits()` for O(n) unique value counting instead of collect→sort→dedup O(n log n)
- For enum ordering, convert order slice to `FxHashSet` before filtering to get O(1) contains
**Data structure choices:**
- CSR (Compressed Sparse Row) for GridIndex — single flat `values` array + `offsets` array eliminates per-cell Vec overhead
- `Box<[f32]>` for fixed-size aggregation arrays — avoids Vec capacity field (8 bytes saved per cell)
- Bit-packed booleans for flags like `is_approx_build_date` — 8x memory savings vs `Vec<bool>`
**What NOT to optimize:**
- String cloning in JSON responses (~10-20 small strings) — negligible vs serialization overhead
- GridIndex 3-pass build (min/max → count → fill) — necessary for CSR without O(n) extra memory
- Arc<str> for enum values — complexity not worth modest benefit

View file

@ -19,9 +19,11 @@ ARCGIS := $(DATA_DIR)/arcgis_data.parquet
PRICE_PAID := $(DATA_DIR)/price-paid-complete.parquet
IOD := $(DATA_DIR)/IoD2025_Scores.parquet
POIS_RAW := $(DATA_DIR)/uk_pois.parquet
GROCERY_RETAIL_POINTS := $(DATA_DIR)/geolytix_retail_points.parquet
POIS_FILTERED := $(DATA_DIR)/filtered_uk_pois.parquet
POI_PROXIMITY := $(DATA_DIR)/poi_proximity.parquet
EPC_PP := $(DATA_DIR)/epc_pp.parquet
POSTCODES_RAW := $(DATA_DIR)/gb-postcodes-v5
POSTCODES_PQ := $(DATA_DIR)/postcode.parquet
PROPERTIES_PQ := $(DATA_DIR)/properties.parquet
MERGE_STAMP := $(DATA_DIR)/.merge_done
@ -62,7 +64,7 @@ PMTILES_VERSION := 1.22.3
.PHONY: prepare merge tiles \
download-arcgis download-price-paid download-deprivation download-ethnicity \
download-naptan download-pois download-ofsted download-broadband download-rental-prices \
download-naptan download-pois download-grocery-retail-points download-ofsted download-broadband download-rental-prices \
download-postcodes download-noise download-inspire \
download-oa-boundaries download-uprn-lookup download-transit-network download-greenspace download-os-greenspace download-pbf download-places download-lsoa-population download-median-age download-england-boundary download-rightmove-outcodes \
transform-pois transform-epc-pp transform-crime transform-poi-proximity \
@ -78,9 +80,10 @@ download-deprivation: $(IOD)
download-ethnicity: $(ETHNICITY)
download-naptan: $(NAPTAN)
download-pois: $(POIS_RAW)
download-grocery-retail-points: $(GROCERY_RETAIL_POINTS)
download-ofsted: $(OFSTED)
download-broadband: $(BROADBAND)
download-postcodes: $(POSTCODES)
download-postcodes: $(POSTCODES_RAW)
download-rental-prices: $(RENTAL)
download-noise: $(NOISE)
download-inspire: $(INSPIRE_STAMP)
@ -148,13 +151,16 @@ $(PBF):
$(POIS_RAW): $(PBF) $(ENGLAND_BOUNDARY)
uv run python -m pipeline.download.pois --output $@ --pbf $(PBF) --boundary $(ENGLAND_BOUNDARY)
$(GROCERY_RETAIL_POINTS):
uv run python -m pipeline.download.geolytix_retail_points --output $@
$(OFSTED):
uv run python -m pipeline.download.ofsted --output $@
$(BROADBAND):
uv run python -m pipeline.download.broadband --output $@
$(POSTCODES):
$(POSTCODES_RAW):
uv run python -m pipeline.download.postcodes --output $@
$(NOISE): $(ARCGIS)
@ -204,8 +210,8 @@ $(RM_OUTCODES): $(MERGE_STAMP)
# ── Transforms ────────────────────────────────────────────────────────────────
$(POIS_FILTERED): $(POIS_RAW) $(NAPTAN) $(ENGLAND_BOUNDARY)
uv run python -m pipeline.transform.transform_poi --input $(POIS_RAW) --naptan $(NAPTAN) --boundary $(ENGLAND_BOUNDARY) --output $@
$(POIS_FILTERED): $(POIS_RAW) $(NAPTAN) $(GROCERY_RETAIL_POINTS) $(ENGLAND_BOUNDARY)
uv run python -m pipeline.transform.transform_poi --input $(POIS_RAW) --naptan $(NAPTAN) --boundary $(ENGLAND_BOUNDARY) --grocery-retail-points $(GROCERY_RETAIL_POINTS) --output $@
$(EPC_PP): $(PRICE_PAID) $(EPC)
uv run python -m pipeline.transform.join_epc_pp --epc $(EPC) --price-paid $(PRICE_PAID) --output $@

185
README.md
View file

@ -1,95 +1,174 @@
# Property Map
## Area
uv run python scripts/remove_bg.py house-og.png 200 house.png
Interactive UK property intelligence map. The app combines transaction, EPC,
postcode, neighbourhood, transport, POI, and travel-time data into local parquet
files, serves fast geospatial aggregations from Rust, and renders the result as
a React/deck.gl map.
4. ambiance
- nature / greenery within 5 mins walk
8. current listings
The public product is branded as Perfect Postcodes, while this repository is
still named `property-map`.
## What Is In Here
rightmove:
curl '<https://www.rightmove.co.uk/api/property-search/listing/search?searchLocation=E14&useLocationIdentifier=true&locationIdentifier=OUTCODE%5E749&buy=For+sale&radius=20.0&_includeSSTC=on&index=0&sortType=2&channel=BUY&transactionType=BUY>'
- `frontend/` - React 18, TypeScript, Tailwind, MapLibre, and deck.gl. The app
has a landing page, map dashboard, saved searches/properties, account pages,
pricing, invites, and shareable URLs.
- `server-rs/` - Rust Axum API. It loads the generated parquet data into memory,
builds spatial indexes, serves H3/postcode aggregations, proxies PocketBase,
serves PMTiles, handles AI filter parsing, screenshots, exports, checkout, and
telemetry.
- `pipeline/` - Python/Polars download and transform pipeline. `Makefile.data`
orchestrates the data DAG.
- `r5-java/` - Batch travel-time generator using Conveyal R5. It writes sparse
per-destination parquet files for car, bicycle, walking, and transit.
- `screenshot/` - Playwright/Express service used by the Rust API for map
screenshots and Open Graph images.
- `property-data/` and `manual-data/` - Local generated/downloaded data. These
are runtime inputs, not source code.
curl '<https://www.onthemarket.com/async/search/properties-v2/?search-type=for-sale&location-id=e13&view=map-list>'
## Runtime Data
interesting links
- https://propertydata.co.uk/videos/quick-overview
- https://osdatahub.os.uk/data/downloads/open
The Rust server expects these files or directories to exist:
```text
property-data/properties.parquet
property-data/postcode.parquet
property-data/filtered_uk_pois.parquet
property-data/places.parquet
property-data/uk.pmtiles
property-data/postcode_boundaries/
property-data/travel-times/
```
mkdir -p data/crime
unzip data/d29f0314840ef7dcbb5cde66e383fe08059dab5a.zip -d data/crime/
rm data/d29f0314840ef7dcbb5cde66e383fe08059dab5a.zip
Most data can be downloaded or generated through `Makefile.data`. Some inputs
are deliberately manual:
- `manual-data/certificates.csv` from the EPC register
- `manual-data/crime/` CSV exports from police.uk
- postcode boundaries, generated from OA boundaries, INSPIRE polygons, and UPRN
lookup data
https://xploria.co.uk/data-sources/
---
- stripe
- Why hexagons?
- Why the price tag?
- contact support
-
Build the main property datasets with:
```bash
uv sync
make -f Makefile.data prepare
make -f Makefile.data tiles
make -f Makefile.data download-places
make -f Makefile.data generate-postcode-boundaries
```
- padding between email and resend verification
`generate-postcode-boundaries` writes to `manual-data/postcode_boundaries/`.
The running server expects the same structure under
`property-data/postcode_boundaries/`; copy or symlink it if needed.
- make the active filters much bigger on the demo page
Travel times are built separately because they are expensive:
- make demo filters adjustable
```bash
make -f Makefile.data download-transit-network
./r5-java/run.sh --threads 8 --heap 40g
```
- add next button to cards
For a quick R5 smoke test:
- make filters half-page or interleaved
```bash
./r5-java/run.sh --demo
```
- fix dev redirect
## Local Development
- start epxloring should bring to dashboard
With the required files in `property-data/`, the full stack can be started with
Docker Compose:
- referal link is broken
```bash
docker compose up --build
```
- load test
Services:
imrpove walkthrough
- frontend: http://localhost:3001
- API: http://localhost:8001
- PocketBase: http://localhost:8090
- screenshot service: http://localhost:8002
load tests with grafana
The frontend dev server proxies `/api` and `/s` to the Rust API and `/pb` to
PocketBase.
house reposession
To run pieces directly:
## execution
```bash
cd frontend
npm install
npm run dev
```
enum colour coding
Export the server's service configuration first:
Better school searchs
```bash
export SCREENSHOT_URL=http://localhost:8002
export PUBLIC_URL=http://localhost:3001
export POCKETBASE_URL=http://localhost:8090
export POCKETBASE_ADMIN_EMAIL=...
export POCKETBASE_ADMIN_PASSWORD=...
export GEMINI_API_KEY=...
export GEMINI_MODEL=...
export GOOGLE_MAPS_API_KEY=...
export STRIPE_SECRET_KEY=...
export STRIPE_WEBHOOK_SECRET=...
export STRIPE_REFERRAL_COUPON_ID=...
export GOOGLE_OAUTH_CLIENT_ID=...
export GOOGLE_OAUTH_CLIENT_SECRET=...
```
save -> dashboard
```bash
cd server-rs
cargo run -- \
--properties ../property-data/properties.parquet \
--postcode-features ../property-data/postcode.parquet \
--pois ../property-data/filtered_uk_pois.parquet \
--places ../property-data/places.parquet \
--tiles ../property-data/uk.pmtiles \
--postcodes ../property-data/postcode_boundaries \
--travel-times ../property-data/travel-times
```
fix links to markets,
## Checks
404,
Run the combined local check script:
Jittery slider number label
```bash
./check.sh
```
Odd vertical spacing on mobile
It runs Python lint/tests, frontend lint/format/typecheck/tests, screenshot
service tests, and Rust clippy/format/tests.
Show even number of cards on mobile
Useful focused commands:
Construction year is spaced oit
```bash
uv run ruff check .
uv run pytest
Make prop density smaller
cd frontend
npm run lint
npm run typecheck
npm run test
npm run build
Test on safari
cd ../server-rs
cargo clippy --all-targets -- -D warnings
cargo fmt --all --check
cargo test
```
Test on android
## Production Build
The root `Dockerfile` builds the frontend and Rust server into a runtime image.
Data is mounted at `/app/data`; it is not baked into the image.
check rendered index html,
```bash
docker build -t property-map .
```
The container entrypoint runs `property-map-server` with the expected data paths
under `/app/data` and serves `frontend/dist` when `--dist` is present.

View file

@ -1,133 +0,0 @@
version: '3'
tasks:
install:
desc: Install dependencies
cmds:
- uv sync
- cd frontend && npm install
download:map-assets:
desc: Download font glyphs and emoji PNGs for local serving
status:
- test -d frontend/public/assets/fonts
- test -d frontend/public/assets/twemoji
cmds:
- uv run python -m pipeline.download.map_assets --output frontend/public/assets
test:
desc: Run all tests (Python and Rust)
cmds:
- task: test:python
- task: test:server
test:python:
cmds:
- uv run pytest pipeline/utils/test_haversine.py
- uv run pytest pipeline/utils/test_poi_counts.py
- uv run pytest pipeline/transform/postcode_boundaries/test_postcode_boundaries.py
test:python:fuzzy-join:
desc: Run fuzzy join test (requires data files in data/)
cmds:
- uv run -m pipeline.utils.test_fuzzy_join
test:server:
desc: Run Rust backend tests
dir: server-rs
cmds:
- cargo test
dev:
desc: Start all services (server, frontend, pocketbase) via Docker Compose
cmds:
- docker compose up --build
build:server:
desc: Build server for production
dir: server-rs
cmds:
- cargo build --release
build:frontend:
desc: Build frontend for production
dir: frontend
cmds:
- npm run typecheck
- npm run build
lint:
desc: Lint all code (Python, TypeScript, and Rust)
cmds:
- task: lint:python
- task: lint:frontend
- task: lint:rust
lint:python:
desc: Lint Python code with ruff and check for unused dependencies
cmds:
- uv run ruff check .
- uv run deptry .
lint:frontend:
desc: Lint frontend TypeScript code
dir: frontend
cmds:
- npm run lint
- npm run format:check
lint:rust:
desc: Lint Rust code with clippy, check formatting, and detect unused dependencies
dir: server-rs
cmds:
- cargo clippy -- -D warnings
- cargo fmt --check
- cargo machete
format:
desc: Format all code (Python, TypeScript, and Rust)
cmds:
- task: format:python
- task: format:frontend
- task: format:rust
format:python:
desc: Format Python code with ruff
cmds:
- uv run ruff check --fix .
- uv run ruff format .
format:frontend:
desc: Format frontend TypeScript code
dir: frontend
cmds:
- npm run lint:fix
- npm run format
format:rust:
desc: Format Rust code with cargo fmt
dir: server-rs
cmds:
- cargo fmt --all
ci:
desc: Run CI checks locally (lint + typecheck + test, no builds)
cmds:
- task: lint
- task: typecheck
- task: test
typecheck:
desc: TypeScript typecheck only
dir: frontend
cmds:
- npm run typecheck
check:
desc: Run all checks (lint, typecheck, build)
cmds:
- task: lint
- task: build:server
- task: build:frontend
- task: test

40
check.sh Executable file
View file

@ -0,0 +1,40 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$ROOT_DIR"
step() {
printf '\n==> %s\n' "$1"
shift
"$@"
}
step "Python lint: ruff" uv run ruff check .
step "Python dependency lint: deptry" uv run deptry .
step "Python unit tests" uv run pytest \
pipeline/utils/test_haversine.py \
pipeline/utils/test_poi_counts.py \
pipeline/download/test_naptan.py \
pipeline/transform/postcode_boundaries/test_postcode_boundaries.py
(
cd "$ROOT_DIR/frontend"
step "Frontend lint: ESLint" npm run lint
step "Frontend format check: Prettier" npm run format:check
step "Frontend typecheck: TypeScript" npm run typecheck
step "Frontend unit tests: Vitest" npm run test
)
(
cd "$ROOT_DIR/screenshot"
step "Screenshot service unit tests" npm run test
)
(
cd "$ROOT_DIR/server-rs"
step "Rust lint: clippy" cargo clippy --all-targets -- -D warnings
step "Rust format check" cargo fmt --all --check
step "Rust dependency lint: cargo machete" cargo machete
step "Rust unit tests" cargo test
)

File diff suppressed because it is too large Load diff

View file

@ -6,6 +6,7 @@
"build": "webpack --mode production && node scripts/prerender.mjs",
"build:no-prerender": "webpack --mode production",
"prerender": "node scripts/prerender.mjs",
"test": "vitest run --environment jsdom",
"typecheck": "tsc --noEmit",
"lint": "eslint src --ext .ts,.tsx",
"lint:fix": "eslint src --ext .ts,.tsx --fix",
@ -39,6 +40,7 @@
"@babel/preset-react": "^7.28.5",
"@babel/preset-typescript": "^7.28.5",
"@pmmmwh/react-refresh-webpack-plugin": "^0.6.2",
"@testing-library/react": "^16.3.2",
"@types/react": "^18.2.0",
"@types/react-dom": "^18.2.0",
"@typescript-eslint/eslint-plugin": "^7.0.0",
@ -53,6 +55,7 @@
"favicons": "^7.2.0",
"favicons-webpack-plugin": "^6.0.1",
"html-webpack-plugin": "^5.6.0",
"jsdom": "^29.1.1",
"mini-css-extract-plugin": "^2.9.0",
"postcss": "^8.4.0",
"postcss-loader": "^8.0.0",
@ -63,6 +66,7 @@
"tailwindcss": "^3.4.0",
"ts-loader": "^9.5.0",
"typescript": "^5.4.0",
"vitest": "^4.1.5",
"webpack": "^5.90.0",
"webpack-cli": "^5.1.0",
"webpack-dev-server": "^5.0.0"

View file

@ -1,4 +1,4 @@
import { useState, useEffect, useRef } from 'react';
import { useState, useEffect, useRef, type ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { useFadeInRef } from '../../hooks/useFadeIn';
import HexCanvas from './HexCanvas';
@ -7,6 +7,386 @@ import { TickerValue } from '../ui/TickerValue';
import { LogoIcon } from '../ui/icons/LogoIcon';
import { trackEvent } from '../../lib/analytics';
const SHOWCASE_STEP_COUNT = 4;
const SHOWCASE_INTERVAL_MS = 4200;
function ProductMapFrame({ children }: { children: ReactNode }) {
return (
<div className="relative h-full min-h-0 rounded-lg overflow-hidden bg-warm-900 border border-white/10">
<div className="absolute inset-0 opacity-70">
<div className="absolute left-[12%] top-[18%] w-[76%] h-[68%] rounded-[45%] border border-warm-500/25" />
<div className="absolute left-[22%] top-[24%] w-[52%] h-[55%] rounded-[45%] border border-warm-500/20" />
<div className="absolute left-[36%] top-[8%] h-[84%] w-px bg-warm-500/20 rotate-12" />
<div className="absolute left-[18%] top-[49%] h-px w-[68%] bg-warm-500/20 -rotate-6" />
<div className="absolute left-[48%] top-[16%] h-[72%] w-px bg-warm-500/20 -rotate-24" />
<div className="absolute left-[4%] top-[34%] h-px w-[92%] bg-teal-400/10 rotate-12" />
</div>
{children}
</div>
);
}
function DemoMapPin({
name,
detail,
x,
y,
active = false,
}: {
name: string;
detail: string;
x: string;
y: string;
active?: boolean;
}) {
return (
<div
className={`absolute -translate-x-1/2 -translate-y-1/2 ${active ? 'z-20' : 'z-10'}`}
style={{ left: x, top: y }}
>
<div className="relative mx-auto w-4 h-4">
<div
className={`absolute inset-0 rounded-full ${
active ? 'bg-coral-400 ring-8 ring-coral-400/20' : 'bg-teal-400 ring-4 ring-teal-400/15'
}`}
/>
</div>
<div
className={`mt-2 w-32 rounded-md border px-2 py-1 shadow-lg ${
active
? 'border-coral-400/40 bg-navy-950/95 text-white'
: 'border-white/10 bg-navy-950/85 text-warm-100'
}`}
>
<div className="text-xs font-semibold leading-tight">{name}</div>
<div className="text-[10px] leading-tight text-warm-400">{detail}</div>
</div>
</div>
);
}
function HeroProductShowcase() {
const { t } = useTranslation();
const [activeStep, setActiveStep] = useState(0);
const [isPaused, setIsPaused] = useState(false);
useEffect(() => {
const prefersReducedMotion =
typeof window !== 'undefined' &&
window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (isPaused || prefersReducedMotion) return;
const timer = window.setInterval(() => {
setActiveStep((step) => (step + 1) % SHOWCASE_STEP_COUNT);
}, SHOWCASE_INTERVAL_MS);
return () => window.clearInterval(timer);
}, [isPaused]);
const steps = [
{
tab: t('home.showcaseStep1Tab'),
title: t('home.showcaseStep1Title'),
body: t('home.showcaseStep1Body'),
},
{
tab: t('home.showcaseStep2Tab'),
title: t('home.showcaseStep2Title'),
body: t('home.showcaseStep2Body'),
},
{
tab: t('home.showcaseStep3Tab'),
title: t('home.showcaseStep3Title'),
body: t('home.showcaseStep3Body'),
},
{
tab: t('home.showcaseStep4Tab'),
title: t('home.showcaseStep4Title'),
body: t('home.showcaseStep4Body'),
},
];
const criteriaChips = [
t('home.showcaseStep1Chip1'),
t('home.showcaseStep1Chip2'),
t('home.showcaseStep1Chip3'),
t('home.showcaseStep1Chip4'),
];
const knownAreas = ['Clapham', 'St Albans', 'Brighton'];
const mapPins = [
{ name: 'Penge', detail: t('home.showcaseMatchPenge'), x: '63%', y: '54%' },
{ name: 'Abbey Wood', detail: t('home.showcaseMatchAbbeyWood'), x: '73%', y: '63%' },
{ name: 'Totterdown', detail: t('home.showcaseMatchTotterdown'), x: '42%', y: '71%' },
{ name: 'Walkley', detail: t('home.showcaseMatchWalkley'), x: '50%', y: '35%' },
];
const evidenceRows = [
t('home.showcaseEvidence1'),
t('home.showcaseEvidence2'),
t('home.showcaseEvidence3'),
t('home.showcaseEvidence4'),
];
const compareRows = [
t('home.showcaseCompare1'),
t('home.showcaseCompare2'),
t('home.showcaseCompare3'),
];
const active = steps[activeStep];
const renderSidePanel = () => {
if (activeStep === 0) {
return (
<div className="space-y-3">
<div className="rounded-lg border border-white/10 bg-white/[0.04] p-3">
<div className="text-[11px] font-semibold uppercase text-warm-400">
{t('aiFilter.describeIdealArea')}
</div>
<div className="mt-2 text-sm leading-snug text-white">
{t('home.showcaseStep1Prompt')}
</div>
</div>
<div className="flex flex-wrap gap-1.5">
{criteriaChips.map((chip) => (
<span
key={chip}
className="rounded-full border border-teal-400/30 bg-teal-400/10 px-2 py-1 text-[11px] font-medium text-teal-200"
>
{chip}
</span>
))}
</div>
</div>
);
}
if (activeStep === 1) {
return (
<div className="space-y-3">
<div className="rounded-lg border border-coral-400/30 bg-coral-400/10 p-3">
<div className="text-2xl font-bold text-white">{t('home.showcaseStep2Metric')}</div>
<div className="text-xs text-coral-100">{t('home.showcaseStep2Note')}</div>
</div>
<div className="grid grid-cols-2 gap-2 text-xs">
<div className="rounded-lg border border-white/10 bg-white/[0.04] p-2">
<div className="font-semibold text-warm-300">{t('home.showcaseKnownAreas')}</div>
{knownAreas.map((area) => (
<div key={area} className="mt-1 flex justify-between gap-2 text-warm-500">
<span>{area}</span>
<span>{t('home.showcaseKnownAreaStatus')}</span>
</div>
))}
</div>
<div className="rounded-lg border border-teal-400/30 bg-teal-400/10 p-2">
<div className="font-semibold text-teal-100">{t('home.showcaseNewMatches')}</div>
{mapPins.slice(0, 3).map((pin) => (
<div key={pin.name} className="mt-1 text-teal-200">
{pin.name}
</div>
))}
</div>
</div>
</div>
);
}
if (activeStep === 2) {
return (
<div className="rounded-lg border border-white/10 bg-white/[0.04] p-3">
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-xs font-semibold text-warm-400">
{t('home.showcaseStep3Postcode')}
</div>
<div className="mt-1 text-lg font-bold text-white">{t('home.showcaseStep3Area')}</div>
</div>
<div className="rounded-full bg-teal-400/15 px-2.5 py-1 text-xs font-semibold text-teal-200">
{t('home.showcaseStep3Score')}
</div>
</div>
<div className="mt-3 space-y-1.5">
{evidenceRows.map((row) => (
<div key={row} className="flex items-center gap-2 text-xs text-warm-300">
<span className="w-4 h-4 rounded-full bg-teal-400/15 text-teal-200 flex items-center justify-center text-[10px]">
&#x2713;
</span>
<span>{row}</span>
</div>
))}
</div>
</div>
);
}
return (
<div className="space-y-2">
{compareRows.map((row, index) => (
<div key={row} className="rounded-lg border border-white/10 bg-white/[0.04] p-2.5">
<div className="flex items-start gap-2">
<span className="mt-0.5 w-5 h-5 rounded-full bg-coral-400/15 text-coral-100 flex items-center justify-center text-xs font-bold">
{index + 1}
</span>
<span className="min-w-0 flex-1 text-xs leading-snug text-warm-200">{row}</span>
</div>
</div>
))}
<div className="text-xs font-semibold text-teal-200">{t('home.showcaseSaveLabel')}</div>
</div>
);
};
const renderVisual = () => {
if (activeStep === 0) {
return (
<ProductMapFrame>
<div className="absolute inset-x-4 top-4 rounded-lg border border-white/10 bg-navy-950/90 p-3 shadow-xl">
<div className="text-[10px] font-semibold uppercase text-warm-400">
{t('home.showcaseStep1Tab')}
</div>
<div className="mt-2 rounded-md bg-white text-warm-800 px-3 py-2 text-xs leading-snug">
{t('home.showcaseStep1Prompt')}
</div>
</div>
<div className="absolute inset-x-4 bottom-4 flex flex-wrap gap-2">
{criteriaChips.map((chip) => (
<span
key={chip}
className="rounded-full border border-teal-400/30 bg-navy-950/85 px-2.5 py-1 text-[11px] font-semibold text-teal-100"
>
{chip}
</span>
))}
</div>
</ProductMapFrame>
);
}
if (activeStep === 1) {
return (
<ProductMapFrame>
<div className="absolute left-3 top-3 rounded-md bg-navy-950/85 px-2.5 py-1.5 text-xs text-warm-300 border border-white/10">
{t('home.showcaseMapLabel')}
</div>
{mapPins.map((pin, index) => (
<DemoMapPin key={pin.name} {...pin} active={index === 0} />
))}
</ProductMapFrame>
);
}
if (activeStep === 2) {
return (
<ProductMapFrame>
<DemoMapPin {...mapPins[0]} active />
<div className="absolute right-3 top-3 w-40 rounded-lg border border-white/10 bg-navy-950/95 p-3 shadow-xl">
<div className="text-xs font-semibold text-warm-400">
{t('home.showcaseStep3Postcode')}
</div>
<div className="mt-1 text-xl font-bold text-white">{t('home.showcaseStep3Code')}</div>
<div className="mt-2 rounded-full bg-teal-400/15 px-2 py-1 text-center text-xs font-semibold text-teal-200">
{t('home.showcaseStep3Score')}
</div>
</div>
<div className="absolute inset-x-3 bottom-3 rounded-lg border border-white/10 bg-navy-950/90 p-3">
<div className="grid grid-cols-2 gap-2">
{evidenceRows.map((row) => (
<div key={row} className="text-[11px] text-warm-300">
<span className="text-teal-300">&#x2713;</span> {row}
</div>
))}
</div>
</div>
</ProductMapFrame>
);
}
return (
<ProductMapFrame>
<div className="absolute inset-3 grid grid-rows-3 gap-2">
{compareRows.map((row, index) => (
<div
key={row}
className="rounded-lg border border-white/10 bg-navy-950/90 p-3 shadow-lg"
>
<div className="flex items-center justify-between gap-3">
<div className="min-w-0 flex-1 text-xs font-semibold leading-snug text-white">
{row}
</div>
<div className="h-2 w-16 shrink-0 rounded-full bg-white/10 overflow-hidden">
<div
className="h-full rounded-full bg-coral-400"
style={{ width: `${88 - index * 14}%` }}
/>
</div>
</div>
</div>
))}
</div>
</ProductMapFrame>
);
};
return (
<div
className="relative w-full max-w-xl mt-10 lg:mt-0"
onMouseEnter={() => setIsPaused(true)}
onMouseLeave={() => setIsPaused(false)}
onFocus={() => setIsPaused(true)}
onBlur={() => setIsPaused(false)}
aria-label={t('home.showcaseHeader')}
>
<div className="h-[44rem] sm:h-[40rem] md:h-[34rem] lg:h-[35rem] xl:h-[33rem] rounded-xl border border-white/10 bg-navy-950/85 shadow-2xl overflow-hidden flex flex-col">
<div className="shrink-0 px-4 py-3 border-b border-white/10 flex items-center gap-2">
<LogoIcon className="w-5 h-5 text-teal-400" />
<div className="text-sm font-semibold text-white">{t('home.showcaseHeader')}</div>
<div className="ml-auto text-xs text-warm-400">{t('home.showcaseContext')}</div>
</div>
<div className="shrink-0 grid grid-cols-2 sm:grid-cols-4 gap-1 p-2 bg-white/[0.03] border-b border-white/10">
{steps.map((step, index) => (
<button
key={step.tab}
type="button"
onClick={() => setActiveStep(index)}
aria-pressed={activeStep === index}
className={`rounded-md px-2 py-2 text-[11px] sm:text-xs font-semibold leading-tight text-left transition-colors ${
activeStep === index
? 'bg-white/10 text-white'
: 'text-warm-400 hover:bg-white/[0.06] hover:text-warm-200'
}`}
>
<span>{step.tab}</span>
<span className="mt-2 block h-0.5 overflow-hidden rounded-full bg-white/10">
{activeStep === index && (
<span
key={activeStep}
className="showcase-progress block h-full origin-left bg-teal-400"
style={{
animationDuration: `${SHOWCASE_INTERVAL_MS}ms`,
animationPlayState: isPaused ? 'paused' : 'running',
}}
/>
)}
</span>
</button>
))}
</div>
<div className="flex-1 min-h-0 p-4 md:p-5">
<div className="grid h-full min-h-0 grid-rows-[minmax(0,0.92fr)_minmax(0,1.18fr)] md:grid-rows-1 md:grid-cols-[0.88fr_1.12fr] gap-4">
<div className="min-h-0 overflow-hidden flex flex-col justify-between gap-4 rounded-lg border border-white/10 bg-white/[0.035] p-4">
<div className="shrink-0" aria-live={isPaused ? 'polite' : 'off'}>
<div className="text-xs font-semibold text-teal-300">{steps[activeStep].tab}</div>
<h2 className="mt-2 text-xl font-bold leading-tight text-white">{active.title}</h2>
<p className="mt-3 text-sm leading-relaxed text-warm-400">{active.body}</p>
</div>
<div className="min-h-0 overflow-hidden">{renderSidePanel()}</div>
</div>
{renderVisual()}
</div>
</div>
</div>
</div>
);
}
export default function HomePage({
onOpenDashboard,
onOpenPricing: _onOpenPricing,
@ -63,13 +443,13 @@ export default function HomePage({
{/* Hero */}
<div className="relative overflow-hidden bg-gradient-to-b from-navy-950 via-navy-900 to-navy-900 dark:from-navy-950 dark:via-navy-900 dark:to-navy-950 min-h-[calc(100dvh-3rem)] flex flex-col">
<HexCanvas isDark={theme === 'dark'} />
<div className="absolute top-1/3 left-1/4 w-[500px] h-[500px] bg-teal-500/[0.04] rounded-full blur-[120px] pointer-events-none" />
<div className="absolute bottom-0 right-1/4 w-[400px] h-[300px] bg-teal-600/[0.03] rounded-full blur-[100px] pointer-events-none" />
<div className="relative z-10 max-w-4xl mx-auto px-6 md:px-10 pt-16 md:pt-24 backdrop-blur-[2px] flex-1 flex flex-col">
<div>
<h1 className="text-3xl md:text-5xl font-extrabold text-white mb-4 leading-[1.1] tracking-tight">
{t('home.heroTitle1')} <span className="text-teal-400">{t('home.heroTitle2')}</span>
.
<div className="relative z-10 max-w-7xl mx-auto w-full px-6 md:px-10 pt-16 md:pt-24 backdrop-blur-[2px] flex-1 flex flex-col">
<div className="grid lg:grid-cols-[1fr_0.9fr] gap-8 lg:gap-12 items-center">
<div className="max-w-4xl">
<p className="text-sm font-semibold text-teal-300 mb-3">{t('home.heroEyebrow')}</p>
<h1 className="text-3xl md:text-5xl font-extrabold text-white mb-4 leading-[1.1]">
{t('home.heroTitle1')}{' '}
<span className="text-teal-400">{t('home.heroTitle2')}</span>.
<br />
{t('home.heroTitle3')}
</h1>
@ -92,7 +472,7 @@ export default function HomePage({
<button
onClick={() => {
trackEvent('CTA Click', { location: 'hero', label: 'see_difference' });
const target = document.getElementById('comparison');
const target = document.getElementById('how-it-works');
if (!target) return;
const scroller = target.closest('.overflow-y-auto') as HTMLElement | null;
if (!scroller) return;
@ -140,12 +520,14 @@ export default function HomePage({
</div>
</div>
</div>
<HeroProductShowcase />
</div>
<div className="flex-1" />
</div>
</div>
{/* Our philosophy */}
<div className="px-6 md:px-12 lg:px-20 pt-12 md:pt-20 pb-4">
<div className="max-w-7xl mx-auto px-6 md:px-10 pt-12 md:pt-20 pb-4">
<h2 className="text-2xl md:text-3xl font-bold text-navy-950 dark:text-warm-100 mb-6">
{t('home.ourPhilosophy')}
</h2>
@ -155,8 +537,34 @@ export default function HomePage({
</div>
</div>
{/* Street by street */}
<div className="max-w-7xl mx-auto px-6 md:px-10 pt-10 pb-2">
<div className="grid md:grid-cols-3 gap-4">
<div className="md:col-span-1">
<h2 className="text-2xl md:text-3xl font-bold text-navy-950 dark:text-warm-100 mb-3">
{t('home.streetTitle')}
</h2>
<p className="text-warm-600 dark:text-warm-400 leading-relaxed">
{t('home.streetIntro')}
</p>
</div>
{[
{ label: t('home.streetCard1Title'), body: t('home.streetCard1Body') },
{ label: t('home.streetCard2Title'), body: t('home.streetCard2Body') },
].map((item) => (
<div
key={item.label}
className="rounded-lg border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 p-5"
>
<h3 className="font-bold text-navy-950 dark:text-warm-100 mb-2">{item.label}</h3>
<p className="text-warm-600 dark:text-warm-400 leading-relaxed">{item.body}</p>
</div>
))}
</div>
</div>
{/* How to use it + Comparison table (two columns) */}
<div id="how-it-works" className="max-w-7xl mx-auto px-6 pt-10 pb-2">
<div id="how-it-works" className="max-w-7xl mx-auto px-6 md:px-10 pt-10 pb-2">
<div ref={whyRef} className="fade-in-section">
<div className="grid lg:grid-cols-[2fr_3fr] gap-8 lg:gap-12 items-start">
{/* Left: How to use it */}
@ -277,8 +685,8 @@ export default function HomePage({
</div>
{/* The real cost CTA */}
<div className="max-w-4xl mx-auto px-6 pt-12 md:pt-20 pb-12">
<div ref={ctaRef} className="fade-in-section text-center">
<div className="max-w-7xl mx-auto px-6 md:px-10 pt-12 md:pt-20 pb-12">
<div ref={ctaRef} className="fade-in-section text-center max-w-4xl mx-auto">
<h2 className="text-2xl md:text-3xl font-bold text-navy-950 dark:text-warm-100 mb-4 leading-snug">
{t('home.ctaTitle')}
</h2>

View file

@ -47,6 +47,11 @@ const DATA_SOURCE_DEFS: DataSourceDef[] = [
url: 'https://download.geofabrik.de/europe/great-britain-latest.osm.pbf',
license: 'Open Data Commons Open Database License (ODbL)',
},
{
id: 'geolytix-retail-points',
url: 'https://geolytix.com/blog/supermarket-retail-points/',
license: 'GEOLYTIX Open Data License',
},
{
id: 'os-open-greenspace',
url: 'https://osdatahub.os.uk/downloads/open/OpenGreenspace',
@ -106,6 +111,11 @@ const DS_KEYS: Record<string, [string, string, string]> = {
],
crime: ['learnPage.dsCrimeName', 'learnPage.dsCrimeOrigin', 'learnPage.dsCrimeUse'],
'osm-pois': ['learnPage.dsOsmName', 'learnPage.dsOsmOrigin', 'learnPage.dsOsmUse'],
'geolytix-retail-points': [
'learnPage.dsGeolytixRetailName',
'learnPage.dsGeolytixRetailOrigin',
'learnPage.dsGeolytixRetailUse',
],
'os-open-greenspace': [
'learnPage.dsGreenspaceName',
'learnPage.dsGreenspaceOrigin',
@ -228,7 +238,6 @@ export default function LearnPage() {
{ question: t('learnPage.faqPricing1Q'), answer: t('learnPage.faqPricing1A') },
{ question: t('learnPage.faqPricing2Q'), answer: t('learnPage.faqPricing2A') },
{ question: t('learnPage.faqPricing3Q'), answer: t('learnPage.faqPricing3A') },
{ question: t('learnPage.faqPricing4Q'), answer: t('learnPage.faqPricing4A') },
],
},
{

View file

@ -9,16 +9,21 @@ import type {
} from '../../types';
import type { TravelTimeEntry } from '../../hooks/useTravelTime';
import type { HexagonLocation } from '../../lib/external-search';
import { formatValue, formatFilterValue, calculateHistogramMean } from '../../lib/format';
import {
formatValue,
formatFilterValue,
calculateHistogramMean,
roundedPercentages,
} from '../../lib/format';
import { groupFeaturesByCategory } from '../../lib/features';
import { STACKED_GROUPS, STACKED_ENUM_GROUPS } from '../../lib/consts';
import { PARTY_FEATURE_COLORS, STACKED_GROUPS, STACKED_ENUM_GROUPS } from '../../lib/consts';
import { DualHistogram, LoadingSkeleton } from './DualHistogram';
import EnumBarChart from './EnumBarChart';
import StackedBarChart from './StackedBarChart';
import StackedEnumChart from './StackedEnumChart';
import PriceHistoryChart from './PriceHistoryChart';
import ExternalSearchLinks from './ExternalSearchLinks';
import { InfoIcon } from '../ui/icons';
import { FilterIcon, InfoIcon } from '../ui/icons';
import { CollapsibleGroupHeader } from '../ui/CollapsibleGroupHeader';
import { FeatureInfoPopup } from '../ui/FeatureInfoPopup';
import { EmptyState } from '../ui/EmptyState';
@ -35,14 +40,26 @@ interface AreaPaneProps {
isPostcode?: boolean;
postcodeData?: PostcodeFeature | null;
onViewProperties: () => void;
onClearFilters?: () => void;
hexagonLocation: HexagonLocation | null;
filters: FeatureFilters;
unfilteredCount?: number | null;
onNavigateToSource?: (slug: string, featureName: string) => void;
travelTimeEntries?: TravelTimeEntry[];
isGroupExpanded: (name: string) => boolean;
onToggleGroup: (name: string) => void;
}
function normalizePercentageSegments<T extends { value: number }>(segments: T[]): T[] {
const total = segments.reduce((sum, segment) => sum + segment.value, 0);
const normalizedValues = roundedPercentages(
segments.map((segment) => segment.value),
total,
1
);
return segments.map((segment, index) => ({ ...segment, value: normalizedValues[index] }));
}
export default function AreaPane({
stats,
globalFeatures,
@ -51,8 +68,10 @@ export default function AreaPane({
isPostcode = false,
postcodeData,
onViewProperties,
onClearFilters,
hexagonLocation,
filters,
unfilteredCount,
onNavigateToSource,
travelTimeEntries,
isGroupExpanded,
@ -60,6 +79,8 @@ export default function AreaPane({
}: AreaPaneProps) {
const { t } = useTranslation();
const propertyCount = isPostcode && postcodeData ? postcodeData.properties.count : stats?.count;
const activeFilterCount = Object.keys(filters).length + (travelTimeEntries?.length ?? 0);
const hasFilteredOutArea = activeFilterCount > 0 && stats?.count === 0;
const featureGroups = useMemo(() => groupFeaturesByCategory(globalFeatures), [globalFeatures]);
const [infoFeature, setInfoFeature] = useState<FeatureMeta | null>(null);
@ -119,8 +140,36 @@ export default function AreaPane({
? t('common.postcode').toLowerCase()
: t('common.area').toLowerCase(),
})}
{Object.keys(filters).length > 0 ? t('areaPane.matchingFilters') : ''}
</p>
<div className="mt-2 flex gap-2 rounded border border-teal-200 bg-teal-50 px-2.5 py-2 text-xs leading-snug text-teal-800 dark:border-teal-800/70 dark:bg-teal-950/40 dark:text-teal-200">
<FilterIcon className="mt-0.5 h-3.5 w-3.5 shrink-0" />
<p>
{activeFilterCount > 0
? t('areaPane.filtersAffectStats', { count: activeFilterCount })
: t('areaPane.noFiltersAffectStats')}
</p>
</div>
{hasFilteredOutArea && (
<div className="mt-2 rounded border border-amber-200 bg-amber-50 px-2.5 py-2 text-xs leading-snug text-amber-900 dark:border-amber-800/70 dark:bg-amber-950/40 dark:text-amber-100">
<p className="font-semibold">{t('areaPane.noFilteredMatches')}</p>
<p className="mt-1">
{unfilteredCount != null && unfilteredCount > 0
? t('areaPane.unfilteredAreaCount', { count: unfilteredCount })
: unfilteredCount === 0
? t('areaPane.noUnfilteredAreaProperties')
: t('areaPane.relaxFiltersHint')}
</p>
{onClearFilters && (
<button
type="button"
onClick={onClearFilters}
className="mt-2 rounded bg-amber-600 px-2 py-1 text-xs font-medium text-white hover:bg-amber-700 dark:bg-amber-500 dark:text-amber-950 dark:hover:bg-amber-400"
>
{t('filters.clearAll')}
</button>
)}
</div>
)}
{stats && stats.count > 0 && (
<button
onClick={onViewProperties}
@ -149,7 +198,7 @@ export default function AreaPane({
) : stats ? (
<div>
{hexagonLocation && <StreetViewEmbed location={hexagonLocation} />}
<HistogramLegend />
{stats.count > 0 && <HistogramLegend />}
{stats.price_history &&
(() => {
const uniqueYears = new Set(stats.price_history.map((p) => Math.floor(p.year)));
@ -197,18 +246,27 @@ export default function AreaPane({
}))
.filter((s) => s.value > 0);
const isPercentageComposition = chart.unit === '%' && !chart.feature;
const displaySegments = isPercentageComposition
? normalizePercentageSegments(segments)
: segments;
const aggregateStats = chart.feature
? numericByName.get(chart.feature)
: undefined;
const total = aggregateStats
? aggregateStats.mean
: segments.reduce((sum, s) => sum + s.value, 0);
: displaySegments.reduce((sum, s) => sum + s.value, 0);
// Use rateFeature (e.g. per-1k) for display if available
const rateStats = chart.rateFeature
? numericByName.get(chart.rateFeature)
: undefined;
const displayValue = rateStats ? rateStats.mean : total;
const displayValue = isPercentageComposition
? 100
: rateStats
? rateStats.mean
: total;
// Use rateFeature for info popup and national average when available
const infoFeatureName = chart.rateFeature ?? chart.feature;
@ -216,8 +274,7 @@ export default function AreaPane({
? globalFeatureByName.get(infoFeatureName)
: undefined;
const globalMean =
featureMeta?.histogram
const globalMean = featureMeta?.histogram
? calculateHistogramMean(featureMeta.histogram)
: undefined;
@ -252,18 +309,32 @@ export default function AreaPane({
)}
</div>
</div>
<StackedBarChart segments={segments} total={total} />
<StackedBarChart
segments={displaySegments}
total={total}
colorMap={
chart.label === 'Political vote share'
? PARTY_FEATURE_COLORS
: undefined
}
/>
</div>
);
})}
{(() => {
const stackedFeatureNames = new Set<string>(
stackedCharts?.flatMap((c) =>
[c.feature, c.rateFeature, ...c.components].filter((s): s is string => Boolean(s))
[c.feature, c.rateFeature, ...c.components].filter((s): s is string =>
Boolean(s)
)
) ?? []
);
return group.features
.filter((f) => !stackedFeatureNames.has(f.name) && !stackedEnumFeatureNames.has(f.name))
.filter(
(f) =>
!stackedFeatureNames.has(f.name) &&
!stackedEnumFeatureNames.has(f.name)
)
.map((feature) => {
const numericStats = numericByName.get(feature.name);
const enumStats = enumByName.get(feature.name);

View file

@ -13,9 +13,7 @@ export default function EnumBarChart({
const localTotal = entries.reduce((sum, [, c]) => sum + c, 0);
// When global counts are available, normalize both to percentages for comparison
const globalTotal = globalCounts
? Object.values(globalCounts).reduce((sum, c) => sum + c, 0)
: 0;
const globalTotal = globalCounts ? Object.values(globalCounts).reduce((sum, c) => sum + c, 0) : 0;
const hasGlobal = globalCounts && globalTotal > 0;
@ -61,9 +59,14 @@ export default function EnumBarChart({
)}
<div
className={
barStyle ? 'h-full rounded relative' : 'h-full bg-teal-500 dark:bg-teal-400 rounded relative'
barStyle
? 'h-full rounded relative'
: 'h-full bg-teal-500 dark:bg-teal-400 rounded relative'
}
style={{ width: `${localWidth}%`, ...(barStyle ? { backgroundColor: barStyle } : {}) }}
style={{
width: `${localWidth}%`,
...(barStyle ? { backgroundColor: barStyle } : {}),
}}
/>
</div>
<span className="w-8 text-warm-500 dark:text-warm-400 text-right shrink-0">

View file

@ -29,6 +29,19 @@ import {
type TravelTimeEntry,
travelFieldKey,
} from '../../hooks/useTravelTime';
import {
SCHOOL_FILTER_NAME,
clampSchoolRange,
getDefaultSchoolFeatureName,
getSchoolBackendFeatureName,
getSchoolFilterConfig,
getSchoolFilterMeta,
isSchoolFilterName,
replaceSchoolFilterKeySelection,
type SchoolDistance,
type SchoolPhase,
type SchoolRating,
} from '../../lib/school-filter';
function EditableLabel({
value,
@ -169,6 +182,223 @@ function SliderLabels({
);
}
function SchoolFilterCard({
features,
schoolFeature,
filters,
activeFeature,
dragValue,
pinnedFeature,
filterImpact,
onFilterChange,
onDragStart,
onDragChange,
onDragEnd,
onTogglePin,
onShowInfo,
onRemove,
}: {
features: FeatureMeta[];
schoolFeature: FeatureMeta;
filters: FeatureFilters;
activeFeature: string | null;
dragValue: [number, number] | null;
pinnedFeature: string | null;
filterImpact?: number;
onFilterChange: (name: string, value: [number, number] | string[]) => void;
onDragStart: (name: string) => void;
onDragChange: (value: [number, number]) => void;
onDragEnd: () => void;
onTogglePin: (name: string) => void;
onShowInfo: (feature: FeatureMeta) => void;
onRemove: () => void;
}) {
const config = getSchoolFilterConfig(schoolFeature.name);
const schoolMeta = getSchoolFilterMeta(features);
const backendFeature = config
? features.find((feature) => feature.name === config.featureName)
: undefined;
const isActive = activeFeature === schoolFeature.name;
const isPinned = pinnedFeature === schoolFeature.name;
const hist = backendFeature?.histogram;
const dataMin = hist?.min ?? backendFeature?.min ?? 0;
const dataMax = hist?.max ?? backendFeature?.max ?? 10;
const displayValue =
isActive && dragValue
? dragValue
: (filters[schoolFeature.name] as [number, number]) || [dataMin, dataMax];
const sliderValue: [number, number] = [
displayValue[0] <= dataMin ? (backendFeature?.min ?? dataMin) : displayValue[0],
displayValue[1] >= dataMax ? (backendFeature?.max ?? dataMax) : displayValue[1],
];
if (!config) return null;
const replaceSchoolFeature = (
next: Partial<{
phase: SchoolPhase;
rating: SchoolRating;
distance: SchoolDistance;
}>
) => {
const nextName = replaceSchoolFilterKeySelection(schoolFeature.name, next);
if (nextName === schoolFeature.name) return;
const nextBackendName = getSchoolBackendFeatureName(nextName);
const nextFeature = nextBackendName
? features.find((feature) => feature.name === nextBackendName)
: undefined;
const nextDataMin = nextFeature?.histogram?.min ?? nextFeature?.min ?? 0;
const nextDataMax = nextFeature?.histogram?.max ?? nextFeature?.max ?? Math.max(1, dataMax);
const nextRange = clampSchoolRange(
[
displayValue[0] <= dataMin ? nextDataMin : displayValue[0],
displayValue[1] >= dataMax ? nextDataMax : displayValue[1],
],
nextFeature
);
onFilterChange(nextName, nextRange);
if (isPinned) onTogglePin(nextName);
};
const segmentedClass =
'grid grid-cols-2 overflow-hidden rounded-md border border-warm-200 dark:border-warm-700 bg-warm-50 dark:bg-warm-800';
const optionClass = (active: boolean) =>
`px-2 py-1 text-xs font-medium border-r last:border-r-0 border-warm-200 dark:border-warm-700 transition-colors ${
active
? 'bg-teal-600 text-white dark:bg-teal-500'
: 'text-warm-600 hover:bg-warm-100 dark:text-warm-300 dark:hover:bg-warm-700'
}`;
return (
<div
data-filter-name={SCHOOL_FILTER_NAME}
className={`space-y-1.5 px-2 py-1.5 rounded ${isActive ? 'ring-2 ring-teal-400 bg-teal-50 dark:bg-teal-900/30' : isPinned ? 'ring-2 ring-teal-400 bg-teal-50/50 dark:bg-teal-900/20' : ''}`}
>
<div className="relative z-10 flex items-center justify-between gap-1">
<FeatureLabel feature={schoolMeta} size="sm" className="min-w-0 shrink" hideIconOnMobile />
<FeatureActions
feature={schoolMeta}
isPinned={isPinned}
isPreviewing={isActive}
onTogglePin={() => onTogglePin(schoolFeature.name)}
onShowInfo={() => onShowInfo(schoolMeta)}
onRemove={onRemove}
/>
</div>
<div className="space-y-1.5">
<div>
<div className="mb-0.5 text-[10px] font-medium uppercase text-warm-400 dark:text-warm-500">
School type
</div>
<div className={segmentedClass} role="radiogroup" aria-label="School type">
<button
type="button"
role="radio"
aria-checked={config.phase === 'primary'}
onClick={() => replaceSchoolFeature({ phase: 'primary' })}
className={optionClass(config.phase === 'primary')}
>
Primary
</button>
<button
type="button"
role="radio"
aria-checked={config.phase === 'secondary'}
onClick={() => replaceSchoolFeature({ phase: 'secondary' })}
className={optionClass(config.phase === 'secondary')}
>
Secondary
</button>
</div>
</div>
<div>
<div className="mb-0.5 text-[10px] font-medium uppercase text-warm-400 dark:text-warm-500">
Rating
</div>
<div className={segmentedClass} role="radiogroup" aria-label="School rating">
<button
type="button"
role="radio"
aria-checked={config.rating === 'good'}
onClick={() => replaceSchoolFeature({ rating: 'good' })}
className={optionClass(config.rating === 'good')}
>
Good+
</button>
<button
type="button"
role="radio"
aria-checked={config.rating === 'outstanding'}
onClick={() => replaceSchoolFeature({ rating: 'outstanding' })}
className={optionClass(config.rating === 'outstanding')}
>
Outstanding
</button>
</div>
</div>
<div>
<div className="mb-0.5 text-[10px] font-medium uppercase text-warm-400 dark:text-warm-500">
Distance
</div>
<div className={segmentedClass} role="radiogroup" aria-label="School distance">
<button
type="button"
role="radio"
aria-checked={config.distance === 2}
onClick={() => replaceSchoolFeature({ distance: 2 })}
className={optionClass(config.distance === 2)}
>
2 km
</button>
<button
type="button"
role="radio"
aria-checked={config.distance === 5}
onClick={() => replaceSchoolFeature({ distance: 5 })}
className={optionClass(config.distance === 5)}
>
5 km
</button>
</div>
</div>
</div>
<Slider
min={backendFeature?.min ?? dataMin}
max={backendFeature?.max ?? dataMax}
step={backendFeature?.step ?? 1}
value={sliderValue}
onValueChange={([min, max]) =>
onDragChange([
min <= (backendFeature?.min ?? dataMin) ? dataMin : min,
max >= (backendFeature?.max ?? dataMax) ? dataMax : max,
])
}
onPointerDown={() => onDragStart(schoolFeature.name)}
onPointerUp={() => onDragEnd()}
/>
<SliderLabels
min={backendFeature?.min ?? dataMin}
max={backendFeature?.max ?? dataMax}
value={sliderValue}
displayValues={displayValue}
isAtMin={displayValue[0] === dataMin}
isAtMax={displayValue[1] === dataMax}
raw={backendFeature?.raw}
feature={backendFeature}
onValueChange={(v) => onFilterChange(schoolFeature.name, v)}
/>
{filterImpact != null && filterImpact > 0 && (
<p className="text-[10px] text-warm-400 dark:text-warm-500 -mt-1 ml-2.5">
+{formatNumber(filterImpact)} without this filter
</p>
)}
</div>
);
}
interface FiltersProps {
features: FeatureMeta[];
filters: FeatureFilters;
@ -214,6 +444,7 @@ interface FiltersProps {
onClearAll: () => void;
onSaveSearch?: (name: string) => Promise<void>;
savingSearch?: boolean;
destinationDropdownPortal?: boolean;
}
export default memo(function Filters({
@ -255,17 +486,57 @@ export default memo(function Filters({
onClearAll,
onSaveSearch,
savingSearch,
destinationDropdownPortal = true,
}: FiltersProps) {
const { t } = useTranslation();
const availableFeatures = useMemo(
() => features.filter((f) => !enabledFeatures.has(f.name)),
[features, enabledFeatures]
);
const enabledFeatureList = useMemo(
() => features.filter((f) => enabledFeatures.has(f.name)),
[features, enabledFeatures]
);
const defaultSchoolFeatureName = useMemo(() => getDefaultSchoolFeatureName(features), [features]);
const schoolMeta = useMemo(() => getSchoolFilterMeta(features), [features]);
const schoolFilterItems = useMemo(() => {
return Object.keys(filters)
.filter(isSchoolFilterName)
.map((name) => {
const backendName = getSchoolBackendFeatureName(name);
const backendFeature = backendName
? features.find((feature) => feature.name === backendName)
: undefined;
return { ...(backendFeature ?? schoolMeta), name, group: 'Education' };
});
}, [filters, features, schoolMeta]);
const availableFeatures = useMemo(() => {
const result: FeatureMeta[] = [];
let insertedSchoolFilter = false;
for (const feature of features) {
if (isSchoolFilterName(feature.name)) {
if (defaultSchoolFeatureName && !insertedSchoolFilter) {
result.push(schoolMeta);
insertedSchoolFilter = true;
}
continue;
}
if (!enabledFeatures.has(feature.name)) result.push(feature);
}
return result;
}, [features, enabledFeatures, defaultSchoolFeatureName, schoolMeta]);
const enabledFeatureList = useMemo(() => {
const result: FeatureMeta[] = [];
let insertedSchoolFilter = false;
for (const feature of features) {
if (isSchoolFilterName(feature.name)) {
if (!insertedSchoolFilter) {
result.push(...schoolFilterItems);
insertedSchoolFilter = true;
}
continue;
}
if (enabledFeatures.has(feature.name)) result.push(feature);
}
return result;
}, [features, enabledFeatures, schoolFilterItems]);
const containerRef = useRef<HTMLDivElement>(null);
const scrollRef = useRef<HTMLDivElement>(null);
@ -279,10 +550,24 @@ export default memo(function Filters({
const handleAddAndScroll = useCallback(
(name: string) => {
if (name === SCHOOL_FILTER_NAME) {
if (!defaultSchoolFeatureName) return;
pendingScrollRef.current = SCHOOL_FILTER_NAME;
onAddFilter(SCHOOL_FILTER_NAME);
return;
}
pendingScrollRef.current = name;
onAddFilter(name);
},
[onAddFilter]
[defaultSchoolFeatureName, onAddFilter]
);
const handleRemoveSchoolFilter = useCallback(
(name: string) => {
onRemoveFilter(name);
},
[onRemoveFilter]
);
const handleAddTravelTimeAndScroll = useCallback(
@ -455,6 +740,59 @@ export default memo(function Filters({
<div className="px-2 py-1 space-y-1">
{enabledFeatureList.map((feature, featureIdx) => {
if (isSchoolFilterName(feature.name)) {
const schoolBackendName = getSchoolBackendFeatureName(feature.name);
return (
<Fragment key={feature.name}>
{featureIdx === travelInsertIdx &&
travelTimeEntries.map((entry, index) => (
<div key={`tt_${index}`} data-filter-name={`tt_${index}`}>
<TravelTimeCard
mode={entry.mode}
slug={entry.slug}
label={entry.label}
timeRange={entry.timeRange}
useBest={entry.useBest}
isPinned={pinnedFeature === travelFieldKey(entry)}
isActive={activeFeature === travelFieldKey(entry)}
dragValue={activeFeature === travelFieldKey(entry) ? dragValue : null}
onTogglePin={() => onTogglePin(travelFieldKey(entry))}
onSetDestination={(slug, label, lat, lon) =>
onTravelTimeSetDestination(index, slug, label, lat, lon)
}
onTimeRangeChange={(range) => onTravelTimeRangeChange(index, range)}
onDragStart={() => onDragStart(travelFieldKey(entry))}
onDragChange={onDragChange}
onDragEnd={() => onTravelTimeDragEnd(index)}
onToggleBest={() => onTravelTimeToggleBest(index)}
onRemove={() => onTravelTimeRemoveEntry(index)}
filterImpact={filterImpacts?.[travelFieldKey(entry)]}
destinationDropdownPortal={destinationDropdownPortal}
/>
</div>
))}
<SchoolFilterCard
features={features}
schoolFeature={feature}
filters={filters}
activeFeature={activeFeature}
dragValue={dragValue}
pinnedFeature={pinnedFeature}
filterImpact={
schoolBackendName ? filterImpacts?.[schoolBackendName] : undefined
}
onFilterChange={onFilterChange}
onDragStart={onDragStart}
onDragChange={onDragChange}
onDragEnd={onDragEnd}
onTogglePin={onTogglePin}
onShowInfo={setActiveInfoFeature}
onRemove={() => handleRemoveSchoolFilter(feature.name)}
/>
</Fragment>
);
}
if (feature.type === 'enum') {
const selectedValues = (filters[feature.name] as string[]) || [];
const allValues = feature.values || [];
@ -483,6 +821,7 @@ export default memo(function Filters({
onToggleBest={() => onTravelTimeToggleBest(index)}
onRemove={() => onTravelTimeRemoveEntry(index)}
filterImpact={filterImpacts?.[travelFieldKey(entry)]}
destinationDropdownPortal={destinationDropdownPortal}
/>
</div>
))}
@ -587,6 +926,7 @@ export default memo(function Filters({
onToggleBest={() => onTravelTimeToggleBest(index)}
onRemove={() => onTravelTimeRemoveEntry(index)}
filterImpact={filterImpacts?.[travelFieldKey(entry)]}
destinationDropdownPortal={destinationDropdownPortal}
/>
</div>
))}
@ -604,6 +944,7 @@ export default memo(function Filters({
<FeatureActions
feature={feature}
isPinned={isPinned}
isPreviewing={isActive}
onTogglePin={onTogglePin}
onShowInfo={setActiveInfoFeature}
onRemove={onRemoveFilter}
@ -688,6 +1029,7 @@ export default memo(function Filters({
onToggleBest={() => onTravelTimeToggleBest(index)}
onRemove={() => onTravelTimeRemoveEntry(index)}
filterImpact={filterImpacts?.[travelFieldKey(entry)]}
destinationDropdownPortal={destinationDropdownPortal}
/>
</div>
))}
@ -721,10 +1063,20 @@ export default memo(function Filters({
<div className="md:flex-1 md:min-h-0 md:overflow-y-auto">
<FeatureBrowser
availableFeatures={availableFeatures}
allFeatures={features}
pinnedFeature={pinnedFeature}
allFeatures={[...features, schoolMeta]}
pinnedFeature={
pinnedFeature && isSchoolFilterName(pinnedFeature)
? SCHOOL_FILTER_NAME
: pinnedFeature
}
onAddFilter={handleAddAndScroll}
onTogglePin={onTogglePin}
onTogglePin={(name) => {
if (name === SCHOOL_FILTER_NAME) {
if (defaultSchoolFeatureName) onTogglePin(defaultSchoolFeatureName);
return;
}
onTogglePin(name);
}}
onNavigateToSource={onNavigateToSource}
openInfoFeature={openInfoFeature}
onClearOpenInfoFeature={onClearOpenInfoFeature}

View file

@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next';
import type { FeatureFilters, FeatureMeta } from '../../types';
import { formatValue } from '../../lib/format';
import { ts } from '../../i18n/server';
import { SCHOOL_FILTER_NAME, getSchoolBackendFeatureName } from '../../lib/school-filter';
interface HoverCardData {
count: number;
@ -41,14 +42,18 @@ export default memo(function HoverCard({
// Show stats for active filters (up to 4)
for (const name of activeFilterNames.slice(0, 4)) {
const val = data[`avg_${name}`] ?? data[`min_${name}`];
const backendName = getSchoolBackendFeatureName(name) ?? name;
const val = data[`avg_${backendName}`] ?? data[`min_${backendName}`];
if (val == null || typeof val !== 'number') continue;
const meta = featureMap.get(name);
const meta = featureMap.get(backendName);
if (meta?.type === 'enum' && meta.values) {
const label = meta.values[Math.round(val)];
if (label) results.push({ name, value: ts(label) });
if (label) results.push({ name: backendName, value: ts(label) });
} else {
results.push({ name, value: formatValue(val, meta) });
results.push({
name: backendName === name ? name : SCHOOL_FILTER_NAME,
value: formatValue(val, meta),
});
}
}

View file

@ -34,10 +34,12 @@ const ZOOM_FOR_TYPE: Record<string, number> = {
export default function LocationSearch({
onFlyTo,
onLocationSearched,
onCurrentLocationFound,
onMouseEnter,
}: {
onFlyTo: (lat: number, lng: number, zoom: number) => void;
onLocationSearched?: (postcode: SearchedLocation | null) => void;
onCurrentLocationFound?: (lat: number, lng: number) => void;
onMouseEnter?: () => void;
}) {
const { t } = useTranslation();
@ -131,27 +133,8 @@ export default function LocationSearch({
});
});
const { latitude, longitude } = position.coords;
const res = await fetch(
`/api/nearest-postcode?lat=${latitude}&lng=${longitude}`,
authHeaders()
);
if (!res.ok) {
setError(t('locationSearch.lookupFailed'));
return;
}
const json: {
postcode: string;
latitude: number;
longitude: number;
geometry: PostcodeGeometry;
} = await res.json();
onFlyTo(json.latitude, json.longitude, 16);
onLocationSearched?.({
postcode: json.postcode,
geometry: json.geometry,
latitude: json.latitude,
longitude: json.longitude,
});
onFlyTo(latitude, longitude, 17);
onCurrentLocationFound?.(latitude, longitude);
search.clear();
if (isMobile) setExpanded(false);
} catch {
@ -159,7 +142,7 @@ export default function LocationSearch({
} finally {
setLocating(false);
}
}, [onFlyTo, onLocationSearched, isMobile, search, t]);
}, [onFlyTo, onCurrentLocationFound, isMobile, search, t]);
// Mobile collapsed state: search icon + locate button
if (isMobile && !expanded) {

View file

@ -14,7 +14,12 @@ import type {
Bounds,
} from '../../types';
import { zoomToResolution, getBoundsFromViewState, getMapStyle } from '../../lib/map-utils';
import {
zoomToResolution,
getBoundsFromViewState,
getMapStyle,
getPoiIconUrl,
} from '../../lib/map-utils';
import {
INITIAL_VIEW_STATE,
MAP_MIN_ZOOM,
@ -56,6 +61,8 @@ interface MapProps {
filters?: FeatureFilters;
selectedPostcodeGeometry?: PostcodeGeometry | null;
onLocationSearched?: (location: SearchedLocation | null) => void;
onCurrentLocationFound?: (lat: number, lng: number) => void;
currentLocation?: { lat: number; lng: number } | null;
bounds?: Bounds | null;
hideLegend?: boolean;
travelTimeEntries?: TravelTimeEntry[];
@ -114,6 +121,8 @@ export default memo(function Map({
filters = {},
selectedPostcodeGeometry,
onLocationSearched,
onCurrentLocationFound,
currentLocation,
bounds: viewportBounds,
hideLegend = false,
travelTimeEntries = EMPTY_TRAVEL_ENTRIES,
@ -225,6 +234,7 @@ export default memo(function Map({
onHexagonHover,
theme,
selectedPostcodeGeometry,
currentLocation,
bounds: viewportBounds,
travelTimeEntries,
});
@ -307,6 +317,7 @@ export default memo(function Map({
<LocationSearch
onFlyTo={handleFlyTo}
onLocationSearched={onLocationSearched}
onCurrentLocationFound={onCurrentLocationFound}
onMouseEnter={handleMouseLeave}
/>
{!hideLegend &&
@ -389,7 +400,14 @@ export default memo(function Map({
) : (
<div className="px-3 py-2">
<div className="flex items-center gap-2">
<span className="text-lg leading-none">{popupInfo.emoji}</span>
<img
src={getPoiIconUrl(popupInfo.category, popupInfo.emoji)}
alt=""
aria-hidden="true"
loading="lazy"
referrerPolicy="no-referrer"
className="h-5 w-5 shrink-0 rounded-[4px] bg-white object-contain p-0.5"
/>
<div>
<div className="font-semibold dark:text-warm-100">{popupInfo.name}</div>
<div className="flex items-center gap-1.5 text-xs text-warm-500 dark:text-warm-400">

View file

@ -2,10 +2,10 @@ import { useTranslation } from 'react-i18next';
import { formatValue } from '../../lib/format';
import { ts } from '../../i18n/server';
import {
FEATURE_GRADIENT,
DENSITY_GRADIENT,
DENSITY_GRADIENT_DARK,
getEnumPaletteForFeature,
getFeatureGradient,
} from '../../lib/consts';
import { gradientToCss } from '../../lib/utils';
import { CloseIcon } from '../ui/icons/CloseIcon';
@ -95,7 +95,9 @@ export default function MapLegend({
const enumPalette = getEnumPaletteForFeature(featureName ?? null, enumValues);
const densityGradient = theme === 'dark' ? DENSITY_GRADIENT_DARK : DENSITY_GRADIENT;
const gradientStyle =
mode === 'density' ? gradientToCss(densityGradient) : gradientToCss(FEATURE_GRADIENT);
mode === 'density'
? gradientToCss(densityGradient)
: gradientToCss(getFeatureGradient(featureName));
const fmt = raw ? { raw: true } : undefined;

View file

@ -1,5 +1,6 @@
import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { cellToLatLng } from 'h3-js';
import type {
FeatureMeta,
FeatureFilters,
@ -16,8 +17,9 @@ import POIPane from './POIPane';
import { PropertiesPane } from './PropertiesPane';
import AreaPane from './AreaPane';
import MobileDrawer from './MobileDrawer';
import MobileBottomSheet from './MobileBottomSheet';
import MapLegend from './MapLegend';
import { TabButton } from '../ui/TabButton';
import { MapPageSelectionPane } from './MapPageSelectionPane';
import { useMapData } from '../../hooks/useMapData';
import { usePOIData } from '../../hooks/usePOIData';
import { useFilters } from '../../hooks/useFilters';
@ -40,9 +42,9 @@ import { useFilterCounts } from '../../hooks/useFilterCounts';
import { ts } from '../../i18n/server';
import { trackEvent } from '../../lib/analytics';
import { INITIAL_VIEW_STATE } from '../../lib/consts';
import { getSchoolBackendFeatureName } from '../../lib/school-filter';
import { useLicense } from '../../hooks/useLicense';
import UpgradeModal from '../ui/UpgradeModal';
import { CloseIcon } from '../ui/icons/CloseIcon';
import { SpinnerIcon } from '../ui/icons/SpinnerIcon';
import { MapPinIcon } from '../ui/icons/MapPinIcon';
import { BookmarkIcon } from '../ui/icons/BookmarkIcon';
@ -116,15 +118,10 @@ export default function MapPage({
const [leftPaneWidth, leftPaneHandlers] = usePaneResize(384, 200, 0.45, 'left');
const [rightPaneWidth, rightPaneHandlers] = usePaneResize(384, 200, 0.45, 'right');
const [, mobileResizeHandlers, mobileMapRef] = usePaneResize(
Math.round(window.innerHeight * 0.4),
120,
0.8,
'top'
);
const [mobileDrawerOpen, setMobileDrawerOpen] = useState(false);
const [poiPaneOpen, setPoiPaneOpen] = useState(false);
const [currentLocation, setCurrentLocation] = useState<{ lat: number; lng: number } | null>(null);
const [showBookmarkToast, setShowBookmarkToast] = useState(false);
const bookmarkToastDismissed = useRef(localStorage.getItem('bookmark_toast_dismissed') === '1');
@ -249,7 +246,14 @@ export default function MapPage({
}
}
},
[fetchAiFilters, handleSetFilters, handleSetEntries, activeEntries, filters, mapData.currentView?.zoom]
[
fetchAiFilters,
handleSetFilters,
handleSetEntries,
activeEntries,
filters,
mapData.currentView?.zoom,
]
);
const handleClearAll = useCallback(() => {
@ -304,6 +308,7 @@ export default function MapPage({
loadingProperties,
areaStats,
loadingAreaStats,
unfilteredAreaCount,
hoveredHexagon,
rightPaneTab,
setRightPaneTab,
@ -315,25 +320,38 @@ export default function MapPage({
handleCloseSelection,
selectedPostcodeGeometry,
handleLocationSearch,
handleCurrentLocationSearch,
} = useHexagonSelection({
filters,
features,
resolution: mapData.resolution,
usePostcodeView: mapData.usePostcodeView,
journeyDest,
});
const handleLocationSearchResult = useCallback(
(result: SearchedLocation | null) => {
if (result) {
setCurrentLocation(null);
handleLocationSearch(result.postcode, result.geometry, result.latitude, result.longitude);
if (isMobile) setMobileDrawerOpen(true);
} else {
setCurrentLocation(null);
handleCloseSelection();
}
},
[handleLocationSearch, handleCloseSelection, isMobile]
);
const handleCurrentLocationFound = useCallback(
(lat: number, lng: number) => {
setCurrentLocation({ lat, lng });
handleCurrentLocationSearch(lat, lng);
if (isMobile) setMobileDrawerOpen(true);
},
[handleCurrentLocationSearch, isMobile]
);
const handleZoomToFreeZone = useCallback(() => {
mapFlyToRef.current?.(
INITIAL_VIEW_STATE.latitude,
@ -428,20 +446,19 @@ export default function MapPage({
const [lon, lat] = postcodeFeature.properties.centroid;
return { lat, lon, resolution: mapData.resolution, postcode: hexId, isPostcode: true };
} else {
// For hexagons, get lat/lon from hexagon data; central postcode comes from stats
const hex = hexId ? mapData.data.find((d) => d.h3 === hexId) : null;
if (!hex || typeof hex.lat !== 'number' || typeof hex.lon !== 'number') return null;
if (!hexId) return null;
const [lat, lon] = cellToLatLng(hexId);
return {
lat: hex.lat as number,
lon: hex.lon as number,
resolution: mapData.resolution,
lat,
lon,
resolution: selectedHexagon?.resolution ?? mapData.resolution,
postcode: areaStats?.central_postcode,
};
}
}, [
selectedHexagon?.id,
selectedHexagon?.resolution,
selectedHexagon?.type,
mapData.data,
mapData.postcodeData,
mapData.resolution,
areaStats?.central_postcode,
@ -487,10 +504,17 @@ export default function MapPage({
}, [mapData.licenseRequired]);
const densityLabel = t('mapLegend.historicalMatches');
const hasActiveFilters = Object.keys(filters).length > 0 || activeEntries.length > 0;
const mobileLegendMeta = useMemo(
() => (viewFeature ? features.find((f) => f.name === viewFeature) || null : null),
[viewFeature, features]
const mobileLegendMeta = useMemo(() => {
const featureName = viewFeature
? (getSchoolBackendFeatureName(viewFeature) ?? viewFeature)
: null;
return featureName ? features.find((f) => f.name === featureName) || null : null;
}, [viewFeature, features]);
const mapViewFeature = useMemo(
() => (viewFeature ? (getSchoolBackendFeatureName(viewFeature) ?? viewFeature) : null),
[viewFeature]
);
const mobileDensityRange = useMemo((): [number, number] => {
const items = mapData.usePostcodeView ? mapData.postcodeData : mapData.data;
@ -573,7 +597,7 @@ export default function MapPage({
usePostcodeView={mapData.usePostcodeView}
pois={[]}
onViewChange={mapData.handleViewChange}
viewFeature={viewFeature}
viewFeature={mapViewFeature}
colorRange={mapData.colorRange}
filterRange={filterRange}
viewSource={viewSource}
@ -607,8 +631,10 @@ export default function MapPage({
: null
}
onViewProperties={handleViewPropertiesFromArea}
onClearFilters={hasActiveFilters ? handleClearAll : undefined}
hexagonLocation={hexagonLocation}
filters={filters}
unfilteredCount={unfilteredAreaCount}
travelTimeEntries={activeEntries}
isGroupExpanded={isAreaGroupExpanded}
onToggleGroup={toggleAreaGroup}
@ -639,7 +665,7 @@ export default function MapPage({
/>
);
const renderFilters = () => (
const renderFilters = (options?: { destinationDropdownPortal?: boolean }) => (
<Filters
features={features}
filters={filters}
@ -678,96 +704,18 @@ export default function MapPage({
onClearAll={handleClearAll}
onSaveSearch={onSaveSearch}
savingSearch={savingSearch}
destinationDropdownPortal={options?.destinationDropdownPortal}
/>
);
if (isMobile) {
const renderMobileLegend = () => {
if (mapViewFeature && mapData.colorRange) {
if (mapViewFeature.startsWith('tt_')) {
return (
<div className="flex-1 flex flex-col overflow-hidden relative touch-pan-y">
{initialLoading && (
<div className="absolute inset-0 z-50 flex items-center justify-center bg-warm-50/80 dark:bg-navy-950/80 backdrop-blur-sm">
<div className="flex flex-col items-center gap-4">
<SpinnerIcon className="w-12 h-12 text-teal-600 dark:text-teal-400 animate-spin" />
<p className="text-warm-600 dark:text-warm-300 text-sm font-medium">
Connecting to server...
</p>
</div>
</div>
)}
<div
ref={mobileMapRef}
className="relative overflow-hidden"
>
<Map
data={mapData.data}
postcodeData={mapData.postcodeData}
usePostcodeView={mapData.usePostcodeView}
pois={pois}
onViewChange={mapData.handleViewChange}
viewFeature={viewFeature}
colorRange={mapData.colorRange}
filterRange={filterRange}
viewSource={viewSource}
onCancelPin={handleCancelPin}
features={features}
selectedHexagonId={selectedHexagon?.id || null}
hoveredHexagonId={hoveredHexagon}
onHexagonClick={handleMobileHexagonClick}
onHexagonHover={handleHexagonHover}
initialViewState={initialViewState}
flyToRef={mapFlyToRef}
theme={theme}
filters={filters}
selectedPostcodeGeometry={selectedPostcodeGeometry}
onLocationSearched={handleLocationSearchResult}
bounds={mapData.bounds}
hideLegend
travelTimeEntries={entries}
/>
{mapData.loading && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<div className="flex items-center gap-2 bg-white/90 dark:bg-warm-800/90 px-4 py-2.5 rounded-lg shadow-lg pointer-events-auto">
<SpinnerIcon className="w-5 h-5 text-teal-600 dark:text-teal-400 animate-spin" />
<span className="text-sm font-medium text-warm-700 dark:text-warm-200">
Loading...
</span>
</div>
</div>
)}
<button
onClick={() => setPoiPaneOpen((p) => !p)}
className={`absolute bottom-2 right-2 z-10 p-2 rounded-lg shadow-lg bg-white dark:bg-warm-800 ${poiPaneOpen ? 'text-teal-600 dark:text-teal-400' : 'text-warm-500 dark:text-warm-400 hover:text-teal-600 dark:hover:text-teal-400'}`}
>
<MapPinIcon className="w-5 h-5" />
</button>
{poiPaneOpen && (
<div className="absolute bottom-12 right-2 z-10 w-[calc(100%-1rem)] max-h-[60%] overflow-hidden flex flex-col rounded-lg shadow-xl border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-900">
{renderPOIPane()}
</div>
)}
</div>
<div
className="relative z-10 py-2 -my-2 cursor-row-resize touch-none group"
{...mobileResizeHandlers}
>
<div className="h-3 flex items-center justify-center bg-warm-100 dark:bg-navy-800 group-hover:bg-warm-200 dark:group-hover:bg-navy-700 border-y border-warm-200 dark:border-navy-700">
<div className="flex flex-row gap-1.5">
<div className="w-1 h-1 rounded-full bg-warm-300 dark:bg-navy-600 group-hover:bg-warm-400 dark:group-hover:bg-navy-500" />
<div className="w-1 h-1 rounded-full bg-warm-300 dark:bg-navy-600 group-hover:bg-warm-400 dark:group-hover:bg-navy-500" />
<div className="w-1 h-1 rounded-full bg-warm-300 dark:bg-navy-600 group-hover:bg-warm-400 dark:group-hover:bg-navy-500" />
</div>
</div>
</div>
<div className="flex-1 min-h-0 bg-white dark:bg-warm-900 overflow-hidden flex flex-col">
{viewFeature && mapData.colorRange ? (
viewFeature.startsWith('tt_') ? (
<MapLegend
featureLabel={t('travel.travelTime', {
mode: modes.label(
viewFeature.split('_')[1] as 'car' | 'bicycle' | 'walking' | 'transit'
mapViewFeature.split('_')[1] as 'car' | 'bicycle' | 'walking' | 'transit'
),
})}
range={mapData.colorRange}
@ -778,7 +726,11 @@ export default function MapPage({
inline
suffix=" min"
/>
) : mobileLegendMeta ? (
);
}
if (mobileLegendMeta) {
return (
<MapLegend
featureLabel={
viewSource === 'eye'
@ -795,8 +747,13 @@ export default function MapPage({
inline
raw={mobileLegendMeta.raw}
/>
) : null
) : (
);
}
return null;
}
return (
<MapLegend
featureLabel={densityLabel}
range={mobileDensityRange}
@ -806,9 +763,85 @@ export default function MapPage({
theme={theme}
inline
/>
)}
<div className="flex-1 min-h-0">{renderFilters()}</div>
);
};
if (isMobile) {
return (
<div className="flex-1 overflow-hidden relative">
{initialLoading && (
<div className="absolute inset-0 z-50 flex items-center justify-center bg-warm-50/80 dark:bg-navy-950/80 backdrop-blur-sm">
<div className="flex flex-col items-center gap-4">
<SpinnerIcon className="w-12 h-12 text-teal-600 dark:text-teal-400 animate-spin" />
<p className="text-warm-600 dark:text-warm-300 text-sm font-medium">
Connecting to server...
</p>
</div>
</div>
)}
<div className="absolute inset-0">
<Map
data={mapData.data}
postcodeData={mapData.postcodeData}
usePostcodeView={mapData.usePostcodeView}
pois={pois}
onViewChange={mapData.handleViewChange}
viewFeature={mapViewFeature}
colorRange={mapData.colorRange}
filterRange={filterRange}
viewSource={viewSource}
onCancelPin={handleCancelPin}
features={features}
selectedHexagonId={selectedHexagon?.id || null}
hoveredHexagonId={hoveredHexagon}
onHexagonClick={handleMobileHexagonClick}
onHexagonHover={handleHexagonHover}
initialViewState={initialViewState}
flyToRef={mapFlyToRef}
theme={theme}
filters={filters}
selectedPostcodeGeometry={selectedPostcodeGeometry}
onLocationSearched={handleLocationSearchResult}
onCurrentLocationFound={handleCurrentLocationFound}
currentLocation={currentLocation}
bounds={mapData.bounds}
hideLegend
travelTimeEntries={entries}
/>
</div>
{mapData.loading && (
<div className="absolute inset-0 z-10 flex items-center justify-center pointer-events-none">
<div className="flex items-center gap-2 bg-white/90 dark:bg-warm-800/90 px-4 py-2.5 rounded-lg shadow-lg pointer-events-auto">
<SpinnerIcon className="w-5 h-5 text-teal-600 dark:text-teal-400 animate-spin" />
<span className="text-sm font-medium text-warm-700 dark:text-warm-200">
Loading...
</span>
</div>
</div>
)}
<button
onClick={() => setPoiPaneOpen((p) => !p)}
className={`absolute top-3 right-3 z-20 p-2 rounded-lg shadow-lg bg-white dark:bg-warm-800 ${poiPaneOpen ? 'text-teal-600 dark:text-teal-400' : 'text-warm-500 dark:text-warm-400 hover:text-teal-600 dark:hover:text-teal-400'}`}
aria-label={t('poiPane.pointsOfInterest')}
>
<MapPinIcon className="w-5 h-5" />
</button>
{poiPaneOpen && (
<div className="absolute top-14 right-3 left-3 z-20 max-h-[45dvh] overflow-hidden flex flex-col rounded-lg shadow-xl border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-900">
{renderPOIPane()}
</div>
)}
<MobileBottomSheet
activeCount={Object.keys(filters).length + entries.length}
legend={renderMobileLegend()}
>
{renderFilters({ destinationDropdownPortal: false })}
</MobileBottomSheet>
{mobileDrawerOpen && selectedHexagon && (
<MobileDrawer
@ -891,7 +924,7 @@ export default function MapPage({
usePostcodeView={mapData.usePostcodeView}
pois={pois}
onViewChange={mapData.handleViewChange}
viewFeature={viewFeature}
viewFeature={mapViewFeature}
colorRange={mapData.colorRange}
filterRange={filterRange}
viewSource={viewSource}
@ -907,10 +940,12 @@ export default function MapPage({
filters={filters}
selectedPostcodeGeometry={selectedPostcodeGeometry}
onLocationSearched={handleLocationSearchResult}
onCurrentLocationFound={handleCurrentLocationFound}
currentLocation={currentLocation}
bounds={mapData.bounds}
travelTimeEntries={entries}
densityLabel={densityLabel}
totalCount={filterCounts.total || undefined}
totalCount={hasActiveFilters ? filterCounts.total : undefined}
/>
{mapData.loading && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
@ -940,47 +975,16 @@ export default function MapPage({
</div>
{selectedHexagon && (
<div
data-tutorial="right-pane"
className="flex bg-white dark:bg-navy-950 shadow-lg z-10"
style={{ width: rightPaneWidth }}
>
<div
className="w-3 cursor-col-resize flex items-center justify-center group bg-warm-100 dark:bg-navy-800 hover:bg-warm-200 dark:hover:bg-navy-700 border-x border-warm-200 dark:border-navy-700"
{...rightPaneHandlers}
>
<div className="flex flex-col gap-1.5">
<div className="w-1 h-1 rounded-full bg-warm-300 dark:bg-navy-600 group-hover:bg-warm-400 dark:group-hover:bg-navy-500" />
<div className="w-1 h-1 rounded-full bg-warm-300 dark:bg-navy-600 group-hover:bg-warm-400 dark:group-hover:bg-navy-500" />
<div className="w-1 h-1 rounded-full bg-warm-300 dark:bg-navy-600 group-hover:bg-warm-400 dark:group-hover:bg-navy-500" />
</div>
</div>
<div className="flex-1 flex flex-col overflow-hidden">
<div className="flex border-b border-warm-200 dark:border-navy-700 text-sm">
<TabButton
label="Area"
isActive={rightPaneTab === 'area'}
onClick={() => setRightPaneTab('area')}
<MapPageSelectionPane
width={rightPaneWidth}
resizeHandlers={rightPaneHandlers}
tab={rightPaneTab}
onAreaTabClick={() => setRightPaneTab('area')}
onPropertiesTabClick={handlePropertiesTabClick}
onClose={handleCloseSelection}
renderAreaPane={renderAreaPane}
renderPropertiesPane={renderPropertiesPane}
/>
<TabButton
label="Properties"
isActive={rightPaneTab === 'properties'}
onClick={handlePropertiesTabClick}
/>
<button
onClick={handleCloseSelection}
className="px-2 flex items-center text-warm-400 hover:text-warm-700 dark:hover:text-warm-300"
title="Close pane"
>
<CloseIcon className="w-4 h-4" />
</button>
</div>
<div className="flex-1 overflow-hidden">
{rightPaneTab === 'properties' ? renderPropertiesPane() : renderAreaPane()}
</div>
</div>
</div>
)}
{bookmarkToast}

View file

@ -0,0 +1,72 @@
import type { PointerEvent, ReactNode } from 'react';
import { TabButton } from '../ui/TabButton';
import { CloseIcon } from '../ui/icons/CloseIcon';
interface ResizeHandlers {
onPointerDown: (event: PointerEvent) => void;
onPointerMove: (event: PointerEvent) => void;
onPointerUp: () => void;
}
interface MapPageSelectionPaneProps {
width: number;
resizeHandlers: ResizeHandlers;
tab: 'properties' | 'area';
onAreaTabClick: () => void;
onPropertiesTabClick: () => void;
onClose: () => void;
renderAreaPane: () => ReactNode;
renderPropertiesPane: () => ReactNode;
}
export function MapPageSelectionPane({
width,
resizeHandlers,
tab,
onAreaTabClick,
onPropertiesTabClick,
onClose,
renderAreaPane,
renderPropertiesPane,
}: MapPageSelectionPaneProps) {
return (
<div
data-tutorial="right-pane"
className="flex bg-white dark:bg-navy-950 shadow-lg z-10"
style={{ width }}
>
<div
className="w-3 cursor-col-resize flex items-center justify-center group bg-warm-100 dark:bg-navy-800 hover:bg-warm-200 dark:hover:bg-navy-700 border-x border-warm-200 dark:border-navy-700"
{...resizeHandlers}
>
<div className="flex flex-col gap-1.5">
<div className="w-1 h-1 rounded-full bg-warm-300 dark:bg-navy-600 group-hover:bg-warm-400 dark:group-hover:bg-navy-500" />
<div className="w-1 h-1 rounded-full bg-warm-300 dark:bg-navy-600 group-hover:bg-warm-400 dark:group-hover:bg-navy-500" />
<div className="w-1 h-1 rounded-full bg-warm-300 dark:bg-navy-600 group-hover:bg-warm-400 dark:group-hover:bg-navy-500" />
</div>
</div>
<div className="flex-1 flex flex-col overflow-hidden">
<div className="flex border-b border-warm-200 dark:border-navy-700 text-sm">
<TabButton label="Area" isActive={tab === 'area'} onClick={onAreaTabClick} />
<TabButton
label="Properties"
isActive={tab === 'properties'}
onClick={onPropertiesTabClick}
/>
<button
onClick={onClose}
className="px-2 flex items-center text-warm-400 hover:text-warm-700 dark:hover:text-warm-300"
title="Close pane"
>
<CloseIcon className="w-4 h-4" />
</button>
</div>
<div className="flex-1 overflow-hidden">
{tab === 'properties' ? renderPropertiesPane() : renderAreaPane()}
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,189 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
interface VisualViewportState {
height: number;
bottomInset: number;
}
interface MobileBottomSheetProps {
activeCount: number;
children: ReactNode;
legend?: ReactNode;
}
function getVisualViewportState(): VisualViewportState {
const vv = window.visualViewport;
if (!vv) {
return {
height: window.innerHeight,
bottomInset: 0,
};
}
const bottomInset = Math.max(0, window.innerHeight - vv.height - vv.offsetTop);
return {
height: vv.height,
bottomInset,
};
}
function useVisualViewportState(): VisualViewportState {
const [state, setState] = useState(getVisualViewportState);
useEffect(() => {
const vv = window.visualViewport;
const update = () => setState(getVisualViewportState());
window.addEventListener('resize', update);
window.addEventListener('orientationchange', update);
vv?.addEventListener('resize', update);
vv?.addEventListener('scroll', update);
return () => {
window.removeEventListener('resize', update);
window.removeEventListener('orientationchange', update);
vv?.removeEventListener('resize', update);
vv?.removeEventListener('scroll', update);
};
}, []);
return state;
}
function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value));
}
export default function MobileBottomSheet({
activeCount,
children,
legend,
}: MobileBottomSheetProps) {
const { t } = useTranslation();
const viewport = useVisualViewportState();
const sheetRef = useRef<HTMLDivElement>(null);
const scrollRef = useRef<HTMLDivElement>(null);
const dragStartYRef = useRef(0);
const dragStartHeightRef = useRef(0);
const [height, setHeight] = useState<number | null>(null);
const [isDragging, setIsDragging] = useState(false);
const heightBounds = useMemo(() => {
const available = viewport.height;
return {
min: Math.min(132, Math.max(104, available * 0.22)),
initial: Math.min(available * 0.56, Math.max(330, available * 0.44)),
max: Math.max(300, available - 12),
};
}, [viewport.height]);
const currentHeight = clamp(height ?? heightBounds.initial, heightBounds.min, heightBounds.max);
useEffect(() => {
setHeight((value) =>
value == null ? value : clamp(value, heightBounds.min, heightBounds.max)
);
}, [heightBounds]);
const handlePointerDown = useCallback(
(e: React.PointerEvent) => {
e.preventDefault();
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
dragStartYRef.current = e.clientY;
dragStartHeightRef.current = currentHeight;
setIsDragging(true);
},
[currentHeight]
);
const handlePointerMove = useCallback(
(e: React.PointerEvent) => {
if (dragStartHeightRef.current === 0) return;
const nextHeight = dragStartHeightRef.current + dragStartYRef.current - e.clientY;
setHeight(clamp(nextHeight, heightBounds.min, heightBounds.max));
},
[heightBounds]
);
const handlePointerUp = useCallback(() => {
if (dragStartHeightRef.current === 0) return;
dragStartHeightRef.current = 0;
setIsDragging(false);
}, []);
useEffect(() => {
const sheet = sheetRef.current;
if (!sheet) return;
const handleFocusIn = (event: FocusEvent) => {
const target = event.target;
if (!(target instanceof HTMLElement)) return;
if (!target.matches('input, textarea, select, [contenteditable="true"]')) return;
const keyboardMinHeight = Math.min(heightBounds.max, Math.max(300, viewport.height * 0.55));
setHeight((value) => Math.max(value ?? heightBounds.initial, keyboardMinHeight));
window.setTimeout(() => {
target.scrollIntoView({ block: 'center', behavior: 'smooth' });
}, 120);
};
sheet.addEventListener('focusin', handleFocusIn);
return () => sheet.removeEventListener('focusin', handleFocusIn);
}, [heightBounds.initial, heightBounds.max, viewport.height]);
const sheetTitle = activeCount === 0 ? t('filters.chooseFilters') : t('filters.activeFilters');
return (
<section
ref={sheetRef}
className="fixed inset-x-0 z-30 flex flex-col rounded-t-2xl bg-white dark:bg-navy-950 shadow-2xl border-t border-warm-200 dark:border-navy-700 overflow-hidden"
style={{
bottom: viewport.bottomInset,
height: currentHeight,
paddingBottom: 'env(safe-area-inset-bottom)',
transition:
isDragging || viewport.bottomInset > 0
? undefined
: 'height 140ms ease, bottom 180ms ease',
}}
aria-label={sheetTitle}
>
<div
className="shrink-0 touch-none px-4 pt-2 pb-1"
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerUp}
>
<div className="w-full flex flex-col items-center gap-2" role="presentation">
<span className="h-1.5 w-12 rounded-full bg-warm-300 dark:bg-navy-600" />
<span className="w-full flex items-center justify-between">
<span className="flex items-center gap-2 min-w-0">
<span className="text-sm font-semibold text-navy-950 dark:text-warm-100 truncate">
{sheetTitle}
</span>
{activeCount > 0 && (
<span className="text-xs font-medium px-1.5 py-0.5 rounded-full bg-teal-50 dark:bg-teal-900/30 text-teal-600 dark:text-teal-400">
{activeCount}
</span>
)}
</span>
</span>
</div>
</div>
{legend && (
<div className="shrink-0 border-y border-warm-200 dark:border-navy-700">{legend}</div>
)}
<div
ref={scrollRef}
className="flex-1 min-h-0 overflow-y-auto overscroll-contain touch-pan-y"
>
{children}
</div>
</section>
);
}

View file

@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next';
import { ts } from '../../i18n/server';
import { useCollapsibleGroups } from '../../hooks/useCollapsibleGroups';
import { trackEvent } from '../../lib/analytics';
import { POI_CATEGORY_LOGOS } from '../../lib/consts';
import type { POICategoryGroup } from '../../types';
import InfoPopup from '../ui/InfoPopup';
import { SearchInput } from '../ui/SearchInput';
@ -186,15 +187,30 @@ export default function POIPane({
{!isCollapsed && (
<div className="px-3 py-2">
<PillGroup>
{group.categories.map((category) => (
{group.categories.map((category) => {
const logo = POI_CATEGORY_LOGOS[category];
return (
<PillToggle
key={category}
label={ts(category)}
icon={
logo ? (
<img
src={logo}
alt=""
aria-hidden="true"
loading="lazy"
referrerPolicy="no-referrer"
className="h-4 w-4 shrink-0 rounded-[3px] bg-white object-contain p-0.5"
/>
) : undefined
}
active={selectedCategories.has(category)}
onClick={() => toggleCategory(category)}
size="xs"
/>
))}
);
})}
</PillGroup>
</div>
)}

View file

@ -316,7 +316,6 @@ function PropertyCard({
</div>
</div>
)}
</div>
);
}

View file

@ -1,6 +1,6 @@
import { useMemo } from 'react';
import { SEGMENT_COLORS } from '../../lib/consts';
import { formatValue } from '../../lib/format';
import { formatValue, roundedPercentages } from '../../lib/format';
interface Segment {
name: string;
@ -30,6 +30,15 @@ function shortenLabel(name: string): string {
export default function StackedBarChart({ segments, total, colorMap }: StackedBarChartProps) {
const sortedSegments = useMemo(() => [...segments].sort((a, b) => b.value - a.value), [segments]);
const roundedPcts = useMemo(
() =>
roundedPercentages(
sortedSegments.map((s) => s.value),
total,
1
),
[sortedSegments, total]
);
if (total === 0) {
return <div className="text-xs text-warm-400 dark:text-warm-500 italic">No data</div>;
@ -51,7 +60,7 @@ export default function StackedBarChart({ segments, total, colorMap }: StackedBa
backgroundColor:
colorMap?.[segment.name] ?? SEGMENT_COLORS[i % SEGMENT_COLORS.length],
}}
title={`${shortenLabel(segment.name)}: ${formatValue(segment.value)} (${pct.toFixed(1)}%)`}
title={`${shortenLabel(segment.name)}: ${formatValue(segment.value)} (${roundedPcts[i].toFixed(1)}%)`}
/>
);
})}

View file

@ -1,4 +1,5 @@
import type { EnumFeatureStats } from '../../types';
import { roundedPercentages } from '../../lib/format';
interface StackedEnumChartProps {
components: { label: string; stats: EnumFeatureStats }[];
@ -30,7 +31,9 @@ export default function StackedEnumChart({
return (
<div className="space-y-1.5">
{visibleRows.map(({ label, stats }) => {
const total = Object.values(stats.counts).reduce((a, b) => a + b, 0);
const counts = valueOrder.map((value) => stats.counts[value] ?? 0);
const total = counts.reduce((a, b) => a + b, 0);
const roundedPcts = roundedPercentages(counts, total, 0);
return (
<div key={label} className="flex items-center gap-2 text-xs">
@ -39,7 +42,7 @@ export default function StackedEnumChart({
</span>
<div className="flex-1 flex h-3.5 rounded overflow-hidden bg-warm-200 dark:bg-warm-700">
{valueOrder.map((value, i) => {
const count = stats.counts[value] ?? 0;
const count = counts[i];
const pct = (count / total) * 100;
if (pct < 0.5) return null;
return (
@ -50,7 +53,7 @@ export default function StackedEnumChart({
width: `${pct}%`,
backgroundColor: valueColors[i],
}}
title={`${value}: ${count} (${pct.toFixed(0)}%)`}
title={`${value}: ${count} (${roundedPcts[i]}%)`}
/>
);
})}

View file

@ -1,7 +1,9 @@
import { useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
import type { HexagonLocation } from '../../lib/external-search';
import { apiUrl, logNonAbortError } from '../../lib/api';
import { CloseIcon, ExpandIcon } from '../ui/icons';
interface StreetViewEmbedProps {
location: HexagonLocation;
@ -13,6 +15,7 @@ export default function StreetViewEmbed({ location }: StreetViewEmbedProps) {
const { t } = useTranslation();
const [status, setStatus] = useState<Status>('loading');
const [panoId, setPanoId] = useState<string | null>(null);
const [expanded, setExpanded] = useState(false);
useEffect(() => {
setStatus('loading');
@ -47,31 +50,107 @@ export default function StreetViewEmbed({ location }: StreetViewEmbedProps) {
return () => controller.abort();
}, [location.lat, location.lon]);
useEffect(() => {
if (!expanded) return;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
setExpanded(false);
}
};
const originalOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
window.addEventListener('keydown', handleKeyDown);
return () => {
document.body.style.overflow = originalOverflow;
window.removeEventListener('keydown', handleKeyDown);
};
}, [expanded]);
if (status === 'none' || status === 'error') return null;
const panoUrl = panoId
? `https://maps.google.com/maps?layer=c&panoid=${panoId}&cbp=11,0,0,0,0&output=svembed`
: null;
return (
<div>
<div className="px-3 py-1.5 text-xs font-bold text-warm-500 bg-warm-50 dark:bg-warm-900 dark:text-warm-400 sticky top-0">
{t('streetView.title')}
<div className="px-3 py-1.5 text-xs font-bold text-warm-500 bg-warm-50 dark:bg-warm-900 dark:text-warm-400 sticky top-0 flex items-center justify-between">
<span>{t('streetView.title')}</span>
{status === 'ok' && panoUrl && (
<button
type="button"
onClick={() => setExpanded(true)}
title={t('streetView.openLarge')}
aria-label={t('streetView.openLarge')}
className="rounded p-1 text-warm-400 hover:bg-warm-100 hover:text-warm-700 dark:text-warm-500 dark:hover:bg-warm-800 dark:hover:text-warm-200"
>
<ExpandIcon className="h-3.5 w-3.5" />
</button>
)}
</div>
<div className="px-3 py-2">
<div className="rounded overflow-hidden border border-warm-200 dark:border-warm-700">
{status === 'loading' ? (
{status === 'loading' || !panoUrl ? (
<div
className="w-full animate-pulse bg-warm-200 dark:bg-warm-700"
style={{ height: 240 }}
/>
) : (
<iframe
title={t('streetView.title')}
className="w-full"
style={{ height: 240, border: 0 }}
loading="lazy"
referrerPolicy="no-referrer-when-downgrade"
src={`https://maps.google.com/maps?layer=c&panoid=${panoId}&cbp=11,0,0,0,0&output=svembed`}
src={panoUrl}
/>
)}
</div>
</div>
{expanded &&
panoUrl &&
createPortal(
<div
className="fixed inset-0 z-50 flex items-center justify-center p-3 sm:p-6"
role="dialog"
aria-modal="true"
aria-label={t('streetView.title')}
>
<div
className="absolute inset-0 bg-black/60 dark:bg-black/75"
aria-hidden="true"
onMouseDown={() => setExpanded(false)}
/>
<div className="relative flex h-[86vh] w-full max-w-7xl flex-col overflow-hidden rounded-lg border border-warm-200 bg-white shadow-2xl dark:border-warm-700 dark:bg-warm-900">
<div className="flex items-center justify-between border-b border-warm-200 px-4 py-3 dark:border-warm-700">
<h2 className="text-sm font-semibold text-navy-950 dark:text-warm-100">
{t('streetView.title')}
</h2>
<button
type="button"
onClick={() => setExpanded(false)}
title={t('common.close')}
aria-label={t('common.close')}
className="rounded p-1 text-warm-400 hover:bg-warm-100 hover:text-warm-700 dark:text-warm-500 dark:hover:bg-warm-800 dark:hover:text-warm-200"
>
<CloseIcon className="h-5 w-5" />
</button>
</div>
<iframe
title={t('streetView.expandedTitle')}
className="min-h-0 flex-1"
style={{ border: 0 }}
loading="lazy"
referrerPolicy="no-referrer-when-downgrade"
src={panoUrl}
/>
</div>
</div>,
document.body
)}
</div>
);
}

View file

@ -31,6 +31,7 @@ interface TravelTimeCardProps {
onToggleBest: () => void;
onRemove: () => void;
filterImpact?: number;
destinationDropdownPortal?: boolean;
}
export function TravelTimeCard({
@ -51,6 +52,7 @@ export function TravelTimeCard({
onToggleBest,
onRemove,
filterImpact,
destinationDropdownPortal = true,
}: TravelTimeCardProps) {
const { t } = useTranslation();
const modes = useTranslatedModes();
@ -90,10 +92,10 @@ export function TravelTimeCard({
{slug && (
<IconButton
onClick={onTogglePin}
active={isPinned}
active={isPinned || isActive}
title={isPinned ? t('travel.stopPreviewing') : t('travel.previewOnMap')}
>
<EyeIcon className="w-3.5 h-3.5" filled={isPinned} />
<EyeIcon className="w-3.5 h-3.5" filled={isPinned || isActive} />
</IconButton>
)}
<IconButton onClick={() => onRemove()} title={t('travel.removeTravelTime')}>
@ -110,6 +112,7 @@ export function TravelTimeCard({
value={label || undefined}
onClear={() => onSetDestination('', '', 0, 0)}
placeholder={t('travel.selectDestination')}
portal={destinationDropdownPortal}
/>
{/* Best-case toggle — transit only, shown when destination is set */}

View file

@ -340,11 +340,11 @@ export default function PricingPage({
{license.error}
</p>
)}
{isFree && (
<p className="text-center text-xs text-warm-400 dark:text-warm-500 mt-2">
{isFree
? t('pricingPage.noCreditCard')
: t('pricingPage.moneyBackGuarantee')}
{t('pricingPage.noCreditCard')}
</p>
)}
</>
) : isFilled ? (
<div className="mt-auto px-5 py-3 bg-warm-100 dark:bg-warm-700 text-warm-400 dark:text-warm-500 rounded-lg font-semibold text-center">

View file

@ -14,6 +14,7 @@ interface DestinationDropdownProps {
onClear?: () => void;
value?: string;
placeholder?: string;
portal?: boolean;
}
export function DestinationDropdown({
@ -23,6 +24,7 @@ export function DestinationDropdown({
onClear,
value,
placeholder,
portal = true,
}: DestinationDropdownProps) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
@ -32,7 +34,7 @@ export function DestinationDropdown({
const dropdownRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const listRef = useRef<HTMLDivElement>(null);
const pos = useDropdownPosition(containerRef, open);
const pos = useDropdownPosition(containerRef, open && portal);
const filtered = useMemo(() => {
if (!filter) return destinations;
@ -212,7 +214,12 @@ export function DestinationDropdown({
)}
</div>
{open && createPortal(dropdown, document.body)}
{open &&
(portal ? (
createPortal(dropdown, document.body)
) : (
<div className="absolute top-full left-0 right-0 mt-1 z-30">{dropdown}</div>
))}
</div>
);
}

View file

@ -5,6 +5,7 @@ import { IconButton } from './IconButton';
interface FeatureActionsProps {
feature: FeatureMeta;
isPinned: boolean;
isPreviewing?: boolean;
onTogglePin: (name: string) => void;
onShowInfo?: (feature: FeatureMeta) => void;
onRemove?: (name: string) => void;
@ -14,11 +15,14 @@ interface FeatureActionsProps {
export function FeatureActions({
feature,
isPinned,
isPreviewing = false,
onTogglePin,
onShowInfo,
onRemove,
onAdd,
}: FeatureActionsProps) {
const isEyeActive = isPinned || isPreviewing;
return (
<div className="flex items-center gap-0.5 shrink-0">
{feature.detail && onShowInfo && (
@ -29,10 +33,10 @@ export function FeatureActions({
<IconButton
onClick={() => onTogglePin(feature.name)}
title={isPinned ? 'Unpin colour view' : 'Colour map by this feature'}
active={isPinned}
active={isEyeActive}
size="md"
>
<EyeIcon filled={isPinned} className="w-5 h-5 md:w-3.5 md:h-3.5" />
<EyeIcon filled={isEyeActive} className="w-5 h-5 md:w-3.5 md:h-3.5" />
</IconButton>
{onAdd && (
<button

View file

@ -72,9 +72,9 @@ export default function MobileMenu({
return (
<>
{/* Backdrop */}
<div className="fixed inset-0 bg-black/50 z-40" onClick={onClose} />
<div className="fixed inset-0 bg-black/50 z-[70]" onClick={onClose} />
{/* Menu panel */}
<div className="fixed top-0 right-0 bottom-0 w-64 bg-navy-900 z-50 flex flex-col shadow-xl">
<div className="fixed top-0 right-0 bottom-0 w-64 bg-navy-900 z-[80] flex flex-col shadow-xl">
<div className="flex items-center justify-between px-4 h-12 border-b border-navy-700">
<span className="font-semibold">{t('mobileMenu.menu')}</span>
<button

View file

@ -1,7 +1,10 @@
import type { ReactNode } from 'react';
interface PillToggleProps {
label: string;
active: boolean;
onClick: () => void;
icon?: ReactNode;
/** Visual hint for partial selection (e.g. some children selected) */
indeterminate?: boolean;
size?: 'sm' | 'xs';
@ -11,6 +14,7 @@ export function PillToggle({
label,
active,
onClick,
icon,
indeterminate,
size = 'sm',
}: PillToggleProps) {
@ -26,8 +30,9 @@ export function PillToggle({
<button
type="button"
onClick={onClick}
className={`${sizeClasses} ${colorClasses} rounded-full font-medium whitespace-nowrap cursor-pointer`}
className={`${sizeClasses} ${colorClasses} inline-flex items-center gap-1.5 rounded-full font-medium whitespace-nowrap cursor-pointer`}
>
{icon}
{label}
</button>
);

View file

@ -0,0 +1,20 @@
interface IconProps {
className?: string;
}
export function ExpandIcon({ className = 'w-4 h-4' }: IconProps) {
return (
<svg
className={className}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 3h6v6" />
<path strokeLinecap="round" strokeLinejoin="round" d="M21 3l-7 7" />
<path strokeLinecap="round" strokeLinejoin="round" d="M9 21H3v-6" />
<path strokeLinecap="round" strokeLinejoin="round" d="M3 21l7-7" />
</svg>
);
}

View file

@ -7,6 +7,7 @@ export { ChevronIcon } from './ChevronIcon';
export { ClipboardIcon } from './ClipboardIcon';
export { CloseIcon } from './CloseIcon';
export { DownloadIcon } from './DownloadIcon';
export { ExpandIcon } from './ExpandIcon';
export { EyeIcon } from './EyeIcon';
export { FilterIcon } from './FilterIcon';
export { GoogleIcon } from './GoogleIcon';

View file

@ -1,8 +1,7 @@
import { useCallback, useRef, useState, useMemo, useEffect } from 'react';
import { H3HexagonLayer } from '@deck.gl/geo-layers';
import { GeoJsonLayer, IconLayer, TextLayer, ScatterplotLayer } from '@deck.gl/layers';
import { GeoJsonLayer, TextLayer, ScatterplotLayer } from '@deck.gl/layers';
import { cellToBoundary } from 'h3-js';
import Supercluster from 'supercluster';
import type { PickingInfo } from '@deck.gl/core';
import type {
HexagonData,
@ -16,16 +15,12 @@ import type {
import {
DENSITY_GRADIENT,
DENSITY_GRADIENT_DARK,
POI_GROUP_COLORS,
POI_DEFAULT_COLOR,
MINOR_POI_CATEGORIES,
MINOR_POI_ZOOM_THRESHOLD,
POI_CLUSTER_RADIUS,
POI_CLUSTER_MAX_ZOOM,
getEnumPaletteForFeature,
getFeatureGradient,
} from '../lib/consts';
import { emojiToTwemojiUrl, getFeatureFillColor } from '../lib/map-utils';
import { getFeatureFillColor } from '../lib/map-utils';
import type { TravelTimeEntry } from './useTravelTime';
import { usePoiLayers } from './usePoiLayers';
import { MarchingAntsExtension } from '../lib/MarchingAntsExtension';
import { PieHexExtension } from '../lib/PieHexExtension';
@ -45,29 +40,11 @@ interface UseDeckLayersProps {
onHexagonHover: (h3: string | null, x?: number, y?: number) => void;
theme: 'light' | 'dark';
selectedPostcodeGeometry?: PostcodeGeometry | null;
currentLocation?: { lat: number; lng: number } | null;
bounds?: Bounds | null;
travelTimeEntries?: TravelTimeEntry[];
}
interface PopupInfo {
x: number;
y: number;
name: string;
category: string;
group: string;
emoji: string;
id: string;
isCluster?: boolean;
clusterCount?: number;
}
interface ClusterPoint {
lng: number;
lat: number;
count: number;
clusterId: number;
}
/** Normalize a distribution count array to [0..1] ratios, padded to 10 values. */
function distToRatios(dist: unknown): number[] {
if (!Array.isArray(dist) || dist.length === 0) return [1, 0, 0, 0, 0, 0, 0, 0, 0, 0];
@ -95,10 +72,10 @@ export function useDeckLayers({
onHexagonHover,
theme,
selectedPostcodeGeometry,
currentLocation,
bounds: viewportBounds,
travelTimeEntries = [],
}: UseDeckLayersProps) {
const [popupInfo, setPopupInfo] = useState<PopupInfo | null>(null);
const [hoverPosition, setHoverPosition] = useState<{ x: number; y: number } | null>(null);
const [hoveredPostcode, setHoveredPostcode] = useState<string | null>(null);
@ -114,6 +91,7 @@ export function useDeckLayers({
const isDark = theme === 'dark';
const densityGradient = isDark ? DENSITY_GRADIENT_DARK : DENSITY_GRADIENT;
const { poiLayers, popupInfo, clearPopupInfo } = usePoiLayers({ pois, zoom, isDark });
// --- Refs for deck.gl accessors ---
const viewFeatureRef = useRef(viewFeature);
@ -126,6 +104,8 @@ export function useDeckLayers({
isDarkRef.current = isDark;
const densityGradientRef = useRef(densityGradient);
densityGradientRef.current = densityGradient;
const featureGradientRef = useRef(getFeatureGradient(viewFeature));
featureGradientRef.current = getFeatureGradient(viewFeature);
const selectedHexagonIdRef = useRef(selectedHexagonId);
selectedHexagonIdRef.current = selectedHexagonId;
const hoveredHexagonIdRef = useRef(hoveredHexagonId);
@ -148,9 +128,7 @@ export function useDeckLayers({
: 0;
// Per-feature color palette (uses overrides when defined)
const enumPaletteRef = useRef(
getEnumPaletteForFeature(viewFeature, colorFeatureMeta?.values)
);
const enumPaletteRef = useRef(getEnumPaletteForFeature(viewFeature, colorFeatureMeta?.values));
enumPaletteRef.current = getEnumPaletteForFeature(viewFeature, colorFeatureMeta?.values);
const countRange = useMemo(() => {
@ -231,52 +209,6 @@ export function useDeckLayers({
}
}, []);
const handlePoiHover = useCallback((info: PickingInfo<POI>) => {
if (info.object && info.x !== undefined && info.y !== undefined) {
setPopupInfo({
x: info.x,
y: info.y,
name: info.object.name,
category: info.object.category,
group: info.object.group,
emoji: info.object.emoji,
id: info.object.id,
});
} else {
setPopupInfo(null);
}
}, []);
const handlePoiHoverRef = useRef(handlePoiHover);
handlePoiHoverRef.current = handlePoiHover;
const stablePoiHover = useCallback((info: PickingInfo<POI>) => {
handlePoiHoverRef.current(info);
}, []);
const handleClusterHover = useCallback((info: PickingInfo<ClusterPoint>) => {
if (info.object && info.x !== undefined && info.y !== undefined) {
setPopupInfo({
x: info.x,
y: info.y,
name: `${info.object.count} places`,
category: 'Zoom in to see details',
group: '',
emoji: '',
id: '',
isCluster: true,
clusterCount: info.object.count,
});
} else {
setPopupInfo(null);
}
}, []);
const handleClusterHoverRef = useRef(handleClusterHover);
handleClusterHoverRef.current = handleClusterHover;
const stableClusterHover = useCallback((info: PickingInfo<ClusterPoint>) => {
handleClusterHoverRef.current(info);
}, []);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const handlePostcodeClick = useCallback((info: PickingInfo<any>) => {
const pc = info.object?.properties?.postcode;
@ -380,7 +312,10 @@ export function useDeckLayers({
0,
densityGradientRef.current,
dark,
255
255,
0,
undefined,
featureGradientRef.current
);
}
@ -399,7 +334,8 @@ export function useDeckLayers({
dark,
255,
enumCountRef.current,
enumPaletteRef.current
enumPaletteRef.current,
featureGradientRef.current
);
}
}
@ -481,7 +417,10 @@ export function useDeckLayers({
0,
densityGradientRef.current,
dark,
180
180,
0,
undefined,
featureGradientRef.current
);
}
@ -501,7 +440,8 @@ export function useDeckLayers({
dark,
180,
enumCountRef.current,
enumPaletteRef.current
enumPaletteRef.current,
featureGradientRef.current
);
}
}
@ -576,148 +516,6 @@ export function useDeckLayers({
[postcodeData, theme]
);
// --- POI clustering ---
const clusterIndex = useMemo(() => {
if (pois.length === 0) return null;
const index = new Supercluster<POI>({
radius: POI_CLUSTER_RADIUS,
maxZoom: POI_CLUSTER_MAX_ZOOM,
});
const features: Supercluster.PointFeature<POI>[] = pois.map((poi) => ({
type: 'Feature',
geometry: { type: 'Point', coordinates: [poi.lng, poi.lat] },
properties: poi,
}));
index.load(features);
return index;
}, [pois]);
const clusterZoom = Math.floor(zoom);
const showMinorPois = zoom >= MINOR_POI_ZOOM_THRESHOLD;
const { visiblePois, clusters } = useMemo(() => {
if (!clusterIndex || pois.length === 0) {
return { visiblePois: [] as POI[], clusters: [] as ClusterPoint[] };
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const allFeatures = clusterIndex.getClusters([-180, -85, 180, 85], clusterZoom) as any[];
const individual: POI[] = [];
const clusterPoints: ClusterPoint[] = [];
for (const feature of allFeatures) {
if (feature.properties.cluster) {
clusterPoints.push({
lng: feature.geometry.coordinates[0],
lat: feature.geometry.coordinates[1],
count: feature.properties.point_count,
clusterId: feature.properties.cluster_id,
});
} else {
const poi = feature.properties as POI;
if (!showMinorPois && MINOR_POI_CATEGORIES.has(poi.category)) continue;
individual.push(poi);
}
}
return { visiblePois: individual, clusters: clusterPoints };
}, [clusterIndex, clusterZoom, showMinorPois, pois]);
// --- Individual POI layers (shadow → background → emoji) ---
const poiShadowLayer = useMemo(
() =>
new ScatterplotLayer<POI>({
id: 'poi-shadow',
data: visiblePois,
getPosition: (d) => [d.lng, d.lat],
getRadius: 16,
radiusUnits: 'pixels',
getFillColor: isDark ? [0, 0, 0, 50] : [0, 0, 0, 25],
pickable: false,
transitions: { getRadius: { duration: 300, enter: () => [0] } },
}),
[visiblePois, isDark]
);
const poiBackgroundLayer = useMemo(
() =>
new ScatterplotLayer<POI>({
id: 'poi-background',
data: visiblePois,
getPosition: (d) => [d.lng, d.lat],
getRadius: 14,
radiusUnits: 'pixels',
getFillColor: isDark ? [41, 37, 36, 255] : [255, 255, 255, 255],
getLineColor: (d) => {
const c = POI_GROUP_COLORS[d.group] || POI_DEFAULT_COLOR;
return [c[0], c[1], c[2], 255] as [number, number, number, number];
},
getLineWidth: 2.5,
lineWidthUnits: 'pixels',
stroked: true,
pickable: true,
onHover: stablePoiHover,
transitions: { getRadius: { duration: 300, enter: () => [0] } },
}),
[visiblePois, isDark, stablePoiHover]
);
const poiIconLayer = useMemo(
() =>
new IconLayer<POI>({
id: 'poi-icons',
data: visiblePois,
getPosition: (d) => [d.lng, d.lat],
getIcon: (d) => ({
url: emojiToTwemojiUrl(d.emoji),
width: 72,
height: 72,
}),
getSize: 18,
sizeUnits: 'pixels',
pickable: false,
transitions: { getSize: { duration: 300, enter: () => [0] } },
}),
[visiblePois]
);
// --- Cluster layers ---
const clusterCircleLayer = useMemo(
() =>
new ScatterplotLayer<ClusterPoint>({
id: 'poi-clusters',
data: clusters,
getPosition: (d) => [d.lng, d.lat],
getRadius: (d) => Math.min(30, 14 + Math.sqrt(d.count) * 2),
radiusUnits: 'pixels',
getFillColor: isDark ? [5, 129, 114, 220] : [20, 184, 166, 220],
getLineColor: [255, 255, 255, isDark ? 60 : 120],
getLineWidth: 2,
lineWidthUnits: 'pixels',
stroked: true,
pickable: true,
onHover: stableClusterHover,
transitions: { getRadius: { duration: 300, enter: () => [0] } },
}),
[clusters, isDark, stableClusterHover]
);
const clusterTextLayer = useMemo(
() =>
new TextLayer<ClusterPoint>({
id: 'poi-cluster-text',
data: clusters,
getPosition: (d) => [d.lng, d.lat],
getText: (d) => (d.count >= 1000 ? `${(d.count / 1000).toFixed(1)}k` : String(d.count)),
getSize: 12,
getColor: [255, 255, 255, 255],
fontWeight: 700,
fontFamily: 'Inter, system-ui, sans-serif',
getTextAnchor: 'middle',
getAlignmentBaseline: 'center',
sizeUnits: 'pixels',
pickable: false,
}),
[clusters]
);
// Marching ants highlight layer for selected hexagon or postcode
const marchingAntsLayer = useMemo(() => {
let geometry: PostcodeGeometry | null = null;
@ -748,10 +546,25 @@ export function useDeckLayers({
});
}, [selectedPostcodeGeometry, selectedHexagonId, marchTime]);
const poiLayers = useMemo(
() => [poiShadowLayer, poiBackgroundLayer, poiIconLayer, clusterCircleLayer, clusterTextLayer],
[poiShadowLayer, poiBackgroundLayer, poiIconLayer, clusterCircleLayer, clusterTextLayer]
);
const currentLocationLayer = useMemo(() => {
if (!currentLocation) return null;
return new ScatterplotLayer<{ lat: number; lng: number; kind: 'ring' | 'dot' }>({
id: 'current-location-dot',
data: [
{ ...currentLocation, kind: 'ring' },
{ ...currentLocation, kind: 'dot' },
],
getPosition: (d) => [d.lng, d.lat],
getRadius: (d) => (d.kind === 'ring' ? 16 : 5),
radiusUnits: 'pixels',
getFillColor: (d) => (d.kind === 'ring' ? [20, 184, 166, 45] : [220, 38, 38, 255]),
getLineColor: (d) => (d.kind === 'ring' ? [20, 184, 166, 240] : [255, 255, 255, 240]),
getLineWidth: 2,
lineWidthUnits: 'pixels',
stroked: true,
pickable: false,
});
}, [currentLocation]);
const layers = useMemo(() => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@ -761,6 +574,7 @@ export function useDeckLayers({
: [postcodeLayer, ...poiLayers]
: [hexLayer, ...poiLayers];
if (marchingAntsLayer) baseLayers.push(marchingAntsLayer);
if (currentLocationLayer) baseLayers.push(currentLocationLayer);
return baseLayers;
}, [
usePostcodeView,
@ -770,16 +584,15 @@ export function useDeckLayers({
postcodeLabelsLayer,
poiLayers,
marchingAntsLayer,
currentLocationLayer,
]);
const handleMouseLeave = useCallback(() => {
setHoverPosition(null);
setHoveredPostcode(null);
setPopupInfo(null);
clearPopupInfo();
onHexagonHoverRef.current(null);
}, []);
const clearPopupInfo = useCallback(() => setPopupInfo(null), []);
}, [clearPopupInfo]);
return {
layers,

View file

@ -1,25 +1,65 @@
import { useCallback, useLayoutEffect, useState } from 'react';
import { useCallback, useLayoutEffect, useRef, useState } from 'react';
import type React from 'react';
export function useDropdownPosition(anchorRef: React.RefObject<HTMLElement | null>, open: boolean) {
const [pos, setPos] = useState<{ top: number; left: number; width: number } | null>(null);
const posRef = useRef(pos);
posRef.current = pos;
const update = useCallback(() => {
if (!anchorRef.current) return;
const rect = anchorRef.current.getBoundingClientRect();
setPos({ top: rect.bottom + 4, left: rect.left, width: rect.width });
const next = { top: rect.bottom + 4, left: rect.left, width: rect.width };
const prev = posRef.current;
if (
prev &&
Math.abs(prev.top - next.top) < 0.5 &&
Math.abs(prev.left - next.left) < 0.5 &&
Math.abs(prev.width - next.width) < 0.5
) {
return;
}
setPos(next);
}, [anchorRef]);
useLayoutEffect(() => {
if (!open) return;
update();
window.addEventListener('scroll', update, true);
window.addEventListener('resize', update);
return () => {
window.removeEventListener('scroll', update, true);
window.removeEventListener('resize', update);
const vv = window.visualViewport;
let raf = 0;
let frame = 0;
const anchor = anchorRef.current;
const updateNextFrame = () => {
if (raf) cancelAnimationFrame(raf);
raf = requestAnimationFrame(update);
};
}, [open, update]);
const trackAnchorMovement = () => {
update();
frame = requestAnimationFrame(trackAnchorMovement);
};
update();
frame = requestAnimationFrame(trackAnchorMovement);
window.addEventListener('scroll', update, true);
window.addEventListener('resize', updateNextFrame);
vv?.addEventListener('resize', updateNextFrame);
vv?.addEventListener('scroll', updateNextFrame);
const observer =
anchor && typeof ResizeObserver !== 'undefined' ? new ResizeObserver(updateNextFrame) : null;
if (anchor && observer) observer.observe(anchor);
return () => {
if (raf) cancelAnimationFrame(raf);
if (frame) cancelAnimationFrame(frame);
window.removeEventListener('scroll', update, true);
window.removeEventListener('resize', updateNextFrame);
vv?.removeEventListener('resize', updateNextFrame);
vv?.removeEventListener('scroll', updateNextFrame);
observer?.disconnect();
};
}, [anchorRef, open, update]);
return pos;
}

View file

@ -1,6 +1,13 @@
import { useState, useCallback, useMemo, useRef, useEffect } from 'react';
import type { FeatureMeta, FeatureFilters } from '../types';
import { trackEvent } from '../lib/analytics';
import {
SCHOOL_FILTER_NAME,
createSchoolFilterKey,
getDefaultSchoolFeatureName,
getSchoolFilterKeyId,
normalizeSchoolFilters,
} from '../lib/school-filter';
interface UseFiltersOptions {
initialFilters: FeatureFilters;
@ -8,7 +15,9 @@ interface UseFiltersOptions {
}
export function useFilters({ initialFilters, features }: UseFiltersOptions) {
const [filters, setFilters] = useState<FeatureFilters>(initialFilters);
const [filters, setFilters] = useState<FeatureFilters>(() =>
normalizeSchoolFilters(initialFilters)
);
const [activeFeature, setActiveFeature] = useState<string | null>(null);
const [dragValue, setDragValue] = useState<[number, number] | null>(null);
const [pinnedFeature, setPinnedFeature] = useState<string | null>(null);
@ -16,6 +25,7 @@ export function useFilters({ initialFilters, features }: UseFiltersOptions) {
const dragActiveRef = useRef<string | null>(null);
const dragValueRef = useRef<[number, number] | null>(null);
const undoStackRef = useRef<FeatureFilters[]>([]);
const schoolFilterIdRef = useRef(1);
const enabledFeatures = useMemo(() => new Set(Object.keys(filters)), [filters]);
@ -33,11 +43,31 @@ export function useFilters({ initialFilters, features }: UseFiltersOptions) {
const handleAddFilter = useCallback(
(name: string) => {
const meta = features.find((f) => f.name === name);
if (!meta) return;
if (name !== SCHOOL_FILTER_NAME && !meta) return;
trackEvent('Filter Add', { feature: name });
setFilters((prev) => {
undoStackRef.current.push(prev);
if (undoStackRef.current.length > 50) undoStackRef.current.shift();
if (name === SCHOOL_FILTER_NAME) {
const schoolKey = createSchoolFilterKey(
'primary',
'good',
2,
schoolFilterIdRef.current++
);
const defaultSchoolFeatureName = getDefaultSchoolFeatureName(features);
const defaultSchoolFeature = defaultSchoolFeatureName
? features.find((feature) => feature.name === defaultSchoolFeatureName)
: undefined;
return {
...prev,
[schoolKey]: [
defaultSchoolFeature?.histogram?.min ?? defaultSchoolFeature?.min ?? 0,
defaultSchoolFeature?.histogram?.max ?? defaultSchoolFeature?.max ?? 10,
],
};
}
if (!meta) return prev;
if (meta.type === 'enum' && meta.values) {
return { ...prev, [name]: [...meta.values!] };
} else if (meta.type === 'numeric' && meta.histogram) {
@ -75,9 +105,27 @@ export function useFilters({ initialFilters, features }: UseFiltersOptions) {
if (Array.isArray(value) && value.length === 0) {
const next = { ...prev };
delete next[name];
return next;
return normalizeSchoolFilters(next);
}
return { ...prev, [name]: value };
const schoolKeyId = getSchoolFilterKeyId(name);
if (schoolKeyId != null) {
let replaced = false;
const next: FeatureFilters = {};
for (const [existingName, existingValue] of Object.entries(prev)) {
if (getSchoolFilterKeyId(existingName) === schoolKeyId) {
if (!replaced) {
next[name] = value;
replaced = true;
}
continue;
}
next[existingName] = existingValue;
}
if (replaced) return normalizeSchoolFilters(next);
}
return normalizeSchoolFilters({ ...prev, [name]: value });
});
}, []);
@ -96,6 +144,7 @@ export function useFilters({ initialFilters, features }: UseFiltersOptions) {
const meta = features.find((f) => f.name === name);
if (meta?.type === 'enum') return;
pendingDragRef.current = name;
setActiveFeature(name);
},
[features]
);
@ -112,8 +161,9 @@ export function useFilters({ initialFilters, features }: UseFiltersOptions) {
const handleDragEnd = useCallback(() => {
if (pendingDragRef.current) {
// Click without drag — no state was changed, just clear the ref
// Click without drag — no filter value was changed, just clear preview state.
pendingDragRef.current = null;
setActiveFeature(null);
return;
}
const af = dragActiveRef.current;
@ -131,6 +181,7 @@ export function useFilters({ initialFilters, features }: UseFiltersOptions) {
const handleDragEndNoCommit = useCallback((): [number, number] | null => {
if (pendingDragRef.current) {
pendingDragRef.current = null;
setActiveFeature(null);
return null;
}
const dv = dragValueRef.current;
@ -142,7 +193,7 @@ export function useFilters({ initialFilters, features }: UseFiltersOptions) {
}, []);
const handleSetFilters = useCallback((newFilters: FeatureFilters) => {
setFilters(newFilters);
setFilters(normalizeSchoolFilters(newFilters));
setActiveFeature(null);
setDragValue(null);
setPinnedFeature(null);

View file

@ -1,5 +1,5 @@
import { useState, useCallback, useEffect, useMemo, useRef } from 'react';
import { latLngToCell } from 'h3-js';
import { cellToLatLng, cellToParent, latLngToCell } from 'h3-js';
import { trackEvent } from '../lib/analytics';
import type {
FeatureMeta,
@ -11,10 +11,13 @@ import type {
} from '../types';
import { buildFilterString, apiUrl, assertOk, logNonAbortError, authHeaders } from '../lib/api';
const CURRENT_LOCATION_HEX_RESOLUTION = 12;
interface SelectedHexagon {
id: string;
type: 'hexagon' | 'postcode';
resolution: number;
lockedResolution?: boolean;
}
interface JourneyDest {
@ -22,10 +25,18 @@ interface JourneyDest {
slug: string;
}
interface PostcodeLookupResponse {
postcode: string;
latitude: number;
longitude: number;
geometry: PostcodeGeometry;
}
interface UseHexagonSelectionOptions {
filters: FeatureFilters;
features: FeatureMeta[];
resolution: number;
usePostcodeView: boolean;
/** First transit destination — used to pick the best central_postcode for journey display. */
journeyDest?: JourneyDest | null;
}
@ -34,6 +45,7 @@ export function useHexagonSelection({
filters,
features,
resolution,
usePostcodeView,
journeyDest,
}: UseHexagonSelectionOptions) {
const [selectedHexagon, setSelectedHexagon] = useState<SelectedHexagon | null>(null);
@ -42,6 +54,7 @@ export function useHexagonSelection({
const [propertiesOffset, setPropertiesOffset] = useState(0);
const [loadingProperties, setLoadingProperties] = useState(false);
const [areaStats, setAreaStats] = useState<HexagonStatsResponse | null>(null);
const [unfilteredAreaCount, setUnfilteredAreaCount] = useState<number | null>(null);
const [loadingAreaStats, setLoadingAreaStats] = useState(false);
const [hoveredHexagon, setHoveredHexagon] = useState<string | null>(null);
const [rightPaneTab, setRightPaneTab] = useState<'properties' | 'area'>('area');
@ -50,12 +63,18 @@ export function useHexagonSelection({
);
const fetchHexagonStats = useCallback(
async (h3: string, res: number, signal?: AbortSignal, fields?: string[]) => {
async (
h3: string,
res: number,
signal?: AbortSignal,
fields?: string[],
includeFilters = true
) => {
const params = new URLSearchParams({
h3,
resolution: res.toString(),
});
const filterStr = buildFilterString(filters, features);
const filterStr = includeFilters ? buildFilterString(filters, features) : '';
if (filterStr) params.append('filters', filterStr);
if (fields) {
params.set('fields', fields.join(';;'));
@ -72,9 +91,9 @@ export function useHexagonSelection({
);
const fetchPostcodeStats = useCallback(
async (postcode: string, signal?: AbortSignal) => {
async (postcode: string, signal?: AbortSignal, includeFilters = true) => {
const params = new URLSearchParams({ postcode });
const filterStr = buildFilterString(filters, features);
const filterStr = includeFilters ? buildFilterString(filters, features) : '';
if (filterStr) params.append('filters', filterStr);
const response = await fetch(apiUrl('postcode-stats', params), authHeaders({ signal }));
assertOk(response, 'postcode-stats');
@ -83,6 +102,47 @@ export function useHexagonSelection({
[filters, features]
);
const filterStr = useMemo(() => buildFilterString(filters, features), [filters, features]);
const fetchUnfilteredAreaCount = useCallback(
async (selection: SelectedHexagon, signal?: AbortSignal) => {
if (!filterStr) {
setUnfilteredAreaCount(null);
return;
}
const stats =
selection.type === 'postcode'
? await fetchPostcodeStats(selection.id, signal, false)
: await fetchHexagonStats(selection.id, selection.resolution, signal, undefined, false);
setUnfilteredAreaCount(stats.count);
},
[filterStr, fetchHexagonStats, fetchPostcodeStats]
);
const refreshUnfilteredAreaCount = useCallback(
(selection: SelectedHexagon, filteredCount: number, signal?: AbortSignal) => {
if (!filterStr || filteredCount > 0) {
setUnfilteredAreaCount(null);
return;
}
fetchUnfilteredAreaCount(selection, signal).catch((error) =>
logNonAbortError('Failed to fetch unfiltered area count', error)
);
},
[filterStr, fetchUnfilteredAreaCount]
);
const fetchPostcodeLookup = useCallback(async (postcode: string, signal?: AbortSignal) => {
const response = await fetch(
`/api/postcode/${encodeURIComponent(postcode)}`,
authHeaders({ signal })
);
assertOk(response, 'postcode lookup');
return (await response.json()) as PostcodeLookupResponse;
}, []);
const fetchHexagonProperties = useCallback(
async (h3: string, res: number, offset = 0) => {
setLoadingProperties(true);
@ -156,33 +216,42 @@ export function useHexagonSelection({
setSelectedHexagon(null);
setProperties([]);
setAreaStats(null);
setUnfilteredAreaCount(null);
setSelectedPostcodeGeometry(null);
} else {
const type = isPostcode ? 'postcode' : 'hexagon';
const type: SelectedHexagon['type'] = isPostcode ? 'postcode' : 'hexagon';
const selection = { id, type, resolution };
trackEvent('Hexagon Click', { type });
setSelectedHexagon({ id, type, resolution });
setSelectedHexagon(selection);
setSelectedPostcodeGeometry(isPostcode && geometry ? geometry : null);
setProperties([]);
setPropertiesTotal(0);
setPropertiesOffset(0);
setUnfilteredAreaCount(null);
setRightPaneTab('area');
if (isPostcode) {
setLoadingAreaStats(true);
fetchPostcodeStats(id)
.then((stats) => setAreaStats(stats))
.then((stats) => {
setAreaStats(stats);
refreshUnfilteredAreaCount(selection, stats.count);
})
.catch((error) => logNonAbortError('Failed to fetch postcode stats', error))
.finally(() => setLoadingAreaStats(false));
} else {
setLoadingAreaStats(true);
fetchHexagonStats(id, resolution)
.then((stats) => setAreaStats(stats))
.then((stats) => {
setAreaStats(stats);
refreshUnfilteredAreaCount(selection, stats.count);
})
.catch((error) => logNonAbortError('Failed to fetch area stats', error))
.finally(() => setLoadingAreaStats(false));
}
}
},
[selectedHexagon, resolution, fetchHexagonStats, fetchPostcodeStats]
[selectedHexagon, resolution, fetchHexagonStats, fetchPostcodeStats, refreshUnfilteredAreaCount]
);
const handleHexagonHover = useCallback((h3: string | null) => {
@ -232,11 +301,111 @@ export function useHexagonSelection({
setSelectedHexagon(null);
setProperties([]);
setAreaStats(null);
setUnfilteredAreaCount(null);
setSelectedPostcodeGeometry(null);
}, []);
// Keep the selected area aligned with the active map view as zoom changes.
useEffect(() => {
if (!selectedHexagon) return;
const selection = selectedHexagon;
const shouldSync =
(usePostcodeView &&
selection.type === 'hexagon' &&
!selection.lockedResolution &&
areaStats?.central_postcode != null) ||
(!usePostcodeView && selection.type === 'postcode') ||
(!usePostcodeView &&
selection.type === 'hexagon' &&
!selection.lockedResolution &&
selection.resolution !== resolution);
if (!shouldSync) return;
let cancelled = false;
const controller = new AbortController();
const refreshProperties = (selection: SelectedHexagon) => {
if (rightPaneTab !== 'properties') return;
if (selection.type === 'postcode') {
fetchPostcodeProperties(selection.id, 0);
} else {
fetchHexagonProperties(selection.id, selection.resolution, 0);
}
};
async function syncSelection() {
let nextSelection: SelectedHexagon | null = null;
let nextGeometry: PostcodeGeometry | null = null;
let nextStats: HexagonStatsResponse | null = null;
if (usePostcodeView && selection.type === 'hexagon' && !selection.lockedResolution) {
if (!areaStats?.central_postcode) return;
const lookup = await fetchPostcodeLookup(areaStats.central_postcode, controller.signal);
nextSelection = { id: lookup.postcode, type: 'postcode', resolution };
nextGeometry = lookup.geometry;
nextStats = await fetchPostcodeStats(lookup.postcode, controller.signal);
} else if (!usePostcodeView && selection.type === 'postcode') {
const lookup = await fetchPostcodeLookup(selection.id, controller.signal);
const nextId = latLngToCell(lookup.latitude, lookup.longitude, resolution);
nextSelection = { id: nextId, type: 'hexagon', resolution };
nextStats = await fetchHexagonStats(nextId, resolution, controller.signal);
} else if (
!usePostcodeView &&
selection.type === 'hexagon' &&
!selection.lockedResolution &&
selection.resolution !== resolution
) {
const nextId =
resolution < selection.resolution
? cellToParent(selection.id, resolution)
: latLngToCell(...cellToLatLng(selection.id), resolution);
nextSelection = { id: nextId, type: 'hexagon', resolution };
nextStats = await fetchHexagonStats(nextId, resolution, controller.signal);
} else {
return;
}
if (cancelled || !nextSelection || !nextStats) return;
setSelectedHexagon(nextSelection);
setSelectedPostcodeGeometry(nextGeometry);
setAreaStats(nextStats);
refreshUnfilteredAreaCount(nextSelection, nextStats.count, controller.signal);
refreshProperties(nextSelection);
}
setProperties([]);
setPropertiesTotal(0);
setPropertiesOffset(0);
setUnfilteredAreaCount(null);
setLoadingAreaStats(true);
syncSelection()
.catch((error) => {
if (!cancelled) logNonAbortError('Failed to sync selected area with map view', error);
})
.finally(() => {
if (!cancelled) setLoadingAreaStats(false);
});
return () => {
cancelled = true;
controller.abort();
};
}, [
selectedHexagon,
resolution,
usePostcodeView,
areaStats?.central_postcode,
fetchHexagonStats,
fetchPostcodeStats,
fetchPostcodeLookup,
fetchHexagonProperties,
fetchPostcodeProperties,
refreshUnfilteredAreaCount,
rightPaneTab,
]);
// Re-fetch stats when filters change while a hexagon is selected
const filterStr = useMemo(() => buildFilterString(filters, features), [filters, features]);
const prevFilterStr = useRef(filterStr);
useEffect(() => {
@ -261,21 +430,16 @@ export function useHexagonSelection({
fetchStats
.then((stats) => {
if (cancelled) return;
if (stats.count === 0) {
setSelectedHexagon(null);
setAreaStats(null);
setSelectedPostcodeGeometry(null);
} else {
setAreaStats(stats);
// Re-fetch properties if the properties tab is active
if (rightPaneTab === 'properties') {
refreshUnfilteredAreaCount(selectedHexagon, stats.count);
// Re-fetch properties if the properties tab is active and the filtered area still has matches.
if (rightPaneTab === 'properties' && stats.count > 0) {
if (selectedHexagon.type === 'postcode') {
fetchPostcodeProperties(selectedHexagon.id, 0);
} else {
fetchHexagonProperties(selectedHexagon.id, selectedHexagon.resolution, 0);
}
}
}
})
.catch((error) => {
if (cancelled) return;
@ -296,6 +460,7 @@ export function useHexagonSelection({
rightPaneTab,
fetchHexagonProperties,
fetchPostcodeProperties,
refreshUnfilteredAreaCount,
]);
const handleLocationSearch = useCallback(
@ -304,6 +469,7 @@ export function useHexagonSelection({
setProperties([]);
setPropertiesTotal(0);
setPropertiesOffset(0);
setUnfilteredAreaCount(null);
setRightPaneTab('area');
setLoadingAreaStats(true);
@ -311,18 +477,22 @@ export function useHexagonSelection({
fetchPostcodeStats(postcode)
.then(async (stats) => {
if (stats.count > 0) {
setSelectedHexagon({ id: postcode, type: 'postcode', resolution });
const selection = { id: postcode, type: 'postcode' as const, resolution };
setSelectedHexagon(selection);
setSelectedPostcodeGeometry(geometry);
setAreaStats(stats);
refreshUnfilteredAreaCount(selection, stats.count);
return;
}
// No properties in this postcode — fall back to hexagons
if (lat == null || lng == null) {
// No coordinates available, show empty postcode anyway
setSelectedHexagon({ id: postcode, type: 'postcode', resolution });
const selection = { id: postcode, type: 'postcode' as const, resolution };
setSelectedHexagon(selection);
setSelectedPostcodeGeometry(geometry);
setAreaStats(stats);
refreshUnfilteredAreaCount(selection, stats.count);
return;
}
@ -332,9 +502,11 @@ export function useHexagonSelection({
const h3 = latLngToCell(lat, lng, res);
const hexStats = await fetchHexagonStats(h3, res);
if (hexStats.count > 1) {
setSelectedHexagon({ id: h3, type: 'hexagon', resolution: res });
const selection = { id: h3, type: 'hexagon' as const, resolution: res };
setSelectedHexagon(selection);
setSelectedPostcodeGeometry(null);
setAreaStats(hexStats);
refreshUnfilteredAreaCount(selection, hexStats.count);
return;
}
}
@ -342,14 +514,47 @@ export function useHexagonSelection({
// Even the coarsest hexagon has ≤1 property — show whatever the finest has
const h3 = latLngToCell(lat, lng, 9);
const fallbackStats = await fetchHexagonStats(h3, 9);
setSelectedHexagon({ id: h3, type: 'hexagon', resolution: 9 });
const selection = { id: h3, type: 'hexagon' as const, resolution: 9 };
setSelectedHexagon(selection);
setSelectedPostcodeGeometry(null);
setAreaStats(fallbackStats);
refreshUnfilteredAreaCount(selection, fallbackStats.count);
})
.catch((error) => logNonAbortError('Failed to fetch postcode stats', error))
.finally(() => setLoadingAreaStats(false));
},
[resolution, fetchPostcodeStats, fetchHexagonStats]
[resolution, fetchPostcodeStats, fetchHexagonStats, refreshUnfilteredAreaCount]
);
const handleCurrentLocationSearch = useCallback(
(lat: number, lng: number) => {
const h3 = latLngToCell(lat, lng, CURRENT_LOCATION_HEX_RESOLUTION);
const selection = {
id: h3,
type: 'hexagon' as const,
resolution: CURRENT_LOCATION_HEX_RESOLUTION,
lockedResolution: true,
};
trackEvent('Current Location Search');
setSelectedHexagon(selection);
setSelectedPostcodeGeometry(null);
setProperties([]);
setPropertiesTotal(0);
setPropertiesOffset(0);
setUnfilteredAreaCount(null);
setRightPaneTab('area');
setLoadingAreaStats(true);
fetchHexagonStats(h3, CURRENT_LOCATION_HEX_RESOLUTION)
.then((stats) => {
setAreaStats(stats);
refreshUnfilteredAreaCount(selection, stats.count);
})
.catch((error) => logNonAbortError('Failed to fetch current location hex stats', error))
.finally(() => setLoadingAreaStats(false));
},
[fetchHexagonStats, refreshUnfilteredAreaCount]
);
return {
@ -359,6 +564,7 @@ export function useHexagonSelection({
loadingProperties,
areaStats,
loadingAreaStats,
unfilteredAreaCount,
hoveredHexagon,
rightPaneTab,
setRightPaneTab,
@ -370,5 +576,6 @@ export function useHexagonSelection({
handleCloseSelection,
selectedPostcodeGeometry,
handleLocationSearch,
handleCurrentLocationSearch,
};
}

View file

@ -16,6 +16,7 @@ import {
authHeaders,
isAbortError,
} from '../lib/api';
import { getSchoolBackendFeatureName } from '../lib/school-filter';
import { POSTCODE_ZOOM_THRESHOLD } from '../lib/consts';
import { COLOR_RANGE_LOW_PERCENTILE, COLOR_RANGE_HIGH_PERCENTILE } from '../lib/consts';
import { type TravelTimeEntry } from './useTravelTime';
@ -74,11 +75,16 @@ export function useMapData({
const prevBoundsRef = useRef<string>('');
const usePostcodeView = zoom >= POSTCODE_ZOOM_THRESHOLD;
const dataViewFeature = useMemo(
() => (viewFeature ? (getSchoolBackendFeatureName(viewFeature) ?? viewFeature) : null),
[viewFeature]
);
// Determine if the current viewFeature is an enum (for enum_dist param)
const viewFeatureIsEnum = useMemo(
() => (viewFeature ? features.find((f) => f.name === viewFeature)?.type === 'enum' : false),
[viewFeature, features]
() =>
dataViewFeature ? features.find((f) => f.name === dataViewFeature)?.type === 'enum' : false,
[dataViewFeature, features]
);
const buildFilterParam = useCallback(
@ -130,17 +136,18 @@ export function useMapData({
const filtersStr = buildFilterString(filters, features, activeFeature);
const boundsStr = `${bounds.south},${bounds.west},${bounds.north},${bounds.east}`;
const isTravelTimeDrag = activeFeature.startsWith('tt_');
const dataActiveFeature = getSchoolBackendFeatureName(activeFeature) ?? activeFeature;
const dragTravelParam = isTravelTimeDrag ? buildTravelParam(activeFeature) : travelParam;
// Travel time fields are computed from the travel param, not regular feature columns.
// Sending a tt_* name as fields would cause a 400 (unknown field). Use empty string instead.
const fieldsParam = isTravelTimeDrag ? '' : activeFeature;
const fieldsParam = isTravelTimeDrag ? '' : dataActiveFeature;
if (usePostcodeView) {
const params = new URLSearchParams({ bounds: boundsStr });
if (filtersStr) params.set('filters', filtersStr);
params.set('fields', fieldsParam);
if (dragTravelParam) params.set('travel', dragTravelParam);
if (viewFeatureIsEnum && viewFeature) params.set('enum_dist', viewFeature);
if (viewFeatureIsEnum && dataViewFeature) params.set('enum_dist', dataViewFeature);
fetch(apiUrl('postcodes', params), authHeaders({ signal: dragAbortRef.current.signal }))
.then((res) => res.json())
@ -158,7 +165,7 @@ export function useMapData({
if (filtersStr) params.set('filters', filtersStr);
params.set('fields', fieldsParam);
if (dragTravelParam) params.set('travel', dragTravelParam);
if (viewFeatureIsEnum && viewFeature) params.set('enum_dist', viewFeature);
if (viewFeatureIsEnum && dataViewFeature) params.set('enum_dist', dataViewFeature);
fetch(apiUrl('hexagons', params), authHeaders({ signal: dragAbortRef.current.signal }))
.then((res) => res.json())
@ -185,7 +192,7 @@ export function useMapData({
usePostcodeView,
travelParam,
buildTravelParam,
viewFeature,
dataViewFeature,
viewFeatureIsEnum,
]);
@ -211,11 +218,14 @@ export function useMapData({
if (usePostcodeView) {
const params = new URLSearchParams({ bounds: boundsStr });
if (filtersStr) params.set('filters', filtersStr);
params.set('fields', viewFeature && !viewFeature.startsWith('tt_') ? viewFeature : '');
params.set(
'fields',
dataViewFeature && !dataViewFeature.startsWith('tt_') ? dataViewFeature : ''
);
if (travelParam) {
params.set('travel', travelParam);
}
if (viewFeatureIsEnum && viewFeature) params.set('enum_dist', viewFeature);
if (viewFeatureIsEnum && dataViewFeature) params.set('enum_dist', dataViewFeature);
const res = await fetch(
apiUrl('postcodes', params),
authHeaders({
@ -242,11 +252,14 @@ export function useMapData({
bounds: boundsStr,
});
if (filtersStr) params.set('filters', filtersStr);
params.set('fields', viewFeature && !viewFeature.startsWith('tt_') ? viewFeature : '');
params.set(
'fields',
dataViewFeature && !dataViewFeature.startsWith('tt_') ? dataViewFeature : ''
);
if (travelParam) {
params.set('travel', travelParam);
}
if (viewFeatureIsEnum && viewFeature) params.set('enum_dist', viewFeature);
if (viewFeatureIsEnum && dataViewFeature) params.set('enum_dist', dataViewFeature);
const res = await fetch(
apiUrl('hexagons', params),
authHeaders({
@ -294,7 +307,7 @@ export function useMapData({
bounds,
filters,
buildFilterParam,
viewFeature,
dataViewFeature,
viewFeatureIsEnum,
usePostcodeView,
travelParam,
@ -311,12 +324,12 @@ export function useMapData({
// Always uses rawData/postcodeData (not drag preview data) so the color
// scale stays stable while dragging a filter slider.
const dataRange = useMemo((): [number, number] | null => {
if (!viewFeature) return null;
if (!dataViewFeature) return null;
const isTravelTime = viewFeature.startsWith('tt_');
const isTravelTime = dataViewFeature.startsWith('tt_');
if (!isTravelTime) {
const meta = features.find((f) => f.name === viewFeature);
const meta = features.find((f) => f.name === dataViewFeature);
if (!meta || meta.type === 'enum') return null;
}
@ -330,7 +343,7 @@ export function useMapData({
if (lat < bounds.south || lat > bounds.north || lng < bounds.west || lng > bounds.east)
continue;
}
const val = feat.properties[`avg_${viewFeature}`];
const val = feat.properties[`avg_${dataViewFeature}`];
if (typeof val === 'number' && !isNaN(val)) vals.push(val);
}
} else {
@ -341,7 +354,7 @@ export function useMapData({
if (lat < bounds.south || lat > bounds.north || lon < bounds.west || lon > bounds.east)
continue;
}
const val = item[`avg_${viewFeature}`];
const val = item[`avg_${dataViewFeature}`];
if (typeof val === 'number' && !isNaN(val)) vals.push(val);
}
}
@ -352,18 +365,18 @@ export function useMapData({
percentile(vals, COLOR_RANGE_LOW_PERCENTILE),
percentile(vals, COLOR_RANGE_HIGH_PERCENTILE),
];
}, [viewFeature, rawData, postcodeData, usePostcodeView, features, bounds]);
}, [dataViewFeature, rawData, postcodeData, usePostcodeView, features, bounds]);
// Color range for the legend and hex coloring
const colorRange = useMemo((): [number, number] | null => {
if (!viewFeature) return null;
if (!dataViewFeature) return null;
// Travel time keys: use dataRange directly (no FeatureMeta)
if (viewFeature.startsWith('tt_')) {
if (dataViewFeature.startsWith('tt_')) {
return dataRange;
}
const meta = features.find((f) => f.name === viewFeature);
const meta = features.find((f) => f.name === dataViewFeature);
if (!meta) return null;
if (meta.type === 'enum' && meta.values && meta.values.length > 0) {
return [0, meta.values.length - 1];
@ -371,7 +384,7 @@ export function useMapData({
if (dataRange) return dataRange;
if (meta.min != null && meta.max != null) return [meta.min, meta.max];
return null;
}, [viewFeature, features, dataRange]);
}, [dataViewFeature, features, dataRange]);
const handleViewChange = useCallback(
({

View file

@ -0,0 +1,150 @@
import { act, renderHook } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import type { POI } from '../types';
import { usePoiLayers } from './usePoiLayers';
const supermarket: POI = {
id: 'poi-1',
name: 'Market Hall',
category: 'Supermarket',
group: 'Groceries',
lat: 51.5,
lng: -0.12,
emoji: '🛒',
};
const waitrose: POI = {
id: 'poi-3',
name: 'Waitrose Marylebone',
category: 'Waitrose',
group: 'Groceries',
lat: 51.52,
lng: -0.15,
emoji: '🛒',
};
const busStop: POI = {
id: 'poi-2',
name: 'High Street Stop',
category: 'Bus stop',
group: 'Public Transport',
lat: 51.501,
lng: -0.121,
emoji: '🚌',
};
function layerById(layers: readonly unknown[], id: string) {
const layer = layers.find((item) => (item as { id?: string }).id === id);
if (!layer) throw new Error(`Layer ${id} not found`);
return layer as { props: Record<string, unknown> };
}
describe('usePoiLayers', () => {
it('returns the expected layer stack', () => {
const { result } = renderHook(() =>
usePoiLayers({ pois: [supermarket], zoom: 15, isDark: false })
);
expect(result.current.poiLayers.map((layer) => layer.id)).toEqual([
'poi-shadow',
'poi-background',
'poi-icons',
'poi-clusters',
'poi-cluster-text',
]);
});
it('uses POI category logos for map marker icons', () => {
const { result } = renderHook(() =>
usePoiLayers({ pois: [waitrose], zoom: 15, isDark: false })
);
const iconLayer = layerById(result.current.poiLayers, 'poi-icons');
const getIcon = iconLayer.props.getIcon as (poi: POI) => { url: string };
expect(getIcon(waitrose).url).toBe(
'https://geolytix.github.io/MapIcons/brands/waitrose_24px.svg'
);
});
it('hides minor POI categories until the configured zoom threshold', () => {
const { result, rerender } = renderHook(
({ zoom }) => usePoiLayers({ pois: [busStop], zoom, isDark: false }),
{ initialProps: { zoom: 13 } }
);
expect(layerById(result.current.poiLayers, 'poi-background').props.data).toEqual([]);
rerender({ zoom: 14 });
expect(layerById(result.current.poiLayers, 'poi-background').props.data).toEqual([busStop]);
});
it('keeps POI hover popup state in sync with layer hover events', () => {
const { result } = renderHook(() =>
usePoiLayers({ pois: [supermarket], zoom: 15, isDark: false })
);
const backgroundLayer = layerById(result.current.poiLayers, 'poi-background');
act(() => {
(backgroundLayer.props.onHover as (info: unknown) => void)({
object: supermarket,
x: 42,
y: 88,
});
});
expect(result.current.popupInfo).toEqual({
x: 42,
y: 88,
name: supermarket.name,
category: supermarket.category,
group: supermarket.group,
emoji: supermarket.emoji,
id: supermarket.id,
});
act(() => {
result.current.clearPopupInfo();
});
expect(result.current.popupInfo).toBeNull();
});
it('creates cluster hover popup state from clustered POIs', () => {
const clusteredPois = Array.from(
{ length: 4 },
(_, index): POI => ({
...supermarket,
id: `clustered-${index}`,
name: `Clustered ${index}`,
lat: 51.5 + index * 0.0001,
lng: -0.12 - index * 0.0001,
})
);
const { result } = renderHook(() =>
usePoiLayers({ pois: clusteredPois, zoom: 5, isDark: true })
);
const clusterLayer = layerById(result.current.poiLayers, 'poi-clusters');
const clusters = clusterLayer.props.data as Array<{ count: number; lng: number; lat: number }>;
expect(clusters).toHaveLength(1);
expect(clusters[0].count).toBe(4);
act(() => {
(clusterLayer.props.onHover as (info: unknown) => void)({
object: clusters[0],
x: 12,
y: 24,
});
});
expect(result.current.popupInfo).toMatchObject({
x: 12,
y: 24,
name: '4 places',
isCluster: true,
clusterCount: 4,
});
});
});

View file

@ -0,0 +1,238 @@
import { useCallback, useMemo, useRef, useState } from 'react';
import type { PickingInfo } from '@deck.gl/core';
import { IconLayer, ScatterplotLayer, TextLayer } from '@deck.gl/layers';
import Supercluster from 'supercluster';
import type { POI } from '../types';
import {
POI_GROUP_COLORS,
POI_DEFAULT_COLOR,
MINOR_POI_CATEGORIES,
MINOR_POI_ZOOM_THRESHOLD,
POI_CLUSTER_RADIUS,
POI_CLUSTER_MAX_ZOOM,
} from '../lib/consts';
import { getPoiIconUrl } from '../lib/map-utils';
export interface PopupInfo {
x: number;
y: number;
name: string;
category: string;
group: string;
emoji: string;
id: string;
isCluster?: boolean;
clusterCount?: number;
}
interface ClusterPoint {
lng: number;
lat: number;
count: number;
clusterId: number;
}
interface UsePoiLayersProps {
pois: POI[];
zoom: number;
isDark: boolean;
}
export function usePoiLayers({ pois, zoom, isDark }: UsePoiLayersProps) {
const [popupInfo, setPopupInfo] = useState<PopupInfo | null>(null);
const handlePoiHover = useCallback((info: PickingInfo<POI>) => {
if (info.object && info.x !== undefined && info.y !== undefined) {
setPopupInfo({
x: info.x,
y: info.y,
name: info.object.name,
category: info.object.category,
group: info.object.group,
emoji: info.object.emoji,
id: info.object.id,
});
} else {
setPopupInfo(null);
}
}, []);
const handlePoiHoverRef = useRef(handlePoiHover);
handlePoiHoverRef.current = handlePoiHover;
const stablePoiHover = useCallback((info: PickingInfo<POI>) => {
handlePoiHoverRef.current(info);
}, []);
const handleClusterHover = useCallback((info: PickingInfo<ClusterPoint>) => {
if (info.object && info.x !== undefined && info.y !== undefined) {
setPopupInfo({
x: info.x,
y: info.y,
name: `${info.object.count} places`,
category: 'Zoom in to see details',
group: '',
emoji: '',
id: '',
isCluster: true,
clusterCount: info.object.count,
});
} else {
setPopupInfo(null);
}
}, []);
const handleClusterHoverRef = useRef(handleClusterHover);
handleClusterHoverRef.current = handleClusterHover;
const stableClusterHover = useCallback((info: PickingInfo<ClusterPoint>) => {
handleClusterHoverRef.current(info);
}, []);
const clusterIndex = useMemo(() => {
if (pois.length === 0) return null;
const index = new Supercluster<POI>({
radius: POI_CLUSTER_RADIUS,
maxZoom: POI_CLUSTER_MAX_ZOOM,
});
const features: Supercluster.PointFeature<POI>[] = pois.map((poi) => ({
type: 'Feature',
geometry: { type: 'Point', coordinates: [poi.lng, poi.lat] },
properties: poi,
}));
index.load(features);
return index;
}, [pois]);
const clusterZoom = Math.floor(zoom);
const showMinorPois = zoom >= MINOR_POI_ZOOM_THRESHOLD;
const { visiblePois, clusters } = useMemo(() => {
if (!clusterIndex || pois.length === 0) {
return { visiblePois: [] as POI[], clusters: [] as ClusterPoint[] };
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const allFeatures = clusterIndex.getClusters([-180, -85, 180, 85], clusterZoom) as any[];
const individual: POI[] = [];
const clusterPoints: ClusterPoint[] = [];
for (const feature of allFeatures) {
if (feature.properties.cluster) {
clusterPoints.push({
lng: feature.geometry.coordinates[0],
lat: feature.geometry.coordinates[1],
count: feature.properties.point_count,
clusterId: feature.properties.cluster_id,
});
} else {
const poi = feature.properties as POI;
if (!showMinorPois && MINOR_POI_CATEGORIES.has(poi.category)) continue;
individual.push(poi);
}
}
return { visiblePois: individual, clusters: clusterPoints };
}, [clusterIndex, clusterZoom, showMinorPois, pois]);
const poiShadowLayer = useMemo(
() =>
new ScatterplotLayer<POI>({
id: 'poi-shadow',
data: visiblePois,
getPosition: (d) => [d.lng, d.lat],
getRadius: 16,
radiusUnits: 'pixels',
getFillColor: isDark ? [0, 0, 0, 50] : [0, 0, 0, 25],
pickable: false,
transitions: { getRadius: { duration: 300, enter: () => [0] } },
}),
[visiblePois, isDark]
);
const poiBackgroundLayer = useMemo(
() =>
new ScatterplotLayer<POI>({
id: 'poi-background',
data: visiblePois,
getPosition: (d) => [d.lng, d.lat],
getRadius: 14,
radiusUnits: 'pixels',
getFillColor: isDark ? [41, 37, 36, 255] : [255, 255, 255, 255],
getLineColor: (d) => {
const c = POI_GROUP_COLORS[d.group] || POI_DEFAULT_COLOR;
return [c[0], c[1], c[2], 255] as [number, number, number, number];
},
getLineWidth: 2.5,
lineWidthUnits: 'pixels',
stroked: true,
pickable: true,
onHover: stablePoiHover,
transitions: { getRadius: { duration: 300, enter: () => [0] } },
}),
[visiblePois, isDark, stablePoiHover]
);
const poiIconLayer = useMemo(
() =>
new IconLayer<POI>({
id: 'poi-icons',
data: visiblePois,
getPosition: (d) => [d.lng, d.lat],
getIcon: (d) => ({
url: getPoiIconUrl(d.category, d.emoji),
width: 72,
height: 72,
}),
getSize: 18,
sizeUnits: 'pixels',
pickable: false,
transitions: { getSize: { duration: 300, enter: () => [0] } },
}),
[visiblePois]
);
const clusterCircleLayer = useMemo(
() =>
new ScatterplotLayer<ClusterPoint>({
id: 'poi-clusters',
data: clusters,
getPosition: (d) => [d.lng, d.lat],
getRadius: (d) => Math.min(30, 14 + Math.sqrt(d.count) * 2),
radiusUnits: 'pixels',
getFillColor: isDark ? [5, 129, 114, 220] : [20, 184, 166, 220],
getLineColor: [255, 255, 255, isDark ? 60 : 120],
getLineWidth: 2,
lineWidthUnits: 'pixels',
stroked: true,
pickable: true,
onHover: stableClusterHover,
transitions: { getRadius: { duration: 300, enter: () => [0] } },
}),
[clusters, isDark, stableClusterHover]
);
const clusterTextLayer = useMemo(
() =>
new TextLayer<ClusterPoint>({
id: 'poi-cluster-text',
data: clusters,
getPosition: (d) => [d.lng, d.lat],
getText: (d) => (d.count >= 1000 ? `${(d.count / 1000).toFixed(1)}k` : String(d.count)),
getSize: 12,
getColor: [255, 255, 255, 255],
fontWeight: 700,
fontFamily: 'Inter, system-ui, sans-serif',
getTextAnchor: 'middle',
getAlignmentBaseline: 'center',
sizeUnits: 'pixels',
pickable: false,
}),
[clusters]
);
const poiLayers = useMemo(
() => [poiShadowLayer, poiBackgroundLayer, poiIconLayer, clusterCircleLayer, clusterTextLayer],
[poiShadowLayer, poiBackgroundLayer, poiIconLayer, clusterCircleLayer, clusterTextLayer]
);
const clearPopupInfo = useCallback(() => setPopupInfo(null), []);
return { poiLayers, popupInfo, clearPopupInfo };
}

View file

@ -0,0 +1,68 @@
import { act, renderHook } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import { travelFieldKey, useTravelTime, type TravelTimeEntry } from './useTravelTime';
describe('useTravelTime', () => {
it('creates backend field keys from mode and destination slug', () => {
expect(
travelFieldKey({
mode: 'transit',
slug: 'kings-cross',
label: 'Kings Cross',
timeRange: [0, 45],
useBest: true,
})
).toBe('tt_transit_kings-cross');
});
it('adds, updates, toggles, and removes travel-time entries', () => {
const { result } = renderHook(() => useTravelTime());
act(() => result.current.handleAddEntry('transit'));
expect(result.current.entries).toEqual([
{ mode: 'transit', slug: '', label: '', timeRange: null, useBest: false },
]);
expect(result.current.activeEntries).toEqual([]);
act(() => result.current.handleSetDestination(0, 'bank', 'Bank'));
expect(result.current.entries[0]).toMatchObject({
slug: 'bank',
label: 'Bank',
timeRange: [0, 120],
});
expect(result.current.activeEntries).toHaveLength(1);
act(() => result.current.handleTimeRangeChange(0, [10, 35]));
expect(result.current.entries[0].timeRange).toEqual([10, 35]);
act(() => result.current.handleToggleBest(0));
expect(result.current.entries[0].useBest).toBe(true);
act(() => result.current.handleRemoveEntry(0));
expect(result.current.entries).toEqual([]);
});
it('replaces entries wholesale for AI-generated filters', () => {
const initial: TravelTimeEntry = {
mode: 'walking',
slug: 'old',
label: 'Old',
timeRange: [0, 20],
useBest: false,
};
const replacement: TravelTimeEntry = {
mode: 'car',
slug: 'new',
label: 'New',
timeRange: [5, 30],
useBest: false,
};
const { result } = renderHook(() => useTravelTime({ entries: [initial] }));
act(() => result.current.handleSetEntries([replacement]));
expect(result.current.entries).toEqual([replacement]);
expect(result.current.activeEntries).toEqual([replacement]);
});
});

View file

@ -11,7 +11,7 @@ export const MODE_LABELS: Record<TransportMode, string> = {
car: 'Car',
bicycle: 'Bicycle',
walking: 'Walking',
transit: 'Transit',
transit: 'Public Transport',
};
export const MODE_DESCRIPTIONS: Record<TransportMode, string> = {

View file

@ -18,7 +18,7 @@ const descriptions: Record<string, Record<string, string>> = {
'Estimated current price': 'Estimation du prix actuel ajusté à linflation',
'Price per sqm': 'Prix de vente divisé par la surface totale',
'Est. price per sqm': 'Prix actuel estimé divisé par la surface totale',
'Estimated monthly rent': 'Loyer mensuel privé médian pour le secteur',
'Estimated monthly rent': 'Loyer mensuel privé moyen pour le secteur',
'Total floor area (sqm)': 'Surface intérieure issue du diagnostic EPC',
'Number of bedrooms & living rooms': 'Nombre de pièces habitables selon le diagnostic EPC',
'Construction year': 'Année de construction estimée selon lEPC',
@ -38,6 +38,14 @@ const descriptions: Record<string, Record<string, string>> = {
'Écoles primaires notées Bien ou Excellent par Ofsted dans un rayon de 5 km',
'Good+ secondary schools within 5km':
'Collèges/lycées notés Bien ou Excellent par Ofsted dans un rayon de 5 km',
'Outstanding primary schools within 2km':
'Écoles primaires notées Excellent par Ofsted dans un rayon de 2 km',
'Outstanding secondary schools within 2km':
'Collèges/lycées notés Excellent par Ofsted dans un rayon de 2 km',
'Outstanding primary schools within 5km':
'Écoles primaires notées Excellent par Ofsted dans un rayon de 5 km',
'Outstanding secondary schools within 5km':
'Collèges/lycées notés Excellent par Ofsted dans un rayon de 5 km',
'Education, Skills and Training Score':
'Score de qualité éducative du secteur (plus élevé = meilleur)',
'Income Score (rate)': 'Taux de précarité de revenu, inversé (plus élevé = moins précaire)',
@ -78,12 +86,8 @@ const descriptions: Record<string, Record<string, string>> = {
'% Mixed':
'Pourcentage de la population se déclarant Métisse ou de plusieurs groupes ethniques',
'% Other': 'Pourcentage de la population se déclarant dun autre groupe ethnique',
'Winning party':
'Parti vainqueur dans la circonscription lors des élections générales de 2024',
'Voter turnout (%)':
'Pourcentage délecteurs inscrits ayant voté aux élections générales de 2024',
'Majority (%)':
'Marge de victoire en pourcentage des votes valides aux élections générales de 2024',
'% Labour': 'Part des voix travaillistes aux élections générales de 2024',
'% Conservative': 'Part des voix conservatrices aux élections générales de 2024',
'% Liberal Democrat': 'Part des voix libérales-démocrates aux élections générales de 2024',
@ -106,7 +110,7 @@ const descriptions: Record<string, Record<string, string>> = {
'Estimated current price': 'Inflationsbereinigter Schätzwert der Immobilie',
'Price per sqm': 'Verkaufspreis geteilt durch die Gesamtfläche',
'Est. price per sqm': 'Geschätzter aktueller Preis geteilt durch die Gesamtfläche',
'Estimated monthly rent': 'Mittlere monatliche Privatmiete in der Gegend',
'Estimated monthly rent': 'Durchschnittliche monatliche Privatmiete in der Gegend',
'Total floor area (sqm)': 'Wohnfläche laut EPC-Gutachten',
'Number of bedrooms & living rooms': 'Anzahl bewohnbarer Räume laut EPC-Gutachten',
'Construction year': 'Geschätztes Baujahr laut EPC',
@ -125,6 +129,14 @@ const descriptions: Record<string, Record<string, string>> = {
'Von Ofsted mit Gut oder Hervorragend bewertete Grundschulen im Umkreis von 5 km',
'Good+ secondary schools within 5km':
'Von Ofsted mit Gut oder Hervorragend bewertete weiterführende Schulen im Umkreis von 5 km',
'Outstanding primary schools within 2km':
'Von Ofsted mit Hervorragend bewertete Grundschulen im Umkreis von 2 km',
'Outstanding secondary schools within 2km':
'Von Ofsted mit Hervorragend bewertete weiterführende Schulen im Umkreis von 2 km',
'Outstanding primary schools within 5km':
'Von Ofsted mit Hervorragend bewertete Grundschulen im Umkreis von 5 km',
'Outstanding secondary schools within 5km':
'Von Ofsted mit Hervorragend bewertete weiterführende Schulen im Umkreis von 5 km',
'Education, Skills and Training Score': 'Bildungsqualitätsscore der Gegend (höher = besser)',
'Income Score (rate)':
'Einkommensbenachteiligungsrate, invertiert (höher = weniger benachteiligt)',
@ -168,12 +180,8 @@ const descriptions: Record<string, Record<string, string>> = {
'% Mixed':
'Anteil der Bevölkerung, der sich als gemischt oder mehreren ethnischen Gruppen zugehörig identifiziert',
'% Other': 'Anteil der Bevölkerung, der sich einer anderen ethnischen Gruppe zuordnet',
'Winning party':
'Siegreiche Partei im Wahlkreis bei der Parlamentswahl 2024',
'Voter turnout (%)':
'Anteil der registrierten Wähler, die bei der Parlamentswahl 2024 gewählt haben',
'Majority (%)':
'Gewinnspanne als Prozentsatz der gültigen Stimmen bei der Parlamentswahl 2024',
'% Labour': 'Labour-Stimmenanteil bei der Parlamentswahl 2024',
'% Conservative': 'Stimmenanteil der Konservativen bei der Parlamentswahl 2024',
'% Liberal Democrat': 'Stimmenanteil der Liberaldemokraten bei der Parlamentswahl 2024',
@ -196,7 +204,7 @@ const descriptions: Record<string, Record<string, string>> = {
'Estimated current price': '经通胀调整后的当前估计价值',
'Price per sqm': '售价除以总建筑面积',
'Est. price per sqm': '估计当前价格除以总建筑面积',
'Estimated monthly rent': '当地私人租赁的中位月租',
'Estimated monthly rent': '当地私人租赁的平均月租',
'Total floor area (sqm)': 'EPC评估的室内建筑面积',
'Number of bedrooms & living rooms': 'EPC评估的宜居房间数',
'Construction year': 'EPC评估的建造年份',
@ -210,6 +218,10 @@ const descriptions: Record<string, Record<string, string>> = {
'Good+ secondary schools within 2km': 'Ofsted评为良好或优秀的2公里内中学',
'Good+ primary schools within 5km': 'Ofsted评为良好或优秀的5公里内小学',
'Good+ secondary schools within 5km': 'Ofsted评为良好或优秀的5公里内中学',
'Outstanding primary schools within 2km': 'Ofsted评为优秀的2公里内小学',
'Outstanding secondary schools within 2km': 'Ofsted评为优秀的2公里内中学',
'Outstanding primary schools within 5km': 'Ofsted评为优秀的5公里内小学',
'Outstanding secondary schools within 5km': 'Ofsted评为优秀的5公里内中学',
'Education, Skills and Training Score': '当地教育质量得分(越高越好)',
'Income Score (rate)': '收入贫困率,反向指标(越高越不贫困)',
'Employment Score (rate)': '就业贫困率,反向指标(越高越不贫困)',
@ -242,9 +254,7 @@ const descriptions: Record<string, Record<string, string>> = {
'% East Asian': '东亚裔人口比例',
'% Mixed': '混血或多族裔人口比例',
'% Other': '其他族裔人口比例',
'Winning party': '2024年大选中该选区获胜的政党',
'Voter turnout (%)': '2024年大选中登记选民的投票率',
'Majority (%)': '2024年大选中获胜者的得票优势占有效票的百分比',
'% Labour': '2024年大选中工党得票率',
'% Conservative': '2024年大选中保守党得票率',
'% Liberal Democrat': '2024年大选中自由民主党得票率',
@ -265,7 +275,7 @@ const descriptions: Record<string, Record<string, string>> = {
'Estimated current price': 'Inflációval korrigált becsült jelenlegi érték',
'Price per sqm': 'Eladási ár osztva az összes alapterülettel',
'Est. price per sqm': 'Becsült jelenlegi ár osztva az összes alapterülettel',
'Estimated monthly rent': 'A környék medián havi magánbérleti díja',
'Estimated monthly rent': 'A környék átlagos havi magánbérleti díja',
'Total floor area (sqm)': 'Az EPC felmérésből származó belső alapterület',
'Number of bedrooms & living rooms': 'Lakószobák száma az EPC felmérés alapján',
'Construction year': 'Becsült építési év az EPC alapján',
@ -285,6 +295,14 @@ const descriptions: Record<string, Record<string, string>> = {
'Ofsted által Jó vagy Kiváló minősítésű általános iskolák 5 km-en belül',
'Good+ secondary schools within 5km':
'Ofsted által Jó vagy Kiváló minősítésű középiskolák 5 km-en belül',
'Outstanding primary schools within 2km':
'Ofsted által Kiváló minősítésű általános iskolák 2 km-en belül',
'Outstanding secondary schools within 2km':
'Ofsted által Kiváló minősítésű középiskolák 2 km-en belül',
'Outstanding primary schools within 5km':
'Ofsted által Kiváló minősítésű általános iskolák 5 km-en belül',
'Outstanding secondary schools within 5km':
'Ofsted által Kiváló minősítésű középiskolák 5 km-en belül',
'Education, Skills and Training Score':
'A környék oktatási minőségi pontszáma (magasabb = jobb)',
'Income Score (rate)': 'Jövedelmi deprivációs ráta, invertálva (magasabb = kevésbé hátrányos)',
@ -322,12 +340,8 @@ const descriptions: Record<string, Record<string, string>> = {
'% East Asian': 'A kelet-ázsiaiként azonosított lakosság aránya',
'% Mixed': 'A vegyes vagy több etnikai csoporthoz tartozóként azonosított lakosság aránya',
'% Other': 'Az egyéb etnikai csoportba tartozóként azonosított lakosság aránya',
'Winning party':
'A 2024-es parlamenti választáson a választókerületben győztes párt',
'Voter turnout (%)':
'A regisztrált választók szavazási aránya a 2024-es parlamenti választáson',
'Majority (%)':
'Győzelmi előny az érvényes szavazatok százalékában a 2024-es parlamenti választáson',
'% Labour': 'A Munkáspárt szavazataránya a 2024-es parlamenti választáson',
'% Conservative': 'A Konzervatív Párt szavazataránya a 2024-es parlamenti választáson',
'% Liberal Democrat': 'A Liberális Demokraták szavazataránya a 2024-es parlamenti választáson',

View file

@ -18,7 +18,7 @@ export const details: Record<string, Record<string, string>> = {
'Est. price per sqm':
"Calculé en divisant le prix actuel estimé et ajusté à l'inflation (y compris toute prime de rénovation) par la surface habitable totale indiquée dans le certificat EPC. Fournit une comparaison prix/superficie plus actualisée que le prix au sqm basé sur le prix de vente historique.",
'Estimated monthly rent':
"Prix médian mensuel de location provenant des statistiques sommaires du marché locatif privé de l'ONS (octobre 2022 - septembre 2023), correspondant à l'autorité locale et au nombre de chambres. Basé sur les données de locations de l'Agence d'évaluation (Valuation Office Agency).",
"Prix moyen mensuel de location provenant de l'indice des loyers privés de l'ONS (PIPR), correspondant à l'autorité locale et au nombre de chambres.",
'Total floor area (sqm)':
"Surface habitable totale en mètres carrés telle que mesurée lors de l'évaluation du certificat de performance énergétique (EPC). Inclut toutes les pièces habitables mais exclut les garages, dépendances et espaces extérieurs.",
'Number of bedrooms & living rooms':
@ -45,6 +45,14 @@ export const details: Record<string, Record<string, string>> = {
"Écoles primaires financées par l'État dans un rayon de 5km ayant une note Ofsted actuelle de Bon ou Exceptionnel. Les écoles n'ayant pas encore été inspectées sont exclues.",
'Good+ secondary schools within 5km':
"Lycées et collèges financés par l'État dans un rayon de 5km ayant une note Ofsted actuelle de Bon ou Exceptionnel. Les établissements n'ayant pas encore été inspectés sont exclus.",
'Outstanding primary schools within 2km':
"Écoles primaires financées par l'État dans un rayon de 2km ayant une note Ofsted actuelle d'Exceptionnel. Les écoles n'ayant pas encore été inspectées sont exclues.",
'Outstanding secondary schools within 2km':
"Lycées et collèges financés par l'État dans un rayon de 2km ayant une note Ofsted actuelle d'Exceptionnel. Les établissements n'ayant pas encore été inspectés sont exclus.",
'Outstanding primary schools within 5km':
"Écoles primaires financées par l'État dans un rayon de 5km ayant une note Ofsted actuelle d'Exceptionnel. Les écoles n'ayant pas encore été inspectées sont exclues.",
'Outstanding secondary schools within 5km':
"Lycées et collèges financés par l'État dans un rayon de 5km ayant une note Ofsted actuelle d'Exceptionnel. Les établissements n'ayant pas encore été inspectés sont exclus.",
'Education, Skills and Training Score':
"Provient des Indices de Déprivation anglais (inversé afin que plus le score est élevé, meilleur est le résultat). Couvre les résultats scolaires, l'accès à l'enseignement supérieur, les qualifications des adultes et la maîtrise de la langue anglaise. Des scores plus élevés indiquent moins de déprivation.",
'Income Score (rate)':
@ -109,24 +117,20 @@ export const details: Record<string, Record<string, string>> = {
"Provient du Census 2021. Pourcentage de la population de l'autorité locale s'identifiant comme Mixte ou appartenant à plusieurs groupes ethniques (Blanc et Noir caribéen, Blanc et Noir africain, Blanc et Asiatique, ou tout autre fond mixte ou multiple).",
'% Other':
"Provient du Census 2021. Pourcentage de la population de l'autorité locale s'identifiant comme appartenant à un autre groupe ethnique (Arabe ou tout autre groupe ethnique non couvert par les catégories principales).",
'Winning party':
"Le parti politique qui a obtenu le plus de votes dans la circonscription couvrant ce code postal, lors des élections générales britanniques de juillet 2024. Basé sur les résultats au scrutin uninominal majoritaire publiés par le Parlement britannique. Les circonscriptions ont été redessinées pour 2024 selon la révision de la Commission des limites de 2023.",
'Voter turnout (%)':
"La proportion de l'électorat inscrit qui a voté de manière valide lors des élections générales britanniques de juillet 2024. Calculée comme le nombre de votes valides divisé par la taille de l'électorat. Une participation plus élevée est généralement corrélée avec des zones plus aisées et des scrutins plus serrés.",
'Majority (%)':
"La différence de voix entre le candidat vainqueur et le second, exprimée en pourcentage du total des votes valides. Une faible majorité indique un siège marginal (compétitif) ; une forte majorité indique un siège sûr. Provient des résultats des élections générales britanniques de juillet 2024 publiés par le Parlement britannique.",
'% Labour':
"Pourcentage des votes valides exprimés pour le Parti travailliste dans la circonscription couvrant ce code postal, lors des élections générales britanniques de juillet 2024. Comprend les votes de tous les candidats travaillistes.",
'Pourcentage des votes valides exprimés pour le Parti travailliste dans la circonscription couvrant ce code postal, lors des élections générales britanniques de juillet 2024. Comprend les votes de tous les candidats travaillistes.',
'% Conservative':
"Pourcentage des votes valides exprimés pour le Parti conservateur dans la circonscription couvrant ce code postal, lors des élections générales britanniques de juillet 2024.",
'Pourcentage des votes valides exprimés pour le Parti conservateur dans la circonscription couvrant ce code postal, lors des élections générales britanniques de juillet 2024.',
'% Liberal Democrat':
"Pourcentage des votes valides exprimés pour les Libéraux-démocrates dans la circonscription couvrant ce code postal, lors des élections générales britanniques de juillet 2024.",
'Pourcentage des votes valides exprimés pour les Libéraux-démocrates dans la circonscription couvrant ce code postal, lors des élections générales britanniques de juillet 2024.',
'% Reform UK':
"Pourcentage des votes valides exprimés pour Reform UK dans la circonscription couvrant ce code postal, lors des élections générales britanniques de juillet 2024.",
'Pourcentage des votes valides exprimés pour Reform UK dans la circonscription couvrant ce code postal, lors des élections générales britanniques de juillet 2024.',
'% Green':
"Pourcentage des votes valides exprimés pour le Parti vert dans la circonscription couvrant ce code postal, lors des élections générales britanniques de juillet 2024.",
'Pourcentage des votes valides exprimés pour le Parti vert dans la circonscription couvrant ce code postal, lors des élections générales britanniques de juillet 2024.',
'% Other parties':
"Pourcentage des votes valides exprimés pour des partis autres que Travailliste, Conservateur, Libéral-démocrate, Reform UK et Vert dans la circonscription couvrant ce code postal. Comprend les indépendants, le Président de la Chambre et les partis mineurs.",
'Pourcentage des votes valides exprimés pour des partis autres que Travailliste, Conservateur, Libéral-démocrate, Reform UK et Vert dans la circonscription couvrant ce code postal. Comprend les indépendants, le Président de la Chambre et les partis mineurs.',
'Distance to nearest park (km)':
"Distance à vol d'oiseau en kilomètres depuis le code postal jusqu'à l'entrée du parc la plus proche. Couvre les parcs publics, jardins, terrains de jeux et espaces de loisirs. Utilise les emplacements des points d'accès issus du jeu de données OS Open Greenspace, de sorte que les propriétés bordant un grand parc affichent correctement une courte distance.",
'Number of parks within 1km':
@ -154,7 +158,7 @@ export const details: Record<string, Record<string, string>> = {
'Est. price per sqm':
'Berechnet durch Division des inflationsbereinigten geschätzten aktuellen Preises (einschließlich etwaiger Renovierungsaufschläge) durch die Gesamtnutzfläche aus dem EPC-Zertifikat. Bietet einen aktuelleren Preis-pro-Fläche-Vergleich als der historische Verkaufspreis pro sqm.',
'Estimated monthly rent':
'Monatlicher Median-Mietpreis aus den ONS Private Rental Market Summary Statistics (Okt. 2022 Sep. 2023), abgeglichen nach Gemeinde und Zimmeranzahl. Basiert auf Vermietungsdaten der Valuation Office Agency.',
'Durchschnittlicher monatlicher Mietpreis aus dem ONS Price Index of Private Rents (PIPR), abgeglichen nach Gemeinde und Zimmeranzahl.',
'Total floor area (sqm)':
'Gesamte nutzbare Wohnfläche in Quadratmetern, gemessen während der Bewertung für das Energieausweis-Zertifikat. Umfasst alle Wohnräume, schließt jedoch Garagen, Nebengebäude und Außenbereiche aus.',
'Number of bedrooms & living rooms':
@ -181,6 +185,14 @@ export const details: Record<string, Record<string, string>> = {
'Staatlich geförderte Grundschulen innerhalb von 5 km mit einer aktuellen Ofsted-Bewertung von „Good" oder „Outstanding". Noch nicht inspizierte Schulen sind ausgeschlossen.',
'Good+ secondary schools within 5km':
'Staatlich geförderte weiterführende Schulen innerhalb von 5 km mit einer aktuellen Ofsted-Bewertung von „Good" oder „Outstanding". Noch nicht inspizierte Schulen sind ausgeschlossen.',
'Outstanding primary schools within 2km':
'Staatlich geförderte Grundschulen innerhalb von 2 km mit einer aktuellen Ofsted-Bewertung von „Outstanding". Noch nicht inspizierte Schulen sind ausgeschlossen.',
'Outstanding secondary schools within 2km':
'Staatlich geförderte weiterführende Schulen innerhalb von 2 km mit einer aktuellen Ofsted-Bewertung von „Outstanding". Noch nicht inspizierte Schulen sind ausgeschlossen.',
'Outstanding primary schools within 5km':
'Staatlich geförderte Grundschulen innerhalb von 5 km mit einer aktuellen Ofsted-Bewertung von „Outstanding". Noch nicht inspizierte Schulen sind ausgeschlossen.',
'Outstanding secondary schools within 5km':
'Staatlich geförderte weiterführende Schulen innerhalb von 5 km mit einer aktuellen Ofsted-Bewertung von „Outstanding". Noch nicht inspizierte Schulen sind ausgeschlossen.',
'Education, Skills and Training Score':
'Aus den englischen Deprivationsindizes (invertiert, sodass höher = besser bedeutet). Umfasst Schulleistungen, Hochschulzugang, Qualifikationen Erwachsener und Englischsprachkenntnisse. Höhere Werte weisen auf geringere Benachteiligung hin.',
'Income Score (rate)':
@ -245,12 +257,8 @@ export const details: Record<string, Record<string, string>> = {
'Aus dem Census 2021. Prozentsatz der Bevölkerung der Gemeinde, die sich als gemischt oder mit mehreren ethnischen Zugehörigkeiten identifiziert (Weiß und Schwarzkaribisch, Weiß und Schwarzafrikanisch, Weiß und Asiatisch oder sonstiger gemischter Hintergrund).',
'% Other':
'Aus dem Census 2021. Prozentsatz der Bevölkerung der Gemeinde, die sich als einer anderen ethnischen Gruppe zugehörig identifiziert (Arabisch oder eine andere ethnische Gruppe, die nicht von den Hauptkategorien abgedeckt wird).',
'Winning party':
'Die politische Partei, die im Wahlkreis dieser Postleitzahl bei der britischen Parlamentswahl im Juli 2024 die meisten Stimmen erhalten hat. Basierend auf den Ergebnissen des Mehrheitswahlrechts, veröffentlicht vom britischen Parlament. Die Wahlkreise wurden für 2024 nach der Überprüfung der Boundary Commission 2023 neu eingeteilt.',
'Voter turnout (%)':
'Der Anteil der registrierten Wahlberechtigten, die bei der britischen Parlamentswahl im Juli 2024 eine gültige Stimme abgegeben haben. Berechnet als gültige Stimmen geteilt durch die Größe der Wählerschaft. Eine höhere Wahlbeteiligung korreliert im Allgemeinen mit wohlhabenderen Gebieten und knapperen Ergebnissen.',
'Majority (%)':
'Die Stimmendifferenz zwischen dem Gewinner und dem Zweitplatzierten, ausgedrückt als Prozentsatz der gesamten gültigen Stimmen. Eine kleine Mehrheit weist auf einen umkämpften Wahlkreis hin; eine große Mehrheit auf einen sicheren Sitz. Aus den Ergebnissen der britischen Parlamentswahl vom Juli 2024, veröffentlicht vom britischen Parlament.',
'% Labour':
'Prozentsatz der gültigen Stimmen für die Labour Party im Wahlkreis dieser Postleitzahl bei der britischen Parlamentswahl im Juli 2024.',
'% Conservative':
@ -290,7 +298,7 @@ export const details: Record<string, Record<string, string>> = {
'Est. price per sqm':
'用经通胀调整的估算当前价格含装修溢价除以EPC证书中的总建筑面积计算得出。与历史成交价格每平方米相比提供更为最新的单位面积价格对比。',
'Estimated monthly rent':
'来自ONS私人租赁市场摘要统计2022年10月至2023年9月的月租金中位数按地方政府和卧室数量匹配。基于估价署租赁数据。',
'来自ONS私人租赁价格指数PIPR的平均月租金按地方政府和卧室数量匹配。',
'Total floor area (sqm)':
'在能源性能证书EPC评估期间测量的总可用建筑面积平方米。包括所有可居住房间但不含车库、附属建筑和外部区域。',
'Number of bedrooms & living rooms':
@ -317,6 +325,14 @@ export const details: Record<string, Record<string, string>> = {
'5km范围内Ofsted评级为"Good"或"Outstanding"的公立小学数量。尚未接受评估的学校不计入。',
'Good+ secondary schools within 5km':
'5km范围内Ofsted评级为"Good"或"Outstanding"的公立中学数量。尚未接受评估的学校不计入。',
'Outstanding primary schools within 2km':
'2km范围内Ofsted评级为"Outstanding"的公立小学数量。尚未接受评估的学校不计入。',
'Outstanding secondary schools within 2km':
'2km范围内Ofsted评级为"Outstanding"的公立中学数量。尚未接受评估的学校不计入。',
'Outstanding primary schools within 5km':
'5km范围内Ofsted评级为"Outstanding"的公立小学数量。尚未接受评估的学校不计入。',
'Outstanding secondary schools within 5km':
'5km范围内Ofsted评级为"Outstanding"的公立中学数量。尚未接受评估的学校不计入。',
'Education, Skills and Training Score':
'来自英格兰剥夺指数(取反后越高越好)。涵盖学校成绩、高等教育入学率、成人学历和英语水平。分数越高表示剥夺程度越低。',
'Income Score (rate)':
@ -379,22 +395,14 @@ export const details: Record<string, Record<string, string>> = {
'来自2021年Census。地方政府人口中认同为<E5908C><E4B8BA>血或多种族群体白人与黑人加勒比裔、白人与黑人非洲裔、白人与亚洲裔或其他混血或多种族背景的百分比。',
'% Other':
'来自2021年Census。地方政府人口中认同为其他族裔群体阿拉伯人或其他未被主要类别涵盖的族裔的百分比。',
'Winning party':
'在2024年7月英国大选中该邮编所属选区得票最多的政党。基于英国议会公布的简单多数制选举结果。选区根据2023年边界委员会审查进行了重新划分。',
'Voter turnout (%)':
'2024年7月英国大选中投出有效选票的登记选民比例。计算方式为有效票数除以选民总数。较高的投票率通常与较富裕地区和竞争更激烈的选举相关。',
'Majority (%)':
'获胜候选人与第二名之间的票数差距以有效投票总数的百分比表示。小的多数票表示边缘选区竞争激烈大的多数票表示安全席位。数据来自英国议会公布的2024年7月大选结果。',
'% Labour':
'2024年7月英国大选中该邮编所属选区投给工党的有效选票百分比。包括所有工党候选人的选票。',
'% Conservative':
'2024年7月英国大选中该邮编所属选区投给保守党的有效选票百分比。',
'% Liberal Democrat':
'2024年7月英国大选中该邮编所属选区投给自由民主党的有效选票百分比。',
'% Reform UK':
'2024年7月英国大选中该邮编所属选区投给英国改革党的有效选票百分比。',
'% Green':
'2024年7月英国大选中该邮编所属选区投给绿党的有效选票百分比。',
'% Conservative': '2024年7月英国大选中该邮编所属选区投给保守党的有效选票百分比。',
'% Liberal Democrat': '2024年7月英国大选中该邮编所属选区投给自由民主党的有效选票百分比。',
'% Reform UK': '2024年7月英国大选中该邮编所属选区投给英国改革党的有效选票百分比。',
'% Green': '2024年7月英国大选中该邮编所属选区投给绿党的有效选票百分比。',
'% Other parties':
'该选区中投给工党、保守党、自由民主党、英国改革党和绿党以外政党的有效选票百分比。包括独立候选人、议长和小型政党。',
'Distance to nearest park (km)':
@ -424,7 +432,7 @@ export const details: Record<string, Record<string, string>> = {
'Est. price per sqm':
'Az inflációval korrigált becsült aktuális árat (beleértve az esetleges felújítási prémiumot) az EPC tanúsítványból származó teljes alapterülettel elosztva számítják ki. Naprakészebb ár/terület összehasonlítást nyújt, mint a korábbi adásvételi ár per sqm.',
'Estimated monthly rent':
'Az ONS Magánbérleti Piaci Összefoglaló Statisztikákból (2022. október 2023. szeptember) származó medián havi bérleti díj, helyi hatóság és hálószobák száma szerint párosítva. A Valuation Office Agency bérbeadási adatain alapul.',
'Az ONS Price Index of Private Rents (PIPR) alapján számított átlagos havi bérleti díj, helyi hatóság és hálószobák száma szerint párosítva.',
'Total floor area (sqm)':
'Az Energy Performance Certificate felmérése során mért teljes hasznos alapterület négyzetméterben. Tartalmazza az összes lakható helyiséget, de kizárja a garázsokat, melléképületeket és külső területeket.',
'Number of bedrooms & living rooms':
@ -451,6 +459,14 @@ export const details: Record<string, Record<string, string>> = {
'5 km-en belüli állami fenntartású általános iskolák, amelyek aktuális Ofsted besorolása Jó vagy Kiemelkedő. A még nem vizsgált iskolák ki vannak zárva.',
'Good+ secondary schools within 5km':
'5 km-en belüli állami fenntartású középiskolák, amelyek aktuális Ofsted besorolása Jó vagy Kiemelkedő. A még nem vizsgált iskolák ki vannak zárva.',
'Outstanding primary schools within 2km':
'2 km-en belüli állami fenntartású általános iskolák, amelyek aktuális Ofsted besorolása Kiemelkedő. A még nem vizsgált iskolák ki vannak zárva.',
'Outstanding secondary schools within 2km':
'2 km-en belüli állami fenntartású középiskolák, amelyek aktuális Ofsted besorolása Kiemelkedő. A még nem vizsgált iskolák ki vannak zárva.',
'Outstanding primary schools within 5km':
'5 km-en belüli állami fenntartású általános iskolák, amelyek aktuális Ofsted besorolása Kiemelkedő. A még nem vizsgált iskolák ki vannak zárva.',
'Outstanding secondary schools within 5km':
'5 km-en belüli állami fenntartású középiskolák, amelyek aktuális Ofsted besorolása Kiemelkedő. A még nem vizsgált iskolák ki vannak zárva.',
'Education, Skills and Training Score':
'Az Angol Nélkülözési Indexekből (megfordítva, így magasabb = jobb). Az iskolai teljesítményt, a felsőoktatásba való bejutást, a felnőttkori képesítéseket és az angol nyelvi jártasságot foglalja magában. A magasabb pontszámok kisebb mértékű nélkülözést jeleznek.',
'Income Score (rate)':
@ -515,12 +531,8 @@ export const details: Record<string, Record<string, string>> = {
'A 2021-es Census alapján. A helyi hatóság területén vegyes vagy többes etnikai csoportként (fehér és fekete karibi, fehér és fekete afrikai, fehér és ázsiai, vagy bármely más vegyes vagy többes háttér) azonosított népesség százaléka.',
'% Other':
'A 2021-es Census alapján. A helyi hatóság területén egyéb etnikai csoportként (arab vagy bármely más, a főkategóriák által nem lefedett etnikai csoport) azonosított népesség százaléka.',
'Winning party':
'Az a politikai párt, amely a legtöbb szavazatot kapta az adott irányítószámhoz tartozó választókerületben a 2024. júliusi brit parlamenti választáson. Az Egyesült Királyság Parlamentje által közzétett, egyéni választókerületi rendszer szerinti eredmények alapján. A választókerületeket a 2023-as Határbizottsági felülvizsgálat alapján alakították át 2024-re.',
'Voter turnout (%)':
'A regisztrált szavazók azon aránya, akik érvényes szavazatot adtak le a 2024. júliusi brit parlamenti választáson. Az érvényes szavazatok száma osztva a választói névjegyzékben szereplők számával. A magasabb részvétel általában a tehetősebb területekkel és a szorosabb versenyekkel korrelál.',
'Majority (%)':
'A győztes jelölt és a második helyezett közötti szavazatkülönbség, az összes érvényes szavazat százalékában kifejezve. Kis többség billegő körzetre utal (versenyképes); nagy többség biztos körzetre. A 2024. júliusi brit parlamenti választás eredményeiből, amelyeket az Egyesült Királyság Parlamentje tett közzé.',
'% Labour':
'Az érvényes szavazatok százaléka, amelyeket a Munkáspártra adtak le az adott irányítószámhoz tartozó választókerületben a 2024. júliusi brit parlamenti választáson.',
'% Conservative':

View file

@ -94,7 +94,7 @@ const de: Translations = {
free: 'Kostenlos',
once: '/einmalig',
freeForEarly: 'Kostenlos für Frühnutzer. Keine Kreditkarte erforderlich.',
oneTimePayment: 'Einmalzahlung. Lebenslanger Zugang. 30 Tage Geld-zurück-Garantie.',
oneTimePayment: 'Einmalzahlung. Lebenslanger Zugang.',
redirecting: 'Weiterleitung...',
claimFreeAccess: 'Kostenlosen Zugang sichern',
upgradeFor: 'Upgrade für {{price}}',
@ -259,6 +259,16 @@ const de: Translations = {
areaStatistics: 'Gebietsstatistiken',
statsFor: 'Statistiken für alle Immobilien in diesem {{type}}',
matchingFilters: ', die allen aktiven Filtern entsprechen',
filtersAffectStats:
'Filter im linken Bereich werden hier angewendet: Werte, Diagramme und Immobilienzahlen nutzen die {{count}} aktiven Filter.',
noFiltersAffectStats:
'Filter im linken Bereich aktualisieren diesen Bereich: Fügen Sie Filter hinzu, um diese Werte für passende Immobilien neu zu berechnen.',
noFilteredMatches: 'Keine Immobilien in diesem Gebiet entsprechen Ihren Filtern.',
unfilteredAreaCount:
'{{count}} Immobilien gibt es hier vor den Filtern; der Ort ist gültig, wird aber herausgefiltert.',
noUnfilteredAreaProperties:
'In diesem ausgewählten Gebiet wurden auch vor den Filtern keine Immobilien gefunden.',
relaxFiltersHint: 'Lockern oder löschen Sie Filter, um Immobilien in diesem Gebiet zu sehen.',
viewProperties: '{{count}} Immobilien ansehen',
priceHistory: 'Preisentwicklung',
journeysFrom: 'Verbindungen ab {{label}}',
@ -283,6 +293,8 @@ const de: Translations = {
// ── Street View ────────────────────────────────────
streetView: {
title: 'Street View',
openLarge: 'Street View größer öffnen',
expandedTitle: 'Vergrößerte Street View',
},
// ── POI Pane ───────────────────────────────────────
@ -320,47 +332,105 @@ const de: Translations = {
// ── Home Page ──────────────────────────────────────
home: {
heroTitle1: 'Maximaler',
heroTitle2: 'Wert',
heroTitle3: 'Minimale Kompromisse.',
heroEyebrow: 'Für Käufer, die fragen: „Wo soll ich überhaupt suchen?“',
heroTitle1: 'Finden Sie die Postleitzahlen',
heroTitle2: 'die zu Ihrem Leben passen',
heroTitle3: 'Nicht nur die Gegenden, die Sie schon kennen.',
heroSubtitle:
'Auf Immobiliensuche? Mach aus deiner größten Investition deine klügste Entscheidung.',
'Von Londoner Stadtteilen über Pendlerorte bis zu regionalen Städten: England hat zu viele Orte, um sie einzeln zu recherchieren.',
heroDescription:
'So viele Möglichkeiten — die richtige Wahl kann überwältigend sein. Unsere interaktive Karte macht es einfach: Wähle deine Muss-Kriterien und sieh sofort die passenden Gebiete.',
exploreTheMap: 'Karte entdecken',
seeTheDifference: 'Den Unterschied sehen',
statProperties: 'Immobilien',
statFilters: 'Filter',
'Legen Sie Budget, Pendelzeit, Schulen, Sicherheit, Lärm, Breitband und Lebensstil fest. Perfect Postcode scannt Englands Postleitzahlen und zeigt Orte, die wirklich passen, auch Gegenden, die Sie nie in ein Immobilienportal eingegeben hätten.',
exploreTheMap: 'Passende Postleitzahlen finden',
seeTheDifference: 'So funktioniert es',
showcaseHeader: 'Produktvorschau',
showcaseContext: 'Käufersuche in ganz England',
showcaseStep1Tab: 'Beschreiben',
showcaseStep1Title: 'Beschreiben Sie das Leben, das Sie möchten',
showcaseStep1Body:
'Nutzen Sie natürliche Sprache oder Filter, um komplexe Kaufkriterien in eine Suche zu verwandeln.',
showcaseStep1Prompt:
'2 Schlafzimmer unter £525k, 45 Min. zur Arbeit, ruhige Straßen, gute Schulen',
showcaseStep1Chip1: '<= £525k',
showcaseStep1Chip2: '2+ Schlafzimmer',
showcaseStep1Chip3: '45 Min. Pendeln',
showcaseStep1Chip4: 'Wenig Straßenlärm',
showcaseStep2Tab: 'Entdecken',
showcaseStep2Title: 'Zeigen Sie Orte, die Sie nicht erwogen hatten',
showcaseStep2Body:
'Die Karte markiert passende Postleitzahlen, auch außerhalb Ihrer bisherigen Shortlist.',
showcaseStep2Metric: '47 passende Postleitzahlen',
showcaseStep2Note: 'jenseits der offensichtlichen Shortlist',
showcaseKnownAreas: 'Bekannte Gegenden',
showcaseNewMatches: 'Neue Treffer',
showcaseKnownAreaStatus: 'wenige Treffer',
showcaseStep3Tab: 'Prüfen',
showcaseStep3Title: 'Verstehen Sie, warum jede Postleitzahl passt',
showcaseStep3Body:
'Öffnen Sie einen Treffer und prüfen Sie die Belege, bevor Sie ein Wochenende für Besichtigungen opfern.',
showcaseStep3Postcode: 'Postleitzahl-Beispiel',
showcaseStep3Area: 'Penge',
showcaseStep3Code: 'SE20',
showcaseStep3Score: 'Starker Fit',
showcaseEvidence1: '42 Min. Pendelzeit',
showcaseEvidence2: 'Weniger Straßenlärm',
showcaseEvidence3: 'Gute Grundschuloptionen',
showcaseEvidence4: 'Verkaufspreise im Budget',
showcaseStep4Tab: 'Vergleichen',
showcaseStep4Title: 'Kompromisse vor Besichtigungen vergleichen',
showcaseStep4Body:
'Erstellen Sie eine Shortlist danach, was Sie gewinnen und aufgeben, nicht nur nach Ruf.',
showcaseCompare1: 'Penge: Londoner Bahnanschluss, mehr Platz',
showcaseCompare2: 'Totterdown: fußläufige Straßen in Bristol',
showcaseCompare3: 'Walkley: größere Häuser, guter Gegenwert',
showcaseMapLabel: 'Passende Postleitzahlen',
showcaseSaveLabel: 'Shortlist bereit',
showcaseMatchPenge: 'London im Budget',
showcaseMatchAbbeyWood: 'Elizabeth line + Grünflächen',
showcaseMatchTotterdown: 'Bristol gut zu Fuß',
showcaseMatchWalkley: 'Sheffield: Platz + Schulen',
statProperties: 'historische Verkäufe',
statFilters: 'kombinierbare Filter',
statEvery: 'Jede',
statPostcodeInEngland: 'Postleitzahl in England',
ourPhilosophy: 'Unsere Philosophie',
ourPhilosophy: 'Beginnen Sie mit Ihrem Leben, nicht mit einer Postleitzahl',
philosophyP1:
'Auf Rightmove wählt man zuerst ein Gebiet und hofft, dass es gut ist. Am Ende vergleicht man Kriminalitätsstatistiken, Schulberichte und Breitband-Checker in einem Dutzend Tabs, eine Postleitzahl nach der anderen.',
'Die meisten Immobilienseiten fragen, wo Sie wohnen möchten. In London ist das besonders schwierig, aber das gleiche Problem gibt es in ganz England: Käufer starten mit wenigen bekannten Orten und prüfen dann Pendelzeit, Schulen, Kriminalität, Street View, Breitband und Verkaufspreise in getrennten Tabs.',
philosophyP2:
'Wir drehen das um. Sag uns, was du brauchst (Budget, Pendelweg, Schulen, Sicherheit), und wir zeigen dir jedes Gebiet in England, das passt. Kein Raten. Keine verschwendeten Besichtigungen.',
'Perfect Postcode dreht die Suche um. Sagen Sie der Karte, was zählt, und sie zeigt passende Postleitzahlen mit nachvollziehbaren Gründen. Erst Daten, dann vor Ort das Gefühl prüfen.',
streetTitle: 'Orte ändern sich von Straße zu Straße',
streetIntro:
'Große Gebietsnamen verdecken die Details, die zählen: Bahnhofsseite, Straßenlärm, Schulmix, genaue Pendelzeit und echte Verkaufspreise.',
streetCard1Title: 'Finden Sie Gegenden, die Sie übersehen hätten',
streetCard1Body:
'Entdecken Sie Postleitzahlen, die Ihren Anforderungen entsprechen, statt sich nur auf bekannte Namen, Empfehlungen oder Hype zu verlassen.',
streetCard2Title: 'Sehen Sie Kompromisse vor Besichtigungen',
streetCard2Body:
'Vergleichen Sie Preis, Platz, Pendelzeit, Sicherheit, Schulen, Breitband, Lärm und Energieeffizienz, bevor Sie Wochenenden mit Besichtigungen verbringen.',
howToUseIt: 'So funktioniert es',
howStep1Title: 'Lege deine Muss-Kriterien fest',
howStep1Desc: 'Budget, Pendelweg, Schulen — die Karte zeigt nur, was passt.',
howStep2Title: 'Entdecke Gebiete und versteckte Perlen',
howStep2Desc: 'Zoom rein, schau dir Details und Kann-Kriterien an.',
howStep3Title: 'Einzelne Postleitzahlen erkunden',
howStep3Desc: 'Sieh einzelne Immobilien, Verkaufspreise, Wohnflächen und vergleiche.',
howStep4Title: 'Engere Auswahl mit Zuversicht',
howStep1Title: 'Beschreiben Sie das Leben, das Sie brauchen',
howStep1Desc: 'Budget, Pendelzeit, Immobilientyp, Schulen, Sicherheit, Platz und Alltag.',
howStep2Title: 'Passende Postleitzahlen anzeigen',
howStep2Desc: 'Die Karte markiert Orte, die Ihre Filter erfüllen, auch unbekanntere Gegenden.',
howStep3Title: 'Die Belege prüfen',
howStep3Desc:
'Prüfen Sie Verkaufspreise, Wohnfläche, EPC, Straßenlärm, Breitband, Kriminalität und Schulen.',
howStep4Title: 'Shortlist vor der Listingsuche',
howStep4Desc:
'Jedes Gebiet auf deiner Liste erfüllt deine tatsächlichen Kriterien — nicht nur, was diese Woche inseriert war.',
'Gehen Sie mit besseren Suchgebieten zu Rightmove, Zoopla, Maklern und Besichtigungen.',
othersVs: 'Andere vs',
checkMyPostcode: '„Meine Postleitzahl prüfen“',
areaGuides: 'Gebietsratgeber',
compSearchWithout: 'Suchen, ohne zuerst ein Gebiet auszuwählen',
compSearchWithoutSub: '(starte mit Bedürfnissen, nicht mit einem Ort)',
compAreaData: 'Gebietsdaten',
compAreaDataSub: '(Kriminalität, Schulen, Lärm, Breitband)',
compPropertyData: 'Immobilienspezifische Daten',
compPropertyDataSub: '(Preis, EPC, Wohnfläche)',
compFilters: '56 kombinierbare Filter an einem Ort',
compFiltersSub: '(alle Einblicke, eine interaktive Karte)',
ctaTitle: 'Mach aus deiner größten Investition deine klügste Entscheidung.',
ctaDescription: 'Das verdient die richtigen Werkzeuge — überlass es nicht dem Zufall.',
checkMyPostcode: 'Immobilienportale',
areaGuides: 'Postleitzahl-Berichte',
compSearchWithout: 'Gegenden entdecken, bevor Sie die Namen kennen',
compSearchWithoutSub: '(erst Anforderungen, dann Ort)',
compAreaData: 'Nachbarschaftsdaten auf Postleitzahl-Ebene',
compAreaDataSub: '(Kriminalität, Schulen, Lärm, Breitband, Ausstattung)',
compPropertyData: 'Historie auf Immobilienebene',
compPropertyDataSub: '(Verkaufspreise, EPC, Wohnfläche, Schätzwert)',
compFilters: '56 Filter, die zusammenarbeiten',
compFiltersSub: '(nicht eine Postleitzahl oder ein Listing nach dem anderen)',
ctaTitle: 'Hören Sie auf zu raten, wo Sie kaufen sollen.',
ctaDescription:
'Erstellen Sie eine Shortlist von Postleitzahlen, die zu Ihrem echten Leben passen, und prüfen Sie sie dann vor Ort.',
},
// ── Pricing Page ───────────────────────────────────
@ -382,7 +452,7 @@ const de: Translations = {
getStarted: 'Jetzt starten',
getStartedPrice: 'Jetzt starten — {{price}}',
noCreditCard: 'Keine Kreditkarte erforderlich',
moneyBackGuarantee: '30 Tage Geld-zurück-Garantie',
soldOut: 'Ausverkauft',
upcoming: 'Demnächst',
failedToLoad: 'Preise konnten nicht geladen werden. Bitte später erneut versuchen.',
@ -446,6 +516,10 @@ const de: Translations = {
dsOsmOrigin: 'OpenStreetMap contributors / Geofabrik',
dsOsmUse:
'Sehenswürdigkeiten und Einrichtungen wie Geschäfte, Restaurants, Gesundheitseinrichtungen, Freizeit, Tourismus und mehr in ganz Großbritannien.',
dsGeolytixRetailName: 'GEOLYTIX Grocery Retail Points',
dsGeolytixRetailOrigin: 'GEOLYTIX',
dsGeolytixRetailUse:
'Supermarkt- und Convenience-Store-Standorte im Vereinigten Königreich, darunter Ketten wie Waitrose, Tesco, Sainsburys, Asda, Morrisons, Aldi, Lidl, Co-op, M&S, Iceland und Spar.',
dsGreenspaceName: 'OS Open Greenspace',
dsGreenspaceOrigin: 'Ordnance Survey',
dsGreenspaceUse:
@ -477,7 +551,7 @@ const de: Translations = {
dsElectionName: 'Ergebnisse der Parlamentswahl 2024',
dsElectionOrigin: 'Britisches Parlament',
dsElectionUse:
'Ergebnisse auf Kandidatenebene der britischen Parlamentswahl vom Juli 2024. Aggregiert auf Wahlkreisebene: siegreiche Partei, Wahlbeteiligung (%) und Mehrheit (%). Über den Wahlkreiscode (pcon) aus dem NSPL-Postleitzahlenverzeichnis mit Immobilien verknüpft.',
'Ergebnisse auf Kandidatenebene der britischen Parlamentswahl vom Juli 2024. Aggregiert auf Wahlkreisebene: Wahlbeteiligung (%) und Parteistimmenanteile (%). Über den Wahlkreiscode (pcon) aus dem NSPL-Postleitzahlenverzeichnis mit Immobilien verknüpft.',
// FAQ section titles
faqFindingTitle: 'Ihr Gebiet finden',
faqCommuteTitle: 'Pendelweg und Reisezeit',
@ -562,9 +636,7 @@ const de: Translations = {
faqPricing3Q: 'Was kann ich mit der kostenlosen Version nutzen?',
faqPricing3A:
'Kostenlose Nutzer können alle Funktionen im Demogebiet erkunden (Innenstadt London, ungefähr Zonen 1 bis 2). Für den Zugang zu Daten für den Rest Englands benötigen Sie den lebenslangen Zugang.',
faqPricing4Q: 'Kann ich eine Rückerstattung erhalten?',
faqPricing4A:
'Selbstverständlich. Wir bieten eine 30-Tage-Geld-zurück-Garantie. Wenn Sie nicht zufrieden sind, schreiben Sie innerhalb von 30 Tagen an support@perfect-postcode.co.uk für eine vollständige Rückerstattung.',
// FAQ items — Tips and Tricks
faqTips1Q: 'Wie nutze ich den KI-Filter, anstatt Filter einzeln hinzuzufügen?',
faqTips1A:
@ -744,6 +816,12 @@ const de: Translations = {
'Good+ secondary schools within 2km': 'Gute+ weiterführende Schulen im Umkreis von 2 km',
'Good+ primary schools within 5km': 'Gute+ Grundschulen im Umkreis von 5 km',
'Good+ secondary schools within 5km': 'Gute+ weiterführende Schulen im Umkreis von 5 km',
'Outstanding primary schools within 2km': 'Hervorragende Grundschulen im Umkreis von 2 km',
'Outstanding secondary schools within 2km':
'Hervorragende weiterführende Schulen im Umkreis von 2 km',
'Outstanding primary schools within 5km': 'Hervorragende Grundschulen im Umkreis von 5 km',
'Outstanding secondary schools within 5km':
'Hervorragende weiterführende Schulen im Umkreis von 5 km',
'Education, Skills and Training Score': 'Score für Bildung, Kompetenzen und Ausbildung',
// ─ Feature names (Deprivation) ─
@ -786,9 +864,7 @@ const de: Translations = {
'% Other': '% Sonstige',
// ─ Feature names (Politics) ─
'Winning party': 'Siegreiche Partei',
'Voter turnout (%)': 'Wahlbeteiligung (%)',
'Majority (%)': 'Mehrheit (%)',
'% Labour': '% Labour',
'% Conservative': '% Conservative',
'% Liberal Democrat': '% Liberal Democrat',
@ -806,12 +882,6 @@ const de: Translations = {
'Max available download speed (Mbps)': 'Max. verfügbare Downloadgeschwindigkeit (Mbps)',
// ─ Enum values ─
Labour: 'Labour',
Conservative: 'Conservative',
'Liberal Democrat': 'Liberal Democrat',
'Reform UK': 'Reform UK',
Green: 'Grüne',
'Other parties': 'Sonstige Parteien',
Detached: 'Freistehend',
'Semi-Detached': 'Doppelhaushälfte',
Terraced: 'Reihenhaus',
@ -826,6 +896,7 @@ const de: Translations = {
'Serious crime': 'Schwere Straftaten',
'Minor crime': 'Leichte Straftaten',
'Ethnic composition': 'Ethnische Zusammensetzung',
'Political vote share': 'Stimmenverteilung',
// ─ POI group names ─
'Public Transport': 'Öffentlicher Nahverkehr',

View file

@ -70,7 +70,7 @@ const en = {
logIn: 'Log in',
createAccount: 'Create account',
resetPassword: 'Reset password',
valueProp: 'Save searches, bookmark properties, and pick up where you left off.',
valueProp: 'Save searches, bookmark properties, and build a shortlist of areas that fit.',
continueWithGoogle: 'Continue with Google',
email: 'Email',
emailPlaceholder: 'you@example.com',
@ -86,13 +86,13 @@ const en = {
// ── Upgrade Modal ──────────────────────────────────
upgrade: {
title: 'See all of England',
title: 'Find every matching postcode',
description:
"Youre currently exploring the demo area. Get lifetime access to every postcode, every filter, every neighbourhood. One payment, forever.",
'Youre currently exploring the demo area. Get lifetime access to every postcode, every filter, and every neighbourhood in England. One payment, forever.',
free: 'Free',
once: '/once',
freeForEarly: 'Free for early adopters. No credit card required.',
oneTimePayment: 'One-time payment. Lifetime access. 30-day money-back guarantee.',
oneTimePayment: 'One-time payment. Lifetime access.',
redirecting: 'Redirecting...',
claimFreeAccess: 'Claim free access',
upgradeFor: 'Upgrade for {{price}}',
@ -115,7 +115,7 @@ const en = {
// ── License Success ────────────────────────────────
licenseSuccess: {
title: "Youre in.",
title: 'Youre in.',
subtitle: 'Your lifetime access is now active.',
description: 'Full access to every feature, every postcode, across all of England.',
startExploring: 'Start exploring',
@ -128,7 +128,7 @@ const en = {
findingPerfectPostcode: 'Finding the Perfect Postcode',
addFiltersHint: 'Add filters below to narrow the map to areas that match your criteria',
upgradePrompt:
'See crime, schools, noise, broadband, and 50+ more filters across all of England.',
'Find matching postcodes using crime, schools, noise, broadband, prices, and 50+ more filters across England.',
oneTimeLifetime: 'One-time payment, lifetime access.',
upgradeToFullMap: 'Upgrade to full map',
chooseFilters: 'Choose the filters that matter to you. The map updates as you go.',
@ -184,7 +184,7 @@ const en = {
modeCar: 'Car',
modeBicycle: 'Bicycle',
modeWalking: 'Walking',
modeTransit: 'Transit',
modeTransit: 'Public Transport',
modeCarDesc: 'Drive time via the fastest road route',
modeBicycleDesc: 'Cycling time using bike-friendly routes',
modeWalkingDesc: 'Walking time along pedestrian paths and pavements',
@ -204,19 +204,19 @@ const en = {
// ── AI Filter ──────────────────────────────────────
aiFilter: {
describeIdealArea: 'Describe your ideal area with AI',
describeIdealArea: 'Describe where you want to live',
aiSearch: 'AI Search',
describeHint: "describe what youre looking for",
placeholder: 'e.g. quiet area, under £400k, near good schools...',
example1: 'House 40 mins from Bank in a low crime area',
example2: 'Flats around good primary schools not too far from Manchester',
example3: 'Best ex-council houses under 200k',
describeHint: 'describe what youre looking for',
placeholder: 'e.g. 2-bed under £525k, 45 mins to work, quiet...',
example1: '2-bed under £525k, 45 mins to work',
example2: 'Family areas near good schools under £650k',
example3: 'More space with a sane commute',
analysing: 'Analysing your query...',
searchingDestinations: 'Searching for destinations...',
generatingFilters: 'Generating filters...',
refiningResults: 'Refining results...',
weeklyLimitReached:
"Youve reached the weekly AI usage limit. It will reset automatically next week.",
'Youve reached the weekly AI usage limit. It will reset automatically next week.',
},
// ── Map Legend ─────────────────────────────────────
@ -256,6 +256,15 @@ const en = {
areaStatistics: 'Area Statistics',
statsFor: 'Stats for all properties in this {{type}}',
matchingFilters: ' matching all active filters',
filtersAffectStats:
'Left-pane filters are applied here: values, charts, and property counts use the {{count}} active filters.',
noFiltersAffectStats:
'Left-pane filters update this pane: add filters to recalculate these values for matching properties.',
noFilteredMatches: 'No properties match your filters in this area.',
unfilteredAreaCount:
'{{count}} properties exist here before filters, so the location is valid but filtered out.',
noUnfilteredAreaProperties: 'No properties were found in this selected area before filters.',
relaxFiltersHint: 'Relax or clear filters to see properties in this area.',
viewProperties: 'View {{count}} Properties',
priceHistory: 'Price History',
journeysFrom: 'Journeys from {{label}}',
@ -280,6 +289,8 @@ const en = {
// ── Street View ────────────────────────────────────
streetView: {
title: 'Street View',
openLarge: 'Open Street View larger',
expandedTitle: 'Expanded Street View',
},
// ── POI Pane ───────────────────────────────────────
@ -287,7 +298,7 @@ const en = {
pois: 'POIs',
pointsOfInterest: 'Points of Interest',
poiDescription:
'Sourced from OpenStreetMap. Covers public transport stops, shops, restaurants, healthcare, leisure, and more. Updated regularly with complete category coverage.',
'Sourced from OpenStreetMap, NaPTAN, and GEOLYTIX Grocery Retail Points. Covers transport stops, shops, chain supermarkets, restaurants, healthcare, leisure, and more.',
searchCategories: 'Search categories...',
dataSourceInfo: 'Data source info',
},
@ -317,55 +328,113 @@ const en = {
// ── Home Page ──────────────────────────────────────
home: {
heroTitle1: 'Maximum',
heroTitle2: 'Value',
heroTitle3: 'Minimum Compromise.',
heroSubtitle: 'House hunting? Make your biggest investment your smartest move.',
heroEyebrow: 'For buyers asking “where should I even look?”',
heroTitle1: 'Find the postcodes',
heroTitle2: 'that fit your life',
heroTitle3: 'Not just the areas you already know.',
heroSubtitle:
'From London boroughs to commuter towns and regional cities, England has too many places to research one by one.',
heroDescription:
'So many options - choosing the right one can feel overwhelming. Our interactive map makes it simple: select your must-haves and instantly see the areas that fit.',
exploreTheMap: 'Explore the map',
seeTheDifference: 'See the difference',
statProperties: 'properties',
statFilters: 'filters',
'Set your budget, commute, schools, safety, noise, broadband, and lifestyle needs. Perfect Postcode scans Englands postcodes and reveals the places that actually fit, including areas you would never have typed into a listing portal.',
exploreTheMap: 'Find my matching postcodes',
seeTheDifference: 'See how it works',
showcaseHeader: 'Product showcase',
showcaseContext: 'England-wide buyer search',
showcaseStep1Tab: 'Describe',
showcaseStep1Title: 'Describe the life you want',
showcaseStep1Body:
'Use natural language or filters to turn messy buyer criteria into one search.',
showcaseStep1Prompt: '2-bed under £525k, 45 mins to work, quiet streets, good schools',
showcaseStep1Chip1: '<= £525k',
showcaseStep1Chip2: '2+ beds',
showcaseStep1Chip3: '45 min commute',
showcaseStep1Chip4: 'Low road noise',
showcaseStep2Tab: 'Discover',
showcaseStep2Title: 'Reveal places you had not considered',
showcaseStep2Body:
'The map lights up matching postcodes, including areas outside your usual shortlist.',
showcaseStep2Metric: '47 matching postcodes',
showcaseStep2Note: 'beyond the obvious shortlist',
showcaseKnownAreas: 'Known areas',
showcaseNewMatches: 'New matches',
showcaseKnownAreaStatus: 'few matches',
showcaseStep3Tab: 'Check',
showcaseStep3Title: 'Understand why each postcode fits',
showcaseStep3Body:
'Open a result and check the evidence before you give up a weekend for viewings.',
showcaseStep3Postcode: 'Postcode example',
showcaseStep3Area: 'Penge',
showcaseStep3Code: 'SE20',
showcaseStep3Score: 'Strong fit',
showcaseEvidence1: '42 min commute',
showcaseEvidence2: 'Lower road noise',
showcaseEvidence3: 'Good primary options',
showcaseEvidence4: 'Sold prices in budget',
showcaseStep4Tab: 'Compare',
showcaseStep4Title: 'Compare trade-offs before viewings',
showcaseStep4Body: 'Shortlist areas by what you gain and give up, not by reputation alone.',
showcaseCompare1: 'Penge: London rail links, more space',
showcaseCompare2: 'Totterdown: walkable Bristol streets',
showcaseCompare3: 'Walkley: larger homes, strong value',
showcaseMapLabel: 'Matching postcodes',
showcaseSaveLabel: 'Shortlist ready',
showcaseMatchPenge: 'London budget fit',
showcaseMatchAbbeyWood: 'Elizabeth line + green space',
showcaseMatchTotterdown: 'Bristol walkability',
showcaseMatchWalkley: 'Sheffield space + schools',
statProperties: 'historical sales',
statFilters: 'combinable filters',
statEvery: 'Every',
statPostcodeInEngland: 'postcode in England',
ourPhilosophy: 'Our philosophy',
ourPhilosophy: 'Start with your life, not a postcode',
philosophyP1:
"On Rightmove, you pick an area first, then hope its good. You end up cross-referencing crime stats, school reports, and broadband checkers across a dozen tabs, one postcode at a time.",
'Most property sites ask where you want to live. In London that is painfully hard, but the same problem shows up across England: buyers choose from the few places they know, then cross-check commute tools, Ofsted, police data, Street View, broadband checkers, and sold prices in separate tabs.',
philosophyP2:
'We flip that. Tell us what you need (budget, commute, schools, safety) and we show you every area in England that qualifies. No guesswork. No wasted viewings.',
'Perfect Postcode flips the search. Tell the map what matters and it shows the postcodes that qualify, with evidence for why they are worth inspecting. Data first, then go test the vibe.',
streetTitle: 'Places change street by street',
streetIntro:
'Broad area names hide the details that matter: the station side, the road noise, the school mix, the exact commute, and what similar homes actually sold for.',
streetCard1Title: 'Find areas you may have missed',
streetCard1Body:
'Surface postcodes that match your requirements instead of relying on familiar names, friend recommendations, or “up-and-coming” hype.',
streetCard2Title: 'See the trade-offs before viewings',
streetCard2Body:
'Compare price, space, commute, safety, schools, broadband, noise, and energy ratings before you spend weekends travelling between viewings.',
howToUseIt: 'How to use it',
howStep1Title: 'Set your must-haves',
howStep1Desc: 'Budget, commute, schools — the map shows only what qualifies.',
howStep2Title: 'Explore areas and discover hidden gems',
howStep2Desc: 'Zoom in, dig into details and nice to haves.',
howStep3Title: 'Drill into postcodes',
howStep3Desc: 'See individual properties, sale prices, floor area, and compare.',
howStep4Title: 'Shortlist with confidence',
howStep4Desc:
'Every area on your list meets your actual criteria — not just what was listed that week.',
howStep1Title: 'Describe the life you need',
howStep1Desc: 'Budget, commute, property type, schools, safety, space, and daily essentials.',
howStep2Title: 'Reveal matching postcodes',
howStep2Desc:
'The map highlights the places that pass your filters, including unfamiliar areas.',
howStep3Title: 'Check the evidence',
howStep3Desc:
'Inspect sold prices, floor area, EPC, road noise, broadband, crime, and schools.',
howStep4Title: 'Shortlist before you browse listings',
howStep4Desc: 'Take a better search area to Rightmove, Zoopla, agents, and viewings.',
othersVs: 'Others vs',
checkMyPostcode: '“Check my postcode”',
areaGuides: 'Area guides',
compSearchWithout: 'Search without choosing an area first',
compSearchWithoutSub: '(start with needs, not a location)',
compAreaData: 'Area data',
compAreaDataSub: '(crime, schools, noise, broadband)',
compPropertyData: 'Property-specific data',
compPropertyDataSub: '(price, EPC, floor area)',
compFilters: '56 combinable filters in one place',
compFiltersSub: '(all insights, one interactive map)',
ctaTitle: 'Make your biggest investment your smartest move.',
ctaDescription: "This deserves proper tools behind it, dont leave it to luck.",
checkMyPostcode: 'Listing portals',
areaGuides: 'Postcode reports',
compSearchWithout: 'Discover areas before you know their names',
compSearchWithoutSub: '(requirements first, location second)',
compAreaData: 'Postcode-level neighbourhood evidence',
compAreaDataSub: '(crime, schools, noise, broadband, amenities)',
compPropertyData: 'Property-level history',
compPropertyDataSub: '(sold prices, EPC, floor area, estimated value)',
compFilters: '56 filters working together',
compFiltersSub: '(not one postcode or one listing at a time)',
ctaTitle: 'Stop guessing where to buy.',
ctaDescription:
'Build a shortlist of postcodes that fit your actual life, then test them in person.',
},
// ── Pricing Page ───────────────────────────────────
pricingPage: {
title: 'Early access pricing',
subtitle: 'Pay once, access forever. The earlier you join, the less you pay.',
title: 'Buy with a better search area',
subtitle:
'Lifetime access to the map that helps you find where to look before you book viewings.',
costContext:
"Buying a home costs £10k+ in stamp duty, £1,500 in solicitor fees, £500 for a survey. Get the wrong area and youre stuck with a long commute, bad schools, or a road you didnt know about.",
lessThanSurvey: 'Less than a home survey. Far more useful.',
'Buyers often spend evenings stitching together listings, commute checks, school reports, crime maps, Street View, and sold prices. In London this is relentless, but the same research problem appears across England. Perfect Postcode puts the area research on one map before you commit your weekends, fees, and attention.',
lessThanSurvey: 'Less than a survey. Useful before you even choose an area.',
currentTier: 'Current tier',
firstNUsers: 'First {{count}} users',
everyoneAfter: 'Everyone after',
@ -378,15 +447,15 @@ const en = {
getStarted: 'Get started',
getStartedPrice: 'Get started - {{price}}',
noCreditCard: 'No credit card required',
moneyBackGuarantee: '30-day money-back guarantee',
soldOut: 'Sold out',
upcoming: 'Upcoming',
failedToLoad: 'Failed to load pricing. Please try again later.',
feat1: '56 data layers across England',
feat2: 'Every postcode scored and filterable',
feat3: 'Unlimited map exploration and exports',
feat4: 'Multiple decades of historical price data',
feat5: 'Crime, schools, transport, broadband and more',
feat1: '56 filters across England',
feat2: 'Every postcode searchable from your needs',
feat3: 'Unlimited map exploration, saved searches and exports',
feat4: '13M historical transactions and price context',
feat5: 'Commute, schools, crime, noise, broadband and more',
feat6: 'All future data updates included',
},
@ -398,7 +467,7 @@ const en = {
dataSourcesIntro:
'This application combines {{count}} open datasets covering property prices, energy performance, transport, demographics, crime, environment, and more.',
faqIntro:
"Whether youre buying, renting, or just exploring, heres how Perfect Postcode helps you find the right area.",
'Whether youre buying in London, comparing commuter towns, or sanity-checking an unfamiliar postcode, heres how Perfect Postcode helps you work out where to look.',
supportIntro: 'Have a question? Check our FAQ or reach out to us directly.',
source: 'Source:',
optOut: 'Opt out of public disclosure',
@ -440,6 +509,10 @@ const en = {
dsOsmOrigin: 'OpenStreetMap contributors / Geofabrik',
dsOsmUse:
'Points of interest covering shops, restaurants, healthcare, leisure, tourism, and more across Great Britain.',
dsGeolytixRetailName: 'GEOLYTIX Grocery Retail Points',
dsGeolytixRetailOrigin: 'GEOLYTIX',
dsGeolytixRetailUse:
'Supermarket and convenience store locations across the UK, including chain retailers such as Waitrose, Tesco, Sainsburys, Asda, Morrisons, Aldi, Lidl, Co-op, M&S, Iceland, and Spar.',
dsGreenspaceName: 'OS Open Greenspace',
dsGreenspaceOrigin: 'Ordnance Survey',
dsGreenspaceUse:
@ -471,7 +544,7 @@ const en = {
dsElectionName: '2024 General Election Results',
dsElectionOrigin: 'UK Parliament',
dsElectionUse:
'Candidate-level results for the July 2024 UK General Election. Aggregated to constituency level: winning party, voter turnout (%), and majority (%). Joined to properties via the parliamentary constituency code (pcon) from the NSPL postcode lookup.',
'Candidate-level results for the July 2024 UK General Election. Aggregated to constituency level: voter turnout (%) and party vote shares (%). Joined to properties via the parliamentary constituency code (pcon) from the NSPL postcode lookup.',
// FAQ section titles
faqFindingTitle: 'Finding Your Area',
faqCommuteTitle: 'Commute and Travel',
@ -483,29 +556,29 @@ const en = {
faqPricingTitle: 'Pricing and Access',
faqTipsTitle: 'Tips and Tricks',
// FAQ items — Finding Your Area
faqFinding1Q: "I dont even know which areas to look at. Can this help?",
faqFinding1Q: 'I dont even know which areas to look at. Can this help?',
faqFinding1A:
'That\'s exactly what it\'s for. Set your filters (budget, commute time, low crime, good schools) and the map lights up to show you every area that ticks every box. No more Googling "best areas to live near Manchester" at midnight.',
faqFinding2Q: "Im moving somewhere Ive never been. How do I even start?",
'That is exactly what it is for. Set your filters (budget, commute time, low crime, good schools, broadband, road noise) and the map lights up to show every postcode that fits. You can discover areas before you know their names.',
faqFinding2Q: 'Im moving somewhere Ive never been. How do I even start?',
faqFinding2A:
'Set your filters for what matters and the map instantly highlights the areas that qualify. You go from "I don\'t know a single street" to a shortlist in minutes.',
'Set what matters and the map highlights the postcodes that qualify. You go from "I do not know a single street" to a shortlist you can inspect in minutes.',
faqFinding3Q: 'How do I find areas that tick all my boxes at once?',
faqFinding3A:
'Stack multiple filters (crime below average, good schools, commute under 40 minutes) then colour the map by price to spot the best value areas. The map updates live as you drag sliders, so you can see results change in real time.',
// FAQ items — Commute and Travel
faqCommute1Q: 'Can I see how long my commute would actually be from different areas?',
faqCommute1A:
"Set your workplace as a destination and well colour every postcode by journey time, whether thats by car, bike, or public transport. Filter to your max commute and the rest disappears.",
'Set your workplace as a destination and well colour every postcode by journey time, whether thats by car, bike, or public transport. Filter to your max commute and the rest disappears.',
faqCommute2Q: 'How is that better than checking Google Maps?',
faqCommute2A:
'Google Maps shows you one journey at a time. We colour every postcode in England by commute time in one go, so you can compare hundreds of areas side by side instead of searching them one by one.',
// FAQ items — Budget and Value
faqBudget1Q: 'How do I find areas where I get the most space for my money?',
faqBudget1A:
"Filter by price per sqm and youll instantly see which postcodes give you the most space per pound. Pair it with the energy rating filter to avoid properties with high heating costs.",
faqBudget2Q: "How do I make sure a cheap area isnt cheap for a reason?",
'Filter by price per sqm and youll instantly see which postcodes give you the most space per pound. Pair it with the energy rating filter to avoid properties with high heating costs.',
faqBudget2Q: 'How do I make sure a cheap area isnt cheap for a reason?',
faqBudget2A:
"Layer deprivation scores, crime stats, school ratings, and broadband speeds alongside price. If a postcode is affordable and scores well on everything that matters, youve found genuine value, not just a low price with trade-offs you havent spotted yet.",
'Layer deprivation scores, crime stats, school ratings, and broadband speeds alongside price. If a postcode is affordable and scores well on everything that matters, youve found genuine value, not just a low price with trade-offs you havent spotted yet.',
// FAQ items — Safety and Neighbourhood
faqSafety1Q: 'How can I check if an area is safe before I move there?',
faqSafety1A:
@ -513,7 +586,7 @@ const en = {
faqSafety2Q:
'I keep finding flats that look great online, then the area turns out to be rough.',
faqSafety2A:
"Thats exactly why this exists. Stack crime rates, noise levels, deprivation scores, nearby pubs and parks, and broadband speeds all on one map so you know what a neighbourhood is actually like before you book a viewing.",
'Thats exactly why this exists. Stack crime rates, noise levels, deprivation scores, nearby pubs and parks, and broadband speeds all on one map so you know what a neighbourhood is actually like before you book a viewing.',
// FAQ items — Families and Schools
faqFamilies1Q: 'Can I find areas with good schools AND low crime in one search?',
faqFamilies1A:
@ -522,7 +595,7 @@ const en = {
faqFamilies2A:
'Toggle on the parks and green spaces POI layer to see them right on the map. You can also filter by how many are within walking distance of each postcode.',
// FAQ items — Environment and Quality of Life
faqEnv1Q: "Can I find energy-efficient homes that arent on a noisy road?",
faqEnv1Q: 'Can I find energy-efficient homes that arent on a noisy road?',
faqEnv1A:
'Filter by EPC rating (A to C), then layer on road noise data to rule out anything above your threshold. Colour-code by either feature to spot quiet, efficient streets at a glance.',
faqEnv2Q: 'Does it show flood or subsidence risk?',
@ -534,34 +607,32 @@ const en = {
// FAQ items — Why Perfect Postcode
faqWhy1Q: 'I already use Rightmove. What does this add?',
faqWhy1A:
'Rightmove shows you houses. We show you areas. Crime rates, school ratings, broadband speeds, noise levels, deprivation scores, and more, all filterable on one map. You can judge a neighbourhood before you even look at listings.',
faqWhy2Q: "Cant I just research all this myself for free?",
'Rightmove shows you listings. Perfect Postcode shows you where to look. Crime rates, school ratings, broadband speeds, noise levels, sold prices, floor area, EPC data, and more are all filterable on one map before you open listings.',
faqWhy2Q: 'Cant I just research all this myself for free?',
faqWhy2A:
'You could cross-reference police data, Ofsted reports, EPC registers, Land Registry records, and ONS statistics one postcode at a time. Or you could have it all filterable and colour-coded on one map in seconds.',
'You could cross-reference police data, Ofsted reports, EPC registers, Land Registry records, ONS statistics, Street View and commute tools one postcode at a time. Or you could have the evidence filterable and colour-coded on one map.',
faqWhy3Q: 'Where does the data actually come from?',
faqWhy3A:
"Every dataset comes from official UK government sources: Land Registry, the EPC register, ONS, Ofsted, Ofcom, data.police.uk, and Defra. We dont scrape estate agents or make anything up. You can verify any record against the original source.",
'Every dataset comes from official UK government sources: Land Registry, the EPC register, ONS, Ofsted, Ofcom, data.police.uk, and Defra. We dont scrape estate agents or make anything up. You can verify any record against the original source.',
// FAQ items — Pricing and Access
faqPricing1Q: 'Is it really worth paying for a property search tool?',
faqPricing1A:
"Buying a home is likely the biggest purchase youll make. Spotting one red flag (a noisy road, poor broadband, rising crime) before committing could save you years of regret. This costs less than a tank of petrol.",
'Buying a home is likely the biggest purchase youll make. Spotting one red flag (a noisy road, weak broadband, awkward commute, poor school access, or bad value) before committing could save you years of regret.',
faqPricing2Q: 'Is this a subscription?',
faqPricing2A:
"No. One-time payment, yours forever. Use it intensively during your search, come back whenever youre curious about a new area, and its still there if you ever move again.",
'No. One-time payment, yours forever. Use it intensively during your search, come back whenever youre curious about a new area, and its still there if you ever move again.',
faqPricing3Q: 'What can I access on the free tier?',
faqPricing3A:
'Free users can explore all features within the demo area (inner London, roughly zones 1 to 2). To access data for the rest of England, you need lifetime access.',
faqPricing4Q: 'Can I get a refund?',
faqPricing4A:
'Absolutely. We offer a 30-day money-back guarantee. If youre not satisfied, email support@perfect-postcode.co.uk within 30 days for a full refund.',
// FAQ items — Tips and Tricks
faqTips1Q: 'How do I use the AI filter instead of adding filters one by one?',
faqTips1A:
'Type what you want in plain English, something like "quiet area near good schools with fast broadband under £400k", and it\'ll set up all the relevant filters in one go. Tweak any of them manually afterwards.',
'Type what you want in plain English, something like "2-bed under £525k, 45 minutes to work, quiet, good broadband", and it will set up the relevant filters in one go. Tweak any of them manually afterwards.',
faqTips2Q: 'Can I save a search and come back to it later?',
faqTips2A:
'Hit the save button and everything is captured: your filters, zoom level, and which data layer youre colouring by. Pick up exactly where you left off or share the link with your partner.',
faqTips3Q: "Can I export the data Im looking at?",
faqTips3Q: 'Can I export the data Im looking at?',
faqTips3A:
'Use the export button to download the currently filtered properties as a spreadsheet. The export respects all your active filters, so you get exactly the data you want.',
},
@ -620,14 +691,14 @@ const en = {
// ── Invite Page ────────────────────────────────────
invitePage: {
youreInvited: "Youre invited!",
youreInvited: 'Youre invited!',
specialOffer: 'Special offer!',
invitedByFree: '{{name}} has invited you to get free lifetime access.',
invitedByDiscount: '{{name}} has shared a 30% discount on lifetime access.',
genericFreeInvite: 'You have been invited to get free lifetime access.',
genericDiscount: 'A friend has shared a 30% discount on lifetime access.',
exploreEvery: 'Explore every neighbourhood in England',
propertyInfo: 'Property prices, energy ratings, crime stats, school ratings and more',
exploreEvery: 'Find postcodes that fit your life',
propertyInfo: 'Prices, commute, schools, crime, noise, broadband, EPC and more',
invalidInvite: 'Invalid invite',
inviteAlreadyUsed: 'Invite already used',
inviteAlreadyUsedDesc: 'This invite link has already been redeemed.',
@ -674,13 +745,13 @@ const en = {
tutorial: {
step1Title: 'Tell the map what matters',
step1Content:
'Set your budget, commute limit, school quality, crime threshold. Whatever matters to you. Only areas that qualify stay lit. Use the eye icon to colour by any feature.',
'Set your budget, commute limit, school quality, crime threshold, noise tolerance, broadband needs, or whatever matters to you. Only matching areas stay lit. Use the eye icon to colour by any feature.',
step2Title: 'Or just describe it',
step2Content:
'Type what you want in plain English, like "quiet area near good schools under £400k", and well set up the filters for you.',
step3Title: 'Explore whats out there',
step3Content:
'Pan and zoom across England. Click any coloured area to see crime, schools, prices, broadband, noise, and more about that neighbourhood.',
'Pan and zoom across England. Click any coloured area to see why it matches: crime, schools, prices, broadband, noise, and more.',
step4Title: 'Jump to a location',
step4Content: 'Search for any place or postcode to fly straight there.',
step5Title: 'Dig into the details',
@ -731,6 +802,10 @@ const en = {
'Good+ secondary schools within 2km': 'Good+ secondary schools within 2km',
'Good+ primary schools within 5km': 'Good+ primary schools within 5km',
'Good+ secondary schools within 5km': 'Good+ secondary schools within 5km',
'Outstanding primary schools within 2km': 'Outstanding primary schools within 2km',
'Outstanding secondary schools within 2km': 'Outstanding secondary schools within 2km',
'Outstanding primary schools within 5km': 'Outstanding primary schools within 5km',
'Outstanding secondary schools within 5km': 'Outstanding secondary schools within 5km',
'Education, Skills and Training Score': 'Education, Skills and Training Score',
// ─ Feature names (Deprivation) ─
@ -771,9 +846,7 @@ const en = {
'% Other': '% Other',
// ─ Feature names (Politics) ─
'Winning party': 'Winning party',
'Voter turnout (%)': 'Voter turnout (%)',
'Majority (%)': 'Majority (%)',
'% Labour': '% Labour',
'% Conservative': '% Conservative',
'% Liberal Democrat': '% Liberal Democrat',
@ -791,12 +864,6 @@ const en = {
'Max available download speed (Mbps)': 'Max available download speed (Mbps)',
// ─ Enum values ─
Labour: 'Labour',
Conservative: 'Conservative',
'Liberal Democrat': 'Liberal Democrat',
'Reform UK': 'Reform UK',
Green: 'Green',
'Other parties': 'Other parties',
Detached: 'Detached',
'Semi-Detached': 'Semi-Detached',
Terraced: 'Terraced',
@ -811,6 +878,7 @@ const en = {
'Serious crime': 'Serious crime',
'Minor crime': 'Minor crime',
'Ethnic composition': 'Ethnic composition',
'Political vote share': 'Political vote share',
// ─ POI group names ─
'Public Transport': 'Public Transport',

View file

@ -89,17 +89,17 @@ const fr: Translations = {
// ── Upgrade Modal ──────────────────────────────────
upgrade: {
title: "Découvrez toute lAngleterre",
title: 'Découvrez toute lAngleterre',
description:
'Vous explorez actuellement la zone de démonstration. Obtenez un accès à vie à chaque code postal, chaque filtre, chaque quartier. Un seul paiement, pour toujours.',
free: 'Gratuit',
once: '/unique',
freeForEarly: 'Gratuit pour les premiers utilisateurs. Aucune carte bancaire requise.',
oneTimePayment: 'Paiement unique. Accès à vie. Garantie satisfait ou remboursé sous 30 jours.',
oneTimePayment: 'Paiement unique. Accès à vie.',
redirecting: 'Redirection...',
claimFreeAccess: "Réclamer laccès gratuit",
claimFreeAccess: 'Réclamer laccès gratuit',
upgradeFor: 'Passer à la version complète pour {{price}}',
registerAndUpgrade: "Sinscrire et passer à la version complète",
registerAndUpgrade: 'Sinscrire et passer à la version complète',
alreadyHaveAccount: 'Vous avez déjà un compte ? Connectez-vous',
continueWithDemo: 'Continuer avec la démo',
checkoutFailed: 'Échec du paiement',
@ -118,10 +118,10 @@ const fr: Translations = {
// ── License Success ────────────────────────────────
licenseSuccess: {
title: "Cest fait.",
title: 'Cest fait.',
subtitle: 'Votre accès à vie est maintenant actif.',
description:
"Accès complet à chaque fonctionnalité, chaque code postal, dans toute lAngleterre.",
'Accès complet à chaque fonctionnalité, chaque code postal, dans toute lAngleterre.',
startExploring: 'Commencer à explorer',
},
@ -133,7 +133,7 @@ const fr: Translations = {
addFiltersHint:
'Ajoutez des filtres ci-dessous pour restreindre la carte aux zones correspondant à vos critères',
upgradePrompt:
"Voir la criminalité, les écoles, le bruit, le débit internet et plus de 50 filtres dans toute lAngleterre.",
'Voir la criminalité, les écoles, le bruit, le débit internet et plus de 50 filtres dans toute lAngleterre.',
oneTimeLifetime: 'Paiement unique, accès à vie.',
upgradeToFullMap: 'Passer à la carte complète',
chooseFilters:
@ -168,7 +168,7 @@ const fr: Translations = {
step5Desc: '(restaurants, parcs, débit internet)',
step6Title: 'Énergie',
step6Desc: '(classements DPE, isolation, coûts de chauffage)',
tip: "Astuce : si rien ne correspond, assouplissez un critère à la fois pour voir quel compromis ouvre le plus doptions.",
tip: 'Astuce : si rien ne correspond, assouplissez un critère à la fois pour voir quel compromis ouvre le plus doptions.',
},
// ── Travel Time ────────────────────────────────────
@ -179,9 +179,9 @@ const fr: Translations = {
bestCase: 'Meilleur cas',
bestCaseTitle: 'Meilleur temps de trajet',
bestCaseDesc:
"Utilise le temps de trajet réaliste le plus rapide (si vous partez au bon moment et avez de bonnes correspondances). Par défaut, la <strong>médiane</strong> est utilisée, représentant un trajet typique quelle que soit lheure de départ.",
'Utilise le temps de trajet réaliste le plus rapide (si vous partez au bon moment et avez de bonnes correspondances). Par défaut, la <strong>médiane</strong> est utilisée, représentant un trajet typique quelle que soit lheure de départ.',
previewOnMap: 'Aperçu sur la carte',
stopPreviewing: "Arrêter laperçu",
stopPreviewing: 'Arrêter laperçu',
removeTravelTime: 'Supprimer le temps de trajet',
addTravelTime: 'Ajouter le temps de trajet en {{mode}}',
clearDestination: 'Effacer la destination',
@ -263,6 +263,16 @@ const fr: Translations = {
areaStatistics: 'Statistiques de la zone',
statsFor: 'Statistiques pour toutes les propriétés de ce/cette {{type}}',
matchingFilters: ' correspondant à tous les filtres actifs',
filtersAffectStats:
'Les filtres du panneau de gauche sont appliqués ici : valeurs, graphiques et nombres de propriétés utilisent les {{count}} filtres actifs.',
noFiltersAffectStats:
'Les filtres du panneau de gauche mettent ce panneau à jour : ajoutez des filtres pour recalculer ces valeurs pour les propriétés correspondantes.',
noFilteredMatches: 'Aucune propriété de cette zone ne correspond à vos filtres.',
unfilteredAreaCount:
'{{count}} propriétés existent ici avant les filtres ; le lieu est valide, mais filtré.',
noUnfilteredAreaProperties:
'Aucune propriété na été trouvée dans cette zone sélectionnée avant les filtres.',
relaxFiltersHint: 'Assouplissez ou effacez les filtres pour voir les propriétés de cette zone.',
viewProperties: 'Voir {{count}} propriétés',
priceHistory: 'Historique des prix',
journeysFrom: 'Trajets depuis {{label}}',
@ -287,14 +297,16 @@ const fr: Translations = {
// ── Street View ────────────────────────────────────
streetView: {
title: 'Street View',
openLarge: 'Ouvrir Street View en grand',
expandedTitle: 'Street View agrandi',
},
// ── POI Pane ───────────────────────────────────────
poiPane: {
pois: 'POI',
pointsOfInterest: "Points dintérêt",
pointsOfInterest: 'Points dintérêt',
poiDescription:
"Données issues dOpenStreetMap. Couvre les arrêts de transport, commerces, restaurants, établissements de santé, loisirs et plus encore. Mise à jour régulière avec une couverture complète des catégories.",
'Données issues dOpenStreetMap. Couvre les arrêts de transport, commerces, restaurants, établissements de santé, loisirs et plus encore. Mise à jour régulière avec une couverture complète des catégories.',
searchCategories: 'Rechercher des catégories...',
dataSourceInfo: 'Informations sur la source',
},
@ -313,7 +325,7 @@ const fr: Translations = {
lookupFailed: 'Échec de la recherche',
searchLabel: 'Rechercher des lieux ou codes postaux',
locateMe: 'Aller à ma position',
geolocationUnsupported: "La géolocalisation nest pas prise en charge par votre navigateur",
geolocationUnsupported: 'La géolocalisation nest pas prise en charge par votre navigateur',
geolocationFailed: 'Impossible de déterminer votre position',
},
@ -324,48 +336,107 @@ const fr: Translations = {
// ── Home Page ──────────────────────────────────────
home: {
heroTitle1: 'Valeur',
heroTitle2: 'Maximale',
heroTitle3: 'Compromis Minimum.',
heroEyebrow: 'Pour les acheteurs qui se demandent « où chercher ? »',
heroTitle1: 'Trouvez les codes postaux',
heroTitle2: 'qui correspondent à votre vie',
heroTitle3: 'Pas seulement les quartiers que vous connaissez déjà.',
heroSubtitle:
'Vous cherchez un bien ? Faites de votre plus gros investissement votre meilleure décision.',
'Des quartiers londoniens aux villes de banlieue et aux villes régionales, lAngleterre compte trop de lieux pour les rechercher un par un.',
heroDescription:
"Tant doptions — choisir la bonne peut sembler décourageant. Notre carte interactive simplifie tout : sélectionnez vos critères et voyez instantanément les zones qui correspondent.",
exploreTheMap: 'Explorer la carte',
seeTheDifference: 'Voir la différence',
statProperties: 'propriétés',
statFilters: 'filtres',
'Définissez votre budget, trajet, écoles, sécurité, bruit, débit internet et style de vie. Perfect Postcode analyse les codes postaux dAngleterre et révèle les lieux qui correspondent vraiment, y compris ceux que vous nauriez jamais cherchés sur un portail immobilier.',
exploreTheMap: 'Trouver mes codes postaux',
seeTheDifference: 'Voir comment ça marche',
showcaseHeader: 'Aperçu du produit',
showcaseContext: 'Recherche dacheteur en Angleterre',
showcaseStep1Tab: 'Décrire',
showcaseStep1Title: 'Décrivez la vie que vous voulez',
showcaseStep1Body:
'Utilisez le langage naturel ou les filtres pour transformer des critères complexes en une seule recherche.',
showcaseStep1Prompt:
'2 chambres sous £525k, 45 min jusquau travail, rues calmes, bonnes écoles',
showcaseStep1Chip1: '<= £525k',
showcaseStep1Chip2: '2+ chambres',
showcaseStep1Chip3: '45 min de trajet',
showcaseStep1Chip4: 'Faible bruit routier',
showcaseStep2Tab: 'Découvrir',
showcaseStep2Title: 'Révélez des lieux que vous naviez pas envisagés',
showcaseStep2Body:
'La carte met en évidence les codes postaux compatibles, y compris hors de votre sélection habituelle.',
showcaseStep2Metric: '47 codes postaux compatibles',
showcaseStep2Note: 'au-delà de la sélection évidente',
showcaseKnownAreas: 'Zones connues',
showcaseNewMatches: 'Nouvelles correspondances',
showcaseKnownAreaStatus: 'peu de résultats',
showcaseStep3Tab: 'Vérifier',
showcaseStep3Title: 'Comprenez pourquoi chaque code postal correspond',
showcaseStep3Body:
'Ouvrez un résultat et vérifiez les preuves avant de réserver votre week-end pour des visites.',
showcaseStep3Postcode: 'Exemple de code postal',
showcaseStep3Area: 'Penge',
showcaseStep3Code: 'SE20',
showcaseStep3Score: 'Très bon ajustement',
showcaseEvidence1: '42 min de trajet',
showcaseEvidence2: 'Bruit routier plus faible',
showcaseEvidence3: 'Bonnes écoles primaires',
showcaseEvidence4: 'Prix vendus dans le budget',
showcaseStep4Tab: 'Comparer',
showcaseStep4Title: 'Comparez les compromis avant les visites',
showcaseStep4Body:
'Sélectionnez les zones selon ce que vous gagnez et perdez, pas seulement selon leur réputation.',
showcaseCompare1: 'Penge : liaisons londoniennes, plus despace',
showcaseCompare2: 'Totterdown : rues accessibles à pied à Bristol',
showcaseCompare3: 'Walkley : logements plus grands, bon rapport qualité-prix',
showcaseMapLabel: 'Codes postaux compatibles',
showcaseSaveLabel: 'Sélection prête',
showcaseMatchPenge: 'budget compatible à Londres',
showcaseMatchAbbeyWood: 'Elizabeth line + espaces verts',
showcaseMatchTotterdown: 'Bristol accessible à pied',
showcaseMatchWalkley: 'espace + écoles à Sheffield',
statProperties: 'ventes historiques',
statFilters: 'filtres combinables',
statEvery: 'Chaque',
statPostcodeInEngland: "code postal dAngleterre",
ourPhilosophy: 'Notre philosophie',
statPostcodeInEngland: 'code postal dAngleterre',
ourPhilosophy: 'Commencez par votre vie, pas par un code postal',
philosophyP1:
"Sur Rightmove, vous choisissez dabord une zone, puis vous espérez quelle convient. Vous finissez par croiser statistiques de criminalité, rapports scolaires et tests de débit sur une dizaine donglets, un code postal à la fois.",
'La plupart des sites immobiliers demandent où vous voulez vivre. À Londres, cest particulièrement difficile, mais le même problème existe partout en Angleterre : les acheteurs partent des quelques lieux quils connaissent, puis vérifient séparément trajets, écoles, criminalité, Street View, débit internet et prix vendus.',
philosophyP2:
"Nous inversons la logique. Dites-nous ce quil vous faut (budget, trajet, écoles, sécurité) et nous vous montrons chaque zone dAngleterre qui correspond. Plus de devinettes. Plus de visites inutiles.",
'Perfect Postcode inverse la recherche. Dites à la carte ce qui compte et elle affiche les codes postaux qui correspondent, avec les raisons pour lesquelles ils méritent dêtre étudiés. Les données dabord, puis allez tester lambiance.',
streetTitle: 'Tout change rue par rue',
streetIntro:
'Les grands noms de quartiers cachent les détails importants : le côté de la gare, le bruit de la route, les écoles, le trajet exact et les vrais prix de vente.',
streetCard1Title: 'Trouvez les zones que vous auriez manquées',
streetCard1Body:
'Faites ressortir les codes postaux qui correspondent à vos critères, au lieu de dépendre seulement des noms connus ou des recommandations.',
streetCard2Title: 'Voyez les compromis avant les visites',
streetCard2Body:
'Comparez prix, surface, trajet, sécurité, écoles, débit internet, bruit et énergie avant de passer vos week-ends à courir les visites.',
howToUseIt: 'Comment lutiliser',
howStep1Title: 'Définissez vos indispensables',
howStep1Desc: 'Budget, trajet, écoles — la carte naffiche que ce qui correspond.',
howStep2Title: 'Explorez les zones et découvrez des pépites cachées',
howStep2Desc: 'Zoomez, examinez les détails et les critères secondaires.',
howStep3Title: 'Plongez dans les codes postaux',
howStep1Title: 'Décrivez la vie dont vous avez besoin',
howStep1Desc:
'Budget, trajet, type de bien, écoles, sécurité, surface et essentiels du quotidien.',
howStep2Title: 'Révélez les codes postaux compatibles',
howStep2Desc:
'La carte met en évidence les lieux qui passent vos filtres, y compris les zones moins connues.',
howStep3Title: 'Vérifiez les preuves',
howStep3Desc:
'Consultez les propriétés individuelles, les prix de vente, la surface et comparez.',
howStep4Title: 'Constituez votre sélection en toute confiance',
'Consultez prix vendus, surface, DPE, bruit routier, débit internet, criminalité et écoles.',
howStep4Title: 'Faites votre sélection avant les annonces',
howStep4Desc:
'Chaque zone de votre liste répond à vos vrais critères — pas seulement à ce qui était en vente cette semaine-là.',
'Arrivez sur Rightmove, Zoopla, chez les agents et aux visites avec de meilleures zones de recherche.',
othersVs: 'Les autres vs',
checkMyPostcode: '« Vérifier mon code postal »',
areaGuides: 'Guides de quartier',
compSearchWithout: "Rechercher sans dabord choisir une zone",
compSearchWithoutSub: "(partir de ses besoins, pas dun lieu)",
compAreaData: 'Données de la zone',
compAreaDataSub: '(criminalité, écoles, bruit, débit internet)',
compPropertyData: 'Données par propriété',
compPropertyDataSub: '(prix, DPE, surface)',
compFilters: '56 filtres combinables en un seul endroit',
compFiltersSub: '(toutes les informations, une seule carte interactive)',
ctaTitle: 'Faites de votre plus gros investissement votre meilleure décision.',
ctaDescription: 'Un tel enjeu mérite de vrais outils, ne laissez pas la chance décider.',
checkMyPostcode: 'Portails dannonces',
areaGuides: 'Rapports de code postal',
compSearchWithout: 'Découvrir des zones avant den connaître le nom',
compSearchWithoutSub: '(besoins dabord, lieu ensuite)',
compAreaData: 'Preuves au niveau du code postal',
compAreaDataSub: '(criminalité, écoles, bruit, débit internet, services)',
compPropertyData: 'Historique par propriété',
compPropertyDataSub: '(prix vendus, DPE, surface, valeur estimée)',
compFilters: '56 filtres qui fonctionnent ensemble',
compFiltersSub: '(pas un code postal ou une annonce à la fois)',
ctaTitle: 'Arrêtez de deviner où acheter.',
ctaDescription:
'Construisez une sélection de codes postaux adaptés à votre vraie vie, puis allez les tester sur place.',
},
// ── Pricing Page ───────────────────────────────────
@ -373,8 +444,8 @@ const fr: Translations = {
title: 'Tarifs early access',
subtitle: 'Payez une fois, accédez pour toujours. Plus vous rejoignez tôt, moins vous payez.',
costContext:
"Lachat dun bien coûte plus de £10 000 en droits de mutation, £1 500 en frais de notaire, £500 pour une expertise. Choisissez le mauvais quartier et vous vous retrouvez avec un long trajet, de mauvaises écoles ou une route dont vous ignoriez lexistence.",
lessThanSurvey: "Moins cher quune expertise immobilière. Bien plus utile.",
'Lachat dun bien coûte plus de £10 000 en droits de mutation, £1 500 en frais de notaire, £500 pour une expertise. Choisissez le mauvais quartier et vous vous retrouvez avec un long trajet, de mauvaises écoles ou une route dont vous ignoriez lexistence.',
lessThanSurvey: 'Moins cher quune expertise immobilière. Bien plus utile.',
currentTier: 'Palier actuel',
firstNUsers: '{{count}} premiers utilisateurs',
everyoneAfter: 'Tous les suivants',
@ -387,11 +458,11 @@ const fr: Translations = {
getStarted: 'Commencer',
getStartedPrice: 'Commencer - {{price}}',
noCreditCard: 'Aucune carte bancaire requise',
moneyBackGuarantee: 'Garantie satisfait ou remboursé sous 30 jours',
soldOut: 'Épuisé',
upcoming: 'À venir',
failedToLoad: 'Échec du chargement des tarifs. Veuillez réessayer plus tard.',
feat1: "56 couches de données à travers lAngleterre",
feat1: '56 couches de données à travers lAngleterre',
feat2: 'Chaque code postal noté et filtrable',
feat3: 'Exploration de la carte et exportations illimitées',
feat4: 'Plusieurs décennies de données historiques de prix',
@ -450,6 +521,10 @@ const fr: Translations = {
dsOsmOrigin: 'OpenStreetMap contributors / Geofabrik',
dsOsmUse:
'Points dintérêt couvrant commerces, restaurants, santé, loisirs, tourisme et plus à travers la Grande-Bretagne.',
dsGeolytixRetailName: 'GEOLYTIX Grocery Retail Points',
dsGeolytixRetailOrigin: 'GEOLYTIX',
dsGeolytixRetailUse:
'Emplacements de supermarchés et magasins de proximité au Royaume-Uni, incluant des chaînes comme Waitrose, Tesco, Sainsburys, Asda, Morrisons, Aldi, Lidl, Co-op, M&S, Iceland et Spar.',
dsGreenspaceName: 'OS Open Greenspace',
dsGreenspaceOrigin: 'Ordnance Survey',
dsGreenspaceUse:
@ -481,7 +556,7 @@ const fr: Translations = {
dsElectionName: 'Résultats des élections générales 2024',
dsElectionOrigin: 'Parlement britannique',
dsElectionUse:
'Résultats par candidat des élections générales britanniques de juillet 2024. Agrégés au niveau de la circonscription : parti vainqueur, participation électorale (%) et majorité (%). Reliés aux propriétés via le code de circonscription parlementaire (pcon) du répertoire de codes postaux NSPL.',
'Résultats par candidat des élections générales britanniques de juillet 2024. Agrégés au niveau de la circonscription : participation électorale (%) et parts des voix par parti (%). Reliés aux propriétés via le code de circonscription parlementaire (pcon) du répertoire de codes postaux NSPL.',
// FAQ section titles
faqFindingTitle: 'Trouver votre quartier',
faqCommuteTitle: 'Trajet et déplacements',
@ -565,9 +640,7 @@ const fr: Translations = {
faqPricing3Q: 'Que puis-je faire avec la version gratuite ?',
faqPricing3A:
'Les utilisateurs gratuits peuvent explorer toutes les fonctionnalités dans la zone de démonstration (centre de Londres, approximativement zones 1 à 2). Pour accéder aux données du reste de lAngleterre, il faut laccès à vie.',
faqPricing4Q: 'Puis-je obtenir un remboursement ?',
faqPricing4A:
'Absolument. Nous offrons une garantie satisfait ou remboursé sous 30 jours. Si vous nêtes pas satisfait, envoyez un e-mail à support@perfect-postcode.co.uk dans les 30 jours pour un remboursement intégral.',
// FAQ items — Tips and Tricks
faqTips1Q: 'Comment utiliser le filtre IA au lieu dajouter les filtres un par un ?',
faqTips1A:
@ -615,16 +688,16 @@ const fr: Translations = {
// ── Invites Page ───────────────────────────────────
invitesPage: {
inviteLinksLicensed: "Les liens dinvitation sont disponibles pour les utilisateurs licenciés.",
inviteLinksLicensed: 'Les liens dinvitation sont disponibles pour les utilisateurs licenciés.',
inviteAdminLabel: 'Inviter des amis (100% de réduction)',
inviteReferralLabel: 'Inviter des amis (30% de réduction)',
generateFreeInvite: "Générer un lien dinvitation gratuit",
generateFreeInvite: 'Générer un lien dinvitation gratuit',
generateReferralLink: 'Générer un lien de parrainage',
copyInviteLink: "Copier le lien dinvitation",
copyInviteLink: 'Copier le lien dinvitation',
adminInvitesTitle: 'Invitations admin (100% de réduction)',
referralInvitesTitle: 'Invitations de parrainage (30% de réduction)',
yourInviteLinks: "Vos liens dinvitation",
noInvitesYet: "Aucune invitation générée pour linstant",
yourInviteLinks: 'Vos liens dinvitation',
noInvitesYet: 'Aucune invitation générée pour linstant',
link: 'Lien',
status: 'Statut',
created: 'Créé',
@ -637,26 +710,26 @@ const fr: Translations = {
youreInvited: 'Vous êtes invité !',
specialOffer: 'Offre spéciale !',
invitedByFree: '{{name}} vous invite à obtenir un accès à vie gratuit.',
invitedByDiscount: "{{name}} vous fait bénéficier dune réduction de 30% sur laccès à vie.",
invitedByDiscount: '{{name}} vous fait bénéficier dune réduction de 30% sur laccès à vie.',
genericFreeInvite: 'Vous avez été invité à obtenir un accès à vie gratuit.',
genericDiscount: "Un ami vous fait bénéficier dune réduction de 30% sur laccès à vie.",
exploreEvery: "Explorez chaque quartier dAngleterre",
genericDiscount: 'Un ami vous fait bénéficier dune réduction de 30% sur laccès à vie.',
exploreEvery: 'Explorez chaque quartier dAngleterre',
propertyInfo:
'Prix immobiliers, classements énergétiques, statistiques de criminalité, notes des écoles et plus encore',
invalidInvite: 'Invitation invalide',
inviteAlreadyUsed: 'Invitation déjà utilisée',
inviteAlreadyUsedDesc: "Ce lien dinvitation a déjà été utilisé.",
invalidInviteLink: "Lien dinvitation invalide",
invalidInviteLinkDesc: "Ce lien dinvitation est invalide ou a expiré.",
inviteAlreadyUsedDesc: 'Ce lien dinvitation a déjà été utilisé.',
invalidInviteLink: 'Lien dinvitation invalide',
invalidInviteLinkDesc: 'Ce lien dinvitation est invalide ou a expiré.',
licenseActivated: 'Licence activée !',
fullAccessGranted: 'Vous avez désormais un accès complet à Perfect Postcode.',
activating: 'Activation...',
activateLicense: 'Activer la licence',
claimDiscount: 'Réclamer la réduction',
registerToClaim: "Sinscrire pour réclamer",
registerToClaim: 'Sinscrire pour réclamer',
youAlreadyHaveLicense: 'Vous avez déjà une licence',
accountHasFullAccess: 'Votre compte dispose déjà dun accès complet.',
failedToValidate: "Échec de la validation du lien dinvitation",
failedToValidate: 'Échec de la validation du lien dinvitation',
},
// ── Map Page ───────────────────────────────────────
@ -747,6 +820,10 @@ const fr: Translations = {
'Good+ secondary schools within 2km': 'Collèges/lycées Bien+ dans un rayon de 2 km',
'Good+ primary schools within 5km': 'Écoles primaires Bien+ dans un rayon de 5 km',
'Good+ secondary schools within 5km': 'Collèges/lycées Bien+ dans un rayon de 5 km',
'Outstanding primary schools within 2km': 'Écoles primaires Excellent dans un rayon de 2 km',
'Outstanding secondary schools within 2km': 'Collèges/lycées Excellent dans un rayon de 2 km',
'Outstanding primary schools within 5km': 'Écoles primaires Excellent dans un rayon de 5 km',
'Outstanding secondary schools within 5km': 'Collèges/lycées Excellent dans un rayon de 5 km',
'Education, Skills and Training Score': 'Score éducation, compétences et formation',
// ─ Feature names (Deprivation) ─
@ -787,9 +864,7 @@ const fr: Translations = {
'% Other': '% Autres',
// ─ Feature names (Politics) ─
'Winning party': 'Parti vainqueur',
'Voter turnout (%)': 'Participation électorale (%)',
'Majority (%)': 'Majorité (%)',
'% Labour': '% Travaillistes',
'% Conservative': '% Conservateurs',
'% Liberal Democrat': '% Libéraux-démocrates',
@ -807,12 +882,6 @@ const fr: Translations = {
'Max available download speed (Mbps)': 'Débit descendant max. disponible (Mbps)',
// ─ Enum values ─
Labour: 'Travailliste',
Conservative: 'Conservateur',
'Liberal Democrat': 'Libéral-démocrate',
'Reform UK': 'Reform UK',
Green: 'Vert',
'Other parties': 'Autres partis',
Detached: 'Individuelle',
'Semi-Detached': 'Jumelée',
Terraced: 'Mitoyenne',
@ -827,6 +896,7 @@ const fr: Translations = {
'Serious crime': 'Crimes graves',
'Minor crime': 'Délits mineurs',
'Ethnic composition': 'Composition ethnique',
'Political vote share': 'Répartition des voix',
// ─ POI group names ─
'Public Transport': 'Transports en commun',

View file

@ -95,8 +95,7 @@ const hu: Translations = {
free: 'Ingyenes',
once: '/egyszeri',
freeForEarly: 'Ingyenes a korai felhasználóknak. Nem szükséges bankkartya.',
oneTimePayment:
'Egyszeri fizetés. Élethosszig tartó hozzáférés. 30 napos pénzvisszatérítési garancia.',
oneTimePayment: 'Egyszeri fizetés. Élethosszig tartó hozzáférés.',
redirecting: 'Átirányítás...',
claimFreeAccess: 'Ingyenes hozzáférés igénylése',
upgradeFor: 'Frissítés {{price}} áron',
@ -258,6 +257,15 @@ const hu: Translations = {
areaStatistics: 'Területi statisztikák',
statsFor: 'Statisztikák a(z) {{type}} összes ingatlanáról',
matchingFilters: ' az összes aktív szűrőnek megfelelően',
filtersAffectStats:
'A bal oldali panel szűrői itt is érvényesek: az értékek, diagramok és ingatlanszámok a(z) {{count}} aktív szűrőt használják.',
noFiltersAffectStats:
'A bal oldali panel szűrői frissítik ezt a panelt: adjon hozzá szűrőket, hogy ezek az értékek az illeszkedő ingatlanokra számolódjanak újra.',
noFilteredMatches: 'Ezen a területen egyetlen ingatlan sem felel meg a szűrőknek.',
unfilteredAreaCount:
'{{count}} ingatlan található itt szűrők nélkül, tehát a hely érvényes, csak a szűrők kizárják.',
noUnfilteredAreaProperties: 'A kiválasztott területen szűrők nélkül sem található ingatlan.',
relaxFiltersHint: 'Lazítson vagy törölje a szűrőket, hogy lássa a terület ingatlanjait.',
viewProperties: '{{count}} ingatlan megtekintése',
priceHistory: 'Ártörténet',
journeysFrom: 'Utazások innen: {{label}}',
@ -282,6 +290,8 @@ const hu: Translations = {
// ── Street View ────────────────────────────────────
streetView: {
title: 'Utcakép',
openLarge: 'Utcakép megnyitása nagyobb méretben',
expandedTitle: 'Nagyított utcakép',
},
// ── POI Pane ───────────────────────────────────────
@ -319,47 +329,105 @@ const hu: Translations = {
// ── Home Page ──────────────────────────────────────
home: {
heroTitle1: 'Maximális',
heroTitle2: 'Érték',
heroTitle3: 'Minimális kompromisszum.',
heroSubtitle: 'Ingatlant keresel? Legyen a legnagyobb befektetésed a legokosabb döntésed.',
heroEyebrow: 'Vevőknek, akik azt kérdezik: „hol is kezdjem?”',
heroTitle1: 'Találd meg az irányítószámokat',
heroTitle2: 'amelyek illenek az életedhez',
heroTitle3: 'Nem csak azokat a környékeket, amelyeket már ismersz.',
heroSubtitle:
'A londoni városrészeken, ingázó településeken és regionális városokon át Angliában túl sok hely van ahhoz, hogy egyenként kutasd át őket.',
heroDescription:
'Annyi lehetőség a megfelelő kiválasztása nehéz lehet. Interaktív térképünk egyszerűvé teszi: válaszd ki a feltételeidet, és azonnal lásd a megfelelő területeket.',
exploreTheMap: 'Térkép felfedezése',
seeTheDifference: 'Nézd meg a különbséget',
statProperties: 'ingatlan',
statFilters: 'szűrő',
'Állítsd be a költségvetést, ingázást, iskolákat, biztonságot, zajt, internetet és életstílust. A Perfect Postcode átnézi Anglia irányítószámait, és megmutatja azokat a helyeket is, amelyeket sosem írtál volna be egy ingatlanportálra.',
exploreTheMap: 'Megfelelő irányítószámok keresése',
seeTheDifference: 'Így működik',
showcaseHeader: 'Termékbemutató',
showcaseContext: 'Vevői keresés egész Angliában',
showcaseStep1Tab: 'Leírás',
showcaseStep1Title: 'Írd le, milyen életet szeretnél',
showcaseStep1Body:
'Természetes nyelvvel vagy szűrőkkel alakítsd a bonyolult vevői igényeket egy kereséssé.',
showcaseStep1Prompt: '2 háló £525k alatt, 45 perc munkáig, csendes utcák, jó iskolák',
showcaseStep1Chip1: '<= £525k',
showcaseStep1Chip2: '2+ háló',
showcaseStep1Chip3: '45 perc ingázás',
showcaseStep1Chip4: 'Alacsony útzaj',
showcaseStep2Tab: 'Felfedezés',
showcaseStep2Title: 'Mutasd meg azokat a helyeket, amelyekre nem gondoltál',
showcaseStep2Body:
'A térkép kiemeli a megfelelő irányítószámokat, a megszokott listádon kívül is.',
showcaseStep2Metric: '47 megfelelő irányítószám',
showcaseStep2Note: 'a kézenfekvő listán túl',
showcaseKnownAreas: 'Ismert területek',
showcaseNewMatches: 'Új találatok',
showcaseKnownAreaStatus: 'kevés találat',
showcaseStep3Tab: 'Ellenőrzés',
showcaseStep3Title: 'Értsd meg, miért illik egy irányítószám',
showcaseStep3Body:
'Nyiss meg egy találatot és ellenőrizd az adatokat, mielőtt egy hétvégét nézelődésre szánsz.',
showcaseStep3Postcode: 'Irányítószám példa',
showcaseStep3Area: 'Penge',
showcaseStep3Code: 'SE20',
showcaseStep3Score: 'Erős egyezés',
showcaseEvidence1: '42 perc ingázás',
showcaseEvidence2: 'Alacsonyabb útzaj',
showcaseEvidence3: 'Jó általános iskolák',
showcaseEvidence4: 'Eladási árak a kereten belül',
showcaseStep4Tab: 'Összevetés',
showcaseStep4Title: 'Hasonlítsd össze a kompromisszumokat megtekintés előtt',
showcaseStep4Body:
'A nyereségek és veszteségek alapján szűkíts, ne csak a környék híre alapján.',
showcaseCompare1: 'Penge: londoni vasút, több tér',
showcaseCompare2: 'Totterdown: gyalogos Bristol-utcák',
showcaseCompare3: 'Walkley: nagyobb otthonok, jó érték',
showcaseMapLabel: 'Megfelelő irányítószámok',
showcaseSaveLabel: 'Lista kész',
showcaseMatchPenge: 'London a kereten belül',
showcaseMatchAbbeyWood: 'Elizabeth line + zöldterület',
showcaseMatchTotterdown: 'Bristol gyalogosan élhető',
showcaseMatchWalkley: 'Sheffield: tér + iskolák',
statProperties: 'korábbi eladás',
statFilters: 'kombinálható szűrő',
statEvery: 'Minden',
statPostcodeInEngland: 'irányítószám Angliában',
ourPhilosophy: 'Filozófiánk',
ourPhilosophy: 'Az életedből indulj ki, ne egy irányítószámból',
philosophyP1:
'A Rightmove-on először területet választasz, és reméled, hogy jó. Végül bűnözési statisztikákat, iskolai jelentéseket és szélessáv-ellenőrzőket böngészel tucat füleken, egyszerre egy irányítószámmal.',
'A legtöbb ingatlanoldal először azt kérdezi, hol szeretnél élni. Londonban ez különösen nehéz, de ugyanez a probléma egész Angliában megjelenik: a vevők néhány ismert helyből indulnak ki, majd külön füleken ellenőrzik az ingázást, iskolákat, bűnözést, Street View-t, internetet és eladási árakat.',
philosophyP2:
'Mi megfordítjuk. Mondd el, mire van szükséged (költségvetés, ingazás, iskolák, biztonság), és megmutatjuk Anglia összes megfelelő területét. Nincs találgatás. Nincs felesleges megtekintés.',
'A Perfect Postcode megfordítja a keresést. Mondd meg a térképnek, mi számít, és megmutatja a megfelelő irányítószámokat, indoklással együtt. Előbb az adatok, aztán a helyszíni benyomás.',
streetTitle: 'A helyek utcáról utcára változnak',
streetIntro:
'A nagy környéknevek elrejtik a fontos részleteket: az állomás melyik oldalát, az útzajt, az iskolákat, a pontos ingázást és a valódi eladási árakat.',
streetCard1Title: 'Találd meg a kihagyott környékeket',
streetCard1Body:
'Hozd felszínre azokat az irányítószámokat, amelyek megfelelnek a feltételeidnek, ne csak ismert nevekre vagy ajánlásokra hagyatkozz.',
streetCard2Title: 'Lásd a kompromisszumokat megtekintés előtt',
streetCard2Body:
'Hasonlítsd össze az árat, méretet, ingázást, biztonságot, iskolákat, internetet, zajt és energiahatékonyságot, mielőtt hétvégéket töltesz megtekintésekkel.',
howToUseIt: 'Hogyan használd',
howStep1Title: 'Állítsd be a feltételeidet',
howStep1Desc: 'Költségvetés, ingazás, iskolák — a térkép csak a megfelelőket mutatja.',
howStep2Title: 'Fedezz fel területeket és rejtett kincseket',
howStep2Desc: 'Nagyíts rá, mélyedj el a részletekben és a pluszokban.',
howStep3Title: 'Vizsgáld meg az irányítószámokat',
howStep1Title: 'Írd le, milyen életre van szükséged',
howStep1Desc:
'Költségvetés, ingázás, ingatlantípus, iskolák, biztonság, tér és napi szükségletek.',
howStep2Title: 'Fedd fel a megfelelő irányítószámokat',
howStep2Desc: 'A térkép kiemeli azokat a helyeket, amelyek átmennek a szűrőiden.',
howStep3Title: 'Ellenőrizd a bizonyítékokat',
howStep3Desc:
'Nézd meg az egyes ingatlanokat, eladási árakat, alapterületet, és hasonlítsd össze.',
howStep4Title: 'Válassz magabiztosan',
'Nézd meg az eladási árakat, alapterületet, EPC-t, zajt, internetet, bűnözést és iskolákat.',
howStep4Title: 'Szűkíts listát hirdetések előtt',
howStep4Desc:
'A listádon minden terület megfelel a valós feltételeidnek — nem csak annak, amit azon a héten hirdettek.',
'Menj Rightmove-ra, Zooplára, ügynökökhöz és megtekintésekre jobb keresési területekkel.',
othersVs: 'Mások vs.',
checkMyPostcode: '“Irányítószám ellenőrzése”',
areaGuides: 'Területi útmutatók',
compSearchWithout: 'Keresés terület előzetes kiválasztása nélkül',
compSearchWithoutSub: '(igényekből indulj, nem helyszínből)',
compAreaData: 'Területi adatok',
compAreaDataSub: '(bűnözés, iskolák, zaj, szélessáv)',
compPropertyData: 'Ingatlanspecifikus adatok',
compPropertyDataSub: '(ár, EPC, alapterület)',
compFilters: '56 kombinálható szűrő egy helyen',
compFiltersSub: '(minden információ, egy interaktív térkép)',
ctaTitle: 'Legyen a legnagyobb befektetésed a legokosabb döntésed.',
ctaDescription: 'Ez megfelelő eszközöket érdemel, ne bízd a szerencsére.',
checkMyPostcode: 'Ingatlanportálok',
areaGuides: 'Irányítószám-riportok',
compSearchWithout: 'Területek felfedezése a nevük ismerete előtt',
compSearchWithoutSub: '(előbb igények, aztán helyszín)',
compAreaData: 'Irányítószám-szintű környékadatok',
compAreaDataSub: '(bűnözés, iskolák, zaj, internet, szolgáltatások)',
compPropertyData: 'Ingatlanszintű előzmények',
compPropertyDataSub: '(eladási árak, EPC, alapterület, becsült érték)',
compFilters: '56 együtt működő szűrő',
compFiltersSub: '(nem egy irányítószám vagy hirdetés egyszerre)',
ctaTitle: 'Ne találgasd, hol vegyél.',
ctaDescription:
'Készíts listát olyan irányítószámokból, amelyek illenek a valós életedhez, majd nézd meg őket személyesen.',
},
// ── Pricing Page ───────────────────────────────────
@ -382,7 +450,7 @@ const hu: Translations = {
getStarted: 'Kezdjük el',
getStartedPrice: 'Kezdjük el {{price}}',
noCreditCard: 'Nem szükséges bankkartya',
moneyBackGuarantee: '30 napos pénzvisszatérítési garancia',
soldOut: 'Elfogyott',
upcoming: 'Következő',
failedToLoad: 'Nem sikerült betölteni az árakat. Kérjük, próbáld újra később.',
@ -445,6 +513,10 @@ const hu: Translations = {
dsOsmOrigin: 'OpenStreetMap contributors / Geofabrik',
dsOsmUse:
'Érdekes pontok, beleértve üzleteket, éttermeket, egészségügyet, szabadidőt, turizmust és még sok mást Nagy-Britanniában.',
dsGeolytixRetailName: 'GEOLYTIX Grocery Retail Points',
dsGeolytixRetailOrigin: 'GEOLYTIX',
dsGeolytixRetailUse:
'Szupermarketek és kisboltok helyei az Egyesült Királyságban, többek között Waitrose, Tesco, Sainsburys, Asda, Morrisons, Aldi, Lidl, Co-op, M&S, Iceland és Spar láncokkal.',
dsGreenspaceName: 'OS Open Greenspace',
dsGreenspaceOrigin: 'Ordnance Survey',
dsGreenspaceUse:
@ -476,7 +548,7 @@ const hu: Translations = {
dsElectionName: '2024-es parlamenti választási eredmények',
dsElectionOrigin: 'Egyesült Királyság Parlamentje',
dsElectionUse:
'Jelöltszintű eredmények a 2024. júliusi brit parlamenti választásról. Választókerületi szintre aggregálva: győztes párt, részvételi arány (%) és többség (%). Az ingatlanokhoz az NSPL irányítószám-keresőből származó parlamenti választókerületi kódon (pcon) keresztül csatolva.',
'Jelöltszintű eredmények a 2024. júliusi brit parlamenti választásról. Választókerületi szintre aggregálva: részvételi arány (%) és pártszavazatarányok (%). Az ingatlanokhoz az NSPL irányítószám-keresőből származó parlamenti választókerületi kódon (pcon) keresztül csatolva.',
// FAQ section titles
faqFindingTitle: 'Területed megtalálása',
faqCommuteTitle: 'Ingazás és utazás',
@ -557,9 +629,7 @@ const hu: Translations = {
faqPricing3Q: 'Mit érhetek el az ingyenes szinten?',
faqPricing3A:
'Az ingyenes felhasználók a demó területen (Belső-London, megközelítőleg az 1-2. zóna) fedezhetik fel az összes funkciót. Anglia többi részének adataihoz élethosszig tartó hozzáférés szükséges.',
faqPricing4Q: 'Kérhetek visszatérítést?',
faqPricing4A:
'Természetesen. 30 napos pénzvisszatérítési garanciát kínálunk. Ha nem vagy elégedett, írj a support@perfect-postcode.co.uk címre 30 napon belül a teljes visszatérítésért.',
// FAQ items — Tips and Tricks
faqTips1Q: 'Hogyan használjam az AI szűrőt a szűrők egyenkénti hozzáadása helyett?',
faqTips1A:
@ -740,6 +810,10 @@ const hu: Translations = {
'Good+ secondary schools within 2km': 'Jó+ középiskolák 2 km-en belül',
'Good+ primary schools within 5km': 'Jó+ általános iskolák 5 km-en belül',
'Good+ secondary schools within 5km': 'Jó+ középiskolák 5 km-en belül',
'Outstanding primary schools within 2km': 'Kiemelkedő általános iskolák 2 km-en belül',
'Outstanding secondary schools within 2km': 'Kiemelkedő középiskolák 2 km-en belül',
'Outstanding primary schools within 5km': 'Kiemelkedő általános iskolák 5 km-en belül',
'Outstanding secondary schools within 5km': 'Kiemelkedő középiskolák 5 km-en belül',
'Education, Skills and Training Score': 'Oktatás, készségek és képzés pontszám',
// ─ Feature names (Deprivation) ─
@ -780,9 +854,7 @@ const hu: Translations = {
'% Other': '% egyéb',
// ─ Feature names (Politics) ─
'Winning party': 'Győztes párt',
'Voter turnout (%)': 'Választási részvétel (%)',
'Majority (%)': 'Többség (%)',
'% Labour': '% Munkáspárt',
'% Conservative': '% Konzervatív',
'% Liberal Democrat': '% Liberális Demokrata',
@ -800,12 +872,6 @@ const hu: Translations = {
'Max available download speed (Mbps)': 'Max elérhető letöltési sebesség (Mbps)',
// ─ Enum values ─
Labour: 'Munkáspárt',
Conservative: 'Konzervatív',
'Liberal Democrat': 'Liberális Demokrata',
'Reform UK': 'Reform UK',
Green: 'Zöld',
'Other parties': 'Egyéb pártok',
Detached: 'Különálló',
'Semi-Detached': 'Ikerház',
Terraced: 'Sorház',
@ -820,6 +886,7 @@ const hu: Translations = {
'Serious crime': 'Súlyos bűncselekmény',
'Minor crime': 'Kisebb bűncselekmény',
'Ethnic composition': 'Etnikai összetétel',
'Political vote share': 'Szavazati megoszlás',
// ─ POI group names ─
'Public Transport': 'Tömegközlekedés',

View file

@ -93,7 +93,7 @@ const zh: Translations = {
free: '免费',
once: '/一次性',
freeForEarly: '早期用户免费。无需信用卡。',
oneTimePayment: '一次性付款。终身访问。30天无条件退款。',
oneTimePayment: '一次性付款。终身访问。',
redirecting: '跳转中...',
claimFreeAccess: '领取免费访问权限',
upgradeFor: '升级仅需 {{price}}',
@ -254,6 +254,14 @@ const zh: Translations = {
areaStatistics: '区域统计',
statsFor: '该{{type}}内所有房产的统计数据',
matchingFilters: ',满足所有当前筛选条件',
filtersAffectStats:
'左侧面板的筛选条件会应用到这里:数值、图表和房产数量都会使用 {{count}} 个当前筛选条件。',
noFiltersAffectStats:
'左侧面板的筛选条件会更新此面板:添加筛选条件后,这些值会按匹配的房产重新计算。',
noFilteredMatches: '该区域没有房产符合当前筛选条件。',
unfilteredAreaCount: '筛选前这里有 {{count}} 处房产;位置有效,但被筛选条件排除了。',
noUnfilteredAreaProperties: '筛选前该选定区域内也没有找到房产。',
relaxFiltersHint: '放宽或清除筛选条件即可查看该区域的房产。',
viewProperties: '查看 {{count}} 处房产',
priceHistory: '价格历史',
journeysFrom: '从 {{label}} 出发的路线',
@ -278,6 +286,8 @@ const zh: Translations = {
// ── Street View ────────────────────────────────────
streetView: {
title: '街景视图',
openLarge: '放大打开街景视图',
expandedTitle: '放大的街景视图',
},
// ── POI Pane ───────────────────────────────────────
@ -315,45 +325,96 @@ const zh: Translations = {
// ── Home Page ──────────────────────────────────────
home: {
heroTitle1: '最大',
heroTitle2: '价值',
heroTitle3: '最小妥协。',
heroSubtitle: '正在找房?让您最大的投资成为最明智的决定。',
heroEyebrow: '适合正在问“我到底该看哪里?”的买家',
heroTitle1: '找到真正',
heroTitle2: '适合您生活的邮编',
heroTitle3: '不只局限于您已经知道的区域。',
heroSubtitle: '从伦敦街区到通勤城镇和英格兰各地城市,可研究的地方太多,无法一个个筛查。',
heroDescription:
'选择太多,找到合适的可能让人不知所措。我们的交互式地图让一切变得简单:选择您的必要条件,立即看到符合的区域。',
exploreTheMap: '探索地图',
seeTheDifference: '看看有何不同',
statProperties: '处房产',
statFilters: '项筛选条件',
'设定预算、通勤、学校、安全、噪音、宽带和生活方式需求。Perfect Postcode 会扫描英格兰的邮编,显示真正匹配的地方,包括您从未想过要在房源网站上搜索的区域。',
exploreTheMap: '找到匹配的邮编',
seeTheDifference: '查看使用方式',
showcaseHeader: '产品展示',
showcaseContext: '英格兰买家搜索示例',
showcaseStep1Tab: '描述',
showcaseStep1Title: '描述您想要的生活',
showcaseStep1Body: '用自然语言或筛选条件,把复杂的买房需求变成一次搜索。',
showcaseStep1Prompt: '2房£525k以内45分钟到工作地点安静街道好学校',
showcaseStep1Chip1: '<= £525k',
showcaseStep1Chip2: '2+卧室',
showcaseStep1Chip3: '45分钟通勤',
showcaseStep1Chip4: '低道路噪音',
showcaseStep2Tab: '发现',
showcaseStep2Title: '发现您没有考虑过的地方',
showcaseStep2Body: '地图会点亮匹配的邮编,包括您原本候选范围之外的区域。',
showcaseStep2Metric: '47个匹配邮编',
showcaseStep2Note: '超出显而易见的候选范围',
showcaseKnownAreas: '熟悉区域',
showcaseNewMatches: '新匹配',
showcaseKnownAreaStatus: '匹配较少',
showcaseStep3Tab: '检查',
showcaseStep3Title: '了解每个邮编为什么匹配',
showcaseStep3Body: '打开结果,在周末看房前先检查证据。',
showcaseStep3Postcode: '邮编示例',
showcaseStep3Area: 'Penge',
showcaseStep3Code: 'SE20',
showcaseStep3Score: '高度匹配',
showcaseEvidence1: '42分钟通勤',
showcaseEvidence2: '较低道路噪音',
showcaseEvidence3: '不错的小学选择',
showcaseEvidence4: '成交价符合预算',
showcaseStep4Tab: '比较',
showcaseStep4Title: '看房前比较取舍',
showcaseStep4Body: '根据得到什么和放弃什么来筛选,而不是只看区域名声。',
showcaseCompare1: 'Penge伦敦铁路连接空间更大',
showcaseCompare2: 'Totterdown布里斯托可步行街区',
showcaseCompare3: 'Walkley更大住房更高性价比',
showcaseMapLabel: '匹配邮编',
showcaseSaveLabel: '候选名单已准备好',
showcaseMatchPenge: '伦敦预算匹配',
showcaseMatchAbbeyWood: 'Elizabeth line + 绿地',
showcaseMatchTotterdown: '布里斯托步行便利',
showcaseMatchWalkley: '谢菲尔德空间 + 学校',
statProperties: '历史成交记录',
statFilters: '可组合筛选条件',
statEvery: '覆盖',
statPostcodeInEngland: '英格兰每个邮编',
ourPhilosophy: '我们的理念',
ourPhilosophy: '从生活需求出发,而不是从邮编出发',
philosophyP1:
'在 Rightmove 上,您需要先选一个区域,然后期望它足够好。最终您不得不在十几个标签页中交叉对比犯罪数据、学校报告和宽带速度,一个邮编一个邮编地查。',
'大多数房产网站先问您想住哪里。在伦敦这个问题尤其困难,但英格兰各地都有同样的问题:买家通常只能从几个熟悉的地方开始,然后分别查询通勤、学校、犯罪率、街景、宽带和成交价。',
philosophyP2:
'我们反其道而行。告诉我们您的需求(预算、通勤、学校、安全),我们为您展示英格兰所有符合条件的区域。不用猜测,不浪费看房时间。',
'Perfect Postcode 反过来做搜索。告诉地图什么重要,它会显示符合条件的邮编,并解释为什么值得查看。先看数据,再去现场感受。',
streetTitle: '每条街都可能不同',
streetIntro:
'大的区域名称会掩盖关键细节:车站哪一侧、道路噪音、学校组合、真实通勤时间,以及类似房产的实际成交价。',
streetCard1Title: '发现您可能错过的区域',
streetCard1Body:
'根据您的条件找出匹配的邮编,而不是只依赖熟悉的地名、朋友推荐或“潜力区域”的宣传。',
streetCard2Title: '看房前先看清取舍',
streetCard2Body:
'在把周末花在看房之前,先比较价格、空间、通勤、安全、学校、宽带、噪音和能源评级。',
howToUseIt: '使用方法',
howStep1Title: '设定必要条件',
howStep1Desc: '预算、通勤、学校——地图只显示符合条件的区域。',
howStep2Title: '探索区域,发现隐藏的好地方',
howStep2Desc: '放大查看,深入了解细节和加分项。',
howStep3Title: '深入邮编级别',
howStep3Desc: '查看单个房产、成交价、建筑面积,并进行比较。',
howStep4Title: '自信地列出候选名单',
howStep4Desc: '您名单上的每个区域都满足您的实际需求——而不只是当周恰好有房源。',
howStep1Title: '描述您需要的生活',
howStep1Desc: '预算、通勤、房产类型、学校、安全、空间和日常生活设施。',
howStep2Title: '显示匹配的邮编',
howStep2Desc: '地图会高亮通过筛选的地方,包括不熟悉的区域。',
howStep3Title: '查看证据',
howStep3Desc: '查看成交价、建筑面积、EPC、道路噪音、宽带、犯罪率和学校。',
howStep4Title: '先筛区域,再看房源',
howStep4Desc: '带着更好的搜索区域去 Rightmove、Zoopla、中介和看房。',
othersVs: '其他平台 vs',
checkMyPostcode: '"查查我的邮编"类网站',
areaGuides: '区域指南',
compSearchWithout: '无需先选区域即可搜索',
compSearchWithoutSub: '(从需求出发,而非地点)',
compAreaData: '区域数据',
compAreaDataSub: '(犯罪率、学校、噪音、宽带)',
compPropertyData: '房产专属数据',
compPropertyDataSub: '(价格、能源性能证书、建筑面积)',
compFilters: '56 项可组合筛选条件,尽在一处',
compFiltersSub: '(所有信息,一张交互式地图)',
ctaTitle: '让您最大的投资成为最明智的 决定。',
ctaDescription: '这值得用专业的工具来做,别全靠运气。',
checkMyPostcode: '房源门户',
areaGuides: '邮编报告',
compSearchWithout: '在知道名称前先发现区域',
compSearchWithoutSub: '先需求,后地点)',
compAreaData: '邮编级社区证据',
compAreaDataSub: '(犯罪率、学校、噪音、宽带、设施',
compPropertyData: '房产级历史记录',
compPropertyDataSub: '成交价、EPC、面积、估值',
compFilters: '56 项联动筛选',
compFiltersSub: '不是一次查一个邮编或一个房源',
ctaTitle: '别再猜哪里值得买。',
ctaDescription: '先建立符合真实生活需求的邮编候选名单,再去实地感受。',
},
// ── Pricing Page ───────────────────────────────────
@ -375,7 +436,7 @@ const zh: Translations = {
getStarted: '立即开始',
getStartedPrice: '立即开始 - {{price}}',
noCreditCard: '无需信用卡',
moneyBackGuarantee: '30天无条件退款保证',
soldOut: '已售罄',
upcoming: '即将开放',
failedToLoad: '加载价格信息失败,请稍后重试。',
@ -433,6 +494,10 @@ const zh: Translations = {
dsOsmName: 'OpenStreetMap POIs',
dsOsmOrigin: 'OpenStreetMap contributors / Geofabrik',
dsOsmUse: '涵盖大不列颠地区的商店、餐厅、医疗、休闲、旅游等兴趣点。',
dsGeolytixRetailName: 'GEOLYTIX Grocery Retail Points',
dsGeolytixRetailOrigin: 'GEOLYTIX',
dsGeolytixRetailUse:
'英国超市和便利店位置数据,包括 Waitrose、Tesco、Sainsburys、Asda、Morrisons、Aldi、Lidl、Co-op、M&S、Iceland 和 Spar 等连锁品牌。',
dsGreenspaceName: 'OS Open Greenspace',
dsGreenspaceOrigin: 'Ordnance Survey',
dsGreenspaceUse:
@ -462,7 +527,7 @@ const zh: Translations = {
dsElectionName: '2024年大选结果',
dsElectionOrigin: '英国议会',
dsElectionUse:
'2024年7月英国大选的候选人级别结果。聚合到选区级别获胜政党、投票率(%)和多数票%。通过NSPL邮编查询中的议会选区代码pcon关联到房产。',
'2024年7月英国大选的候选人级别结果。聚合到选区级别投票率(%)和各政党得票率%。通过NSPL邮编查询中的议会选区代码pcon关联到房产。',
// FAQ section titles
faqFindingTitle: '寻找理想区域',
faqCommuteTitle: '通勤与出行',
@ -541,9 +606,7 @@ const zh: Translations = {
faqPricing3Q: '免费版能用哪些功能?',
faqPricing3A:
'免费用户可以在演示区域(伦敦市中心,大约 1 至 2 区)内探索所有功能。要访问英格兰其他地区的数据,需要获取终身访问权限。',
faqPricing4Q: '可以退款吗?',
faqPricing4A:
'当然可以。我们提供 30 天退款保证。如果您不满意,请在 30 天内发送邮件至 support@perfect-postcode.co.uk 申请全额退款。',
// FAQ items — Tips and Tricks
faqTips1Q: '如何使用 AI 筛选功能,而不是逐个添加筛选条件?',
faqTips1A:
@ -663,7 +726,7 @@ const zh: Translations = {
'设置预算、通勤上限、学校质量、犯罪门槛。您关心的一切。只有符合条件的区域会保持高亮。使用眼睛图标按任意特征着色。',
step2Title: '或者直接描述',
step2Content:
'用中文输入您的需求例如“安静的地区靠近好学校£400k 以下”,我们会为您设置筛选。',
'用中文输入您的需求例如“安静的地区靠近好学校£40 以下”,我们会为您设置筛选。',
step3Title: '探索现有住宅',
step3Content:
'在英格兰各地平移和缩放。点击任何彩色区域查看犯罪、学校、价格、宽带、噪音等信息。',
@ -714,6 +777,10 @@ const zh: Translations = {
'Good+ secondary schools within 2km': '2公里内良好+中学数量',
'Good+ primary schools within 5km': '5公里内良好+小学数量',
'Good+ secondary schools within 5km': '5公里内良好+中学数量',
'Outstanding primary schools within 2km': '2公里内优秀小学数量',
'Outstanding secondary schools within 2km': '2公里内优秀中学数量',
'Outstanding primary schools within 5km': '5公里内优秀小学数量',
'Outstanding secondary schools within 5km': '5公里内优秀中学数量',
'Education, Skills and Training Score': '教育、技能和培训得分',
// ─ Feature names (Deprivation) ─
@ -754,9 +821,7 @@ const zh: Translations = {
'% Other': '% 其他',
// ─ Feature names (Politics) ─
'Winning party': '获胜政党',
'Voter turnout (%)': '投票率(%',
'Majority (%)': '多数票(%',
'% Labour': '% 工党',
'% Conservative': '% 保守党',
'% Liberal Democrat': '% 自由民主党',
@ -773,12 +838,6 @@ const zh: Translations = {
'Max available download speed (Mbps)': '最大可用下载速度Mbps',
// ─ Enum values ─
Labour: '工党',
Conservative: '保守党',
'Liberal Democrat': '自由民主党',
'Reform UK': '英国改革党',
Green: '绿党',
'Other parties': '其他政党',
Detached: '独立式住宅',
'Semi-Detached': '半独立式住宅',
Terraced: '联排住宅',
@ -793,6 +852,7 @@ const zh: Translations = {
'Serious crime': '严重犯罪',
'Minor crime': '轻微犯罪',
'Ethnic composition': '族裔组成',
'Political vote share': '政党得票率',
// ─ POI group names ─
'Public Transport': '公共交通',

View file

@ -79,6 +79,28 @@ h3 {
transform: translateY(0);
}
@keyframes showcase-progress {
from {
transform: scaleX(0);
}
to {
transform: scaleX(1);
}
}
.showcase-progress {
animation-name: showcase-progress;
animation-timing-function: linear;
animation-fill-mode: forwards;
}
@media (prefers-reduced-motion: reduce) {
.showcase-progress {
animation: none !important;
transform: scaleX(1);
}
}
/* Cereal aside — hover to reveal */
@keyframes cereal-wobble {
0%,

View file

@ -6,8 +6,8 @@
<meta name="theme-color" content="#fafaf9" media="(prefers-color-scheme: light)" />
<meta name="theme-color" content="#0a0e1a" media="(prefers-color-scheme: dark)" />
<meta name="referrer" content="no-referrer" />
<title>Perfect Postcode - Every neighbourhood in England</title>
<meta name="description" content="Explore property prices, energy ratings, crime stats, school ratings, and more across England on one interactive map." />
<title>Perfect Postcode - Find where to buy before browsing listings</title>
<meta name="description" content="Search every postcode by budget, commute, schools, safety, noise, broadband, prices and more. Build a better home-buying shortlist before viewings." />
<meta name="x-og-placeholder" content="__PERFECT_POSTCODE_OG_TAGS__" />
<script>
(function() {

View file

@ -44,7 +44,7 @@ export class PieHexExtension extends LayerExtension {
return layer.id.endsWith('-fill');
}
getShaders(extension: any): any {
getShaders(this: any, extension: any): any {
if (!extension.isEnabled(this)) return null;
return {
modules: [
@ -74,7 +74,7 @@ in vec4 vRatios0;
in vec4 vRatios1;
in vec2 vRatios2;
const vec3 pieColors[10] = vec3[10](
${this.paletteGlsl}
${extension.paletteGlsl}
);`,
'fs:DECKGL_FILTER_COLOR': `\
{

View file

@ -0,0 +1,84 @@
import { describe, expect, it } from 'vitest';
import type { FeatureMeta } from '../types';
import { apiUrl, assertOk, buildFilterString, isAbortError } from './api';
import { createSchoolFilterKey } from './school-filter';
describe('api utilities', () => {
it('builds API URLs from endpoint names, paths, and params', () => {
expect(apiUrl('features')).toBe('/api/features');
expect(apiUrl('/custom/path')).toBe('/custom/path');
expect(apiUrl('hexagons', new URLSearchParams({ bounds: '1,2,3,4' }))).toBe(
'/api/hexagons?bounds=1%2C2%2C3%2C4'
);
});
it('throws helpful errors for non-OK responses', () => {
expect(() => assertOk(new Response(null, { status: 204 }), 'empty')).not.toThrow();
expect(() =>
assertOk(new Response(null, { status: 404, statusText: 'Not Found' }), 'lookup')
).toThrow('lookup: HTTP 404 Not Found');
});
it('recognizes AbortError instances', () => {
const abort = new Error('Aborted');
abort.name = 'AbortError';
const regular = new Error('nope');
expect(isAbortError(abort)).toBe(true);
expect(isAbortError(regular)).toBe(false);
});
it('serializes numeric, absolute, and enum filters for backend routes', () => {
const features: FeatureMeta[] = [
{ name: 'Last known price', type: 'numeric', min: 0, max: 1_000_000 },
{
name: 'Estimated current price',
type: 'numeric',
absolute: true,
histogram: { min: 0, max: 2_000_000, p1: 0, p99: 2_000_000, counts: [1] },
},
{ name: 'Property type', type: 'enum', values: ['Flat', 'House'] },
];
expect(
buildFilterString(
{
'Last known price': [100_000, 500_000],
'Estimated current price': [0, 2_000_000],
'Property type': ['Flat', 'House'],
},
features
)
).toBe(
'Last known price:100000:500000;;Estimated current price:0:inf;;Property type:Flat|House'
);
expect(
buildFilterString(
{
'Last known price': [100_000, 500_000],
'Property type': ['Flat'],
},
features,
'Last known price'
)
).toBe('Property type:Flat');
});
it('deduplicates repeated synthetic school filters before backend routes', () => {
const features: FeatureMeta[] = [
{ name: 'Good+ primary schools within 2km', type: 'numeric', min: 0, max: 10 },
];
expect(
buildFilterString(
{
[createSchoolFilterKey('primary', 'good', 2, 1)]: [1, 10],
[createSchoolFilterKey('primary', 'good', 2, 2)]: [2, 8],
},
features
)
).toBe('Good+ primary schools within 2km:2:8');
});
});

View file

@ -1,6 +1,7 @@
import type { FeatureMeta, FeatureFilters } from '../types';
import { INITIAL_RETRY_MS, MAX_RETRY_MS } from './consts';
import pb from './pocketbase';
import { getSchoolBackendFeatureName } from './school-filter';
export function logNonAbortError(label: string, error: unknown): void {
if (error instanceof Error && error.name === 'AbortError') {
@ -82,8 +83,29 @@ export function buildFilterString(
): string {
const entries = Object.entries(filters);
if (entries.length === 0) return '';
return entries
.filter(([name]) => name !== exclude)
const merged = new Map<string, [number, number] | string[]>();
for (const [name, value] of entries) {
if (name === exclude) continue;
const backendName = getSchoolBackendFeatureName(name) ?? name;
const prev = merged.get(backendName);
if (
prev &&
Array.isArray(prev) &&
Array.isArray(value) &&
typeof prev[0] === 'number' &&
typeof value[0] === 'number'
) {
merged.set(backendName, [
Math.max(prev[0] as number, value[0] as number),
Math.min(prev[1] as number, value[1] as number),
]);
} else if (!prev) {
merged.set(backendName, value);
}
}
return [...merged.entries()]
.map(([name, value]) => {
const meta = features.find((f) => f.name === name);
if (meta?.type === 'enum') {

View file

@ -44,6 +44,46 @@ export const FEATURE_GRADIENT: { t: number; color: [number, number, number] }[]
{ t: 1, color: [142, 68, 173] },
];
export type GradientStop = { t: number; color: [number, number, number] };
function partyGradient(color: [number, number, number]): GradientStop[] {
return [
{ t: 0, color: [255, 255, 255] },
{
t: 0.5,
color: [
Math.round(255 + (color[0] - 255) * 0.45),
Math.round(255 + (color[1] - 255) * 0.45),
Math.round(255 + (color[2] - 255) * 0.45),
],
},
{ t: 1, color },
];
}
/** UK party colours for the 2024 General Election vote-share map layers. */
export const PARTY_FEATURE_GRADIENTS: Record<string, GradientStop[]> = {
'% Labour': partyGradient([228, 0, 59]), // Labour red
'% Conservative': partyGradient([0, 135, 220]), // Conservative blue
'% Liberal Democrat': partyGradient([255, 100, 0]), // Liberal Democrat orange
'% Reform UK': partyGradient([18, 182, 207]), // Reform UK cyan
'% Green': partyGradient([106, 176, 35]), // Green Party green
'% Other parties': partyGradient([107, 114, 128]), // neutral fallback for grouped parties
};
export const PARTY_FEATURE_COLORS: Record<string, string> = Object.fromEntries(
Object.entries(PARTY_FEATURE_GRADIENTS).map(([featureName, gradient]) => {
const color = gradient[gradient.length - 1].color;
return [featureName, `rgb(${color[0]}, ${color[1]}, ${color[2]})`];
})
);
export function getFeatureGradient(featureName: string | null | undefined): GradientStop[] {
return featureName
? (PARTY_FEATURE_GRADIENTS[featureName] ?? FEATURE_GRADIENT)
: FEATURE_GRADIENT;
}
/** Number of properties gradient — light mode (cream → orange) */
export const DENSITY_GRADIENT: { t: number; color: [number, number, number] }[] = [
{ t: 0, color: [255, 255, 255] },
@ -86,6 +126,47 @@ export const POI_GROUP_COLORS: Record<string, [number, number, number]> = {
/** Default color for unknown POI groups */
export const POI_DEFAULT_COLOR: [number, number, number] = [107, 114, 128];
/** POI category → icon/logo URL for branded and transport categories */
export const POI_CATEGORY_LOGOS: Record<string, string> = {
Airport: '/assets/twemoji/2708.png',
Aldi: 'https://geolytix.github.io/MapIcons/brands/aldi_24px.svg',
Amazon: 'https://geolytix.github.io/MapIcons/brands/amazon_fresh_alt_24px.svg',
Asda: 'https://geolytix.github.io/MapIcons/asda/asda_primary.svg',
Bakery: '/assets/twemoji/1f950.png',
Booths: 'https://geolytix.github.io/MapIcons/brands/booths_24px.svg',
Budgens: 'https://geolytix.github.io/MapIcons/brands/budgens_24px.svg',
'Bus station': '/assets/twemoji/1f68c.png',
'Bus stop': '/assets/twemoji/1f68f.png',
'Butcher & Fishmonger': '/assets/twemoji/1f969.png',
Centra: 'https://geolytix.github.io/MapIcons/brands/centra_24px.svg',
'Co-op': 'https://geolytix.github.io/MapIcons/brands/coop_24px.svg',
COOK: 'https://geolytix.github.io/MapIcons/brands/cook.svg',
'Convenience Store': '/assets/twemoji/1f3ea.png',
Costco: 'https://geolytix.github.io/MapIcons/brands/costco_24px.svg',
'Deli & Specialty': '/assets/twemoji/1f9c6.png',
'Dunnes Stores': 'https://geolytix.github.io/MapIcons/brands/dunnes_stores_24px.svg',
Farmfoods: 'https://geolytix.github.io/MapIcons/brands/farmfoods_updated_24px.svg',
Ferry: '/assets/twemoji/26f4.png',
Greengrocer: '/assets/twemoji/1f96c.png',
'Heron Foods': 'https://geolytix.github.io/MapIcons/brands/heron_24px.svg',
Iceland: 'https://geolytix.github.io/MapIcons/brands/iceland_24px.svg',
Lidl: 'https://geolytix.github.io/MapIcons/brands/lidl_24px.svg',
Makro: 'https://geolytix.github.io/MapIcons/brands/makro_24px.svg',
'M&S': 'https://geolytix.github.io/MapIcons/brands/mns_24px.svg',
Morrisons: 'https://geolytix.github.io/MapIcons/brands/morrisons_24px.svg',
'Off-Licence': '/assets/twemoji/1f377.png',
'Planet Organic': 'https://geolytix.github.io/MapIcons/logos/planet_organic_24px.svg',
'Rail station': '/assets/twemoji/1f686.png',
"Sainsbury's": 'https://geolytix.github.io/MapIcons/brands/sainsburys_24px.svg',
Spar: 'https://geolytix.github.io/MapIcons/brands/spar_24px.svg',
Supermarket: '/assets/twemoji/1f6d2.png',
Tesco: 'https://geolytix.github.io/MapIcons/brands/tesco_24px.svg',
'Taxi rank': '/assets/twemoji/1f695.png',
'Tube station': 'https://geolytix.github.io/MapIcons/public_transport/london_tube.svg',
Waitrose: 'https://geolytix.github.io/MapIcons/brands/waitrose_24px.svg',
'Whole Foods Market': 'https://geolytix.github.io/MapIcons/brands/wholefoods_24px.svg',
};
/** Categories only shown when zoomed in past MINOR_POI_ZOOM_THRESHOLD */
export const MINOR_POI_CATEGORIES = new Set(['Bus stop', 'Taxi rank', 'EV Charging', 'Playground']);
@ -156,6 +237,20 @@ export const STACKED_GROUPS: Record<
components: ['% White', '% South Asian', '% East Asian', '% Black', '% Mixed', '% Other'],
},
],
Politics: [
{
label: 'Political vote share',
unit: '%',
components: [
'% Labour',
'% Conservative',
'% Liberal Democrat',
'% Reform UK',
'% Green',
'% Other parties',
],
},
],
};
/**
@ -218,14 +313,6 @@ export const ENUM_PALETTE: [number, number, number][] = [
* Any value not listed falls back to ENUM_PALETTE by index.
*/
export const ENUM_COLOR_OVERRIDES: Record<string, Record<string, [number, number, number]>> = {
'Winning party': {
Labour: [220, 36, 31], // Labour red
Conservative: [0, 135, 220], // Conservative blue
'Liberal Democrat': [253, 187, 48], // Lib Dem gold
'Reform UK': [18, 178, 196], // Reform teal
Green: [106, 176, 35], // Green party green
'Other parties': [148, 130, 160], // muted purple
},
'Property type': {
Detached: [249, 115, 22], // orange
'Semi-Detached': [59, 130, 246], // blue

View file

@ -0,0 +1,78 @@
import { describe, expect, it } from 'vitest';
import { buildPropertySearchUrls } from './external-search';
describe('external property search URLs', () => {
it('returns null when no postcode is available', () => {
expect(
buildPropertySearchUrls({
location: { lat: 51.5, lon: -0.1, resolution: 8 },
filters: {},
})
).toBeNull();
});
it('builds Rightmove, OnTheMarket, and Zoopla URLs with snapped filters', () => {
const urls = buildPropertySearchUrls({
location: {
lat: 51.501,
lon: -0.141,
resolution: 8,
postcode: 'SW1A 1AA',
isPostcode: false,
},
rightmoveLocationId: 'POSTCODE^123456',
filters: {
'Last known price': [123_456, 376_000],
'Property type': ['Detached', 'Flats/Maisonettes'],
'Leasehold/Freehold': ['Freehold'],
'Number of bedrooms & living rooms': [2, 4],
},
});
expect(urls).not.toBeNull();
const rightmove = new URL(urls!.rightmove!);
const onthemarket = new URL(urls!.onthemarket);
const zoopla = new URL(urls!.zoopla);
expect(rightmove.hostname).toBe('www.rightmove.co.uk');
expect(rightmove.searchParams.get('searchLocation')).toBe('SW1A 1AA');
expect(rightmove.searchParams.get('locationIdentifier')).toBe('POSTCODE^123456');
expect(rightmove.searchParams.get('radius')).toBe('0.5');
expect(rightmove.searchParams.get('minPrice')).toBe('120000');
expect(rightmove.searchParams.get('maxPrice')).toBe('400000');
expect(rightmove.searchParams.get('minBedrooms')).toBe('1');
expect(rightmove.searchParams.get('maxBedrooms')).toBe('3');
expect(rightmove.searchParams.get('propertyTypes')).toBe('detached,flat');
expect(rightmove.searchParams.get('tenureTypes')).toBe('FREEHOLD');
expect(onthemarket.pathname).toBe('/for-sale/property/sw1a-1aa/');
expect(onthemarket.searchParams.get('radius')).toBe('0.5');
expect(onthemarket.searchParams.get('min-price')).toBe('120000');
expect(onthemarket.searchParams.get('max-price')).toBe('400000');
expect(onthemarket.searchParams.getAll('prop-types')).toEqual(['detached', 'flats']);
expect(zoopla.searchParams.get('q')).toBe('SW1A 1AA');
expect(zoopla.searchParams.get('radius')).toBe('0.5');
expect(zoopla.searchParams.get('price_min')).toBe('100000');
expect(zoopla.searchParams.get('price_max')).toBe('400000');
expect(zoopla.searchParams.getAll('property_sub_type')).toEqual(['detached', 'flat']);
});
it('omits Rightmove when location identifier is missing and uses zero radius for postcodes', () => {
const urls = buildPropertySearchUrls({
location: {
lat: 51.501,
lon: -0.141,
resolution: 9,
postcode: 'E1 6AN',
isPostcode: true,
},
filters: {},
});
expect(urls?.rightmove).toBeNull();
expect(new URL(urls!.onthemarket).searchParams.get('radius')).toBe('0.25');
expect(new URL(urls!.zoopla).searchParams.get('radius')).toBe('0.25');
});
});

View file

@ -103,8 +103,7 @@ export function buildPropertySearchUrls({
const radiusMiles = isPostcode ? 0 : (H3_RADIUS_MILES[resolution] ?? 1);
const priceFilter =
filters['Estimated current price'] ?? filters['Last known price'];
const priceFilter = filters['Estimated current price'] ?? filters['Last known price'];
const minPrice =
Array.isArray(priceFilter) && typeof priceFilter[0] === 'number' ? priceFilter[0] : undefined;
const maxPrice =
@ -122,6 +121,16 @@ export function buildPropertySearchUrls({
? (tenureFilter as string[])
: [];
const habitableRoomsFilter = filters['Number of bedrooms & living rooms'];
const minBedrooms =
Array.isArray(habitableRoomsFilter) && typeof habitableRoomsFilter[0] === 'number'
? Math.max(0, habitableRoomsFilter[0] - 1)
: undefined;
const maxBedrooms =
Array.isArray(habitableRoomsFilter) && typeof habitableRoomsFilter[1] === 'number'
? Math.max(0, habitableRoomsFilter[1] - 1)
: undefined;
// Rightmove — requires locationIdentifier from typeahead API
let rightmove: string | null = null;
if (rightmoveLocationId) {
@ -134,6 +143,8 @@ export function buildPropertySearchUrls({
rmParams.set('minPrice', String(snapToAllowed(minPrice, RIGHTMOVE_PRICES, 'floor')));
if (maxPrice !== undefined)
rmParams.set('maxPrice', String(snapToAllowed(maxPrice, RIGHTMOVE_PRICES, 'ceil')));
if (minBedrooms !== undefined) rmParams.set('minBedrooms', String(minBedrooms));
if (maxBedrooms !== undefined) rmParams.set('maxBedrooms', String(maxBedrooms));
if (selectedTypes.length > 0) {
const rmTypes = [
...new Set(
@ -161,6 +172,8 @@ export function buildPropertySearchUrls({
otmParams.set('min-price', String(snapToAllowed(minPrice, OTM_PRICES, 'floor')));
if (maxPrice !== undefined)
otmParams.set('max-price', String(snapToAllowed(maxPrice, OTM_PRICES, 'ceil')));
if (minBedrooms !== undefined) otmParams.set('min-bedrooms', String(minBedrooms));
if (maxBedrooms !== undefined) otmParams.set('max-bedrooms', String(maxBedrooms));
if (selectedTypes.length > 0) {
const otmTypes = [
...new Set(selectedTypes.map((t) => PROPERTY_TYPE_MAP[t]?.onthemarket).filter(Boolean)),
@ -181,6 +194,8 @@ export function buildPropertySearchUrls({
zParams.set('price_min', String(snapToAllowed(minPrice, ZOOPLA_PRICES, 'floor')));
if (maxPrice !== undefined)
zParams.set('price_max', String(snapToAllowed(maxPrice, ZOOPLA_PRICES, 'ceil')));
if (minBedrooms !== undefined) zParams.set('beds_min', String(minBedrooms));
if (maxBedrooms !== undefined) zParams.set('beds_max', String(maxBedrooms));
if (selectedTypes.length > 0) {
const zTypes = [
...new Set(selectedTypes.map((t) => PROPERTY_TYPE_MAP[t]?.zoopla).filter(Boolean)),

View file

@ -129,6 +129,19 @@ const FEATURE_ICON_PATHS: Record<string, ReactNode> = {
<path d="M6 12v5c0 2.5 3 4 6 4s6-1.5 6-4v-5" />
</>
),
'Outstanding primary schools within 5km': (
<>
<path d="M4 19V9l8-6 8 6v10" />
<path d="M9 19v-6h6v6" />
<line x1="4" y1="19" x2="20" y2="19" />
</>
),
'Outstanding secondary schools within 5km': (
<>
<path d="M22 10v6M2 10l10-5 10 5-10 5z" />
<path d="M6 12v5c0 2.5 3 4 6 4s6-1.5 6-4v-5" />
</>
),
'Good+ primary schools within 2km': (
<>
<path d="M4 19V9l8-6 8 6v10" />
@ -142,6 +155,19 @@ const FEATURE_ICON_PATHS: Record<string, ReactNode> = {
<path d="M6 12v5c0 2.5 3 4 6 4s6-1.5 6-4v-5" />
</>
),
'Outstanding primary schools within 2km': (
<>
<path d="M4 19V9l8-6 8 6v10" />
<path d="M9 19v-6h6v6" />
<line x1="4" y1="19" x2="20" y2="19" />
</>
),
'Outstanding secondary schools within 2km': (
<>
<path d="M22 10v6M2 10l10-5 10 5-10 5z" />
<path d="M6 12v5c0 2.5 3 4 6 4s6-1.5 6-4v-5" />
</>
),
// ── Deprivation ──────────────────────────────
'Income Score (rate)': (

View file

@ -0,0 +1,42 @@
import { describe, expect, it } from 'vitest';
import {
buildPercentileScale,
calculateHistogramMean,
formatFilterValue,
formatTransactionDate,
parseInputValue,
roundedPercentages,
} from './format';
describe('format utilities', () => {
it('formats compact filter values and transaction dates', () => {
expect(formatFilterValue(1250)).toBe('1.3k');
expect(formatFilterValue(1_250_000)).toBe('1.3M');
expect(formatFilterValue(1250, true)).toBe('1250');
expect(formatTransactionDate(2024.5)).toBe('Jul 2024');
});
it('parses user-entered compact numeric values', () => {
expect(parseInputValue('£1.25M', { prefix: '£', step: 5000 })).toBe(1_250_000);
expect(parseInputValue('45 sqm', { suffix: ' sqm' })).toBe(45);
expect(parseInputValue('2.5万')).toBe(25_000);
expect(parseInputValue('not a number')).toBeNull();
});
it('rounds percentages so displayed values sum to 100', () => {
expect(roundedPercentages([1, 1, 1], 3)).toEqual([34, 33, 33]);
expect(roundedPercentages([1, 2, 3], 6, 1)).toEqual([16.7, 33.3, 50]);
expect(roundedPercentages([5, 5], 0)).toEqual([0, 0]);
});
it('maps histogram percentiles and weighted means consistently', () => {
const histogram = { min: 0, p1: 10, p99: 90, max: 100, counts: [10, 80, 10] };
const scale = buildPercentileScale(histogram);
expect(scale.toValue(0)).toBe(0);
expect(scale.toValue(50)).toBeCloseTo(50);
expect(scale.toPercentile(50)).toBeCloseTo(50);
expect(calculateHistogramMean(histogram)).toBeCloseTo(50);
});
});

View file

@ -1,3 +1,5 @@
import i18n from 'i18next';
interface ValueFormat {
prefix?: string;
suffix?: string;
@ -5,10 +7,31 @@ interface ValueFormat {
raw?: boolean;
}
function usesChineseNumberUnits(): boolean {
return i18n.language?.toLowerCase().startsWith('zh') ?? false;
}
function formatChineseCompactNumber(value: number): string | null {
const abs = Math.abs(value);
if (abs >= 100_000_000) return `${trimFixed(value / 100_000_000)}亿`;
if (abs >= 10_000) return `${trimFixed(value / 10_000)}`;
return null;
}
function trimFixed(value: number): string {
return value.toFixed(1).replace(/\.0$/, '');
}
export function formatValue(value: number, fmt?: ValueFormat): string {
const p = fmt?.prefix ?? '';
const s = fmt?.suffix ?? '';
if (fmt?.raw) return `${p}${Math.round(value)}${s}`;
if (usesChineseNumberUnits()) {
const chineseCompactValue = formatChineseCompactNumber(value);
if (chineseCompactValue) return `${p}${chineseCompactValue}${s}`;
if (Number.isInteger(value)) return `${p}${value.toLocaleString()}${s}`;
return `${p}${value.toFixed(1)}${s}`;
}
if (Math.abs(value) >= 1_000_000) return `${p}${(value / 1_000_000).toFixed(1)}M${s}`;
if (Math.abs(value) >= 1_000) return `${p}${(value / 1_000).toFixed(1)}k${s}`;
if (Number.isInteger(value)) return `${p}${value.toLocaleString()}${s}`;
@ -17,6 +40,12 @@ export function formatValue(value: number, fmt?: ValueFormat): string {
export function formatFilterValue(value: number, raw?: boolean): string {
if (raw) return Math.round(value).toString();
if (usesChineseNumberUnits()) {
const chineseCompactValue = formatChineseCompactNumber(value);
if (chineseCompactValue) return chineseCompactValue;
if (Number.isInteger(value)) return value.toString();
return value.toFixed(2);
}
if (Math.abs(value) >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`;
if (Math.abs(value) >= 1_000) return `${(value / 1_000).toFixed(1)}k`;
if (Number.isInteger(value)) return value.toString();
@ -31,14 +60,17 @@ export function parseInputValue(
let s = text.trim();
if (opts?.prefix) s = s.replace(new RegExp(`^\\${opts.prefix}`), '');
if (opts?.suffix) s = s.replace(new RegExp(`${opts.suffix.trim()}$`), '');
s = s.trim().replace(/,/g, '');
const m = s.match(/^(-?\d+\.?\d*)\s*([kKmM]?)$/);
s = s.trim().replace(/[,]/g, '');
const m = s.match(/^(-?\d+\.?\d*)\s*([kKmM万亿億]?)$/);
if (!m) return null;
let val = parseFloat(m[1]);
if (isNaN(val)) return null;
const unit = m[2].toLowerCase();
const unit = m[2];
if (unit === 'k') val *= 1_000;
else if (unit === 'm') val *= 1_000_000;
else if (unit === 'K') val *= 1_000;
else if (unit === 'm' || unit === 'M') val *= 1_000_000;
else if (unit === '万') val *= 10_000;
else if (unit === '亿' || unit === '億') val *= 100_000_000;
if (opts?.step) val = Math.round(val / opts.step) * opts.step;
return val;
}
@ -86,6 +118,30 @@ export function formatNumber(value: number | undefined, decimals = 0): string {
return decimals > 0 ? value.toFixed(decimals) : Math.round(value).toLocaleString();
}
/**
* Compute percentages that always sum to exactly 100, using the largest-remainder
* (Hamilton) method. Floors each raw percentage, then distributes the residual to
* the segments with the largest fractional parts. Eliminates rounding drift where
* three 33.3% segments would otherwise display as "33%, 33%, 33% = 99%".
*
* Assumes `total` equals (or closely equals) the sum of `values`.
*/
export function roundedPercentages(values: number[], total: number, decimals = 0): number[] {
if (total <= 0 || values.length === 0) return values.map(() => 0);
const scale = 10 ** decimals;
const targetSum = 100 * scale;
const raw = values.map((v) => (v / total) * 100 * scale);
const floors = raw.map((r) => Math.floor(r));
const result = floors.slice();
let diff = targetSum - floors.reduce((a, b) => a + b, 0);
const order = raw.map((r, i) => ({ i, frac: r - floors[i] })).sort((a, b) => b.frac - a.frac);
for (let k = 0; k < order.length && diff > 0; k++) {
result[order[k].i] += 1;
diff -= 1;
}
return result.map((v) => v / scale);
}
export function formatRelativeTime(isoDate: string): string {
// eslint-disable-next-line @typescript-eslint/no-var-requires
const i18n = require('../i18n').default as {

View file

@ -0,0 +1,101 @@
import { describe, expect, it } from 'vitest';
import { DENSITY_GRADIENT, ENUM_PALETTE, FEATURE_GRADIENT } from './consts';
import {
emojiToTwemojiUrl,
enumIndexToColor,
getBoundsFromViewState,
getFeatureFillColor,
getPoiIconUrl,
zoomToResolution,
} from './map-utils';
describe('map utilities', () => {
it('maps zoom levels to H3 resolutions at configured thresholds', () => {
expect(zoomToResolution(6.9)).toBe(5);
expect(zoomToResolution(7)).toBe(6);
expect(zoomToResolution(10.6)).toBe(8);
expect(zoomToResolution(14)).toBe(9);
});
it('computes buffered bounds around a view state', () => {
const bounds = getBoundsFromViewState(
{ latitude: 51.5, longitude: -0.1, zoom: 12, pitch: 0 },
1200,
800
);
expect(bounds.south).toBeLessThan(51.5);
expect(bounds.north).toBeGreaterThan(51.5);
expect(bounds.west).toBeLessThan(-0.1);
expect(bounds.east).toBeGreaterThan(-0.1);
});
it('builds twemoji URLs and wraps enum colors', () => {
expect(emojiToTwemojiUrl('🛒')).toBe('/assets/twemoji/1f6d2.png');
expect(emojiToTwemojiUrl('')).toBe('/assets/twemoji/1f4cd.png');
expect(enumIndexToColor(ENUM_PALETTE.length)).toEqual(ENUM_PALETTE[0]);
});
it('prefers POI category logos before falling back to emoji icons', () => {
expect(getPoiIconUrl('Waitrose', '🛒')).toBe(
'https://geolytix.github.io/MapIcons/brands/waitrose_24px.svg'
);
expect(getPoiIconUrl('Unknown category', '🛒')).toBe('/assets/twemoji/1f6d2.png');
});
it('returns fallback, filtered, enum, feature, and density colors', () => {
expect(
getFeatureFillColor(
null,
undefined,
undefined,
[0, 100],
null,
0,
DENSITY_GRADIENT,
false,
180
)
).toEqual([128, 128, 128, 80]);
expect(
getFeatureFillColor(50, 50, 60, [0, 100], [70, 90], 0, DENSITY_GRADIENT, true, 180)
).toEqual([60, 55, 50, 60]);
expect(
getFeatureFillColor(1, 1, 1, [0, 2], null, 0, DENSITY_GRADIENT, false, 180, 3, ENUM_PALETTE)
).toEqual([...ENUM_PALETTE[1], 180]);
expect(
getFeatureFillColor(
0,
0,
100,
[0, 100],
null,
0,
DENSITY_GRADIENT,
false,
200,
0,
undefined,
FEATURE_GRADIENT
)
).toEqual([...FEATURE_GRADIENT[0].color, 200]);
expect(
getFeatureFillColor(
undefined,
undefined,
undefined,
null,
null,
0,
DENSITY_GRADIENT,
false,
150
)
).toEqual([...DENSITY_GRADIENT[0].color, 150]);
});
});

View file

@ -9,6 +9,8 @@ import {
TWEMOJI_BASE,
BUFFER_MULTIPLIER,
ENUM_PALETTE,
POI_CATEGORY_LOGOS,
type GradientStop,
} from './consts';
const ROAD_OPACITY = 0.4;
@ -64,8 +66,6 @@ export function getMapStyle(theme: 'light' | 'dark'): StyleSpecification {
} as StyleSpecification;
}
type GradientStop = { t: number; color: [number, number, number] };
// Oklab color space for perceptually uniform interpolation
function srgbToLinear(c: number): number {
const v = c / 255;
@ -131,8 +131,11 @@ function interpolateGradient(t: number, gradient: GradientStop[]): [number, numb
return gradient[gradient.length - 1].color;
}
function normalizedToColor(t: number): [number, number, number] {
return interpolateGradient(t, FEATURE_GRADIENT);
function normalizedToColor(
t: number,
gradient: GradientStop[] = FEATURE_GRADIENT
): [number, number, number] {
return interpolateGradient(t, gradient);
}
function countToColor(
@ -194,6 +197,10 @@ export function emojiToTwemojiUrl(emoji: string): string {
return `${TWEMOJI_BASE}${hex}.png`;
}
export function getPoiIconUrl(category: string, emoji: string): string {
return POI_CATEGORY_LOGOS[category] ?? emojiToTwemojiUrl(emoji);
}
/** Look up a discrete color from the enum palette by index (wraps if > palette size). */
export function enumIndexToColor(
index: number,
@ -220,7 +227,8 @@ export function getFeatureFillColor(
isDark: boolean,
alpha: number,
enumCount: number = 0,
enumPalette?: [number, number, number][]
enumPalette?: [number, number, number][],
featureGradient: GradientStop[] = FEATURE_GRADIENT
): [number, number, number, number] {
if (colorRange) {
if (value == null)
@ -244,9 +252,9 @@ export function getFeatureFillColor(
const range = colorRange[1] - colorRange[0];
if (range === 0)
return [...FEATURE_GRADIENT[0].color, alpha] as [number, number, number, number];
return [...featureGradient[0].color, alpha] as [number, number, number, number];
const t = ((value as number) - colorRange[0]) / range;
const rgb = normalizedToColor(Math.max(0, Math.min(1, t)));
const rgb = normalizedToColor(Math.max(0, Math.min(1, t)), featureGradient);
return [...rgb, alpha] as [number, number, number, number];
}
return [...countToColor(Math.max(0, Math.min(1, countNormalized)), densityGradient), alpha] as [

View file

@ -0,0 +1,216 @@
import type { FeatureFilters, FeatureMeta } from '../types';
export const SCHOOL_FILTER_NAME = 'Schools';
export const SCHOOL_FILTER_KEY_PREFIX = `${SCHOOL_FILTER_NAME}:`;
export type SchoolPhase = 'primary' | 'secondary';
export type SchoolRating = 'good' | 'outstanding';
export type SchoolDistance = 2 | 5;
export interface SchoolFilterConfig {
phase: SchoolPhase;
rating: SchoolRating;
distance: SchoolDistance;
featureName: string;
}
export const SCHOOL_FILTERS: SchoolFilterConfig[] = [
{
phase: 'primary',
rating: 'good',
distance: 2,
featureName: 'Good+ primary schools within 2km',
},
{
phase: 'secondary',
rating: 'good',
distance: 2,
featureName: 'Good+ secondary schools within 2km',
},
{
phase: 'primary',
rating: 'outstanding',
distance: 2,
featureName: 'Outstanding primary schools within 2km',
},
{
phase: 'secondary',
rating: 'outstanding',
distance: 2,
featureName: 'Outstanding secondary schools within 2km',
},
{
phase: 'primary',
rating: 'good',
distance: 5,
featureName: 'Good+ primary schools within 5km',
},
{
phase: 'secondary',
rating: 'good',
distance: 5,
featureName: 'Good+ secondary schools within 5km',
},
{
phase: 'primary',
rating: 'outstanding',
distance: 5,
featureName: 'Outstanding primary schools within 5km',
},
{
phase: 'secondary',
rating: 'outstanding',
distance: 5,
featureName: 'Outstanding secondary schools within 5km',
},
];
const SCHOOL_FEATURE_NAMES = new Set(SCHOOL_FILTERS.map((filter) => filter.featureName));
export function isBackendSchoolFeatureName(name: string): boolean {
return SCHOOL_FEATURE_NAMES.has(name);
}
export function isSchoolFilterName(name: string): boolean {
return isBackendSchoolFeatureName(name) || name.startsWith(SCHOOL_FILTER_KEY_PREFIX);
}
export function getSchoolFilterConfig(name: string): SchoolFilterConfig | null {
const synthetic = parseSchoolFilterKey(name);
if (synthetic) return synthetic;
return SCHOOL_FILTERS.find((filter) => filter.featureName === name) ?? null;
}
export function getSchoolFeatureName(
phase: SchoolPhase,
rating: SchoolRating,
distance: SchoolDistance
): string {
return (
SCHOOL_FILTERS.find(
(filter) => filter.phase === phase && filter.rating === rating && filter.distance === distance
)?.featureName ?? SCHOOL_FILTERS[0].featureName
);
}
export function createSchoolFilterKey(
phase: SchoolPhase,
rating: SchoolRating,
distance: SchoolDistance,
id: number | string
): string {
return `${SCHOOL_FILTER_KEY_PREFIX}${phase}:${rating}:${distance}:${id}`;
}
export function getSchoolFilterKeyId(name: string): string | null {
if (!name.startsWith(SCHOOL_FILTER_KEY_PREFIX)) return null;
return name.split(':')[4] ?? null;
}
export function parseSchoolFilterKey(name: string): SchoolFilterConfig | null {
if (!name.startsWith(SCHOOL_FILTER_KEY_PREFIX)) return null;
const [, phaseRaw, ratingRaw, distanceRaw] = name.split(':');
const phase = phaseRaw as SchoolPhase;
const rating = ratingRaw as SchoolRating;
const distance = Number(distanceRaw) as SchoolDistance;
if (
(phase !== 'primary' && phase !== 'secondary') ||
(rating !== 'good' && rating !== 'outstanding') ||
(distance !== 2 && distance !== 5)
) {
return null;
}
return {
phase,
rating,
distance,
featureName: getSchoolFeatureName(phase, rating, distance),
};
}
export function getSchoolBackendFeatureName(name: string): string | null {
if (isBackendSchoolFeatureName(name)) return name;
return parseSchoolFilterKey(name)?.featureName ?? null;
}
export function replaceSchoolFilterKeySelection(
key: string,
next: {
phase?: SchoolPhase;
rating?: SchoolRating;
distance?: SchoolDistance;
}
): string {
const config = getSchoolFilterConfig(key) ?? SCHOOL_FILTERS[0];
const parts = key.startsWith(SCHOOL_FILTER_KEY_PREFIX) ? key.split(':') : [];
const id = parts[4] ?? '0';
return createSchoolFilterKey(
next.phase ?? config.phase,
next.rating ?? config.rating,
next.distance ?? config.distance,
id
);
}
export function getDefaultSchoolFeatureName(features: FeatureMeta[]): string | null {
return (
SCHOOL_FILTERS.find((filter) => features.some((feature) => feature.name === filter.featureName))
?.featureName ?? null
);
}
export function getActiveSchoolFeatureName(filters: FeatureFilters): string | null {
return Object.keys(filters).find(isSchoolFilterName) ?? null;
}
export function normalizeSchoolFilters(filters: FeatureFilters): FeatureFilters {
let changed = false;
const next: FeatureFilters = {};
for (const [name, value] of Object.entries(filters)) {
if (isBackendSchoolFeatureName(name)) {
const config = getSchoolFilterConfig(name);
if (!config) continue;
next[
createSchoolFilterKey(
config.phase,
config.rating,
config.distance,
Object.keys(next).length
)
] = value;
changed = true;
continue;
}
next[name] = value;
}
return changed ? next : filters;
}
export function getSchoolFilterMeta(features: FeatureMeta[]): FeatureMeta {
const sourceFeatureName = getDefaultSchoolFeatureName(features);
const sourceFeature = sourceFeatureName
? features.find((feature) => feature.name === sourceFeatureName)
: undefined;
return {
name: SCHOOL_FILTER_NAME,
type: 'numeric',
group: 'Education',
min: sourceFeature?.min ?? 0,
max: sourceFeature?.max ?? 10,
step: 1,
description: 'Rated primary and secondary schools nearby',
detail:
'Filter by primary or secondary schools, Ofsted rating, and whether schools are within 2km or 5km.',
source: 'ofsted',
raw: true,
};
}
export function clampSchoolRange(value: [number, number], feature?: FeatureMeta): [number, number] {
const min = feature?.histogram?.min ?? feature?.min ?? 0;
const max = feature?.histogram?.max ?? feature?.max ?? Math.max(1, value[1]);
return [Math.max(min, Math.min(value[0], max)), Math.max(min, Math.min(value[1], max))];
}

View file

@ -0,0 +1,118 @@
import { beforeEach, describe, expect, it } from 'vitest';
import type { FeatureMeta } from '../types';
import { parseUrlState, stateToParams } from './url-state';
import { createSchoolFilterKey } from './school-filter';
describe('url-state', () => {
beforeEach(() => {
window.history.replaceState({}, '', '/');
});
it('parses view, filters, POIs, tab, postcode, and travel-time params', () => {
window.history.replaceState(
{},
'',
'/?lat=51.5074&lon=-0.1278&zoom=12.5&filter=Last%20known%20price:100000:500000&filter=Property%20type:Flat|House&poi=supermarket&tab=properties&pc=SW1A%201AA&tt=transit:kings-cross:Kings%20Cross:b:0:30'
);
const state = parseUrlState();
expect(state.viewState).toEqual({
latitude: 51.5074,
longitude: -0.1278,
zoom: 12.5,
pitch: 0,
});
expect(state.filters).toEqual({
'Last known price': [100000, 500000],
'Property type': ['Flat', 'House'],
});
expect(state.poiCategories).toEqual(new Set(['supermarket']));
expect(state.tab).toBe('properties');
expect(state.postcode).toBe('SW1A 1AA');
expect(state.travelTime?.entries).toEqual([
{
mode: 'transit',
slug: 'kings-cross',
label: 'Kings Cross',
timeRange: [0, 30],
useBest: true,
},
]);
});
it('serializes map state and active filters into stable URL params', () => {
const features: FeatureMeta[] = [
{ name: 'Last known price', type: 'numeric' },
{ name: 'Property type', type: 'enum', values: ['Flat', 'House'] },
];
const params = stateToParams(
{ latitude: 51.50742, longitude: -0.12781, zoom: 12.47 },
{
'Last known price': [100000, 500000],
'Property type': ['Flat', 'House'],
},
features,
new Set(['supermarket']),
'properties',
[
{
mode: 'bicycle',
slug: 'bank',
label: 'Bank',
useBest: false,
timeRange: [5, 25],
},
]
);
expect(params.get('lat')).toBe('51.5074');
expect(params.get('lon')).toBe('-0.1278');
expect(params.get('zoom')).toBe('12.5');
expect(params.getAll('filter')).toEqual([
'Last known price:100000:500000',
'Property type:Flat|House',
]);
expect(params.getAll('poi')).toEqual(['supermarket']);
expect(params.get('tab')).toBe('properties');
expect(params.getAll('tt')).toEqual(['bicycle:bank:Bank:5:25']);
});
it('round-trips repeated school filters with dedicated URL params', () => {
const schoolOne = createSchoolFilterKey('primary', 'good', 2, 1);
const schoolTwo = createSchoolFilterKey('secondary', 'outstanding', 5, 2);
const params = stateToParams(
null,
{
[schoolOne]: [1, 10],
[schoolTwo]: [2, 15],
},
[],
new Set(),
'area'
);
expect(params.getAll('school')).toEqual([
'primary:good:2:1:10',
'secondary:outstanding:5:2:15',
]);
expect(params.getAll('filter')).toEqual([]);
window.history.replaceState({}, '', `/?${params.toString()}`);
const state = parseUrlState();
expect(state.filters).toEqual({
[createSchoolFilterKey('primary', 'good', 2, 0)]: [1, 10],
[createSchoolFilterKey('secondary', 'outstanding', 5, 1)]: [2, 15],
});
});
it('omits the default area tab', () => {
const params = stateToParams(null, {}, [], new Set(), 'area');
expect(params.has('tab')).toBe(false);
});
});

View file

@ -5,10 +5,20 @@ import {
type TravelTimeEntry,
type TravelTimeInitial,
} from '../hooks/useTravelTime';
import {
SCHOOL_FILTER_NAME,
createSchoolFilterKey,
getSchoolFilterConfig,
isSchoolFilterName,
type SchoolDistance,
type SchoolPhase,
type SchoolRating,
} from './school-filter';
function parseFilters(params: URLSearchParams): FeatureFilters | undefined {
const filterParams = params.getAll('filter');
if (filterParams.length === 0) return undefined;
const schoolParams = params.getAll('school');
if (filterParams.length === 0 && schoolParams.length === 0) return undefined;
const filters: FeatureFilters = {};
for (const entry of filterParams) {
@ -29,6 +39,27 @@ function parseFilters(params: URLSearchParams): FeatureFilters | undefined {
filters[name] = [rest];
}
}
schoolParams.forEach((entry, index) => {
const parts = entry.split(':');
if (parts.length !== 5) return;
const phase = parts[0] as SchoolPhase;
const rating = parts[1] as SchoolRating;
const distance = Number(parts[2]) as SchoolDistance;
const min = Number(parts[3]);
const max = Number(parts[4]);
if (
(phase !== 'primary' && phase !== 'secondary') ||
(rating !== 'good' && rating !== 'outstanding') ||
(distance !== 2 && distance !== 5) ||
isNaN(min) ||
isNaN(max)
) {
return;
}
filters[createSchoolFilterKey(phase, rating, distance, index)] = [min, max];
});
return Object.keys(filters).length > 0 ? filters : undefined;
}
@ -126,6 +157,16 @@ export function stateToParams(
}
for (const [name, value] of Object.entries(filters)) {
const schoolConfig = getSchoolFilterConfig(name);
if (schoolConfig && isSchoolFilterName(name)) {
const [min, max] = value as [number, number];
params.append(
'school',
`${schoolConfig.phase}:${schoolConfig.rating}:${schoolConfig.distance}:${min}:${max}`
);
continue;
}
const meta = features.find((f) => f.name === name);
if (meta?.type === 'enum') {
params.append('filter', `${name}:${(value as string[]).join('|')}`);
@ -170,13 +211,15 @@ export function summarizeParams(queryString: string): string {
const parts: string[] = [];
const filterParams = params.getAll('filter');
if (filterParams.length > 0) {
const schoolParams = params.getAll('school');
if (filterParams.length > 0 || schoolParams.length > 0) {
const filterNames = filterParams
.map((entry) => {
const colonIdx = entry.indexOf(':');
return colonIdx > 0 ? entry.substring(0, colonIdx) : entry;
})
.filter((n) => n);
for (let i = 0; i < schoolParams.length; i++) filterNames.push(SCHOOL_FILTER_NAME);
if (filterNames.length > 0) {
parts.push(
filterNames.length <= 2

Binary file not shown.

View file

@ -221,7 +221,7 @@ def main() -> None:
deleted = _delete_files(args.travel_times, bad_files)
print(f"Deleted {deleted}/{len(bad_files)} files.")
else:
print(f"\nRun with --delete to remove these files so R5 can recompute them.")
print("\nRun with --delete to remove these files so R5 can recompute them.")
else:
print("\nNo corrupted files found.")

View file

@ -5,11 +5,26 @@ from pathlib import Path
from pipeline.utils import download, extract_zip
URL = "https://www.arcgis.com/sharing/rest/content/items/077631e063eb4e1ab43575d01381ec33/data"
URL = "https://www.arcgis.com/sharing/rest/content/items/36b718ad00de49afb9ad364f8b815b9e/data"
def convert_to_parquet(data_path: Path, parquet_path: Path) -> None:
df = pl.scan_csv(data_path / "Data/NSPL_MAY_2025_UK.csv", try_parse_dates=True)
# Classification code columns (ruc21ind, oac11ind, imd20ind) look numeric
# in early rows but contain string codes like "UN1" (Unclassified) later
# on. Force them to String to avoid mid-stream dtype inference failures.
# Note: NSPL renames these year suffixes as new releases roll in (e.g.
# Feb 2026 bumped oac from oac21ind → oac11ind, imd from imd19ind →
# imd20ind), so keep this dict in sync with the current CSV headers —
# polars silently ignores overrides for missing columns, masking drift.
df = pl.scan_csv(
data_path / "Data/NSPL_FEB_2026_UK.csv",
try_parse_dates=True,
schema_overrides={
"ruc21ind": pl.String,
"oac11ind": pl.String,
"imd20ind": pl.String,
},
)
print(f"Columns: {df.collect_schema().names()}")
parquet_path.parent.mkdir(parents=True, exist_ok=True)
df.sink_parquet(parquet_path, compression="zstd")

View file

@ -1,14 +1,53 @@
import argparse
import shutil
import sys
import tempfile
import polars as pl
from pathlib import Path
import httpx
from pipeline.utils import download, extract_zip
# Ofcom Connected Nations 2025 - Fixed broadband performance (output area & local authority level)
# Source: https://www.ofcom.org.uk/phones-and-broadband/coverage-and-speeds/connected-nations-20252/data-downloads-2025
PERFORMANCE_URL = "https://www.ofcom.org.uk/siteassets/resources/documents/research-and-data/multi-sector/infrastructure-research/connected-nations-2025/202507_fixed_broadband_coverage_r01.zip?v=407830"
# Pre-staged file path. Ofcom put the entire ofcom.org.uk domain behind
# Cloudflare's Managed Challenge in 2026, which requires a JS-executing
# browser to pass — no amount of User-Agent / TLS-impersonation spoofing
# (curl_cffi chrome120..131, safari17, firefox133, chrome_android) gets
# past it. When the automated download fails, the user must download the
# zip manually from the Source URL above and place it at this path.
MANUAL_ZIP_PATH = Path("manual-data/fixed_broadband_coverage.zip")
def _manual_download_instructions() -> str:
return (
f"\nOfcom has blocked automated downloads via Cloudflare's Managed\n"
f"Challenge. Download the zip manually and re-run:\n\n"
f" 1. Open in a browser:\n"
f" {PERFORMANCE_URL}\n"
f" 2. Save the downloaded zip to:\n"
f" {MANUAL_ZIP_PATH.resolve()}\n"
f" 3. Re-run `make -f Makefile.data property-data/broadband.parquet`\n"
)
def _obtain_zip(dest: Path) -> None:
"""Copy the pre-staged manual zip if present; otherwise attempt download."""
if MANUAL_ZIP_PATH.exists():
print(f"Using pre-staged zip: {MANUAL_ZIP_PATH}")
shutil.copyfile(MANUAL_ZIP_PATH, dest)
return
try:
download(PERFORMANCE_URL, dest)
except httpx.HTTPStatusError as e:
if e.response.status_code == 403:
print(_manual_download_instructions(), file=sys.stderr)
raise
def convert_to_parquet(extract_dir: Path, parquet_path: Path) -> None:
# Find CSV files in the extracted directory
@ -51,7 +90,7 @@ def main() -> None:
extract_dir = cache / "extracted"
extracted_again_dir = cache / "extracted-again"
download(PERFORMANCE_URL, zip_path)
_obtain_zip(zip_path)
extract_zip(zip_path, extract_dir)
extract_zip(
extract_dir

View file

@ -8,7 +8,7 @@ import polars as pl
# One row per candidate per constituency — we aggregate to per-constituency stats.
URL = "https://electionresults.parliament.uk/general-elections/6/candidacies.csv"
# Map party names to a smaller set for the enum feature and vote share columns.
# Map party names to a smaller set for vote share columns.
# Only parties that won seats in England are kept; the rest become "Other parties".
PARTY_MAP = {
"Labour": "Labour",
@ -37,13 +37,9 @@ def download_and_convert(output_path: Path) -> None:
.alias("party_group"),
)
# ── Per-constituency winner stats ──
winners = df.filter(pl.col("Candidate result position") == 1).select(
# ── Per-constituency turnout ──
turnout = df.filter(pl.col("Candidate result position") == 1).select(
pl.col("Constituency geographic code").alias("pcon"),
pl.col("party_group").alias("winning_party"),
(pl.col("Majority") / pl.col("Election valid vote count") * 100)
.round(1)
.alias("majority_pct"),
(pl.col("Election valid vote count") / pl.col("Electorate") * 100)
.round(1)
.alias("turnout_pct"),
@ -75,14 +71,11 @@ def download_and_convert(output_path: Path) -> None:
rename_map = {col: f"% {col}" for col in party_pct.columns if col != "pcon"}
party_pct = party_pct.rename(rename_map)
# Join winner stats with party vote shares
result = winners.join(party_pct, on="pcon", how="left")
# Join turnout with party vote shares
result = turnout.join(party_pct, on="pcon", how="left")
print(f"Constituencies: {result.height}")
print(f"Columns: {result.columns}")
print(
f"Party breakdown:\n{result['winning_party'].value_counts().sort('count', descending=True)}"
)
output_path.parent.mkdir(parents=True, exist_ok=True)
result.write_parquet(output_path, compression="zstd")

View file

@ -0,0 +1,98 @@
"""Download GEOLYTIX Grocery Retail Points and keep the latest CSV release."""
import argparse
import re
from pathlib import Path
from tempfile import TemporaryDirectory
from zipfile import ZipFile
import polars as pl
from pipeline.utils.download import download
GEOLYTIX_RETAIL_POINTS_FILE_ID = "1B8M7m86rQg2sx2TsHhFa2d-x-dZ1DbSy"
GEOLYTIX_RETAIL_POINTS_URL = (
"https://drive.usercontent.google.com/download"
f"?id={GEOLYTIX_RETAIL_POINTS_FILE_ID}&export=download&confirm=t"
)
CSV_NAME_RE = re.compile(
r"^geolytix_retailpoints_v(?P<version>\d+)_(?P<release>\d{6})\.csv$"
)
REQUIRED_COLUMNS = {
"id",
"retailer",
"fascia",
"store_name",
"postcode",
"long_wgs",
"lat_wgs",
}
def select_latest_csv_name(names: list[str]) -> str:
"""Return the latest root-level retail points CSV from a ZIP namelist."""
candidates: list[tuple[str, int, str]] = []
for name in names:
path = Path(name)
if path.parent != Path("."):
continue
match = CSV_NAME_RE.match(path.name)
if not match:
continue
candidates.append(
(match.group("release"), int(match.group("version")), name)
)
if not candidates:
raise ValueError("No root-level GEOLYTIX retail points CSV found")
return max(candidates)[2]
def read_latest_csv(zip_path: Path) -> pl.DataFrame:
"""Read the latest root-level CSV from a GEOLYTIX ZIP file."""
with ZipFile(zip_path) as zip_file:
csv_name = select_latest_csv_name(zip_file.namelist())
with zip_file.open(csv_name) as csv_file:
df = pl.read_csv(csv_file, infer_schema_length=10_000)
missing = REQUIRED_COLUMNS - set(df.columns)
if missing:
raise ValueError(
f"GEOLYTIX retail points CSV is missing columns: {sorted(missing)}"
)
return df
def download_geolytix_retail_points(output_path: Path) -> None:
"""Download the GEOLYTIX ZIP, extract the latest CSV, and write parquet."""
output_path.parent.mkdir(parents=True, exist_ok=True)
with TemporaryDirectory(prefix="geolytix_retail_points_") as tmp:
zip_path = Path(tmp) / "geolytix_retail_points.zip"
download(GEOLYTIX_RETAIL_POINTS_URL, zip_path, timeout=300)
df = read_latest_csv(zip_path)
df.write_parquet(output_path)
size_mb = output_path.stat().st_size / (1024 * 1024)
print(f"Wrote {output_path} ({size_mb:.1f} MB, {len(df):,} stores)")
def main() -> None:
parser = argparse.ArgumentParser(
description="Download GEOLYTIX Grocery Retail Points"
)
parser.add_argument(
"--output", type=Path, required=True, help="Output parquet file path"
)
args = parser.parse_args()
download_geolytix_retail_points(args.output)
if __name__ == "__main__":
main()

View file

@ -22,6 +22,92 @@ STOP_TYPES = {
}
OUTPUT_COLUMNS = ["id", "name", "category", "lat", "lng"]
def canonical_station_name_expr(name_col: str = "name") -> pl.Expr:
"""Normalize station names so entrances/transport-mode variants collapse."""
expr = pl.col(name_col).str.to_lowercase()
expr = expr.str.replace_all(r"\([^)]*\)", " ")
expr = expr.str.replace_all(r"['`]", "")
expr = expr.str.replace_all(r"&", " and ")
expr = expr.str.replace_all(r"[^a-z0-9]+", " ")
expr = expr.str.replace_all(r"\s+", " ").str.strip_chars()
expr = expr.str.replace_all(
r"\s+(underground|tube|dlr|metro|rail|railway)\s+station$", ""
)
expr = expr.str.replace_all(r"\s+tram\s+stop$", "")
expr = expr.str.replace_all(r"\s+(station|stop)$", "")
return expr.str.strip_chars()
def _has_locality() -> pl.Expr:
return pl.col("locality").is_not_null() & (pl.col("locality") != "")
def _deduplicate_tube_partition(
df: pl.DataFrame, group_cols: list[str]
) -> pl.DataFrame:
if len(df) == 0:
return pl.DataFrame(
{
"id": pl.Series([], dtype=pl.String),
"name": pl.Series([], dtype=pl.String),
"category": pl.Series([], dtype=pl.String),
"lat": pl.Series([], dtype=pl.Float64),
"lng": pl.Series([], dtype=pl.Float64),
}
)
name_len = pl.col("name").str.len_chars()
return (
df.group_by(group_cols)
.agg(
pl.col("id").sort_by(name_len).first(),
pl.col("name").sort_by(name_len).first(),
pl.col("category").first(),
pl.col("lat").mean(),
pl.col("lng").mean(),
)
.select(OUTPUT_COLUMNS)
)
def deduplicate_naptan(df: pl.DataFrame) -> pl.DataFrame:
"""Deduplicate NaPTAN stops, with stricter station-level merging for Tube POIs."""
has_loc = df.filter(_has_locality())
no_loc = df.filter(~_has_locality())
cols_with_locality = [*OUTPUT_COLUMNS, "locality"]
# First pass: one record per exact stop name/category/locality.
deduped_has_loc = (
has_loc.group_by("name", "category", "locality")
.agg(
pl.col("id").first(),
pl.col("lat").mean(),
pl.col("lng").mean(),
)
.select(cols_with_locality)
)
df = pl.concat([deduped_has_loc, no_loc.select(cols_with_locality)])
tube = df.filter(pl.col("category") == "Tube station").with_columns(
canonical_station_name_expr().alias("_station_key")
)
other = df.filter(pl.col("category") != "Tube station")
tube_with_loc = tube.filter(_has_locality())
tube_no_loc = tube.filter(~_has_locality())
deduped_tube = pl.concat(
[
_deduplicate_tube_partition(tube_with_loc, ["_station_key", "locality"]),
_deduplicate_tube_partition(tube_no_loc, ["_station_key"]),
]
)
return pl.concat([other.select(OUTPUT_COLUMNS), deduped_tube])
def download_naptan(output: Path) -> None:
output.parent.mkdir(parents=True, exist_ok=True)
@ -50,24 +136,12 @@ def download_naptan(output: Path) -> None:
)
before = len(df)
df = deduplicate_naptan(df)
# Deduplicate: one record per name+category+locality
# (merges entrances, bus stop pairs on opposite sides of the road, etc.)
has_loc = df.filter(
pl.col("locality").is_not_null() & (pl.col("locality") != "")
print(
f"Deduplicated {before:,}{len(df):,} stops "
"(by name+category+locality; tube stations by normalized station name)"
)
no_loc = df.filter(
pl.col("locality").is_null() | (pl.col("locality") == "")
)
cols = ["id", "name", "category", "lat", "lng"]
deduped = has_loc.group_by("name", "category", "locality").agg(
pl.col("id").first(),
pl.col("lat").mean(),
pl.col("lng").mean(),
)
df = pl.concat([deduped.select(cols), no_loc.select(cols)])
print(f"Deduplicated {before:,}{len(df):,} stops (by name+category+locality)")
df.write_parquet(output)
size_mb = output.stat().st_size / (1024 * 1024)

View file

@ -5,9 +5,9 @@ from pathlib import Path
from pipeline.utils import download
# Management information - state-funded schools - latest inspections (as at 30 Apr 2025)
# Management information - state-funded schools - latest inspections (as at 28 Feb 2026)
# Source: https://www.gov.uk/government/statistical-data-sets/monthly-management-information-ofsteds-school-inspections-outcomes
URL = "https://assets.publishing.service.gov.uk/media/681cd390275cb67b18d870fc/Management_information_-_state-funded_schools_-_latest_inspections_as_at_30_Apr_2025.csv"
URL = "https://assets.publishing.service.gov.uk/media/69c5269b4a06660f0854427b/Management_information_-_state-funded_schools_-_latest_inspections_as_at_28_Feb_2026.csv"
def convert_to_parquet(csv_path: Path, parquet_path: Path) -> None:

View file

@ -1,125 +1,91 @@
"""Download ONS Price Index of Private Rents (PIPR) monthly price statistics.
Provides mean monthly private rent by local authority and bedroom count.
Replaces the discontinued Private Rental Market Summary Statistics.
Source: https://www.ons.gov.uk/economy/inflationandpriceindices/datasets/priceindexofprivaterentsukmonthlypricestatistics
License: Open Government Licence v3.0
"""
import argparse
import tempfile
from pathlib import Path
import polars as pl
from pathlib import Path
from pipeline.utils import download
URL = "https://www.ons.gov.uk/file?uri=/peoplepopulationandcommunity/housing/datasets/privaterentalmarketsummarystatisticsinengland/october2022toseptember2023/privaterentalmarketstatistics231220.xls"
URL = "https://www.ons.gov.uk/file?uri=/economy/inflationandpriceindices/datasets/priceindexofprivaterentsukmonthlypricestatistics/25march2026/priceindexofprivaterentsukmonthlypricestatistics.xlsx"
# Sheets 12-16 are LA-level breakdowns: Studio, 1 Bed, 2 Bed, 3 Bed, 4+ Bed
# (Sheet 11 is "Room" — shared house rooms, not self-contained, so skip it)
BEDROOM_SHEETS = {
12: 0, # Studio
13: 1, # One Bedroom
14: 2, # Two Bedrooms
15: 3, # Three Bedrooms
16: 4, # Four or more Bedrooms
}
# Local authority district codes in England, https://en.wikipedia.org/wiki/ONS_coding_system
# Local authority district codes in England
LA_PREFIXES = ("E06", "E07", "E08", "E09")
# April 2021 + April 2023 LA reorganizations: old district codes → new unitary authority codes.
# The ONS rental data (Oct 2022 Sep 2023) uses the old codes; IoD 2025 uses the new ones.
# We remap old → new and average the medians so the join in merge.py works.
LA_CONSOLIDATION = {
# North Northamptonshire (April 2021)
"E07000150": "E06000061", # Corby
"E07000152": "E06000061", # East Northamptonshire
"E07000153": "E06000061", # Kettering
"E07000156": "E06000061", # Wellingborough
# West Northamptonshire (April 2021)
"E07000151": "E06000062", # Daventry
"E07000154": "E06000062", # Northampton
"E07000155": "E06000062", # South Northamptonshire
# Cumberland (April 2023)
"E07000026": "E06000063", # Allerdale
"E07000028": "E06000063", # Carlisle
"E07000029": "E06000063", # Copeland
# Westmorland and Furness (April 2023)
"E07000027": "E06000064", # Barrow-in-Furness
"E07000030": "E06000064", # Eden
"E07000031": "E06000064", # South Lakeland
# North Yorkshire (April 2023)
"E07000163": "E06000065", # Craven
"E07000164": "E06000065", # Hambleton
"E07000165": "E06000065", # Harrogate
"E07000166": "E06000065", # Richmondshire
"E07000167": "E06000065", # Ryedale
"E07000168": "E06000065", # Scarborough
"E07000169": "E06000065", # Selby
# Somerset (April 2023)
"E07000187": "E06000066", # Mendip
"E07000188": "E06000066", # Sedgemoor
"E07000189": "E06000066", # South Somerset
"E07000246": "E06000066", # Somerset West and Taunton
}
def convert_to_parquet(xlsx_path: Path, parquet_path: Path) -> None:
print("Reading PIPR Excel file (Table 1)...")
def _read_sheet(xls_path: Path, sheet_id: int, bedrooms: int) -> pl.DataFrame:
"""Read one bedroom category sheet, extract LA-level median rents."""
df = pl.read_excel(xls_path, sheet_id=sheet_id)
# Table 1 layout: row 0 = title, row 1 = column headers, row 2+ = data.
# 40 columns in repeating blocks of 4 (index, monthly change, annual change,
# rental price) for each category. Rental price columns (0-indexed):
# 7 = All categories, 11 = One bed, 15 = Two bed, 19 = Three bed,
# 23 = Four or more bed
df = pl.read_excel(xlsx_path, sheet_name="Table 1", has_header=False)
df = df.slice(2) # Skip title and header rows
# Columns are unnamed; positional:
# 0=LA Code, 1=Area Code, 2=Area Name, 3=Count, 4=Mean, 5=LQ, 6=Median, 7=UQ
# First 4 rows are headers (title, notes, bedroom label, column headers)
df = df.slice(4)
area_code_col = df.columns[1]
median_col = df.columns[6]
return (
df.select(
pl.col(area_code_col).alias("area_code"),
pl.col(median_col).alias("median_monthly_rent"),
df = df.select(
pl.col("column_1").alias("time_period"),
pl.col("column_2").alias("area_code"),
pl.col("column_12").cast(pl.Float32, strict=False).alias("rent_1bed"),
pl.col("column_16").cast(pl.Float32, strict=False).alias("rent_2bed"),
pl.col("column_20").cast(pl.Float32, strict=False).alias("rent_3bed"),
pl.col("column_24").cast(pl.Float32, strict=False).alias("rent_4plus"),
)
.filter(
pl.col("area_code").is_not_null()
& pl.any_horizontal(
# Filter to English local authorities
df = df.filter(
pl.any_horizontal(
pl.col("area_code").str.starts_with(p) for p in LA_PREFIXES
)
)
.with_columns(
# Suppressed values are ".." — cast will turn them to null
pl.col("median_monthly_rent").cast(pl.Float32, strict=False),
# Use only the latest month
latest = df["time_period"].max()
print(f"Latest month in data: {latest}")
df = df.filter(pl.col("time_period") == latest)
print(f"LAs in latest month: {df.height}")
# Melt to long format: one row per area x bedroom count.
# PIPR has no Studio category — one-bed rent used as proxy for bedrooms=0.
frames = []
for col, bedrooms in [
("rent_1bed", 0), # Studio (proxy)
("rent_1bed", 1),
("rent_2bed", 2),
("rent_3bed", 3),
("rent_4plus", 4),
]:
frames.append(
df.select(
pl.col("area_code"),
pl.col(col).alias("mean_monthly_rent"),
pl.lit(bedrooms).cast(pl.UInt8).alias("bedrooms"),
)
)
def convert_to_parquet(xls_path: Path, parquet_path: Path) -> None:
frames = []
for sheet_id, bedrooms in BEDROOM_SHEETS.items():
df = _read_sheet(xls_path, sheet_id, bedrooms)
print(f" Sheet {sheet_id} (bedrooms={bedrooms}): {df.height} rows")
frames.append(df)
combined = pl.concat(frames)
# Remap old LA codes to new unitary authority codes and average medians
combined = (
combined.with_columns(
pl.col("area_code").replace(LA_CONSOLIDATION),
)
.group_by("area_code", "bedrooms")
.agg(
pl.col("median_monthly_rent").mean(),
)
)
print(f"Combined: {combined.shape}")
print(f"Non-null medians: {combined['median_monthly_rent'].drop_nulls().len()}")
print(f"Non-null rents: {combined['mean_monthly_rent'].drop_nulls().len()}")
print(combined.head(10))
parquet_path.parent.mkdir(parents=True, exist_ok=True)
combined.write_parquet(parquet_path, compression="zstd")
print(f"Saved to {parquet_path}")
def main() -> None:
parser = argparse.ArgumentParser(
description="Download and convert ONS private rental market statistics"
description="Download ONS private rent monthly price statistics"
)
parser.add_argument(
"--output", type=Path, required=True, help="Output parquet file path"
@ -127,9 +93,9 @@ def main() -> None:
args = parser.parse_args()
with tempfile.TemporaryDirectory() as cache_dir:
xls_path = Path(cache_dir) / "rental_prices.xls"
download(URL, xls_path, timeout=60)
convert_to_parquet(xls_path, args.output)
xlsx_path = Path(cache_dir) / "pipr_monthly.xlsx"
download(URL, xlsx_path, timeout=120)
convert_to_parquet(xlsx_path, args.output)
if __name__ == "__main__":

View file

@ -0,0 +1,41 @@
from zipfile import ZipFile
import polars as pl
from pipeline.download.geolytix_retail_points import (
read_latest_csv,
select_latest_csv_name,
)
def test_select_latest_csv_ignores_previous_versions():
names = [
"README.txt",
"geolytix_retailpoints_v41_202602.csv",
"geolytix_retailpoints_v43_202603.csv",
"Previous Versions/geolytix_retailpoints_v99_209901.csv",
]
assert select_latest_csv_name(names) == "geolytix_retailpoints_v43_202603.csv"
def test_read_latest_csv_validates_required_columns(tmp_path):
zip_path = tmp_path / "retail_points.zip"
df = pl.DataFrame(
{
"id": [1],
"retailer": ["Waitrose"],
"fascia": ["Waitrose"],
"store_name": ["Waitrose Test"],
"postcode": ["SW1A 1AA"],
"long_wgs": [-0.1],
"lat_wgs": [51.5],
}
)
with ZipFile(zip_path, "w") as zip_file:
zip_file.writestr("geolytix_retailpoints_v1_202401.csv", "not,the,latest\n")
with zip_file.open("geolytix_retailpoints_v2_202402.csv", "w") as csv_file:
df.write_csv(csv_file)
assert read_latest_csv(zip_path).to_dicts() == df.to_dicts()

View file

@ -0,0 +1,71 @@
import polars as pl
import pytest
from pipeline.download.naptan import canonical_station_name_expr, deduplicate_naptan
def test_canonical_station_name_expr_normalizes_transport_suffixes():
df = pl.DataFrame(
{
"name": [
"Bank",
"Bank Underground Station",
"Bank DLR Station",
"Pleasure Beach (Blackpool Tramway)",
"Earl's Court Tube Station",
]
}
)
result = df.select(canonical_station_name_expr().alias("key"))["key"].to_list()
assert result == [
"bank",
"bank",
"bank",
"pleasure beach",
"earls court",
]
def test_deduplicate_naptan_merges_tube_station_variants_by_locality():
df = pl.DataFrame(
{
"id": ["bank", "bank-lu", "bank-dlr", "other-bank"],
"name": [
"Bank",
"Bank Underground Station",
"Bank DLR Station",
"Bank Underground Station",
],
"category": ["Tube station"] * 4,
"lat": [51.5129, 51.5134, 51.5132, 55.0140],
"lng": [-0.0889, -0.0890, -0.0885, -1.6781],
"locality": ["LOC1", "LOC1", "LOC1", "LOC2"],
}
)
result = deduplicate_naptan(df).sort("lat")
assert len(result) == 2
assert result["name"].to_list() == ["Bank", "Bank Underground Station"]
assert result["lat"].to_list()[0] == pytest.approx(
(51.5129 + 51.5134 + 51.5132) / 3
)
def test_deduplicate_naptan_does_not_merge_missing_locality_bus_stops():
df = pl.DataFrame(
{
"id": ["a", "b"],
"name": ["High Street", "High Street"],
"category": ["Bus stop", "Bus stop"],
"lat": [51.5, 52.5],
"lng": [-0.1, -1.1],
"locality": [None, None],
}
)
result = deduplicate_naptan(df)
assert len(result) == 2

View file

@ -19,6 +19,8 @@ Output directory: property-data/transit/
"""
import argparse
import csv
import io
import json
import os
import shutil
@ -108,6 +110,30 @@ def download_bods_gtfs(output_dir: Path) -> Path:
return dest
def _parse_csv_line(line: bytes | str) -> list[str]:
"""Parse a single GTFS CSV record."""
if isinstance(line, bytes):
line = line.decode("utf-8", errors="replace")
line = line.rstrip("\r\n")
if not line:
return []
return next(csv.reader([line]))
def _format_csv_row(parts: list[str]) -> bytes:
"""Serialize one GTFS CSV row with stable LF line endings."""
output = io.StringIO()
csv.writer(output, lineterminator="\n").writerow(parts)
return output.getvalue().encode("utf-8")
def _format_csv_rows(rows: list[list[str]]) -> str:
output = io.StringIO()
writer = csv.writer(output, lineterminator="\n")
writer.writerows(rows)
return output.getvalue()
def clean_gtfs(src: Path, dst: Path) -> None:
"""Fix R5-incompatible entries in GTFS.
@ -128,8 +154,7 @@ def clean_gtfs(src: Path, dst: Path) -> None:
dropped = 0
with zin.open(info) as f:
header = f.readline()
header_str = header.decode("utf-8").strip()
cols = header_str.split(",")
cols = _parse_csv_line(header)
arr_idx = (
cols.index("arrival_time") if "arrival_time" in cols else -1
)
@ -143,10 +168,9 @@ def clean_gtfs(src: Path, dst: Path) -> None:
tmp.write(header)
for line in f:
line_str = line.decode("utf-8", errors="replace").strip()
if not line_str:
parts = _parse_csv_line(line)
if not parts:
continue
parts = line_str.split(",")
skip = False
for idx in [arr_idx, dep_idx]:
if 0 <= idx < len(parts):
@ -171,12 +195,13 @@ def clean_gtfs(src: Path, dst: Path) -> None:
elif info.filename == "feed_info.txt":
data = zin.read(info).decode("utf-8")
lines = data.strip().split("\n")
header_line = lines[0]
feed_cols = header_line.split(",")
fixed_lines = [header_line]
for line in lines[1:]:
parts = line.split(",")
rows = list(csv.reader(io.StringIO(data)))
if not rows:
zout.writestr("feed_info.txt", data)
continue
feed_cols = rows[0]
fixed_rows = [feed_cols]
for parts in rows[1:]:
for i, col_name in enumerate(feed_cols):
if "end_date" in col_name.lower() and i < len(parts):
date_val = parts[i].strip('"')
@ -187,8 +212,8 @@ def clean_gtfs(src: Path, dst: Path) -> None:
print(
f" feed_info: capped end_date {date_val} → 20991231"
)
fixed_lines.append(",".join(parts))
zout.writestr("feed_info.txt", "\n".join(fixed_lines) + "\n")
fixed_rows.append(parts)
zout.writestr("feed_info.txt", _format_csv_rows(fixed_rows))
else:
zout.writestr(info, zin.read(info))
@ -237,12 +262,11 @@ def convert_high_freq_to_frequency_based(
# Step 1: Find metro/tram route IDs
target_route_ids: set[str] = set()
with zin.open("routes.txt") as f:
header = f.readline().decode("utf-8").strip()
cols = header.split(",")
cols = _parse_csv_line(f.readline())
route_id_idx = cols.index("route_id")
rt_idx = cols.index("route_type")
for line in f:
parts = line.decode("utf-8", errors="replace").strip().split(",")
parts = _parse_csv_line(line)
if not parts:
continue
route_type = parts[rt_idx].strip('"')
@ -259,14 +283,13 @@ def convert_high_freq_to_frequency_based(
# Step 2: Map target trips to grouping keys
trip_group_key: dict[str, tuple[str, str, str]] = {}
with zin.open("trips.txt") as f:
header = f.readline().decode("utf-8").strip()
cols = header.split(",")
cols = _parse_csv_line(f.readline())
trip_id_idx = cols.index("trip_id")
route_id_idx = cols.index("route_id")
dir_idx = cols.index("direction_id") if "direction_id" in cols else -1
service_idx = cols.index("service_id")
for line in f:
parts = line.decode("utf-8", errors="replace").strip().split(",")
parts = _parse_csv_line(line)
if not parts:
continue
route_id = parts[route_id_idx].strip('"')
@ -282,14 +305,13 @@ def convert_high_freq_to_frequency_based(
trip_first_dep: dict[str, int] = {}
trip_first_stop: dict[str, str] = {}
with zin.open("stop_times.txt") as f:
header = f.readline().decode("utf-8").strip()
cols = header.split(",")
cols = _parse_csv_line(f.readline())
trip_id_idx = cols.index("trip_id")
dep_idx = cols.index("departure_time")
seq_idx = cols.index("stop_sequence")
stop_id_idx = cols.index("stop_id")
for line in f:
parts = line.decode("utf-8", errors="replace").strip().split(",")
parts = _parse_csv_line(line)
if not parts:
continue
trip_id = parts[trip_id_idx].strip('"')
@ -361,8 +383,7 @@ def convert_high_freq_to_frequency_based(
if info.filename == "trips.txt":
with zin.open(info) as f:
header = f.readline()
header_str = header.decode("utf-8").strip()
cols = header_str.split(",")
cols = _parse_csv_line(header)
trip_id_idx = cols.index("trip_id")
tmp = tempfile.NamedTemporaryFile(
@ -370,9 +391,7 @@ def convert_high_freq_to_frequency_based(
)
tmp.write(header)
for line in f:
parts = (
line.decode("utf-8", errors="replace").strip().split(",")
)
parts = _parse_csv_line(line)
if not parts:
continue
if parts[trip_id_idx].strip('"') not in trips_to_remove:
@ -384,8 +403,7 @@ def convert_high_freq_to_frequency_based(
elif info.filename == "stop_times.txt":
with zin.open(info) as f:
header = f.readline()
header_str = header.decode("utf-8").strip()
cols = header_str.split(",")
cols = _parse_csv_line(header)
trip_id_idx = cols.index("trip_id")
tmp = tempfile.NamedTemporaryFile(
@ -393,9 +411,7 @@ def convert_high_freq_to_frequency_based(
)
tmp.write(header)
for line in f:
parts = (
line.decode("utf-8", errors="replace").strip().split(",")
)
parts = _parse_csv_line(line)
if not parts:
continue
if parts[trip_id_idx].strip('"') not in trips_to_remove:
@ -535,25 +551,23 @@ def clean_national_rail_gtfs(src: Path, dst: Path) -> None:
with zipfile.ZipFile(src, "r") as zin:
# Load valid stop IDs
with zin.open("stops.txt") as f:
header = f.readline().decode("utf-8").strip()
stop_id_idx = header.split(",").index("stop_id")
lat_idx = header.split(",").index("stop_lat")
cols = _parse_csv_line(f.readline())
stop_id_idx = cols.index("stop_id")
for line in f:
parts = line.decode("utf-8", errors="replace").strip().split(",")
parts = _parse_csv_line(line)
if parts:
stop_ids.add(parts[stop_id_idx])
# Find trips with backwards travel times
with zin.open("stop_times.txt") as f:
st_header = f.readline().decode("utf-8").strip()
st_cols = st_header.split(",")
st_cols = _parse_csv_line(f.readline())
trip_id_idx = st_cols.index("trip_id")
dep_idx = st_cols.index("departure_time")
prev_trip = ""
prev_dep_secs = -1
for line in f:
parts = line.decode("utf-8", errors="replace").strip().split(",")
parts = _parse_csv_line(line)
if not parts:
continue
trip_id = parts[trip_id_idx].strip('"')
@ -594,8 +608,7 @@ def clean_national_rail_gtfs(src: Path, dst: Path) -> None:
if info.filename == "stop_times.txt":
with zin.open(info) as f:
header = f.readline()
header_str = header.decode("utf-8").strip()
cols = header_str.split(",")
cols = _parse_csv_line(header)
trip_id_idx = cols.index("trip_id")
stop_id_idx = cols.index("stop_id")
seq_idx = cols.index("stop_sequence")
@ -614,10 +627,9 @@ def clean_national_rail_gtfs(src: Path, dst: Path) -> None:
prev_trip = ""
seq_counter = 0
for line in f:
line_str = line.decode("utf-8", errors="replace").strip()
if not line_str:
parts = _parse_csv_line(line)
if not parts:
continue
parts = line_str.split(",")
trip_id = parts[trip_id_idx].strip('"')
stop_id = parts[stop_id_idx].strip('"')
@ -651,7 +663,7 @@ def clean_national_rail_gtfs(src: Path, dst: Path) -> None:
if old_seq != str(seq_counter):
seqs_renumbered += 1
tmp.write((",".join(parts) + "\n").encode("utf-8"))
tmp.write(_format_csv_row(parts))
tmp.close()
zout.write(tmp.name, "stop_times.txt")
@ -660,8 +672,7 @@ def clean_national_rail_gtfs(src: Path, dst: Path) -> None:
elif info.filename == "stops.txt":
with zin.open(info) as f:
header = f.readline()
header_str = header.decode("utf-8").strip()
cols = header_str.split(",")
cols = _parse_csv_line(header)
lat_idx = cols.index("stop_lat")
lon_idx = cols.index("stop_lon")
@ -671,10 +682,9 @@ def clean_national_rail_gtfs(src: Path, dst: Path) -> None:
tmp.write(header)
for line in f:
line_str = line.decode("utf-8", errors="replace").strip()
if not line_str:
parts = _parse_csv_line(line)
if not parts:
continue
parts = line_str.split(",")
try:
lat = float(parts[lat_idx])
# Fix bogus Irish CIE coordinates (South Atlantic)
@ -685,7 +695,7 @@ def clean_national_rail_gtfs(src: Path, dst: Path) -> None:
coords_fixed += 1
except ValueError:
pass
tmp.write((",".join(parts) + "\n").encode("utf-8"))
tmp.write(_format_csv_row(parts))
tmp.close()
zout.write(tmp.name, "stops.txt")
@ -694,8 +704,7 @@ def clean_national_rail_gtfs(src: Path, dst: Path) -> None:
elif info.filename == "routes.txt":
with zin.open(info) as f:
header = f.readline()
header_str = header.decode("utf-8").strip()
cols = header_str.split(",")
cols = _parse_csv_line(header)
rt_idx = cols.index("route_type")
tmp = tempfile.NamedTemporaryFile(
@ -704,14 +713,13 @@ def clean_national_rail_gtfs(src: Path, dst: Path) -> None:
tmp.write(header)
for line in f:
line_str = line.decode("utf-8", errors="replace").strip()
if not line_str:
parts = _parse_csv_line(line)
if not parts:
continue
parts = line_str.split(",")
if parts[rt_idx].strip('"') == "714":
parts[rt_idx] = "3"
route_types_fixed += 1
tmp.write((",".join(parts) + "\n").encode("utf-8"))
tmp.write(_format_csv_row(parts))
tmp.close()
zout.write(tmp.name, "routes.txt")
@ -721,8 +729,7 @@ def clean_national_rail_gtfs(src: Path, dst: Path) -> None:
# Remove trips that have backwards travel times
with zin.open(info) as f:
header = f.readline()
header_str = header.decode("utf-8").strip()
cols = header_str.split(",")
cols = _parse_csv_line(header)
trip_id_idx = cols.index("trip_id")
tmp = tempfile.NamedTemporaryFile(
@ -731,10 +738,9 @@ def clean_national_rail_gtfs(src: Path, dst: Path) -> None:
tmp.write(header)
for line in f:
line_str = line.decode("utf-8", errors="replace").strip()
if not line_str:
parts = _parse_csv_line(line)
if not parts:
continue
parts = line_str.split(",")
if parts[trip_id_idx].strip('"') not in bad_trip_ids:
tmp.write(line)
@ -746,8 +752,7 @@ def clean_national_rail_gtfs(src: Path, dst: Path) -> None:
# Cap end_date year to 2099
with zin.open(info) as f:
header = f.readline()
header_str = header.decode("utf-8").strip()
cols = header_str.split(",")
cols = _parse_csv_line(header)
end_idx = cols.index("end_date")
tmp = tempfile.NamedTemporaryFile(
@ -756,10 +761,9 @@ def clean_national_rail_gtfs(src: Path, dst: Path) -> None:
tmp.write(header)
for line in f:
line_str = line.decode("utf-8", errors="replace").strip()
if not line_str:
parts = _parse_csv_line(line)
if not parts:
continue
parts = line_str.split(",")
date_val = parts[end_idx].strip('"')
if len(date_val) == 8:
try:
@ -768,7 +772,7 @@ def clean_national_rail_gtfs(src: Path, dst: Path) -> None:
parts[end_idx] = "20991231"
except ValueError:
pass
tmp.write((",".join(parts) + "\n").encode("utf-8"))
tmp.write(_format_csv_row(parts))
tmp.close()
zout.write(tmp.name, "calendar.txt")

View file

@ -60,12 +60,14 @@ _AREA_COLUMNS = [
"Good+ secondary schools within 5km",
"Good+ primary schools within 2km",
"Good+ secondary schools within 2km",
"Outstanding primary schools within 5km",
"Outstanding secondary schools within 5km",
"Outstanding primary schools within 2km",
"Outstanding secondary schools within 2km",
# Demographics
"Median age",
# Politics
"Winning party",
"Voter turnout (%)",
"Majority (%)",
"% Labour",
"% Conservative",
"% Liberal Democrat",
@ -116,15 +118,19 @@ def _build(
arcgis = (
pl.scan_parquet(arcgis_path)
.filter(pl.col("ctry") == "E92000001") # England only
.filter(pl.col("ctry25cd") == "E92000001") # England only
.filter(pl.col("doterm").is_null()) # Active postcodes only
# NSPL Feb 2026 renamed geographic code columns to {field}{year}cd.
# Alias them back to the short canonical names used across the
# pipeline so downstream joins don't need to know about NSPL's
# versioning scheme.
.select(
pl.col("pcds").alias("postcode"),
"lat",
pl.col("long").alias("lon"),
"lsoa21",
"oa21",
"pcon",
pl.col("lsoa21cd").alias("lsoa21"),
pl.col("oa21cd").alias("oa21"),
pl.col("pcon24cd").alias("pcon"),
)
)
wide = wide.join(arcgis, on="postcode", how="left")
@ -349,18 +355,20 @@ def _build(
"good_secondary_5km": "Good+ secondary schools within 5km",
"good_primary_2km": "Good+ primary schools within 2km",
"good_secondary_2km": "Good+ secondary schools within 2km",
"outstanding_primary_5km": "Outstanding primary schools within 5km",
"outstanding_secondary_5km": "Outstanding secondary schools within 5km",
"outstanding_primary_2km": "Outstanding primary schools within 2km",
"outstanding_secondary_2km": "Outstanding secondary schools within 2km",
"max_download_speed": "Max available download speed (Mbps)",
"serious_crime_avg_yr": "Serious crime (avg/yr)",
"minor_crime_avg_yr": "Minor crime (avg/yr)",
"serious_crime_per_1k": "Serious crime per 1k residents (avg/yr)",
"minor_crime_per_1k": "Minor crime per 1k residents (avg/yr)",
"median_monthly_rent": "Estimated monthly rent",
"mean_monthly_rent": "Estimated monthly rent",
"floor_height": "Interior height (m)",
"was_council_house": "Former council house",
"median_age": "Median age",
"winning_party": "Winning party",
"turnout_pct": "Voter turnout (%)",
"majority_pct": "Majority (%)",
}
)
)

View file

@ -1,4 +1,4 @@
"""Compute good-rated school proximity counts per postcode."""
"""Compute Ofsted-rated school proximity counts per postcode."""
import argparse
from pathlib import Path
@ -8,14 +8,16 @@ import polars as pl
from pipeline.utils.poi_counts import count_pois_per_postcode
SCHOOL_GROUPS = {
"good_primary": ["good_primary"],
"good_secondary": ["good_secondary"],
"good_primary": ["good_primary", "outstanding_primary"],
"good_secondary": ["good_secondary", "outstanding_secondary"],
"outstanding_primary": ["outstanding_primary"],
"outstanding_secondary": ["outstanding_secondary"],
}
def main():
parser = argparse.ArgumentParser(
description="Count good+ primary/secondary schools within 2km per postcode"
description="Count good+ and outstanding primary/secondary schools near each postcode"
)
parser.add_argument(
"--ofsted", type=Path, required=True, help="Ofsted inspection parquet"
@ -28,19 +30,36 @@ def main():
)
args = parser.parse_args()
# Load Ofsted data: filter to good+ (1, 2) primary/secondary schools
# Load Ofsted data: filter to good+ (1, 2) primary/secondary schools.
# Post-2025 reform the single "Overall effectiveness" grade was retired;
# the legacy 14 scale is now carried forward under "Latest OEIF overall
# effectiveness" (OEIF = the previous Ofsted Education Inspection
# Framework). The new report-card columns use text judgements instead.
ofsted = pl.read_parquet(args.ofsted).filter(
pl.col("Ofsted phase").is_in(["Primary", "Secondary"])
& pl.col("Overall effectiveness").is_in(["1", "2"])
& pl.col("Latest OEIF overall effectiveness").is_in(["1", "2"])
)
print(f"Good+ schools: {len(ofsted):,}")
print(
"Outstanding schools: "
f"{ofsted.filter(pl.col('Latest OEIF overall effectiveness') == '1').height:,}"
)
# Assign category based on phase
# Assign category based on phase and rating. Good+ groups include both
# category variants; outstanding groups count grade 1 only.
ofsted = ofsted.with_columns(
pl.when(pl.col("Ofsted phase") == "Primary")
.then(pl.lit("good_primary"))
.then(
pl.when(pl.col("Latest OEIF overall effectiveness") == "1")
.then(pl.lit("outstanding_primary"))
.otherwise(pl.lit("good_primary"))
)
.otherwise(
pl.when(pl.col("Latest OEIF overall effectiveness") == "1")
.then(pl.lit("outstanding_secondary"))
.otherwise(pl.lit("good_secondary"))
)
.alias("category")
).select(
pl.col("Postcode").alias("postcode"),

View file

@ -0,0 +1,59 @@
import polars as pl
from pipeline.transform.transform_poi import transform_grocery_retail_points
def test_transform_grocery_retail_points_outputs_chain_categories():
raw = pl.DataFrame(
{
"id": [101, 102, 103],
"retailer": ["Waitrose", "Sainsburys", "The Co-operative Group"],
"fascia": ["Waitrose", "Sainsbury's Local", "Co-op Food"],
"store_name": ["Waitrose Test", "Sainsbury''s Test", "Co-op Test"],
"long_wgs": [-0.141, -0.142, -0.143],
"lat_wgs": [51.515, 51.516, 51.517],
}
)
pois = transform_grocery_retail_points(raw)
assert pois.select("id", "name", "category", "group", "emoji").to_dicts() == [
{
"id": "glx-101",
"name": "Waitrose Test",
"category": "Waitrose",
"group": "Groceries",
"emoji": "🛒",
},
{
"id": "glx-102",
"name": "Sainsbury's Test",
"category": "Sainsbury's",
"group": "Groceries",
"emoji": "🛒",
},
{
"id": "glx-103",
"name": "Co-op Test",
"category": "Co-op",
"group": "Groceries",
"emoji": "🛒",
},
]
def test_transform_grocery_retail_points_drops_invalid_rows():
raw = pl.DataFrame(
{
"id": [101, 102],
"retailer": ["Waitrose", ""],
"fascia": ["Waitrose", "Tesco"],
"store_name": ["Waitrose Test", "Tesco Test"],
"long_wgs": [-0.141, -0.142],
"lat_wgs": [51.515, 51.516],
}
)
pois = transform_grocery_retail_points(raw)
assert pois["category"].to_list() == ["Waitrose"]

View file

@ -1058,10 +1058,91 @@ NAPTAN_EMOJIS: dict[str, str] = {
}
COOP_RETAILERS = {
"Allendale Co-operative Society",
"Central England Co-operative",
"Channel Islands Co-operative Society",
"Chelmsford Star Co-operative Society",
"Clydebank Co-operative",
"Coniston Co-operative Society",
"East of England Co-operative",
"Heart of England Co-operative",
"Langdale Co-operative Society",
"Lincolnshire Co-operative",
"Midcounties Co-operative",
"Scottish Midland Co-operative",
"Tamworth Co-operative Society",
"The Co-operative Group",
"The Radstock Co-operative Society",
"The Southern Co-operative",
}
GROCERY_RETAILER_DISPLAY_NAMES: dict[str, str] = {
"Cook": "COOK",
"Heron": "Heron Foods",
"Marks and Spencer": "M&S",
"Sainsburys": "Sainsbury's",
**{retailer: "Co-op" for retailer in COOP_RETAILERS},
}
def normalize_grocery_retailer(retailer: str | None) -> str:
if retailer is None:
return ""
return GROCERY_RETAILER_DISPLAY_NAMES.get(retailer, retailer)
def transform_grocery_retail_points(
grocery_df: pl.DataFrame,
boundary_path: Path | None = None,
) -> pl.DataFrame:
"""Convert GEOLYTIX Grocery Retail Points into the POI parquet schema."""
required = {"id", "retailer", "fascia", "store_name", "long_wgs", "lat_wgs"}
missing = required - set(grocery_df.columns)
if missing:
raise ValueError(
f"GEOLYTIX retail points missing columns: {sorted(missing)}"
)
df = (
grocery_df.select(
pl.col("id").cast(pl.String),
pl.col("retailer").cast(pl.String),
pl.col("fascia").cast(pl.String),
pl.col("store_name").cast(pl.String),
pl.col("lat_wgs").cast(pl.Float64).alias("lat"),
pl.col("long_wgs").cast(pl.Float64).alias("lng"),
)
.drop_nulls(["id", "retailer", "lat", "lng"])
.filter(pl.col("retailer").str.len_chars() > 0)
)
if boundary_path is not None and len(df) > 0:
mask = in_england_mask(
boundary_path,
df["lat"].to_numpy(),
df["lng"].to_numpy(),
)
df = df.filter(pl.Series(mask))
return df.with_columns(
pl.concat_str([pl.lit("glx-"), pl.col("id")]).alias("id"),
pl.coalesce(["store_name", "fascia", "retailer"])
.str.replace_all("''", "'")
.alias("name"),
pl.col("retailer")
.map_elements(normalize_grocery_retailer, return_dtype=pl.String)
.alias("category"),
pl.lit("Groceries").alias("group"),
pl.lit("🛒").alias("emoji"),
).select("id", "name", "category", "group", "lat", "lng", "emoji")
def transform(
input_path: Path,
naptan_path: Path | None = None,
boundary_path: Path | None = None,
grocery_retail_points_path: Path | None = None,
) -> pl.LazyFrame:
lf = pl.scan_parquet(input_path)
@ -1123,7 +1204,14 @@ def transform(
pl.col("category").replace_strict(NAPTAN_EMOJIS).alias("emoji"),
pl.lit("Public Transport").alias("group"),
)
return pl.concat([lf, naptan], how="diagonal_relaxed")
frames = [lf, naptan]
if grocery_retail_points_path is not None:
grocery_df = pl.read_parquet(grocery_retail_points_path)
grocery_pois = transform_grocery_retail_points(grocery_df, boundary_path)
frames.append(grocery_pois.lazy())
return pl.concat(frames, how="diagonal_relaxed")
def main():
@ -1142,12 +1230,22 @@ def main():
required=True,
help="England boundary GeoJSON file",
)
parser.add_argument(
"--grocery-retail-points",
type=Path,
help="GEOLYTIX Grocery Retail Points parquet",
)
parser.add_argument(
"--output", type=Path, required=True, help="Output filtered POIs parquet file"
)
args = parser.parse_args()
df = transform(args.input, args.naptan, args.boundary).collect(engine="streaming")
df = transform(
args.input,
args.naptan,
args.boundary,
args.grocery_retail_points,
).collect(engine="streaming")
df.write_parquet(args.output)

View file

@ -10,19 +10,19 @@ from scipy.spatial import cKDTree
def build_postcode_mapping(arcgis_path: Path) -> pl.DataFrame:
"""Build a mapping from terminated England postcodes to their nearest active postcode.
Uses OS National Grid coordinates (oseast1m, osnrth1m) which are Cartesian metres,
Uses OS National Grid coordinates (east1m, north1m) which are Cartesian metres,
so Euclidean distance via cKDTree gives accurate results without projection.
"""
arcgis = pl.scan_parquet(arcgis_path).filter(pl.col("ctry") == "E92000001")
arcgis = pl.scan_parquet(arcgis_path).filter(pl.col("ctry25cd") == "E92000001")
active = (
arcgis.filter(pl.col("doterm").is_null())
.select("pcds", "oseast1m", "osnrth1m")
.select("pcds", "east1m", "north1m")
.collect()
)
terminated = (
arcgis.filter(pl.col("doterm").is_not_null())
.select("pcds", "oseast1m", "osnrth1m")
.select("pcds", "east1m", "north1m")
.collect()
)
@ -39,10 +39,10 @@ def build_postcode_mapping(arcgis_path: Path) -> pl.DataFrame:
)
active_coords = np.column_stack(
[active["oseast1m"].to_numpy(), active["osnrth1m"].to_numpy()]
[active["east1m"].to_numpy(), active["north1m"].to_numpy()]
)
terminated_coords = np.column_stack(
[terminated["oseast1m"].to_numpy(), terminated["osnrth1m"].to_numpy()]
[terminated["east1m"].to_numpy(), terminated["north1m"].to_numpy()]
)
tree = cKDTree(active_coords)

View file

@ -26,10 +26,8 @@ dependencies = [
"pyproj>=3.7.2",
"pyshp>=2.3.0",
"folium>=0.20.0",
"flask",
"httpx",
"polars",
"fake-useragent>=2.2.0",
]
[tool.uv]

View file

@ -4,6 +4,7 @@
"private": true,
"scripts": {
"build": "tsc",
"test": "npm run build && node --test dist/**/*.test.js",
"start": "node dist/server.js",
"dev": "tsc --watch & node --watch dist/server.js"
},

View file

@ -1,10 +1,14 @@
import express from 'express';
import express, { type Request, type Response } from 'express';
import { ScreenshotCache } from './cache.js';
import { takeScreenshot, checkWebGL, closeBrowser, initialize } from './screenshot.js';
import { buildScreenshotRequest, ValidationError } from './validation.js';
const PORT = parseInt(process.env.PORT || '8002', 10);
const APP_URL = process.env.APP_URL;
const CACHE_DIR = process.env.CACHE_DIR;
const SCREENSHOT_CONCURRENCY = parsePositiveIntEnv('SCREENSHOT_CONCURRENCY', 3);
const RATE_LIMIT_WINDOW_MS = parsePositiveIntEnv('SCREENSHOT_RATE_WINDOW_MS', 60_000);
const RATE_LIMIT_MAX = parsePositiveIntEnv('SCREENSHOT_RATE_LIMIT', 30);
if (!APP_URL) {
console.error('Error: APP_URL environment variable is required');
@ -18,6 +22,96 @@ if (!CACHE_DIR) {
const cache = new ScreenshotCache(CACHE_DIR);
const app = express();
app.set('trust proxy', true);
let activeScreenshots = 0;
let lastRateLimitPrune = 0;
const rateLimitBuckets = new Map<string, { count: number; resetAt: number }>();
type ReleaseScreenshotSlot = () => void;
type PendingScreenshotSlot = {
resolve: (release: ReleaseScreenshotSlot | null) => void;
cleanup: () => void;
};
const screenshotSlotQueue: PendingScreenshotSlot[] = [];
function parsePositiveIntEnv(name: string, fallback: number): number {
const value = Number.parseInt(process.env[name] || '', 10);
return Number.isFinite(value) && value > 0 ? value : fallback;
}
function grantScreenshotSlot(): ReleaseScreenshotSlot {
activeScreenshots += 1;
let released = false;
return () => {
if (released) return;
released = true;
activeScreenshots = Math.max(0, activeScreenshots - 1);
drainScreenshotSlotQueue();
};
}
function drainScreenshotSlotQueue(): void {
while (activeScreenshots < SCREENSHOT_CONCURRENCY && screenshotSlotQueue.length > 0) {
const pending = screenshotSlotQueue.shift();
if (!pending) return;
pending.cleanup();
pending.resolve(grantScreenshotSlot());
}
}
function acquireScreenshotSlot(res: Response): Promise<ReleaseScreenshotSlot | null> {
if (activeScreenshots < SCREENSHOT_CONCURRENCY) {
return Promise.resolve(grantScreenshotSlot());
}
return new Promise((resolve) => {
let pending: PendingScreenshotSlot;
const onClose = () => {
if (res.writableEnded) return;
pending.cleanup();
const index = screenshotSlotQueue.indexOf(pending);
if (index !== -1) screenshotSlotQueue.splice(index, 1);
resolve(null);
};
pending = {
resolve,
cleanup: () => res.off('close', onClose),
};
res.on('close', onClose);
screenshotSlotQueue.push(pending);
console.log(`Queued screenshot request; queue length: ${screenshotSlotQueue.length}`);
});
}
function rateLimitKey(req: Request): string {
const forwardedFor = req.get('x-forwarded-for')?.split(',')[0]?.trim();
return forwardedFor || req.ip || req.socket.remoteAddress || 'unknown';
}
function allowScreenshotRequest(req: Request): boolean {
const now = Date.now();
if (now - lastRateLimitPrune > RATE_LIMIT_WINDOW_MS) {
lastRateLimitPrune = now;
for (const [key, bucket] of rateLimitBuckets) {
if (bucket.resetAt <= now) {
rateLimitBuckets.delete(key);
}
}
}
const key = rateLimitKey(req);
let bucket = rateLimitBuckets.get(key);
if (!bucket || bucket.resetAt <= now) {
bucket = { count: 0, resetAt: now + RATE_LIMIT_WINDOW_MS };
rateLimitBuckets.set(key, bucket);
}
if (bucket.count >= RATE_LIMIT_MAX) {
return false;
}
bucket.count += 1;
return true;
}
app.get('/health', (_req, res) => {
res.status(200).send('ok');
@ -33,28 +127,9 @@ app.get('/debug', async (_req, res) => {
});
app.get('/screenshot', async (req, res) => {
let releaseSlot: (() => void) | null = null;
try {
const qs = new URLSearchParams();
for (const key of ['lat', 'lon', 'zoom', 'tab', 'og']) {
const val = req.query[key];
if (typeof val === 'string' && val) {
qs.set(key, val);
}
}
// Repeated params: filter, poi, tt (travel time)
for (const key of ['filter', 'poi', 'tt']) {
const val = req.query[key];
if (typeof val === 'string' && val) {
qs.append(key, val);
} else if (Array.isArray(val)) {
for (const v of val) {
if (typeof v === 'string' && v) qs.append(key, v);
}
}
}
// Extract path param (used for non-root pages like /invite/CODE)
const pagePath = typeof req.query.path === 'string' && req.query.path ? req.query.path : '/';
const { pagePath, qs } = buildScreenshotRequest(req.query as Record<string, unknown>);
if (pagePath !== '/') qs.set('path', pagePath);
// Include auth status in cache key so authenticated screenshots
@ -75,6 +150,16 @@ app.get('/screenshot', async (req, res) => {
return;
}
if (!allowScreenshotRequest(req)) {
res.status(429).json({ error: 'Screenshot rate limit exceeded' });
return;
}
releaseSlot = await acquireScreenshotSlot(res);
if (!releaseSlot) {
return;
}
// Build the URL for the frontend in screenshot mode
qs.set('screenshot', '1');
const url = `${APP_URL}${pagePath}?${qs}`;
@ -90,8 +175,14 @@ app.get('/screenshot', async (req, res) => {
res.setHeader('X-Cache', 'MISS');
res.send(jpeg);
} catch (err) {
if (err instanceof ValidationError) {
res.status(err.status).json({ error: err.message });
return;
}
console.error('Screenshot failed:', err);
res.status(500).json({ error: 'Screenshot failed' });
} finally {
releaseSlot?.();
}
});
@ -99,6 +190,8 @@ const server = app.listen(PORT, () => {
console.log(`Screenshot service listening on port ${PORT}`);
console.log(` APP_URL: ${APP_URL}`);
console.log(` CACHE_DIR: ${CACHE_DIR}`);
console.log(` SCREENSHOT_CONCURRENCY: ${SCREENSHOT_CONCURRENCY}`);
console.log(` SCREENSHOT_RATE_LIMIT: ${RATE_LIMIT_MAX}/${RATE_LIMIT_WINDOW_MS}ms`);
// Pre-warm browser and populate network cache in background.
// The health endpoint is available immediately; screenshot requests

View file

@ -0,0 +1,58 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { buildScreenshotRequest, ValidationError } from './validation.js';
test('buildScreenshotRequest accepts supported screenshot parameters', () => {
const result = buildScreenshotRequest({
lat: '51.5074',
lon: '-0.1278',
zoom: '12.5',
tab: 'properties',
og: '1',
path: '/invite/abc123',
filter: ['Last known price:100000:500000', 'Total floor area (sqm):50:150'],
poi: 'supermarket',
tt: 'transit:kings-cross:Kings Cross:b:0:30',
});
assert.equal(result.pagePath, '/invite/abc123');
assert.equal(result.qs.get('lat'), '51.5074');
assert.equal(result.qs.get('lon'), '-0.1278');
assert.equal(result.qs.get('zoom'), '12.5');
assert.equal(result.qs.get('tab'), 'properties');
assert.deepEqual(result.qs.getAll('filter'), [
'Last known price:100000:500000',
'Total floor area (sqm):50:150',
]);
});
test('buildScreenshotRequest rejects invalid numeric values', () => {
assert.throws(
() => buildScreenshotRequest({ lat: '91', lon: '-0.1', zoom: '12' }),
ValidationError,
);
assert.throws(
() => buildScreenshotRequest({ lat: '51abc', lon: '-0.1', zoom: '12' }),
ValidationError,
);
});
test('buildScreenshotRequest rejects unsafe paths', () => {
assert.throws(() => buildScreenshotRequest({ path: '//example.com' }), ValidationError);
assert.throws(() => buildScreenshotRequest({ path: '/../../etc/passwd' }), ValidationError);
});
test('buildScreenshotRequest limits repeated parameters', () => {
assert.throws(
() =>
buildScreenshotRequest({
filter: Array.from({ length: 41 }, (_, index) => `Feature ${index}:0:1`),
}),
ValidationError,
);
});
test('buildScreenshotRequest rejects control characters', () => {
assert.throws(() => buildScreenshotRequest({ filter: 'Feature:\u0000:1' }), ValidationError);
});

View file

@ -0,0 +1,114 @@
export class ValidationError extends Error {
status = 400;
}
export interface ValidatedScreenshotRequest {
pagePath: string;
qs: URLSearchParams;
}
const MAX_REPEATED_PARAMS = 40;
const MAX_VALUE_LENGTH = 500;
const NUMERIC_RE = /^-?(?:\d+|\d*\.\d+)$/;
const PATH_RE = /^\/(?:invite\/[A-Za-z0-9]{1,20})?$/;
const SAFE_VALUE_RE = /^[^\u0000-\u001f\u007f]+$/;
const REPEATED_KEYS = ['filter', 'poi', 'tt'] as const;
type Query = Record<string, unknown>;
function validationError(message: string): never {
throw new ValidationError(message);
}
function firstString(query: Query, key: string): string | undefined {
const value = query[key];
if (value == null) return undefined;
if (Array.isArray(value)) {
validationError(`${key} must not be repeated`);
}
if (typeof value !== 'string') {
validationError(`${key} must be a string`);
}
return value || undefined;
}
function repeatedStrings(query: Query, key: string): string[] {
const value = query[key];
if (value == null) return [];
const values = Array.isArray(value) ? value : [value];
if (values.length > MAX_REPEATED_PARAMS) {
validationError(`${key} has too many values`);
}
return values.filter((item): item is string => {
if (typeof item !== 'string') {
validationError(`${key} values must be strings`);
}
return item.length > 0;
});
}
function assertSafeValue(key: string, value: string): void {
if (value.length > MAX_VALUE_LENGTH) {
validationError(`${key} is too long`);
}
if (!SAFE_VALUE_RE.test(value)) {
validationError(`${key} contains invalid characters`);
}
}
function appendBoundedNumber(
qs: URLSearchParams,
query: Query,
key: string,
min: number,
max: number,
): void {
const value = firstString(query, key);
if (value == null) return;
if (value.length > 40 || !NUMERIC_RE.test(value)) {
validationError(`${key} must be a number`);
}
const numeric = Number(value);
if (!Number.isFinite(numeric) || numeric < min || numeric > max) {
validationError(`${key} is out of range`);
}
qs.set(key, value);
}
export function buildScreenshotRequest(query: Query): ValidatedScreenshotRequest {
const qs = new URLSearchParams();
appendBoundedNumber(qs, query, 'lat', -90, 90);
appendBoundedNumber(qs, query, 'lon', -180, 180);
appendBoundedNumber(qs, query, 'zoom', 0, 22);
const tab = firstString(query, 'tab');
if (tab != null) {
if (tab !== 'area' && tab !== 'properties') {
validationError('tab is invalid');
}
qs.set('tab', tab);
}
const og = firstString(query, 'og');
if (og != null) {
if (og !== '1') {
validationError('og is invalid');
}
qs.set('og', og);
}
for (const key of REPEATED_KEYS) {
for (const value of repeatedStrings(query, key)) {
assertSafeValue(key, value);
qs.append(key, value);
}
}
const pagePath = firstString(query, 'path') ?? '/';
if (!PATH_RE.test(pagePath)) {
validationError('path is invalid');
}
return { pagePath, qs };
}

View file

@ -1,522 +0,0 @@
2026-02-19T21:26:54.458535Z INFO property_map_server: Prometheus metrics initialized
2026-02-19T21:26:54.458730Z INFO property_map_server: Loading property data from /app/data/properties.parquet, /app/data/postcode.parquet, /app/data-scraped/online_listings_buy.parquet, /app/data-scraped/online_listings_rent.parquet
2026-02-19T21:26:54.458738Z INFO property_map_server::data::property: Loading postcode features from "/app/data/postcode.parquet"
2026-02-19T21:26:54.560667Z INFO property_map_server::data::property: Postcode features loaded rows=1262367
2026-02-19T21:26:54.560677Z INFO property_map_server::data::property: Loading properties from "/app/data/properties.parquet"
2026-02-19T21:27:01.536771Z INFO property_map_server::data::property: Properties joined with postcodes rows=15203381
2026-02-19T21:27:01.536788Z INFO property_map_server::data::property: Loading buy listings from "/app/data-scraped/online_listings_buy.parquet"
2026-02-19T21:27:01.858493Z INFO property_map_server::data::property: buy listings joined rows=444605
2026-02-19T21:27:01.858503Z INFO property_map_server::data::property: Loading rent listings from "/app/data-scraped/online_listings_rent.parquet"
2026-02-19T21:27:01.970421Z INFO property_map_server::data::property: rent listings joined rows=125656
2026-02-19T21:27:01.970430Z INFO property_map_server::data::property: Concatenating all data sources
2026-02-19T21:27:52.277322Z INFO property_map_server::data::property: All data sources combined properties=15203381 buy_listings=444605 rent_listings=125656 total=15773642
2026-02-19T21:27:52.277425Z INFO property_map_server::data::property: Feature columns from config numeric=55 enums=12 total=67
2026-02-19T21:27:53.590317Z INFO property_map_server::data::property: Combined data selected rows=15773642
2026-02-19T21:27:53.731832Z INFO property_map_server::data::property: Extracting numeric feature columns
2026-02-19T21:27:58.340459Z INFO property_map_server::data::property: Computing histograms for numeric features
2026-02-19T21:27:59.454079Z INFO property_map_server::data::property: Extracting string columns
2026-02-19T21:28:01.589530Z INFO property_map_server::data::property: Building enum features
2026-02-19T21:29:17.412343Z INFO property_map_server::data::property: Extracting renovation history
2026-02-19T21:29:19.625773Z INFO property_map_server::data::property: Renovation history extracted properties_with_events=1829807
2026-02-19T21:29:19.625780Z INFO property_map_server::data::property: Extracting listing features
2026-02-19T21:29:21.764449Z INFO property_map_server::data::property: Listing features extracted properties_with_features=508871
2026-02-19T21:29:21.764457Z INFO property_map_server::data::property: Sorting rows by spatial locality
2026-02-19T21:29:28.223647Z INFO property_map_server::data::property: Building interned strings
2026-02-19T21:30:05.730665Z INFO property_map_server::data::property: Transposing to row-major layout (spatially sorted)
2026-02-19T21:32:02.361349Z INFO property_map_server::data::property: Data loading complete
2026-02-19T21:32:03.976002Z INFO property_map_server: Property data loaded rows=15773642 features=67 enums=12
2026-02-19T21:32:03.976011Z INFO property_map_server: Building spatial grid index (0.01° cells)
2026-02-19T21:32:04.076953Z INFO property_map_server: Precomputing H3 cells at resolution 12
2026-02-19T21:32:04.076963Z INFO property_map_server::data::property: Precomputing H3 cells at resolution 12
2026-02-19T21:32:04.487571Z INFO property_map_server::data::property: H3 precomputation complete (15773642 cells)
2026-02-19T21:32:04.490452Z INFO property_map_server: Loading POI data from /app/data/filtered_uk_pois.parquet
2026-02-19T21:32:04.490466Z INFO property_map_server::data::poi: Loading POI data from "/app/data/filtered_uk_pois.parquet"...
2026-02-19T21:32:04.740771Z INFO property_map_server::data::poi: Loaded 811937 POIs
2026-02-19T21:32:04.865428Z INFO property_map_server::data::poi: POI string columns interned category_unique=74 group_unique=11 emoji_unique=71
2026-02-19T21:32:04.866050Z INFO property_map_server::data::poi: POI data loading complete.
2026-02-19T21:32:04.896188Z INFO property_map_server: POI data loaded pois=811937
2026-02-19T21:32:04.896196Z INFO property_map_server: Building POI spatial grid index
2026-02-19T21:32:04.903631Z INFO property_map_server: Loading place data from /app/data/places.parquet
2026-02-19T21:32:04.908488Z INFO property_map_server::data::places: Loading place data from "/app/data/places.parquet"...
2026-02-19T21:32:04.916283Z INFO property_map_server::data::places: Loaded 90807 places
2026-02-19T21:32:04.951763Z INFO property_map_server::data::places: Place data loaded places=90807 types=11 with_population=2112 with_city=87577
2026-02-19T21:32:04.952866Z INFO property_map_server: Place data loaded places=90807
2026-02-19T21:32:04.952882Z INFO property_map_server: Loading postcode boundaries from /app/data/postcode_boundaries
2026-02-19T21:32:04.952983Z INFO property_map_server::data::postcodes: Loading postcode boundaries from "/app/data/postcode_boundaries"
2026-02-19T21:32:04.956401Z INFO property_map_server::data::postcodes: Found GeoJSON files to process files=2361
2026-02-19T21:32:07.669253Z INFO property_map_server::data::postcodes: Postcode boundary data ready postcodes=1490140
2026-02-19T21:32:07.669264Z INFO property_map_server: Postcode boundaries loaded postcodes=1490140
2026-02-19T21:32:07.669278Z INFO property_map_server: Loading PMTiles from /app/data/uk.pmtiles
2026-02-19T21:32:07.677413Z INFO property_map_server: PMTiles loaded successfully
2026-02-19T21:32:07.729382Z INFO property_map_server: No --dist provided; static serving and OG injection disabled
2026-02-19T21:32:07.792213Z INFO property_map_server: Screenshot service configured: http://screenshot:8002
2026-02-19T21:32:07.792458Z INFO property_map_server: Precomputed features response groups=9
2026-02-19T21:32:07.792535Z INFO property_map_server: Precomputed AI filters schema and system prompt
2026-02-19T21:32:07.796454Z INFO property_map_server: PocketBase configured: http://pocketbase:8090
2026-02-19T21:32:13.118788Z INFO property_map_server::pocketbase: PocketBase users collection already has is_admin, subscription, and newsletter fields
2026-02-19T21:32:13.169893Z INFO property_map_server::pocketbase: PocketBase collection 'saved_searches' API rules updated
2026-02-19T21:32:13.169910Z INFO property_map_server::pocketbase: PocketBase collection 'invites' already exists
2026-02-19T21:32:13.169913Z INFO property_map_server::pocketbase: PocketBase collection 'short_urls' already exists
2026-02-19T21:32:13.230971Z WARN property_map_server::pocketbase: PocketBase settings missing oauth2.providers array — cannot configure OAuth
2026-02-19T21:32:13.230981Z INFO property_map_server: Ollama configured: http://host.docker.internal:11434 (model: gpt-oss:20b)
2026-02-19T21:32:13.230992Z INFO property_map_server: Loading travel time data from /app/data/travel-times
2026-02-19T21:32:13.251928Z INFO property_map_server::data::travel_time: Travel time mode discovered mode="bicycle" destinations=76039
2026-02-19T21:32:13.272182Z INFO property_map_server::data::travel_time: Travel time mode discovered mode="walking" destinations=76039
2026-02-19T21:32:13.291900Z INFO property_map_server::data::travel_time: Travel time mode discovered mode="transit" destinations=69290
2026-02-19T21:32:13.291945Z INFO property_map_server: Travel time store loaded modes=3
2026-02-19T21:32:13.296486Z INFO property_map_server: Server listening on 0.0.0.0:8001
2026-02-19T21:32:14.023023Z INFO property_map_server::routes::features: GET /api/features
2026-02-19T21:32:16.790595Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=5 cells_before_filter=687 cells_after_filter=687 truncated=false bounds=47.0000,-14.0000,57.0000,10.0000 filters=0 filters_raw="-" travel_entries=0 agg_ms=1729.5 total_ms=1748.8
2026-02-19T21:32:23.004683Z INFO property_map_server::routes::pois: GET /api/poi-categories count=74 groups=11
2026-02-19T21:32:24.013755Z INFO property_map_server::routes::features: GET /api/features
2026-02-19T21:32:24.013792Z INFO property_map_server::routes::pois: GET /api/poi-categories count=74 groups=11
2026-02-19T21:32:25.049834Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=10 cells_before_filter=1419 cells_after_filter=1259 truncated=false bounds=51.4908,-0.1363,51.5292,-0.0637 filters=0 filters_raw="-" travel_entries=0 agg_ms=11.2 total_ms=38.8
2026-02-19T21:36:25.033898Z INFO property_map_server: Prometheus metrics initialized
2026-02-19T21:36:25.034069Z INFO property_map_server: Loading property data from /app/data/properties.parquet, /app/data/postcode.parquet, /app/data-scraped/online_listings_buy.parquet, /app/data-scraped/online_listings_rent.parquet
2026-02-19T21:36:25.034079Z INFO property_map_server::data::property: Loading postcode features from "/app/data/postcode.parquet"
2026-02-19T21:36:25.129912Z INFO property_map_server::data::property: Postcode features loaded rows=1262367
2026-02-19T21:36:25.129921Z INFO property_map_server::data::property: Loading properties from "/app/data/properties.parquet"
2026-02-19T21:36:32.499133Z INFO property_map_server: Prometheus metrics initialized
2026-02-19T21:36:32.499319Z INFO property_map_server: Loading property data from /app/data/properties.parquet, /app/data/postcode.parquet, /app/data-scraped/online_listings_buy.parquet, /app/data-scraped/online_listings_rent.parquet
2026-02-19T21:36:32.499330Z INFO property_map_server::data::property: Loading postcode features from "/app/data/postcode.parquet"
2026-02-19T21:36:32.558294Z INFO property_map_server::data::property: Postcode features loaded rows=1262367
2026-02-19T21:36:32.558304Z INFO property_map_server::data::property: Loading properties from "/app/data/properties.parquet"
2026-02-19T21:36:34.752324Z INFO property_map_server::data::property: Properties joined with postcodes rows=15203381
2026-02-19T21:36:34.752339Z INFO property_map_server::data::property: Loading buy listings from "/app/data-scraped/online_listings_buy.parquet"
2026-02-19T21:36:35.020717Z INFO property_map_server::data::property: buy listings joined rows=444605
2026-02-19T21:36:35.020727Z INFO property_map_server::data::property: Loading rent listings from "/app/data-scraped/online_listings_rent.parquet"
2026-02-19T21:36:35.117921Z INFO property_map_server::data::property: rent listings joined rows=125656
2026-02-19T21:36:35.117931Z INFO property_map_server::data::property: Concatenating all data sources
2026-02-19T21:38:50.238928Z INFO property_map_server::data::property: All data sources combined properties=15203381 buy_listings=444605 rent_listings=125656 total=15773642
2026-02-19T21:38:50.239009Z INFO property_map_server::data::property: Feature columns from config numeric=55 enums=12 total=67
2026-02-19T21:38:51.489522Z INFO property_map_server::data::property: Combined data selected rows=15773642
2026-02-19T21:38:51.661991Z INFO property_map_server::data::property: Extracting numeric feature columns
2026-02-19T21:39:38.714760Z INFO property_map_server: Prometheus metrics initialized
2026-02-19T21:39:38.714936Z INFO property_map_server: Loading property data from /app/data/properties.parquet, /app/data/postcode.parquet, /app/data-scraped/online_listings_buy.parquet, /app/data-scraped/online_listings_rent.parquet
2026-02-19T21:39:38.714944Z INFO property_map_server::data::property: Loading postcode features from "/app/data/postcode.parquet"
2026-02-19T21:39:38.790493Z INFO property_map_server::data::property: Postcode features loaded rows=1262367
2026-02-19T21:39:38.790504Z INFO property_map_server::data::property: Loading properties from "/app/data/properties.parquet"
2026-02-19T21:39:41.014520Z INFO property_map_server::data::property: Properties joined with postcodes rows=15203381
2026-02-19T21:39:41.014538Z INFO property_map_server::data::property: Loading buy listings from "/app/data-scraped/online_listings_buy.parquet"
2026-02-19T21:39:41.299979Z INFO property_map_server::data::property: buy listings joined rows=444605
2026-02-19T21:39:41.299990Z INFO property_map_server::data::property: Loading rent listings from "/app/data-scraped/online_listings_rent.parquet"
2026-02-19T21:39:41.400048Z INFO property_map_server::data::property: rent listings joined rows=125656
2026-02-19T21:39:41.400059Z INFO property_map_server::data::property: Concatenating all data sources
2026-02-19T21:39:47.989385Z INFO property_map_server::data::property: All data sources combined properties=15203381 buy_listings=444605 rent_listings=125656 total=15773642
2026-02-19T21:39:47.989481Z INFO property_map_server::data::property: Feature columns from config numeric=55 enums=12 total=67
2026-02-19T21:39:49.243773Z INFO property_map_server::data::property: Combined data selected rows=15773642
2026-02-19T21:39:49.422488Z INFO property_map_server::data::property: Extracting numeric feature columns
2026-02-19T21:39:54.408464Z INFO property_map_server::data::property: Computing histograms for numeric features
2026-02-19T21:39:55.491023Z INFO property_map_server::data::property: Extracting string columns
2026-02-19T21:39:57.641424Z INFO property_map_server::data::property: Building enum features
2026-02-19T21:40:09.935990Z INFO property_map_server::data::property: Extracting renovation history
2026-02-19T21:40:12.074163Z INFO property_map_server::data::property: Renovation history extracted properties_with_events=1829807
2026-02-19T21:40:12.074172Z INFO property_map_server::data::property: Extracting listing features
2026-02-19T21:40:12.681574Z INFO property_map_server::data::property: Listing features extracted properties_with_features=508871
2026-02-19T21:40:12.681588Z INFO property_map_server::data::property: Sorting rows by spatial locality
2026-02-19T21:40:18.238722Z INFO property_map_server::data::property: Building interned strings
2026-02-19T21:40:24.556588Z INFO property_map_server::data::property: Transposing to row-major layout (spatially sorted)
2026-02-19T21:40:52.861550Z INFO property_map_server::data::property: Data loading complete
2026-02-19T21:40:54.156096Z INFO property_map_server: Property data loaded rows=15773642 features=67 enums=12
2026-02-19T21:40:54.156105Z INFO property_map_server: Building spatial grid index (0.01° cells)
2026-02-19T21:40:54.550391Z INFO property_map_server: Precomputing H3 cells at resolution 12
2026-02-19T21:40:54.550401Z INFO property_map_server::data::property: Precomputing H3 cells at resolution 12
2026-02-19T21:40:54.950194Z INFO property_map_server::data::property: H3 precomputation complete (15773642 cells)
2026-02-19T21:40:54.950226Z INFO property_map_server: Loading POI data from /app/data/filtered_uk_pois.parquet
2026-02-19T21:40:54.950233Z INFO property_map_server::data::poi: Loading POI data from "/app/data/filtered_uk_pois.parquet"...
2026-02-19T21:40:54.970688Z INFO property_map_server::data::poi: Loaded 811937 POIs
2026-02-19T21:40:55.091891Z INFO property_map_server::data::poi: POI string columns interned category_unique=74 group_unique=11 emoji_unique=71
2026-02-19T21:40:55.092505Z INFO property_map_server::data::poi: POI data loading complete.
2026-02-19T21:40:55.122637Z INFO property_map_server: POI data loaded pois=811937
2026-02-19T21:40:55.122650Z INFO property_map_server: Building POI spatial grid index
2026-02-19T21:40:55.132909Z INFO property_map_server: Loading place data from /app/data/places.parquet
2026-02-19T21:40:55.132919Z INFO property_map_server::data::places: Loading place data from "/app/data/places.parquet"...
2026-02-19T21:40:55.135615Z INFO property_map_server::data::places: Loaded 90807 places
2026-02-19T21:40:55.155573Z INFO property_map_server::data::places: Place data loaded places=90807 types=11 with_population=2112 with_city=87577
2026-02-19T21:40:55.157182Z INFO property_map_server: Place data loaded places=90807
2026-02-19T21:40:55.157198Z INFO property_map_server: Loading postcode boundaries from /app/data/postcode_boundaries
2026-02-19T21:40:55.157202Z INFO property_map_server::data::postcodes: Loading postcode boundaries from "/app/data/postcode_boundaries"
2026-02-19T21:40:55.169027Z INFO property_map_server::data::postcodes: Found GeoJSON files to process files=2361
2026-02-19T21:40:56.700286Z INFO property_map_server::data::postcodes: Postcode boundary data ready postcodes=1490140
2026-02-19T21:40:56.700297Z INFO property_map_server: Postcode boundaries loaded postcodes=1490140
2026-02-19T21:40:56.700310Z INFO property_map_server: Loading PMTiles from /app/data/uk.pmtiles
2026-02-19T21:40:56.711874Z INFO property_map_server: PMTiles loaded successfully
2026-02-19T21:40:56.767004Z INFO property_map_server: No --dist provided; static serving and OG injection disabled
2026-02-19T21:40:56.793907Z INFO property_map_server: Screenshot service configured: http://screenshot:8002
2026-02-19T21:40:56.794046Z INFO property_map_server: Precomputed features response groups=9
2026-02-19T21:40:56.794089Z INFO property_map_server: Precomputed AI filters schema and system prompt
2026-02-19T21:40:56.794100Z INFO property_map_server: PocketBase configured: http://pocketbase:8090
2026-02-19T21:40:56.883854Z INFO property_map_server::pocketbase: PocketBase users collection already has is_admin, subscription, and newsletter fields
2026-02-19T21:40:56.887425Z INFO property_map_server::pocketbase: PocketBase collection 'saved_searches' API rules updated
2026-02-19T21:40:56.887435Z INFO property_map_server::pocketbase: PocketBase collection 'invites' already exists
2026-02-19T21:40:56.887438Z INFO property_map_server::pocketbase: PocketBase collection 'short_urls' already exists
2026-02-19T21:40:56.936330Z WARN property_map_server::pocketbase: PocketBase settings missing oauth2.providers array — cannot configure OAuth
2026-02-19T21:40:56.936343Z INFO property_map_server: Ollama configured: http://host.docker.internal:11434 (model: gpt-oss:20b)
2026-02-19T21:40:56.936362Z INFO property_map_server: Loading travel time data from /app/data/travel-times
2026-02-19T21:40:57.078090Z INFO property_map_server::data::travel_time: Travel time mode discovered mode="bicycle" destinations=76039
2026-02-19T21:40:57.241363Z INFO property_map_server::data::travel_time: Travel time mode discovered mode="walking" destinations=76039
2026-02-19T21:40:57.424132Z INFO property_map_server::data::travel_time: Travel time mode discovered mode="transit" destinations=69290
2026-02-19T21:40:57.424164Z INFO property_map_server: Travel time store loaded modes=3
2026-02-19T21:40:57.424333Z INFO property_map_server: Server listening on 0.0.0.0:8001
2026-02-19T21:45:48.088981Z INFO property_map_server: Prometheus metrics initialized
2026-02-19T21:45:48.089157Z INFO property_map_server: Loading property data from /app/data/properties.parquet, /app/data/postcode.parquet, /app/data-scraped/online_listings_buy.parquet, /app/data-scraped/online_listings_rent.parquet
2026-02-19T21:45:48.089163Z INFO property_map_server::data::property: Loading postcode features from "/app/data/postcode.parquet"
2026-02-19T21:45:48.151222Z INFO property_map_server::data::property: Postcode features loaded rows=1262367
2026-02-19T21:45:48.151231Z INFO property_map_server::data::property: Loading properties from "/app/data/properties.parquet"
2026-02-19T21:45:50.419725Z INFO property_map_server::data::property: Properties joined with postcodes rows=15203381
2026-02-19T21:45:50.419740Z INFO property_map_server::data::property: Loading buy listings from "/app/data-scraped/online_listings_buy.parquet"
2026-02-19T21:45:50.680792Z INFO property_map_server::data::property: buy listings joined rows=444605
2026-02-19T21:45:50.680801Z INFO property_map_server::data::property: Loading rent listings from "/app/data-scraped/online_listings_rent.parquet"
2026-02-19T21:45:50.790235Z INFO property_map_server::data::property: rent listings joined rows=125656
2026-02-19T21:45:50.790245Z INFO property_map_server::data::property: Concatenating all data sources
2026-02-19T21:45:59.531271Z INFO property_map_server::data::property: All data sources combined properties=15203381 buy_listings=444605 rent_listings=125656 total=15773642
2026-02-19T21:45:59.531351Z INFO property_map_server::data::property: Feature columns from config numeric=55 enums=12 total=67
2026-02-19T21:46:00.677779Z INFO property_map_server::data::property: Combined data selected rows=15773642
2026-02-19T21:46:00.823682Z INFO property_map_server::data::property: Extracting numeric feature columns
2026-02-19T21:46:11.566611Z INFO property_map_server: Prometheus metrics initialized
2026-02-19T21:46:11.566786Z INFO property_map_server: Loading property data from /app/data/properties.parquet, /app/data/postcode.parquet, /app/data-scraped/online_listings_buy.parquet, /app/data-scraped/online_listings_rent.parquet
2026-02-19T21:46:11.566792Z INFO property_map_server::data::property: Loading postcode features from "/app/data/postcode.parquet"
2026-02-19T21:46:11.644730Z INFO property_map_server::data::property: Postcode features loaded rows=1262367
2026-02-19T21:46:11.644739Z INFO property_map_server::data::property: Loading properties from "/app/data/properties.parquet"
2026-02-19T21:46:17.296113Z INFO property_map_server: Prometheus metrics initialized
2026-02-19T21:46:17.296298Z INFO property_map_server: Loading property data from /app/data/properties.parquet, /app/data/postcode.parquet, /app/data-scraped/online_listings_buy.parquet, /app/data-scraped/online_listings_rent.parquet
2026-02-19T21:46:17.296309Z INFO property_map_server::data::property: Loading postcode features from "/app/data/postcode.parquet"
2026-02-19T21:46:17.355178Z INFO property_map_server::data::property: Postcode features loaded rows=1262367
2026-02-19T21:46:17.355187Z INFO property_map_server::data::property: Loading properties from "/app/data/properties.parquet"
2026-02-19T21:46:19.508288Z INFO property_map_server::data::property: Properties joined with postcodes rows=15203381
2026-02-19T21:46:19.508307Z INFO property_map_server::data::property: Loading buy listings from "/app/data-scraped/online_listings_buy.parquet"
2026-02-19T21:46:19.775415Z INFO property_map_server::data::property: buy listings joined rows=444605
2026-02-19T21:46:19.775424Z INFO property_map_server::data::property: Loading rent listings from "/app/data-scraped/online_listings_rent.parquet"
2026-02-19T21:46:19.877913Z INFO property_map_server::data::property: rent listings joined rows=125656
2026-02-19T21:46:19.877923Z INFO property_map_server::data::property: Concatenating all data sources
2026-02-19T21:46:22.229279Z INFO property_map_server::data::property: All data sources combined properties=15203381 buy_listings=444605 rent_listings=125656 total=15773642
2026-02-19T21:46:22.229352Z INFO property_map_server::data::property: Feature columns from config numeric=55 enums=12 total=67
2026-02-19T21:46:23.385234Z INFO property_map_server::data::property: Combined data selected rows=15773642
2026-02-19T21:46:23.566673Z INFO property_map_server::data::property: Extracting numeric feature columns
2026-02-19T21:46:28.957436Z INFO property_map_server::data::property: Computing histograms for numeric features
2026-02-19T21:46:34.625853Z INFO property_map_server: Prometheus metrics initialized
2026-02-19T21:46:34.626033Z INFO property_map_server: Loading property data from /app/data/properties.parquet, /app/data/postcode.parquet, /app/data-scraped/online_listings_buy.parquet, /app/data-scraped/online_listings_rent.parquet
2026-02-19T21:46:34.626039Z INFO property_map_server::data::property: Loading postcode features from "/app/data/postcode.parquet"
2026-02-19T21:46:34.683165Z INFO property_map_server::data::property: Postcode features loaded rows=1262367
2026-02-19T21:46:34.683174Z INFO property_map_server::data::property: Loading properties from "/app/data/properties.parquet"
2026-02-19T21:46:39.619046Z INFO property_map_server: Prometheus metrics initialized
2026-02-19T21:46:39.619206Z INFO property_map_server: Loading property data from /app/data/properties.parquet, /app/data/postcode.parquet, /app/data-scraped/online_listings_buy.parquet, /app/data-scraped/online_listings_rent.parquet
2026-02-19T21:46:39.619211Z INFO property_map_server::data::property: Loading postcode features from "/app/data/postcode.parquet"
2026-02-19T21:46:39.682402Z INFO property_map_server::data::property: Postcode features loaded rows=1262367
2026-02-19T21:46:39.682412Z INFO property_map_server::data::property: Loading properties from "/app/data/properties.parquet"
2026-02-19T21:46:41.896969Z INFO property_map_server::data::property: Properties joined with postcodes rows=15203381
2026-02-19T21:46:41.896985Z INFO property_map_server::data::property: Loading buy listings from "/app/data-scraped/online_listings_buy.parquet"
2026-02-19T21:46:42.158027Z INFO property_map_server::data::property: buy listings joined rows=444605
2026-02-19T21:46:42.158037Z INFO property_map_server::data::property: Loading rent listings from "/app/data-scraped/online_listings_rent.parquet"
2026-02-19T21:46:42.256671Z INFO property_map_server::data::property: rent listings joined rows=125656
2026-02-19T21:46:42.256682Z INFO property_map_server::data::property: Concatenating all data sources
2026-02-19T21:46:44.596786Z INFO property_map_server::data::property: All data sources combined properties=15203381 buy_listings=444605 rent_listings=125656 total=15773642
2026-02-19T21:46:44.596858Z INFO property_map_server::data::property: Feature columns from config numeric=55 enums=12 total=67
2026-02-19T21:46:45.729422Z INFO property_map_server::data::property: Combined data selected rows=15773642
2026-02-19T21:46:45.884768Z INFO property_map_server::data::property: Extracting numeric feature columns
2026-02-19T21:46:51.133252Z INFO property_map_server::data::property: Computing histograms for numeric features
2026-02-19T21:46:52.129246Z INFO property_map_server::data::property: Extracting string columns
2026-02-19T21:46:54.247724Z INFO property_map_server::data::property: Building enum features
2026-02-19T21:47:06.556048Z INFO property_map_server::data::property: Extracting renovation history
2026-02-19T21:47:08.635978Z INFO property_map_server::data::property: Renovation history extracted properties_with_events=1829807
2026-02-19T21:47:08.635986Z INFO property_map_server::data::property: Extracting listing features
2026-02-19T21:47:09.231804Z INFO property_map_server::data::property: Listing features extracted properties_with_features=508871
2026-02-19T21:47:09.231812Z INFO property_map_server::data::property: Sorting rows by spatial locality
2026-02-19T21:47:14.793765Z INFO property_map_server::data::property: Building interned strings
2026-02-19T21:47:21.074369Z INFO property_map_server::data::property: Transposing to row-major layout (spatially sorted)
2026-02-19T21:47:49.305297Z INFO property_map_server::data::property: Data loading complete
2026-02-19T21:47:50.726389Z INFO property_map_server: Property data loaded rows=15773642 features=67 enums=12
2026-02-19T21:47:50.726398Z INFO property_map_server: Building spatial grid index (0.01° cells)
2026-02-19T21:47:50.825433Z INFO property_map_server: Precomputing H3 cells at resolution 12
2026-02-19T21:47:50.825442Z INFO property_map_server::data::property: Precomputing H3 cells at resolution 12
2026-02-19T21:47:51.183337Z INFO property_map_server::data::property: H3 precomputation complete (15773642 cells)
2026-02-19T21:47:51.183366Z INFO property_map_server: Loading POI data from /app/data/filtered_uk_pois.parquet
2026-02-19T21:47:51.183377Z INFO property_map_server::data::poi: Loading POI data from "/app/data/filtered_uk_pois.parquet"...
2026-02-19T21:47:51.239629Z INFO property_map_server::data::poi: Loaded 811937 POIs
2026-02-19T21:47:51.358287Z INFO property_map_server::data::poi: POI string columns interned category_unique=74 group_unique=11 emoji_unique=71
2026-02-19T21:47:51.358921Z INFO property_map_server::data::poi: POI data loading complete.
2026-02-19T21:47:51.389530Z INFO property_map_server: POI data loaded pois=811937
2026-02-19T21:47:51.389537Z INFO property_map_server: Building POI spatial grid index
2026-02-19T21:47:51.397611Z INFO property_map_server: Loading place data from /app/data/places.parquet
2026-02-19T21:47:51.397621Z INFO property_map_server::data::places: Loading place data from "/app/data/places.parquet"...
2026-02-19T21:47:51.404147Z INFO property_map_server::data::places: Loaded 90807 places
2026-02-19T21:47:51.422097Z INFO property_map_server::data::places: Place data loaded places=90807 types=11 with_population=2112 with_city=87577
2026-02-19T21:47:51.423272Z INFO property_map_server: Place data loaded places=90807
2026-02-19T21:47:51.423286Z INFO property_map_server: Loading postcode boundaries from /app/data/postcode_boundaries
2026-02-19T21:47:51.423293Z INFO property_map_server::data::postcodes: Loading postcode boundaries from "/app/data/postcode_boundaries"
2026-02-19T21:47:51.427524Z INFO property_map_server::data::postcodes: Found GeoJSON files to process files=2361
2026-02-19T21:47:52.459962Z INFO property_map_server::data::postcodes: Postcode boundary data ready postcodes=1490140
2026-02-19T21:47:52.459974Z INFO property_map_server: Postcode boundaries loaded postcodes=1490140
2026-02-19T21:47:52.459991Z INFO property_map_server: Loading PMTiles from /app/data/uk.pmtiles
2026-02-19T21:47:52.460299Z INFO property_map_server: PMTiles loaded successfully
2026-02-19T21:47:52.509697Z INFO property_map_server: No --dist provided; static serving and OG injection disabled
2026-02-19T21:47:52.548802Z INFO property_map_server: Screenshot service configured: http://screenshot:8002
2026-02-19T21:47:52.548955Z INFO property_map_server: Precomputed features response groups=9
2026-02-19T21:47:52.548998Z INFO property_map_server: Precomputed AI filters schema and system prompt
2026-02-19T21:47:52.549010Z INFO property_map_server: PocketBase configured: http://pocketbase:8090
2026-02-19T21:47:52.651249Z INFO property_map_server::pocketbase: PocketBase users collection already has is_admin, subscription, and newsletter fields
2026-02-19T21:47:52.657303Z INFO property_map_server::pocketbase: PocketBase collection 'saved_searches' API rules updated
2026-02-19T21:47:52.657312Z INFO property_map_server::pocketbase: PocketBase collection 'invites' already exists
2026-02-19T21:47:52.657314Z INFO property_map_server::pocketbase: PocketBase collection 'short_urls' already exists
2026-02-19T21:47:52.699918Z WARN property_map_server::pocketbase: PocketBase settings missing oauth2.providers array — cannot configure OAuth
2026-02-19T21:47:52.699928Z INFO property_map_server: Ollama configured: http://host.docker.internal:11434 (model: gpt-oss:20b)
2026-02-19T21:47:52.699941Z INFO property_map_server: Loading travel time data from /app/data/travel-times
2026-02-19T21:47:52.776587Z INFO property_map_server::data::travel_time: Travel time mode discovered mode="bicycle" destinations=76039
2026-02-19T21:47:52.892688Z INFO property_map_server::data::travel_time: Travel time mode discovered mode="walking" destinations=76039
2026-02-19T21:47:52.988301Z INFO property_map_server::data::travel_time: Travel time mode discovered mode="transit" destinations=69290
2026-02-19T21:47:52.988332Z INFO property_map_server: Travel time store loaded modes=3
2026-02-19T21:47:52.988500Z INFO property_map_server: Server listening on 0.0.0.0:8001
2026-02-19T21:51:21.780285Z INFO property_map_server: Prometheus metrics initialized
2026-02-19T21:51:21.780462Z INFO property_map_server: Loading property data from /app/data/properties.parquet, /app/data/postcode.parquet, /app/data-scraped/online_listings_buy.parquet, /app/data-scraped/online_listings_rent.parquet
2026-02-19T21:51:21.780471Z INFO property_map_server::data::property: Loading postcode features from "/app/data/postcode.parquet"
2026-02-19T21:51:21.851737Z INFO property_map_server::data::property: Postcode features loaded rows=1262367
2026-02-19T21:51:21.851746Z INFO property_map_server::data::property: Loading properties from "/app/data/properties.parquet"
2026-02-19T21:51:24.076503Z INFO property_map_server::data::property: Properties joined with postcodes rows=15203381
2026-02-19T21:51:24.076522Z INFO property_map_server::data::property: Loading buy listings from "/app/data-scraped/online_listings_buy.parquet"
2026-02-19T21:51:24.334934Z INFO property_map_server::data::property: buy listings joined rows=444605
2026-02-19T21:51:24.334944Z INFO property_map_server::data::property: Loading rent listings from "/app/data-scraped/online_listings_rent.parquet"
2026-02-19T21:51:24.438503Z INFO property_map_server::data::property: rent listings joined rows=125656
2026-02-19T21:51:24.438513Z INFO property_map_server::data::property: Concatenating all data sources
2026-02-19T21:51:35.892299Z INFO property_map_server::data::property: All data sources combined properties=15203381 buy_listings=444605 rent_listings=125656 total=15773642
2026-02-19T21:51:35.892405Z INFO property_map_server::data::property: Feature columns from config numeric=55 enums=12 total=67
2026-02-19T21:51:36.985627Z INFO property_map_server::data::property: Combined data selected rows=15773642
2026-02-19T21:51:37.144498Z INFO property_map_server::data::property: Extracting numeric feature columns
2026-02-19T21:51:42.441195Z INFO property_map_server::data::property: Computing histograms for numeric features
2026-02-19T21:51:43.421336Z INFO property_map_server::data::property: Extracting string columns
2026-02-19T21:51:45.512575Z INFO property_map_server::data::property: Building enum features
2026-02-19T21:51:57.729162Z INFO property_map_server::data::property: Extracting renovation history
2026-02-19T21:51:59.870295Z INFO property_map_server::data::property: Renovation history extracted properties_with_events=1829807
2026-02-19T21:51:59.870303Z INFO property_map_server::data::property: Extracting listing features
2026-02-19T21:52:00.496544Z INFO property_map_server::data::property: Listing features extracted properties_with_features=508871
2026-02-19T21:52:00.496553Z INFO property_map_server::data::property: Sorting rows by spatial locality
2026-02-19T21:52:05.810063Z INFO property_map_server::data::property: Building interned strings
2026-02-19T21:52:12.478733Z INFO property_map_server::data::property: Transposing to row-major layout (spatially sorted)
2026-02-19T21:52:57.041288Z INFO property_map_server::data::property: Data loading complete
2026-02-19T21:52:58.190166Z INFO property_map_server: Property data loaded rows=15773642 features=67 enums=12
2026-02-19T21:52:58.190227Z INFO property_map_server: Building spatial grid index (0.01° cells)
2026-02-19T21:52:58.612625Z INFO property_map_server: Precomputing H3 cells at resolution 12
2026-02-19T21:52:58.612634Z INFO property_map_server::data::property: Precomputing H3 cells at resolution 12
2026-02-19T21:52:59.010323Z INFO property_map_server::data::property: H3 precomputation complete (15773642 cells)
2026-02-19T21:52:59.010352Z INFO property_map_server: Loading POI data from /app/data/filtered_uk_pois.parquet
2026-02-19T21:52:59.010357Z INFO property_map_server::data::poi: Loading POI data from "/app/data/filtered_uk_pois.parquet"...
2026-02-19T21:52:59.033154Z INFO property_map_server::data::poi: Loaded 811937 POIs
2026-02-19T21:52:59.168962Z INFO property_map_server::data::poi: POI string columns interned category_unique=74 group_unique=11 emoji_unique=71
2026-02-19T21:52:59.169620Z INFO property_map_server::data::poi: POI data loading complete.
2026-02-19T21:52:59.200062Z INFO property_map_server: POI data loaded pois=811937
2026-02-19T21:52:59.200069Z INFO property_map_server: Building POI spatial grid index
2026-02-19T21:52:59.209004Z INFO property_map_server: Loading place data from /app/data/places.parquet
2026-02-19T21:52:59.209026Z INFO property_map_server::data::places: Loading place data from "/app/data/places.parquet"...
2026-02-19T21:52:59.217593Z INFO property_map_server::data::places: Loaded 90807 places
2026-02-19T21:52:59.237507Z INFO property_map_server::data::places: Place data loaded places=90807 types=11 with_population=2112 with_city=87577
2026-02-19T21:52:59.238659Z INFO property_map_server: Place data loaded places=90807
2026-02-19T21:52:59.238673Z INFO property_map_server: Loading postcode boundaries from /app/data/postcode_boundaries
2026-02-19T21:52:59.238677Z INFO property_map_server::data::postcodes: Loading postcode boundaries from "/app/data/postcode_boundaries"
2026-02-19T21:52:59.239461Z INFO property_map_server::data::postcodes: Found GeoJSON files to process files=2361
2026-02-19T21:53:00.577770Z INFO property_map_server::data::postcodes: Postcode boundary data ready postcodes=1490140
2026-02-19T21:53:00.577784Z INFO property_map_server: Postcode boundaries loaded postcodes=1490140
2026-02-19T21:53:00.577800Z INFO property_map_server: Loading PMTiles from /app/data/uk.pmtiles
2026-02-19T21:53:00.578044Z INFO property_map_server: PMTiles loaded successfully
2026-02-19T21:53:00.635812Z INFO property_map_server: No --dist provided; static serving and OG injection disabled
2026-02-19T21:53:00.661112Z INFO property_map_server: Screenshot service configured: http://screenshot:8002
2026-02-19T21:53:00.661276Z INFO property_map_server: Precomputed features response groups=9
2026-02-19T21:53:00.661327Z INFO property_map_server: Precomputed AI filters schema and system prompt
2026-02-19T21:53:00.661341Z INFO property_map_server: PocketBase configured: http://pocketbase:8090
2026-02-19T21:53:00.777123Z INFO property_map_server::pocketbase: PocketBase users collection already has is_admin, subscription, and newsletter fields
2026-02-19T21:53:00.780442Z INFO property_map_server::pocketbase: PocketBase collection 'saved_searches' API rules updated
2026-02-19T21:53:00.780453Z INFO property_map_server::pocketbase: PocketBase collection 'invites' already exists
2026-02-19T21:53:00.780455Z INFO property_map_server::pocketbase: PocketBase collection 'short_urls' already exists
2026-02-19T21:53:00.827713Z WARN property_map_server::pocketbase: PocketBase settings missing oauth2.providers array — cannot configure OAuth
2026-02-19T21:53:00.827728Z INFO property_map_server: Ollama configured: http://host.docker.internal:11434 (model: gpt-oss:20b)
2026-02-19T21:53:00.827756Z INFO property_map_server: Loading travel time data from /app/data/travel-times
2026-02-19T21:53:00.853494Z INFO property_map_server::data::travel_time: Travel time mode discovered mode="bicycle" destinations=76039
2026-02-19T21:53:00.875117Z INFO property_map_server::data::travel_time: Travel time mode discovered mode="walking" destinations=76039
2026-02-19T21:53:00.897287Z INFO property_map_server::data::travel_time: Travel time mode discovered mode="transit" destinations=69290
2026-02-19T21:53:00.897342Z INFO property_map_server: Travel time store loaded modes=3
2026-02-19T21:53:00.897521Z WARN property_map_server: mlockall failed (need CAP_IPC_LOCK or sufficient RLIMIT_MEMLOCK): Cannot allocate memory (os error 12)
2026-02-19T21:53:00.897564Z INFO property_map_server: Server listening on 0.0.0.0:8001
2026-02-19T21:53:43.607361Z INFO property_map_server: Prometheus metrics initialized
2026-02-19T21:53:43.607524Z INFO property_map_server: Loading property data from /app/data/properties.parquet, /app/data/postcode.parquet, /app/data-scraped/online_listings_buy.parquet, /app/data-scraped/online_listings_rent.parquet
2026-02-19T21:53:43.607533Z INFO property_map_server::data::property: Loading postcode features from "/app/data/postcode.parquet"
2026-02-19T21:53:43.703745Z INFO property_map_server::data::property: Postcode features loaded rows=1262367
2026-02-19T21:53:43.703756Z INFO property_map_server::data::property: Loading properties from "/app/data/properties.parquet"
2026-02-19T21:53:46.126315Z INFO property_map_server::data::property: Properties joined with postcodes rows=15203381
2026-02-19T21:53:46.126336Z INFO property_map_server::data::property: Loading buy listings from "/app/data-scraped/online_listings_buy.parquet"
2026-02-19T21:53:46.404697Z INFO property_map_server::data::property: buy listings joined rows=444605
2026-02-19T21:53:46.404708Z INFO property_map_server::data::property: Loading rent listings from "/app/data-scraped/online_listings_rent.parquet"
2026-02-19T21:53:46.508191Z INFO property_map_server::data::property: rent listings joined rows=125656
2026-02-19T21:53:46.508203Z INFO property_map_server::data::property: Concatenating all data sources
2026-02-19T21:54:04.815379Z INFO property_map_server::data::property: All data sources combined properties=15203381 buy_listings=444605 rent_listings=125656 total=15773642
2026-02-19T21:54:04.815453Z INFO property_map_server::data::property: Feature columns from config numeric=55 enums=12 total=67
2026-02-19T21:54:05.957615Z INFO property_map_server::data::property: Combined data selected rows=15773642
2026-02-19T21:54:06.114182Z INFO property_map_server::data::property: Extracting numeric feature columns
2026-02-19T21:54:11.113430Z INFO property_map_server::data::property: Computing histograms for numeric features
2026-02-19T21:54:12.014906Z INFO property_map_server::data::property: Extracting string columns
2026-02-19T21:54:14.114892Z INFO property_map_server::data::property: Building enum features
2026-02-19T21:54:26.216628Z INFO property_map_server::data::property: Extracting renovation history
2026-02-19T21:54:28.295021Z INFO property_map_server::data::property: Renovation history extracted properties_with_events=1829807
2026-02-19T21:54:28.295030Z INFO property_map_server::data::property: Extracting listing features
2026-02-19T21:54:28.894967Z INFO property_map_server::data::property: Listing features extracted properties_with_features=508871
2026-02-19T21:54:28.894975Z INFO property_map_server::data::property: Sorting rows by spatial locality
2026-02-19T21:54:34.492548Z INFO property_map_server::data::property: Building interned strings
2026-02-19T21:54:40.775643Z INFO property_map_server::data::property: Transposing to row-major layout (spatially sorted)
2026-02-19T21:55:09.150090Z INFO property_map_server::data::property: Data loading complete
2026-02-19T21:55:10.482353Z INFO property_map_server: Property data loaded rows=15773642 features=67 enums=12
2026-02-19T21:55:10.482362Z INFO property_map_server: Building spatial grid index (0.01° cells)
2026-02-19T21:55:10.579983Z INFO property_map_server: Precomputing H3 cells at resolution 12
2026-02-19T21:55:10.579992Z INFO property_map_server::data::property: Precomputing H3 cells at resolution 12
2026-02-19T21:55:10.931346Z INFO property_map_server::data::property: H3 precomputation complete (15773642 cells)
2026-02-19T21:55:10.931371Z INFO property_map_server: Loading POI data from /app/data/filtered_uk_pois.parquet
2026-02-19T21:55:10.931376Z INFO property_map_server::data::poi: Loading POI data from "/app/data/filtered_uk_pois.parquet"...
2026-02-19T21:55:10.956383Z INFO property_map_server::data::poi: Loaded 811937 POIs
2026-02-19T21:55:11.076557Z INFO property_map_server::data::poi: POI string columns interned category_unique=74 group_unique=11 emoji_unique=71
2026-02-19T21:55:11.077176Z INFO property_map_server::data::poi: POI data loading complete.
2026-02-19T21:55:11.107885Z INFO property_map_server: POI data loaded pois=811937
2026-02-19T21:55:11.107892Z INFO property_map_server: Building POI spatial grid index
2026-02-19T21:55:11.115230Z INFO property_map_server: Loading place data from /app/data/places.parquet
2026-02-19T21:55:11.115239Z INFO property_map_server::data::places: Loading place data from "/app/data/places.parquet"...
2026-02-19T21:55:11.117955Z INFO property_map_server::data::places: Loaded 90807 places
2026-02-19T21:55:11.135514Z INFO property_map_server::data::places: Place data loaded places=90807 types=11 with_population=2112 with_city=87577
2026-02-19T21:55:11.136623Z INFO property_map_server: Place data loaded places=90807
2026-02-19T21:55:11.136637Z INFO property_map_server: Loading postcode boundaries from /app/data/postcode_boundaries
2026-02-19T21:55:11.136641Z INFO property_map_server::data::postcodes: Loading postcode boundaries from "/app/data/postcode_boundaries"
2026-02-19T21:55:11.138240Z INFO property_map_server::data::postcodes: Found GeoJSON files to process files=2361
2026-02-19T21:55:12.410370Z INFO property_map_server::data::postcodes: Postcode boundary data ready postcodes=1490140
2026-02-19T21:55:12.410381Z INFO property_map_server: Postcode boundaries loaded postcodes=1490140
2026-02-19T21:55:12.410398Z INFO property_map_server: Loading PMTiles from /app/data/uk.pmtiles
2026-02-19T21:55:12.410625Z INFO property_map_server: PMTiles loaded successfully
2026-02-19T21:55:12.462925Z INFO property_map_server: No --dist provided; static serving and OG injection disabled
2026-02-19T21:55:12.494298Z INFO property_map_server: Screenshot service configured: http://screenshot:8002
2026-02-19T21:55:12.494439Z INFO property_map_server: Precomputed features response groups=9
2026-02-19T21:55:12.494482Z INFO property_map_server: Precomputed AI filters schema and system prompt
2026-02-19T21:55:12.494496Z INFO property_map_server: PocketBase configured: http://pocketbase:8090
2026-02-19T21:55:12.541473Z INFO property_map_server::pocketbase: PocketBase users collection already has is_admin, subscription, and newsletter fields
2026-02-19T21:55:12.543245Z INFO property_map_server::pocketbase: PocketBase collection 'saved_searches' API rules updated
2026-02-19T21:55:12.543253Z INFO property_map_server::pocketbase: PocketBase collection 'invites' already exists
2026-02-19T21:55:12.543255Z INFO property_map_server::pocketbase: PocketBase collection 'short_urls' already exists
2026-02-19T21:55:12.586588Z WARN property_map_server::pocketbase: PocketBase settings missing oauth2.providers array — cannot configure OAuth
2026-02-19T21:55:12.586599Z INFO property_map_server: Ollama configured: http://host.docker.internal:11434 (model: gpt-oss:20b)
2026-02-19T21:55:12.586612Z INFO property_map_server: Loading travel time data from /app/data/travel-times
2026-02-19T21:55:12.608664Z INFO property_map_server::data::travel_time: Travel time mode discovered mode="bicycle" destinations=76039
2026-02-19T21:55:12.629502Z INFO property_map_server::data::travel_time: Travel time mode discovered mode="walking" destinations=76039
2026-02-19T21:55:12.648844Z INFO property_map_server::data::travel_time: Travel time mode discovered mode="transit" destinations=69290
2026-02-19T21:55:12.648881Z INFO property_map_server: Travel time store loaded modes=3
2026-02-19T21:55:12.649064Z WARN property_map_server: mlockall failed (need CAP_IPC_LOCK or sufficient RLIMIT_MEMLOCK): Cannot allocate memory (os error 12)
2026-02-19T21:55:12.649102Z INFO property_map_server: Server listening on 0.0.0.0:8001
2026-02-19T21:56:27.019313Z INFO property_map_server: Prometheus metrics initialized
2026-02-19T21:56:27.019464Z INFO property_map_server: Loading property data from /app/data/properties.parquet, /app/data/postcode.parquet, /app/data-scraped/online_listings_buy.parquet, /app/data-scraped/online_listings_rent.parquet
2026-02-19T21:56:27.019473Z INFO property_map_server::data::property: Loading postcode features from "/app/data/postcode.parquet"
2026-02-19T21:56:27.077642Z INFO property_map_server::data::property: Postcode features loaded rows=1262367
2026-02-19T21:56:27.077651Z INFO property_map_server::data::property: Loading properties from "/app/data/properties.parquet"
2026-02-19T21:56:29.376382Z INFO property_map_server::data::property: Properties joined with postcodes rows=15203381
2026-02-19T21:56:29.376400Z INFO property_map_server::data::property: Loading buy listings from "/app/data-scraped/online_listings_buy.parquet"
2026-02-19T21:56:29.643314Z INFO property_map_server::data::property: buy listings joined rows=444605
2026-02-19T21:56:29.643325Z INFO property_map_server::data::property: Loading rent listings from "/app/data-scraped/online_listings_rent.parquet"
2026-02-19T21:56:29.750729Z INFO property_map_server::data::property: rent listings joined rows=125656
2026-02-19T21:56:29.750740Z INFO property_map_server::data::property: Concatenating all data sources
2026-02-19T21:56:36.764563Z INFO property_map_server::data::property: All data sources combined properties=15203381 buy_listings=444605 rent_listings=125656 total=15773642
2026-02-19T21:56:36.764643Z INFO property_map_server::data::property: Feature columns from config numeric=55 enums=12 total=67
2026-02-19T21:56:37.882852Z INFO property_map_server::data::property: Combined data selected rows=15773642
2026-02-19T21:56:38.041464Z INFO property_map_server::data::property: Extracting numeric feature columns
2026-02-19T21:56:43.567623Z INFO property_map_server::data::property: Computing histograms for numeric features
2026-02-19T21:56:44.547629Z INFO property_map_server::data::property: Extracting string columns
2026-02-19T21:56:46.638073Z INFO property_map_server::data::property: Building enum features
2026-02-19T21:56:58.743576Z INFO property_map_server::data::property: Extracting renovation history
2026-02-19T21:57:00.886254Z INFO property_map_server::data::property: Renovation history extracted properties_with_events=1829807
2026-02-19T21:57:00.886263Z INFO property_map_server::data::property: Extracting listing features
2026-02-19T21:57:01.500069Z INFO property_map_server::data::property: Listing features extracted properties_with_features=508871
2026-02-19T21:57:01.500077Z INFO property_map_server::data::property: Sorting rows by spatial locality
2026-02-19T21:57:07.085398Z INFO property_map_server::data::property: Building interned strings
2026-02-19T21:57:13.333355Z INFO property_map_server::data::property: Transposing to row-major layout (spatially sorted)
2026-02-19T21:57:42.416818Z INFO property_map_server::data::property: Data loading complete
2026-02-19T21:57:43.429909Z INFO property_map_server: Property data loaded rows=15773642 features=67 enums=12
2026-02-19T21:57:43.429917Z INFO property_map_server: Building spatial grid index (0.01° cells)
2026-02-19T21:57:43.809875Z INFO property_map_server: Precomputing H3 cells at resolution 12
2026-02-19T21:57:43.809884Z INFO property_map_server::data::property: Precomputing H3 cells at resolution 12
2026-02-19T21:57:44.161951Z INFO property_map_server::data::property: H3 precomputation complete (15773642 cells)
2026-02-19T21:57:44.161990Z INFO property_map_server: Loading POI data from /app/data/filtered_uk_pois.parquet
2026-02-19T21:57:44.161997Z INFO property_map_server::data::poi: Loading POI data from "/app/data/filtered_uk_pois.parquet"...
2026-02-19T21:57:44.184832Z INFO property_map_server::data::poi: Loaded 811937 POIs
2026-02-19T21:57:44.303890Z INFO property_map_server::data::poi: POI string columns interned category_unique=74 group_unique=11 emoji_unique=71
2026-02-19T21:57:44.304500Z INFO property_map_server::data::poi: POI data loading complete.
2026-02-19T21:57:44.335104Z INFO property_map_server: POI data loaded pois=811937
2026-02-19T21:57:44.335113Z INFO property_map_server: Building POI spatial grid index
2026-02-19T21:57:44.342807Z INFO property_map_server: Loading place data from /app/data/places.parquet
2026-02-19T21:57:44.342817Z INFO property_map_server::data::places: Loading place data from "/app/data/places.parquet"...
2026-02-19T21:57:44.345902Z INFO property_map_server::data::places: Loaded 90807 places
2026-02-19T21:57:44.365611Z INFO property_map_server::data::places: Place data loaded places=90807 types=11 with_population=2112 with_city=87577
2026-02-19T21:57:44.368113Z INFO property_map_server: Place data loaded places=90807
2026-02-19T21:57:44.368128Z INFO property_map_server: Loading postcode boundaries from /app/data/postcode_boundaries
2026-02-19T21:57:44.368133Z INFO property_map_server::data::postcodes: Loading postcode boundaries from "/app/data/postcode_boundaries"
2026-02-19T21:57:44.368931Z INFO property_map_server::data::postcodes: Found GeoJSON files to process files=2361
2026-02-19T21:57:45.407981Z INFO property_map_server::data::postcodes: Postcode boundary data ready postcodes=1490140
2026-02-19T21:57:45.407992Z INFO property_map_server: Postcode boundaries loaded postcodes=1490140
2026-02-19T21:57:45.408009Z INFO property_map_server: Loading PMTiles from /app/data/uk.pmtiles
2026-02-19T21:57:45.408231Z INFO property_map_server: PMTiles loaded successfully
2026-02-19T21:57:45.458216Z INFO property_map_server: No --dist provided; static serving and OG injection disabled
2026-02-19T21:57:45.483167Z INFO property_map_server: Screenshot service configured: http://screenshot:8002
2026-02-19T21:57:45.483325Z INFO property_map_server: Precomputed features response groups=9
2026-02-19T21:57:45.483362Z INFO property_map_server: Precomputed AI filters schema and system prompt
2026-02-19T21:57:45.483372Z INFO property_map_server: PocketBase configured: http://pocketbase:8090
2026-02-19T21:57:45.527603Z INFO property_map_server::pocketbase: PocketBase users collection already has is_admin, subscription, and newsletter fields
2026-02-19T21:57:45.529274Z INFO property_map_server::pocketbase: PocketBase collection 'saved_searches' API rules updated
2026-02-19T21:57:45.529282Z INFO property_map_server::pocketbase: PocketBase collection 'invites' already exists
2026-02-19T21:57:45.529284Z INFO property_map_server::pocketbase: PocketBase collection 'short_urls' already exists
2026-02-19T22:00:02.316188Z INFO property_map_server: Prometheus metrics initialized
2026-02-19T22:00:02.316363Z INFO property_map_server: Loading property data from /app/data/properties.parquet, /app/data/postcode.parquet, /app/data-scraped/online_listings_buy.parquet, /app/data-scraped/online_listings_rent.parquet
2026-02-19T22:00:02.316376Z INFO property_map_server::data::property: Loading postcode features from "/app/data/postcode.parquet"
2026-02-19T22:00:02.374613Z INFO property_map_server::data::property: Postcode features loaded rows=1262367
2026-02-19T22:00:02.374622Z INFO property_map_server::data::property: Loading properties from "/app/data/properties.parquet"
2026-02-19T22:00:04.644372Z INFO property_map_server::data::property: Properties joined with postcodes rows=15203381
2026-02-19T22:00:04.644386Z INFO property_map_server::data::property: Loading buy listings from "/app/data-scraped/online_listings_buy.parquet"
2026-02-19T22:00:04.911541Z INFO property_map_server::data::property: buy listings joined rows=444605
2026-02-19T22:00:04.911554Z INFO property_map_server::data::property: Loading rent listings from "/app/data-scraped/online_listings_rent.parquet"
2026-02-19T22:00:05.012469Z INFO property_map_server::data::property: rent listings joined rows=125656
2026-02-19T22:00:05.012480Z INFO property_map_server::data::property: Concatenating all data sources
2026-02-19T22:00:22.135033Z INFO property_map_server::data::property: All data sources combined properties=15203381 buy_listings=444605 rent_listings=125656 total=15773642
2026-02-19T22:00:22.135120Z INFO property_map_server::data::property: Feature columns from config numeric=55 enums=12 total=67
2026-02-19T22:00:23.338993Z INFO property_map_server::data::property: Combined data selected rows=15773642
2026-02-19T22:00:23.510508Z INFO property_map_server::data::property: Extracting numeric feature columns
2026-02-19T22:00:28.484323Z INFO property_map_server::data::property: Computing histograms for numeric features
2026-02-19T22:00:29.357751Z INFO property_map_server::data::property: Extracting string columns
2026-02-19T22:00:31.494852Z INFO property_map_server::data::property: Building enum features
2026-02-19T22:00:43.668748Z INFO property_map_server::data::property: Extracting renovation history
2026-02-19T22:00:45.723371Z INFO property_map_server::data::property: Renovation history extracted properties_with_events=1829807
2026-02-19T22:00:45.723379Z INFO property_map_server::data::property: Extracting listing features
2026-02-19T22:00:46.332508Z INFO property_map_server::data::property: Listing features extracted properties_with_features=508871
2026-02-19T22:00:46.332517Z INFO property_map_server::data::property: Sorting rows by spatial locality
2026-02-19T22:00:51.842163Z INFO property_map_server::data::property: Building interned strings
2026-02-19T22:00:57.978323Z INFO property_map_server::data::property: Transposing to row-major layout (spatially sorted)
2026-02-19T22:01:26.614095Z INFO property_map_server::data::property: Data loading complete
2026-02-19T22:01:27.675542Z INFO property_map_server: Property data loaded rows=15773642 features=67 enums=12
2026-02-19T22:01:27.675552Z INFO property_map_server: Building spatial grid index (0.01° cells)
2026-02-19T22:01:28.060580Z INFO property_map_server: Precomputing H3 cells at resolution 12
2026-02-19T22:01:28.060590Z INFO property_map_server::data::property: Precomputing H3 cells at resolution 12
2026-02-19T22:01:28.424039Z INFO property_map_server::data::property: H3 precomputation complete (15773642 cells)
2026-02-19T22:01:28.424101Z INFO property_map_server: Loading POI data from /app/data/filtered_uk_pois.parquet
2026-02-19T22:01:28.424183Z INFO property_map_server::data::poi: Loading POI data from "/app/data/filtered_uk_pois.parquet"...
2026-02-19T22:01:28.448672Z INFO property_map_server::data::poi: Loaded 811937 POIs
2026-02-19T22:01:28.566180Z INFO property_map_server::data::poi: POI string columns interned category_unique=74 group_unique=11 emoji_unique=71
2026-02-19T22:01:28.566791Z INFO property_map_server::data::poi: POI data loading complete.
2026-02-19T22:01:28.596675Z INFO property_map_server: POI data loaded pois=811937
2026-02-19T22:01:28.596685Z INFO property_map_server: Building POI spatial grid index
2026-02-19T22:01:28.603824Z INFO property_map_server: Loading place data from /app/data/places.parquet
2026-02-19T22:01:28.603830Z INFO property_map_server::data::places: Loading place data from "/app/data/places.parquet"...
2026-02-19T22:01:28.606465Z INFO property_map_server::data::places: Loaded 90807 places
2026-02-19T22:01:28.623823Z INFO property_map_server::data::places: Place data loaded places=90807 types=11 with_population=2112 with_city=87577
2026-02-19T22:01:28.624990Z INFO property_map_server: Place data loaded places=90807
2026-02-19T22:01:28.625002Z INFO property_map_server: Loading postcode boundaries from /app/data/postcode_boundaries
2026-02-19T22:01:28.625006Z INFO property_map_server::data::postcodes: Loading postcode boundaries from "/app/data/postcode_boundaries"
2026-02-19T22:01:28.637363Z INFO property_map_server::data::postcodes: Found GeoJSON files to process files=2361
2026-02-19T22:01:29.656030Z INFO property_map_server::data::postcodes: Postcode boundary data ready postcodes=1490140
2026-02-19T22:01:29.656042Z INFO property_map_server: Postcode boundaries loaded postcodes=1490140
2026-02-19T22:01:29.656058Z INFO property_map_server: Loading PMTiles from /app/data/uk.pmtiles
2026-02-19T22:01:29.656288Z INFO property_map_server: PMTiles loaded successfully
2026-02-19T22:01:29.705260Z INFO property_map_server: No --dist provided; static serving and OG injection disabled
2026-02-19T22:01:29.738938Z INFO property_map_server: Screenshot service configured: http://screenshot:8002
2026-02-19T22:01:29.739087Z INFO property_map_server: Precomputed features response groups=9
2026-02-19T22:01:29.739137Z INFO property_map_server: Precomputed AI filters schema and system prompt
2026-02-19T22:01:29.739149Z INFO property_map_server: PocketBase configured: http://pocketbase:8090
2026-02-19T22:01:29.786529Z INFO property_map_server::pocketbase: PocketBase users collection already has is_admin, subscription, and newsletter fields
2026-02-19T22:01:29.788719Z INFO property_map_server::pocketbase: PocketBase collection 'saved_searches' API rules updated
2026-02-19T22:01:29.788726Z INFO property_map_server::pocketbase: PocketBase collection 'invites' already exists
2026-02-19T22:01:29.788728Z INFO property_map_server::pocketbase: PocketBase collection 'short_urls' already exists

File diff suppressed because it is too large Load diff

View file

@ -1,335 +0,0 @@
2026-03-17T07:30:51.418735Z INFO property_map_server: Prometheus metrics initialized
2026-03-17T07:30:51.418950Z INFO property_map_server: Loading property data from /app/data/properties.parquet, /app/data/postcode.parquet, /app/data-scraped/online_listings_buy.parquet, /app/data-scraped/online_listings_rent.parquet
2026-03-17T07:30:51.418957Z INFO property_map_server::data::property: Loading postcode features from "/app/data/postcode.parquet"
2026-03-17T07:30:51.591217Z INFO property_map_server::data::property: Postcode features loaded rows=1262367
2026-03-17T07:30:51.591228Z INFO property_map_server::data::property: Loading properties from "/app/data/properties.parquet"
2026-03-17T07:31:03.482386Z INFO property_map_server::data::property: Properties joined with postcodes rows=15203381
2026-03-17T07:31:03.482398Z INFO property_map_server::data::property: Loading buy listings from "/app/data-scraped/online_listings_buy.parquet"
2026-03-17T07:31:06.206982Z INFO property_map_server::data::property: buy listings joined rows=457076
2026-03-17T07:31:06.207003Z INFO property_map_server::data::property: Loading rent listings from "/app/data-scraped/online_listings_rent.parquet"
2026-03-17T07:31:08.031097Z INFO property_map_server::data::property: rent listings joined rows=122594
2026-03-17T07:31:08.031106Z INFO property_map_server::data::property: Concatenating all data sources
2026-03-17T07:32:00.170695Z INFO property_map_server::data::property: All data sources combined properties=15203381 buy_listings=457076 rent_listings=122594 total=15783051
2026-03-17T07:32:00.170797Z INFO property_map_server::data::property: Feature columns from config numeric=55 enums=13 total=68
2026-03-17T07:32:01.527808Z INFO property_map_server::data::property: Combined data selected rows=15783051
2026-03-17T07:32:01.738022Z INFO property_map_server::data::property: Extracting numeric feature columns
2026-03-17T07:32:02.164093Z INFO property_map_server::data::property: Computing histograms for numeric features
2026-03-17T07:32:03.346133Z INFO property_map_server::data::property: Extracting string columns
2026-03-17T07:32:05.803712Z INFO property_map_server::data::property: Building enum features
2026-03-17T07:32:07.359340Z INFO property_map_server::data::property: Extracting renovation history
2026-03-17T07:32:09.567602Z INFO property_map_server::data::property: Renovation history extracted properties_with_events=1829807
2026-03-17T07:32:09.567612Z INFO property_map_server::data::property: Extracting listing features
2026-03-17T07:32:10.194293Z INFO property_map_server::data::property: Listing features extracted properties_with_features=518063
2026-03-17T07:32:10.194304Z INFO property_map_server::data::property: Sorting rows by spatial locality
2026-03-17T07:32:11.130691Z INFO property_map_server::data::property: Building interned strings
2026-03-17T07:32:17.391642Z INFO property_map_server::data::property: Transposing to row-major layout (spatially sorted, quantized to u16)
2026-03-17T07:32:20.030170Z INFO property_map_server::data::property: Data loading complete
2026-03-17T07:32:21.686179Z INFO property_map_server: Property data loaded rows=15783051 features=68 enums=13
2026-03-17T07:32:21.686189Z INFO property_map_server: Building spatial grid index (0.01° cells)
2026-03-17T07:32:22.119885Z INFO property_map_server: Precomputing H3 cells at resolution 12
2026-03-17T07:32:22.119896Z INFO property_map_server::data::property: Precomputing H3 cells at resolution 12
2026-03-17T07:32:22.577256Z INFO property_map_server::data::property: H3 precomputation complete (15783051 cells)
2026-03-17T07:32:22.577783Z INFO property_map_server: Loading POI data from /app/data/filtered_uk_pois.parquet
2026-03-17T07:32:22.577790Z INFO property_map_server::data::poi: Loading POI data from "/app/data/filtered_uk_pois.parquet"...
2026-03-17T07:32:22.606628Z INFO property_map_server::data::poi: Loaded 678242 POIs
2026-03-17T07:32:22.723396Z INFO property_map_server::data::poi: POI string columns interned category_unique=74 group_unique=11 emoji_unique=71
2026-03-17T07:32:22.724011Z INFO property_map_server::data::poi: POI data loading complete.
2026-03-17T07:32:22.763121Z INFO property_map_server: POI data loaded pois=678242
2026-03-17T07:32:22.763130Z INFO property_map_server: Building POI spatial grid index
2026-03-17T07:32:22.768959Z INFO property_map_server: Loading place data from /app/data/places.parquet
2026-03-17T07:32:22.768968Z INFO property_map_server::data::places: Loading place data from "/app/data/places.parquet"...
2026-03-17T07:32:22.772858Z INFO property_map_server::data::places: Loaded 3474 places
2026-03-17T07:32:22.773855Z INFO property_map_server::data::places: Place data loaded places=3474 types=2 with_population=71 with_city=3392
2026-03-17T07:32:22.774015Z INFO property_map_server: Place data loaded places=3474
2026-03-17T07:32:22.774027Z INFO property_map_server: Loading postcode boundaries from /app/data/postcode_boundaries
2026-03-17T07:32:22.774032Z INFO property_map_server::data::postcodes: Loading postcode boundaries from "/app/data/postcode_boundaries"
2026-03-17T07:32:22.787541Z INFO property_map_server::data::postcodes: Found GeoJSON files to process files=2361
2026-03-17T07:32:31.937299Z INFO property_map_server::data::postcodes: Postcode boundary data ready postcodes=1490140
2026-03-17T07:32:32.173875Z INFO property_map_server: Postcode boundaries loaded postcodes=1490140
2026-03-17T07:32:32.174039Z INFO property_map_server: Loading PMTiles from /app/data/uk.pmtiles
2026-03-17T07:32:32.271059Z INFO property_map_server: PMTiles loaded successfully
2026-03-17T07:32:32.315679Z INFO property_map_server: No --dist provided; static serving and OG injection disabled
2026-03-17T07:32:32.394604Z INFO property_map_server: Screenshot service configured: http://screenshot:8002
2026-03-17T07:32:32.394776Z INFO property_map_server: Precomputed features response groups=8
2026-03-17T07:32:32.394795Z INFO property_map_server: PocketBase configured: http://pocketbase:8090
2026-03-17T07:32:32.593635Z INFO property_map_server::pocketbase: PocketBase users collection already has all required fields
2026-03-17T07:32:32.598562Z INFO property_map_server::pocketbase: PocketBase collection 'saved_searches' API rules updated
2026-03-17T07:32:32.602615Z INFO property_map_server::pocketbase: PocketBase collection 'saved_properties' API rules updated
2026-03-17T07:32:32.700044Z INFO property_map_server::pocketbase: PocketBase meta.appURL set to https://perfect-postcodes.co.uk/pb
2026-03-17T07:32:32.703401Z INFO property_map_server::pocketbase: PocketBase OAuth configured on users collection
2026-03-17T07:32:32.703422Z INFO property_map_server: Gemini configured (model: gemini-3-flash-preview)
2026-03-17T07:32:32.703435Z INFO property_map_server: Loading travel time data from /app/data/travel-times
2026-03-17T07:32:33.124089Z INFO property_map_server::data::travel_time: Travel time mode discovered mode="bicycle" destinations=2780
2026-03-17T07:32:33.129130Z INFO property_map_server::data::travel_time: Travel time mode discovered mode="walking" destinations=350
2026-03-17T07:32:33.136319Z INFO property_map_server::data::travel_time: Travel time mode discovered mode="car" destinations=355
2026-03-17T07:32:33.199470Z INFO property_map_server::data::travel_time: Travel time mode discovered mode="transit" destinations=1869
2026-03-17T07:32:33.199512Z INFO property_map_server: Travel time store loaded modes=4
2026-03-17T07:32:33.199568Z INFO property_map_server: Precomputed AI filters system prompt
2026-03-17T07:32:33.247029Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
2026-03-17T07:32:41.343709Z INFO property_map_server: All memory pages locked (mlockall)
2026-03-17T07:32:41.343741Z INFO property_map_server: Server listening on 0.0.0.0:8001
2026-03-17T07:33:33.247983Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
2026-03-17T07:34:33.248115Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
2026-03-17T07:35:33.247077Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
2026-03-17T07:36:33.246775Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
2026-03-17T07:37:33.245462Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
2026-03-17T07:38:33.245965Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
2026-03-17T07:39:33.245978Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
2026-03-17T07:40:33.246783Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
2026-03-17T07:41:33.245498Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
2026-03-17T07:42:33.245587Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
2026-03-17T07:43:33.245907Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
2026-03-17T07:44:33.246696Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
2026-03-17T07:45:33.246006Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
2026-03-17T07:46:30.259530Z INFO property_map_server: Prometheus metrics initialized
2026-03-17T07:46:30.259726Z INFO property_map_server: Loading property data from /app/data/properties.parquet, /app/data/postcode.parquet, /app/data-scraped/online_listings_buy.parquet, /app/data-scraped/online_listings_rent.parquet
2026-03-17T07:46:30.259735Z INFO property_map_server::data::property: Loading postcode features from "/app/data/postcode.parquet"
2026-03-17T07:46:30.325086Z INFO property_map_server::data::property: Postcode features loaded rows=1262367
2026-03-17T07:46:30.325097Z INFO property_map_server::data::property: Loading properties from "/app/data/properties.parquet"
2026-03-17T07:46:32.757459Z INFO property_map_server::data::property: Properties joined with postcodes rows=15203381
2026-03-17T07:46:32.757469Z INFO property_map_server::data::property: Loading buy listings from "/app/data-scraped/online_listings_buy.parquet"
2026-03-17T07:46:33.043727Z INFO property_map_server::data::property: buy listings joined rows=457076
2026-03-17T07:46:33.043750Z INFO property_map_server::data::property: Loading rent listings from "/app/data-scraped/online_listings_rent.parquet"
2026-03-17T07:46:33.139537Z INFO property_map_server::data::property: rent listings joined rows=122594
2026-03-17T07:46:33.139545Z INFO property_map_server::data::property: Concatenating all data sources
2026-03-17T08:31:50.056528Z INFO property_map_server: Prometheus metrics initialized
2026-03-17T08:31:50.056716Z INFO property_map_server: Loading property data from /app/data/properties.parquet, /app/data/postcode.parquet, /app/data-scraped/online_listings_buy.parquet, /app/data-scraped/online_listings_rent.parquet
2026-03-17T08:31:50.056723Z INFO property_map_server::data::property: Loading postcode features from "/app/data/postcode.parquet"
2026-03-17T08:31:50.259958Z INFO property_map_server::data::property: Postcode features loaded rows=1262367
2026-03-17T08:31:50.259971Z INFO property_map_server::data::property: Loading properties from "/app/data/properties.parquet"
2026-03-17T08:32:02.569149Z INFO property_map_server::data::property: Properties joined with postcodes rows=15203381
2026-03-17T08:32:02.569201Z INFO property_map_server::data::property: Loading buy listings from "/app/data-scraped/online_listings_buy.parquet"
2026-03-17T08:32:03.699632Z INFO property_map_server::data::property: buy listings joined rows=457076
2026-03-17T08:32:03.699651Z INFO property_map_server::data::property: Loading rent listings from "/app/data-scraped/online_listings_rent.parquet"
2026-03-17T08:32:03.826074Z INFO property_map_server::data::property: rent listings joined rows=122594
2026-03-17T08:32:03.826084Z INFO property_map_server::data::property: Concatenating all data sources
2026-03-17T08:32:43.785403Z INFO property_map_server::data::property: All data sources combined properties=15203381 buy_listings=457076 rent_listings=122594 total=15783051
2026-03-17T08:32:43.785499Z INFO property_map_server::data::property: Feature columns from config numeric=55 enums=13 total=68
2026-03-17T08:32:45.220814Z INFO property_map_server::data::property: Combined data selected rows=15783051
2026-03-17T08:32:45.421342Z INFO property_map_server::data::property: Extracting numeric feature columns
2026-03-17T08:32:45.834125Z INFO property_map_server::data::property: Computing histograms for numeric features
2026-03-17T08:32:47.061266Z INFO property_map_server::data::property: Extracting string columns
2026-03-17T08:32:49.344991Z INFO property_map_server::data::property: Building enum features
2026-03-17T08:32:50.754854Z INFO property_map_server::data::property: Extracting renovation history
2026-03-17T08:32:52.906620Z INFO property_map_server::data::property: Renovation history extracted properties_with_events=1829807
2026-03-17T08:32:52.906629Z INFO property_map_server::data::property: Extracting listing features
2026-03-17T08:32:53.563050Z INFO property_map_server::data::property: Listing features extracted properties_with_features=518063
2026-03-17T08:32:53.563059Z INFO property_map_server::data::property: Sorting rows by spatial locality
2026-03-17T08:32:54.502830Z INFO property_map_server::data::property: Building interned strings
2026-03-17T08:33:00.593312Z INFO property_map_server::data::property: Transposing to row-major layout (spatially sorted, quantized to u16)
2026-03-17T08:33:03.178312Z INFO property_map_server::data::property: Data loading complete
2026-03-17T08:33:04.964374Z INFO property_map_server: Property data loaded rows=15783051 features=68 enums=13
2026-03-17T08:33:04.964383Z INFO property_map_server: Building spatial grid index (0.01° cells)
2026-03-17T08:33:05.065094Z INFO property_map_server: Precomputing H3 cells at resolution 12
2026-03-17T08:33:05.065102Z INFO property_map_server::data::property: Precomputing H3 cells at resolution 12
2026-03-17T08:33:05.486703Z INFO property_map_server::data::property: H3 precomputation complete (15783051 cells)
2026-03-17T08:33:05.486729Z INFO property_map_server: Loading POI data from /app/data/filtered_uk_pois.parquet
2026-03-17T08:33:05.486734Z INFO property_map_server::data::poi: Loading POI data from "/app/data/filtered_uk_pois.parquet"...
2026-03-17T08:33:05.529351Z INFO property_map_server::data::poi: Loaded 678242 POIs
2026-03-17T08:33:05.642021Z INFO property_map_server::data::poi: POI string columns interned category_unique=74 group_unique=11 emoji_unique=71
2026-03-17T08:33:05.642611Z INFO property_map_server::data::poi: POI data loading complete.
2026-03-17T08:33:05.681563Z INFO property_map_server: POI data loaded pois=678242
2026-03-17T08:33:05.681574Z INFO property_map_server: Building POI spatial grid index
2026-03-17T08:33:05.687162Z INFO property_map_server: Loading place data from /app/data/places.parquet
2026-03-17T08:33:05.687169Z INFO property_map_server::data::places: Loading place data from "/app/data/places.parquet"...
2026-03-17T08:33:05.705798Z INFO property_map_server::data::places: Loaded 3474 places
2026-03-17T08:33:05.706609Z INFO property_map_server::data::places: Place data loaded places=3474 types=2 with_population=71 with_city=3392
2026-03-17T08:33:05.706675Z INFO property_map_server: Place data loaded places=3474
2026-03-17T08:33:05.706689Z INFO property_map_server: Loading postcode boundaries from /app/data/postcode_boundaries
2026-03-17T08:33:05.706695Z INFO property_map_server::data::postcodes: Loading postcode boundaries from "/app/data/postcode_boundaries"
2026-03-17T08:33:05.780250Z INFO property_map_server::data::postcodes: Found GeoJSON files to process files=2361
2026-03-17T08:33:14.655514Z INFO property_map_server::data::postcodes: Postcode boundary data ready postcodes=1490140
2026-03-17T08:33:14.888462Z INFO property_map_server: Postcode boundaries loaded postcodes=1490140
2026-03-17T08:33:14.888478Z INFO property_map_server: Loading PMTiles from /app/data/uk.pmtiles
2026-03-17T08:33:15.021983Z INFO property_map_server: PMTiles loaded successfully
2026-03-17T08:33:15.065572Z INFO property_map_server: No --dist provided; static serving and OG injection disabled
2026-03-17T08:33:15.140720Z INFO property_map_server: Screenshot service configured: http://screenshot:8002
2026-03-17T08:33:15.141331Z INFO property_map_server: Precomputed features response groups=8
2026-03-17T08:33:15.141349Z INFO property_map_server: PocketBase configured: http://pocketbase:8090
2026-03-17T08:33:15.246791Z INFO property_map_server::pocketbase: PocketBase users collection already has all required fields
2026-03-17T08:33:15.254863Z INFO property_map_server::pocketbase: PocketBase collection 'saved_searches' API rules updated
2026-03-17T08:33:15.258892Z INFO property_map_server::pocketbase: PocketBase collection 'saved_properties' API rules updated
2026-03-17T08:33:15.329192Z INFO property_map_server::pocketbase: PocketBase meta.appURL set to https://perfect-postcodes.co.uk/pb
2026-03-17T08:33:15.333036Z INFO property_map_server::pocketbase: PocketBase OAuth configured on users collection
2026-03-17T08:33:15.333055Z INFO property_map_server: Gemini configured (model: gemini-3-flash-preview)
2026-03-17T08:33:15.333066Z INFO property_map_server: Loading travel time data from /app/data/travel-times
2026-03-17T08:33:15.398969Z INFO property_map_server::data::travel_time: Travel time mode discovered mode="bicycle" destinations=2780
2026-03-17T08:33:15.403743Z INFO property_map_server::data::travel_time: Travel time mode discovered mode="walking" destinations=350
2026-03-17T08:33:15.404640Z INFO property_map_server::data::travel_time: Travel time mode discovered mode="car" destinations=355
2026-03-17T08:33:15.414586Z INFO property_map_server::data::travel_time: Travel time mode discovered mode="transit" destinations=1869
2026-03-17T08:33:15.414612Z INFO property_map_server: Travel time store loaded modes=4
2026-03-17T08:33:15.414666Z INFO property_map_server: Precomputed AI filters system prompt
2026-03-17T08:33:16.003045Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
2026-03-17T08:33:19.581012Z INFO property_map_server: All memory pages locked (mlockall)
2026-03-17T08:33:19.581049Z INFO property_map_server: Server listening on 0.0.0.0:8001
2026-03-17T08:33:22.213990Z INFO property_map_server::routes::pois: GET /api/poi-categories count=74 groups=11
2026-03-17T08:33:22.216578Z INFO property_map_server::routes::features: GET /api/features
2026-03-17T08:33:22.227193Z INFO property_map_server::routes::pois: GET /api/poi-categories count=74 groups=11
2026-03-17T08:33:22.232847Z INFO property_map_server::routes::features: GET /api/features
2026-03-17T08:33:22.409378Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=145554 parallel=true cells_before_filter=455 cells_after_filter=297 truncated=false bounds=51.4896,-0.1648,51.5404,-0.0952 filters=1 filters_raw="Listing status:Historical sale" fields=0 travel_entries=0 grid_ms=0.4 agg_ms=7.5 json_ms=0.8 total_ms=8.7
2026-03-17T08:33:22.446379Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=145554 parallel=true cells_before_filter=455 cells_after_filter=297 truncated=false bounds=51.4896,-0.1648,51.5404,-0.0952 filters=1 filters_raw="Listing status:Historical sale" fields=0 travel_entries=0 grid_ms=0.1 agg_ms=4.1 json_ms=0.5 total_ms=4.7
2026-03-17T08:34:15.461433Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
2026-03-17T08:34:29.710796Z INFO property_map_server::routes::features: GET /api/features
2026-03-17T08:34:29.713513Z INFO property_map_server::routes::pois: GET /api/poi-categories count=74 groups=11
2026-03-17T08:34:30.274542Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=145554 parallel=true cells_before_filter=455 cells_after_filter=297 truncated=false bounds=51.4896,-0.1648,51.5404,-0.0952 filters=1 filters_raw="Listing status:Historical sale" fields=0 travel_entries=0 grid_ms=0.1 agg_ms=2.5 json_ms=0.6 total_ms=3.2
2026-03-17T08:34:31.462250Z INFO property_map_server::routes::hexagon_stats: GET /api/hexagon-stats h3=89195da4987ffff resolution=9 total_count=243 filters=1 filters_raw="Listing status:Historical sale" ms=0.2
2026-03-17T08:34:31.674788Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=82474 parallel=true cells_before_filter=296 cells_after_filter=201 truncated=false bounds=51.4896,-0.1524,51.5404,-0.1076 filters=1 filters_raw="Listing status:Historical sale" fields=0 travel_entries=0 grid_ms=0.1 agg_ms=0.9 json_ms=0.5 total_ms=1.5
2026-03-17T08:34:32.542179Z INFO property_map_server::routes::hexagon_stats: GET /api/hexagon-stats h3=89195da4d33ffff resolution=9 total_count=746 filters=1 filters_raw="Listing status:Historical sale" ms=0.5
2026-03-17T08:34:34.469487Z INFO property_map_server::routes::hexagon_stats: GET /api/hexagon-stats h3=89195da4d33ffff resolution=9 total_count=6 filters=1 filters_raw="Listing status:For rent" ms=0.1
2026-03-17T08:34:34.620706Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=82474 parallel=true cells_before_filter=274 cells_after_filter=196 truncated=false bounds=51.4896,-0.1524,51.5404,-0.1076 filters=1 filters_raw="Listing status:For rent" fields=0 travel_entries=0 grid_ms=0.1 agg_ms=0.7 json_ms=0.4 total_ms=1.1
2026-03-17T08:35:15.464691Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
2026-03-17T08:36:15.461317Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
2026-03-17T08:37:15.462465Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
2026-03-17T08:38:15.461428Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
2026-03-17T08:39:15.463264Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
2026-03-17T08:40:15.466916Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
2026-03-17T08:41:15.463402Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
2026-03-17T08:42:15.462539Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
2026-03-17T08:43:15.461880Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
2026-03-17T08:44:15.462263Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
2026-03-17T08:45:15.461882Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
2026-03-17T08:46:15.462228Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
2026-03-17T08:47:15.462476Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
2026-03-17T08:47:28.935265Z INFO property_map_server: Prometheus metrics initialized
2026-03-17T08:47:28.935449Z INFO property_map_server: Loading property data from /app/data/properties.parquet, /app/data/postcode.parquet, /app/data-scraped/online_listings_buy.parquet, /app/data-scraped/online_listings_rent.parquet
2026-03-17T08:47:28.935457Z INFO property_map_server::data::property: Loading postcode features from "/app/data/postcode.parquet"
2026-03-17T08:47:29.007775Z INFO property_map_server::data::property: Postcode features loaded rows=1262367
2026-03-17T08:47:29.007785Z INFO property_map_server::data::property: Loading properties from "/app/data/properties.parquet"
2026-03-17T08:47:31.674791Z INFO property_map_server::data::property: Properties joined with postcodes rows=15203381
2026-03-17T08:47:31.674802Z INFO property_map_server::data::property: Loading buy listings from "/app/data-scraped/online_listings_buy.parquet"
2026-03-17T08:47:31.972527Z INFO property_map_server::data::property: buy listings joined rows=457076
2026-03-17T08:47:31.972545Z INFO property_map_server::data::property: Loading rent listings from "/app/data-scraped/online_listings_rent.parquet"
2026-03-17T08:47:32.082470Z INFO property_map_server::data::property: rent listings joined rows=122594
2026-03-17T08:47:32.082480Z INFO property_map_server::data::property: Concatenating all data sources
2026-03-17T08:47:43.806418Z INFO property_map_server::data::property: All data sources combined properties=15203381 buy_listings=457076 rent_listings=122594 total=15783051
2026-03-17T08:47:43.806509Z INFO property_map_server::data::property: Feature columns from config numeric=55 enums=13 total=68
2026-03-17T08:47:45.135285Z INFO property_map_server::data::property: Combined data selected rows=15783051
2026-03-17T08:47:45.326377Z INFO property_map_server::data::property: Extracting numeric feature columns
2026-03-17T08:47:45.712528Z INFO property_map_server::data::property: Computing histograms for numeric features
2026-03-17T08:47:46.876195Z INFO property_map_server::data::property: Extracting string columns
2026-03-17T08:47:49.145516Z INFO property_map_server::data::property: Building enum features
2026-03-17T08:47:50.661409Z INFO property_map_server::data::property: Extracting renovation history
2026-03-17T08:47:52.947453Z INFO property_map_server::data::property: Renovation history extracted properties_with_events=1829807
2026-03-17T08:47:52.947462Z INFO property_map_server::data::property: Extracting listing features
2026-03-17T08:47:53.599162Z INFO property_map_server::data::property: Listing features extracted properties_with_features=518063
2026-03-17T08:47:53.599171Z INFO property_map_server::data::property: Sorting rows by spatial locality
2026-03-17T08:47:54.619942Z INFO property_map_server::data::property: Building interned strings
2026-03-17T08:48:00.802774Z INFO property_map_server::data::property: Transposing to row-major layout (spatially sorted, quantized to u16)
2026-03-17T08:48:03.547995Z INFO property_map_server::data::property: Data loading complete
2026-03-17T08:48:05.049275Z INFO property_map_server: Property data loaded rows=15783051 features=68 enums=13
2026-03-17T08:48:05.049293Z INFO property_map_server: Building spatial grid index (0.01° cells)
2026-03-17T08:48:05.459943Z INFO property_map_server: Precomputing H3 cells at resolution 12
2026-03-17T08:48:05.459953Z INFO property_map_server::data::property: Precomputing H3 cells at resolution 12
2026-03-17T08:48:05.865563Z INFO property_map_server::data::property: H3 precomputation complete (15783051 cells)
2026-03-17T08:48:05.865637Z INFO property_map_server: Loading POI data from /app/data/filtered_uk_pois.parquet
2026-03-17T08:48:05.865651Z INFO property_map_server::data::poi: Loading POI data from "/app/data/filtered_uk_pois.parquet"...
2026-03-17T08:48:05.886166Z INFO property_map_server::data::poi: Loaded 678242 POIs
2026-03-17T08:48:06.006159Z INFO property_map_server::data::poi: POI string columns interned category_unique=74 group_unique=11 emoji_unique=71
2026-03-17T08:48:06.006744Z INFO property_map_server::data::poi: POI data loading complete.
2026-03-17T08:48:06.043360Z INFO property_map_server: POI data loaded pois=678242
2026-03-17T08:48:06.043368Z INFO property_map_server: Building POI spatial grid index
2026-03-17T08:48:06.048757Z INFO property_map_server: Loading place data from /app/data/places.parquet
2026-03-17T08:48:06.048766Z INFO property_map_server::data::places: Loading place data from "/app/data/places.parquet"...
2026-03-17T08:48:06.049291Z INFO property_map_server::data::places: Loaded 3474 places
2026-03-17T08:48:06.050002Z INFO property_map_server::data::places: Place data loaded places=3474 types=2 with_population=71 with_city=3392
2026-03-17T08:48:06.050053Z INFO property_map_server: Place data loaded places=3474
2026-03-17T08:48:06.050061Z INFO property_map_server: Loading postcode boundaries from /app/data/postcode_boundaries
2026-03-17T08:48:06.050064Z INFO property_map_server::data::postcodes: Loading postcode boundaries from "/app/data/postcode_boundaries"
2026-03-17T08:48:06.062151Z INFO property_map_server::data::postcodes: Found GeoJSON files to process files=2361
2026-03-17T08:48:15.297171Z INFO property_map_server::data::postcodes: Postcode boundary data ready postcodes=1490140
2026-03-17T08:48:15.545357Z INFO property_map_server: Postcode boundaries loaded postcodes=1490140
2026-03-17T08:48:15.545379Z INFO property_map_server: Loading PMTiles from /app/data/uk.pmtiles
2026-03-17T08:48:15.640450Z INFO property_map_server: PMTiles loaded successfully
2026-03-17T08:48:15.684715Z INFO property_map_server: No --dist provided; static serving and OG injection disabled
2026-03-17T08:48:15.789766Z INFO property_map_server: Screenshot service configured: http://screenshot:8002
2026-03-17T08:48:15.790261Z INFO property_map_server: Precomputed features response groups=8
2026-03-17T08:48:15.790275Z INFO property_map_server: PocketBase configured: http://pocketbase:8090
2026-03-17T08:48:15.852396Z INFO property_map_server::pocketbase: PocketBase users collection already has all required fields
2026-03-17T08:48:15.854872Z INFO property_map_server::pocketbase: PocketBase collection 'saved_searches' API rules updated
2026-03-17T08:48:15.858800Z INFO property_map_server::pocketbase: PocketBase collection 'saved_properties' API rules updated
2026-03-17T08:48:15.911308Z INFO property_map_server::pocketbase: PocketBase meta.appURL set to https://perfect-postcodes.co.uk/pb
2026-03-17T08:48:15.915275Z INFO property_map_server::pocketbase: PocketBase OAuth configured on users collection
2026-03-17T08:48:15.915303Z INFO property_map_server: Gemini configured (model: gemini-3-flash-preview)
2026-03-17T08:48:15.915316Z INFO property_map_server: Loading travel time data from /app/data/travel-times
2026-03-17T08:48:16.153964Z INFO property_map_server::data::travel_time: Travel time mode discovered mode="bicycle" destinations=2780
2026-03-17T08:48:16.155556Z INFO property_map_server::data::travel_time: Travel time mode discovered mode="walking" destinations=350
2026-03-17T08:48:16.156564Z INFO property_map_server::data::travel_time: Travel time mode discovered mode="car" destinations=355
2026-03-17T08:48:16.168132Z INFO property_map_server::data::travel_time: Travel time mode discovered mode="transit" destinations=1869
2026-03-17T08:48:16.168166Z INFO property_map_server: Travel time store loaded modes=4
2026-03-17T08:48:16.168228Z INFO property_map_server: Precomputed AI filters system prompt
2026-03-17T08:48:16.774064Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
2026-03-17T08:48:18.295547Z INFO property_map_server: All memory pages locked (mlockall)
2026-03-17T08:48:18.295586Z INFO property_map_server: Server listening on 0.0.0.0:8001
2026-03-17T08:49:16.216499Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
2026-03-17T08:50:16.215664Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
2026-03-17T08:51:16.214094Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
2026-03-17T08:52:16.215038Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
2026-03-17T08:53:00.492875Z INFO property_map_server: Prometheus metrics initialized
2026-03-17T08:53:00.493149Z INFO property_map_server: Loading property data from /app/data/properties.parquet, /app/data/postcode.parquet, /app/data-scraped/online_listings_buy.parquet, /app/data-scraped/online_listings_rent.parquet
2026-03-17T08:53:00.493156Z INFO property_map_server::data::property: Loading postcode features from "/app/data/postcode.parquet"
2026-03-17T08:53:00.728565Z INFO property_map_server::data::property: Postcode features loaded rows=1262367
2026-03-17T08:53:00.728575Z INFO property_map_server::data::property: Loading properties from "/app/data/properties.parquet"
2026-03-17T08:53:03.595748Z INFO property_map_server::data::property: Properties joined with postcodes rows=15203381
2026-03-17T08:53:03.595759Z INFO property_map_server::data::property: Loading buy listings from "/app/data-scraped/online_listings_buy.parquet"
2026-03-17T08:53:03.975669Z INFO property_map_server::data::property: buy listings joined rows=457076
2026-03-17T08:53:03.975687Z INFO property_map_server::data::property: Loading rent listings from "/app/data-scraped/online_listings_rent.parquet"
2026-03-17T08:53:04.083853Z INFO property_map_server::data::property: rent listings joined rows=122594
2026-03-17T08:53:04.083863Z INFO property_map_server::data::property: Concatenating all data sources
2026-03-17T08:53:19.531799Z INFO property_map_server::data::property: All data sources combined properties=15203381 buy_listings=457076 rent_listings=122594 total=15783051
2026-03-17T08:53:19.531893Z INFO property_map_server::data::property: Feature columns from config numeric=55 enums=13 total=68
2026-03-17T08:53:20.977401Z INFO property_map_server::data::property: Combined data selected rows=15783051
2026-03-17T08:53:21.166389Z INFO property_map_server::data::property: Extracting numeric feature columns
2026-03-17T08:53:21.555895Z INFO property_map_server::data::property: Computing histograms for numeric features
2026-03-17T08:53:22.777545Z INFO property_map_server::data::property: Extracting string columns
2026-03-17T08:53:25.067611Z INFO property_map_server::data::property: Building enum features
2026-03-17T08:53:26.433346Z INFO property_map_server::data::property: Extracting renovation history
2026-03-17T08:53:28.667594Z INFO property_map_server::data::property: Renovation history extracted properties_with_events=1829807
2026-03-17T08:53:28.667602Z INFO property_map_server::data::property: Extracting listing features
2026-03-17T08:53:29.309247Z INFO property_map_server::data::property: Listing features extracted properties_with_features=518063
2026-03-17T08:53:29.309255Z INFO property_map_server::data::property: Sorting rows by spatial locality
2026-03-17T08:53:30.205482Z INFO property_map_server::data::property: Building interned strings
2026-03-17T08:53:36.247881Z INFO property_map_server::data::property: Transposing to row-major layout (spatially sorted, quantized to u16)
2026-03-17T08:53:38.758705Z INFO property_map_server::data::property: Data loading complete
2026-03-17T08:53:40.180446Z INFO property_map_server: Property data loaded rows=15783051 features=68 enums=13
2026-03-17T08:53:40.180455Z INFO property_map_server: Building spatial grid index (0.01° cells)
2026-03-17T08:53:40.577820Z INFO property_map_server: Precomputing H3 cells at resolution 12
2026-03-17T08:53:40.577828Z INFO property_map_server::data::property: Precomputing H3 cells at resolution 12
2026-03-17T08:53:40.972135Z INFO property_map_server::data::property: H3 precomputation complete (15783051 cells)
2026-03-17T08:53:40.972155Z INFO property_map_server: Loading POI data from /app/data/filtered_uk_pois.parquet
2026-03-17T08:53:40.972161Z INFO property_map_server::data::poi: Loading POI data from "/app/data/filtered_uk_pois.parquet"...
2026-03-17T08:53:41.018292Z INFO property_map_server::data::poi: Loaded 678242 POIs
2026-03-17T08:53:41.129204Z INFO property_map_server::data::poi: POI string columns interned category_unique=74 group_unique=11 emoji_unique=71
2026-03-17T08:53:41.129769Z INFO property_map_server::data::poi: POI data loading complete.
2026-03-17T08:53:41.168005Z INFO property_map_server: POI data loaded pois=678242
2026-03-17T08:53:41.168011Z INFO property_map_server: Building POI spatial grid index
2026-03-17T08:53:41.173291Z INFO property_map_server: Loading place data from /app/data/places.parquet
2026-03-17T08:53:41.173297Z INFO property_map_server::data::places: Loading place data from "/app/data/places.parquet"...
2026-03-17T08:53:41.175229Z INFO property_map_server::data::places: Loaded 3474 places
2026-03-17T08:53:41.176075Z INFO property_map_server::data::places: Place data loaded places=3474 types=2 with_population=71 with_city=3392
2026-03-17T08:53:41.176126Z INFO property_map_server: Place data loaded places=3474
2026-03-17T08:53:41.176134Z INFO property_map_server: Loading postcode boundaries from /app/data/postcode_boundaries
2026-03-17T08:53:41.176137Z INFO property_map_server::data::postcodes: Loading postcode boundaries from "/app/data/postcode_boundaries"
2026-03-17T08:53:41.178186Z INFO property_map_server::data::postcodes: Found GeoJSON files to process files=2361
2026-03-17T08:53:51.542107Z INFO property_map_server::data::postcodes: Postcode boundary data ready postcodes=1490140
2026-03-17T08:53:51.769077Z INFO property_map_server: Postcode boundaries loaded postcodes=1490140
2026-03-17T08:53:51.769098Z INFO property_map_server: Loading PMTiles from /app/data/uk.pmtiles
2026-03-17T08:53:51.769313Z INFO property_map_server: PMTiles loaded successfully
2026-03-17T08:53:51.811454Z INFO property_map_server: No --dist provided; static serving and OG injection disabled
2026-03-17T08:53:51.881249Z INFO property_map_server: Screenshot service configured: http://screenshot:8002
2026-03-17T08:53:51.881405Z INFO property_map_server: Precomputed features response groups=8
2026-03-17T08:53:51.881422Z INFO property_map_server: PocketBase configured: http://pocketbase:8090
2026-03-17T08:53:51.933372Z INFO property_map_server::pocketbase: PocketBase users collection already has all required fields
2026-03-17T08:53:51.935544Z INFO property_map_server::pocketbase: PocketBase collection 'saved_searches' API rules updated
2026-03-17T08:53:51.938605Z INFO property_map_server::pocketbase: PocketBase collection 'saved_properties' API rules updated
2026-03-17T08:53:51.988188Z INFO property_map_server::pocketbase: PocketBase meta.appURL set to https://perfect-postcodes.co.uk/pb
2026-03-17T08:53:51.992737Z INFO property_map_server::pocketbase: PocketBase OAuth configured on users collection
2026-03-17T08:53:51.992761Z INFO property_map_server: Gemini configured (model: gemini-3-flash-preview)
2026-03-17T08:53:51.992778Z INFO property_map_server: Loading travel time data from /app/data/travel-times
2026-03-17T08:53:52.012596Z INFO property_map_server::data::travel_time: Travel time mode discovered mode="bicycle" destinations=2780
2026-03-17T08:53:52.012912Z INFO property_map_server::data::travel_time: Travel time mode discovered mode="walking" destinations=350
2026-03-17T08:53:52.013296Z INFO property_map_server::data::travel_time: Travel time mode discovered mode="car" destinations=355
2026-03-17T08:53:52.015215Z INFO property_map_server::data::travel_time: Travel time mode discovered mode="transit" destinations=1869
2026-03-17T08:53:52.015233Z INFO property_map_server: Travel time store loaded modes=4
2026-03-17T08:53:52.015276Z INFO property_map_server: Precomputed AI filters system prompt
2026-03-17T08:53:54.777281Z INFO property_map_server: All memory pages locked (mlockall)
2026-03-17T08:53:54.777322Z INFO property_map_server: Server listening on 0.0.0.0:8001

File diff suppressed because it is too large Load diff

View file

@ -514,10 +514,7 @@ pub fn precompute_h3(lat: &[f32], lon: &[f32]) -> anyhow::Result<Vec<u64>> {
}
impl PropertyData {
pub fn load(
properties_path: &Path,
postcode_features_path: &Path,
) -> anyhow::Result<Self> {
pub fn load(properties_path: &Path, postcode_features_path: &Path) -> anyhow::Result<Self> {
// Load postcode.parquet
tracing::info!(
"Loading postcode features from {:?}",
@ -643,11 +640,22 @@ impl PropertyData {
}
let df = combined
.lazy()
.filter(col("lat").is_not_null().and(col("lon").is_not_null()))
.select(select_exprs)
.collect()
.context("Failed to select columns from combined data")?;
let row_count = df.height();
if row_count == 0 {
bail!("No property rows have usable coordinates after joining postcode data");
}
let dropped_coordinate_rows = total_rows.saturating_sub(row_count);
if dropped_coordinate_rows > 0 {
tracing::warn!(
rows = dropped_coordinate_rows,
"Dropped properties with missing postcode coordinates"
);
}
tracing::info!(rows = row_count, "Combined data selected");
let lat_series = df
@ -659,8 +667,8 @@ impl PropertyData {
.f32()
.context("Failed to read 'lat' as f32")?
.into_iter()
.map(|value| value.unwrap_or(0.0))
.collect();
.map(|value| value.context("Missing 'lat' value after coordinate filter"))
.collect::<anyhow::Result<Vec<_>>>()?;
let lon_series = df
.column("lon")
@ -671,8 +679,14 @@ impl PropertyData {
.f32()
.context("Failed to read 'lon' as f32")?
.into_iter()
.map(|value| value.unwrap_or(0.0))
.collect();
.map(|value| value.context("Missing 'lon' value after coordinate filter"))
.collect::<anyhow::Result<Vec<_>>>()?;
for (row, (&latitude, &longitude)) in lat.iter().zip(&lon).enumerate() {
if !(-90.0..=90.0).contains(&latitude) || !(-180.0..=180.0).contains(&longitude) {
bail!("Invalid coordinates at row {row}: lat={latitude}, lon={longitude}");
}
}
tracing::info!("Extracting numeric feature columns");
let numeric_col_major: Vec<Vec<f32>> = numeric_names
@ -705,12 +719,19 @@ impl PropertyData {
})
.collect::<anyhow::Result<Vec<_>>>()?;
// Compute quantization parameters from feature stats (numeric features)
// 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
// "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.
let mut quant_min = Vec::with_capacity(num_features);
let mut quant_range = Vec::with_capacity(num_features);
for stats in &numeric_feature_stats {
let min = stats.histogram.min;
let max = stats.histogram.max;
for (feat_idx, stats) in numeric_feature_stats.iter().enumerate() {
let (min, max) = match features::bounds_for(numeric_names[feat_idx]) {
Some(Bounds::Fixed { min, max }) => (*min, *max),
_ => (stats.histogram.min, stats.histogram.max),
};
quant_min.push(min);
quant_range.push(if max > min { max - min } else { 0.0 });
}

View file

@ -32,8 +32,7 @@ pub struct FeatureConfig {
/// Features whose histogram bins should be exactly 1 unit wide (one per integer).
/// p1/p99 are snapped to integer boundaries before binning.
pub const INTEGER_BIN_FEATURES: &[&str] =
&["Number of bedrooms & living rooms"];
pub const INTEGER_BIN_FEATURES: &[&str] = &["Number of bedrooms & living rooms"];
pub struct EnumFeatureConfig {
pub name: &'static str,
@ -143,8 +142,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
name: "Estimated monthly rent",
bounds: Bounds::Percentile { low: 2.0, high: 98.0 },
step: 25.0,
description: "Median monthly private rent for the local area",
detail: "Median monthly rental price from ONS Private Rental Market Summary Statistics (Oct 2022 - Sep 2023), matched by local authority and bedroom count. Based on Valuation Office Agency lettings data.",
description: "Mean monthly private rent for the local area",
detail: "Mean monthly rental price from ONS Price Index of Private Rents (PIPR), matched by local authority and bedroom count.",
source: "ons-rental",
prefix: "£",
suffix: "/mo",
@ -302,6 +301,36 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
raw: false,
absolute: false,
}),
Feature::Numeric(FeatureConfig {
name: "Outstanding primary schools within 2km",
bounds: Bounds::Fixed {
min: 0.0,
max: 10.0,
},
step: 1.0,
description: "Primary schools rated Outstanding by Ofsted within 2km",
detail: "State-funded primary schools within 2km with a current Ofsted rating of Outstanding. Schools not yet inspected are excluded.",
source: "ofsted",
prefix: "",
suffix: "",
raw: false,
absolute: false,
}),
Feature::Numeric(FeatureConfig {
name: "Outstanding secondary schools within 2km",
bounds: Bounds::Fixed {
min: 0.0,
max: 5.0,
},
step: 1.0,
description: "Secondary schools rated Outstanding by Ofsted within 2km",
detail: "State-funded secondary schools within 2km with a current Ofsted rating of Outstanding. Schools not yet inspected are excluded.",
source: "ofsted",
prefix: "",
suffix: "",
raw: false,
absolute: false,
}),
Feature::Numeric(FeatureConfig {
name: "Good+ primary schools within 5km",
bounds: Bounds::Fixed {
@ -332,6 +361,36 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
raw: false,
absolute: false,
}),
Feature::Numeric(FeatureConfig {
name: "Outstanding primary schools within 5km",
bounds: Bounds::Fixed {
min: 0.0,
max: 30.0,
},
step: 1.0,
description: "Primary schools rated Outstanding by Ofsted within 5km",
detail: "State-funded primary schools within 5km with a current Ofsted rating of Outstanding. Schools not yet inspected are excluded.",
source: "ofsted",
prefix: "",
suffix: "",
raw: false,
absolute: false,
}),
Feature::Numeric(FeatureConfig {
name: "Outstanding secondary schools within 5km",
bounds: Bounds::Fixed {
min: 0.0,
max: 15.0,
},
step: 1.0,
description: "Secondary schools rated Outstanding by Ofsted within 5km",
detail: "State-funded secondary schools within 5km with a current Ofsted rating of Outstanding. Schools not yet inspected are excluded.",
source: "ofsted",
prefix: "",
suffix: "",
raw: false,
absolute: false,
}),
Feature::Numeric(FeatureConfig {
name: "Education, Skills and Training Score",
bounds: Bounds::Percentile {
@ -826,21 +885,6 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
FeatureGroup {
name: "Politics",
features: &[
Feature::Enum(EnumFeatureConfig {
name: "Winning party",
order: Some(&[
"Labour",
"Conservative",
"Liberal Democrat",
"Reform UK",
"Green",
"Other parties",
]),
description:
"Party that won the parliamentary constituency in the 2024 General Election",
detail: "The political party that won the most votes in the constituency covering this postcode, from the July 2024 UK General Election. Based on first-past-the-post results published by the UK Parliament. Constituencies were redrawn for 2024 using the Boundary Commission's 2023 review.",
source: "election-results",
}),
Feature::Numeric(FeatureConfig {
name: "% Labour",
bounds: Bounds::Fixed {
@ -947,22 +991,6 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
raw: false,
absolute: false,
}),
Feature::Numeric(FeatureConfig {
name: "Majority (%)",
bounds: Bounds::Percentile {
low: 2.0,
high: 98.0,
},
step: 0.5,
description:
"Winning margin as a percentage of valid votes in the 2024 General Election",
detail: "The difference in votes between the winning candidate and the runner-up, expressed as a percentage of total valid votes cast. A small majority indicates a marginal seat (competitive); a large majority indicates a safe seat. From the July 2024 UK General Election results published by the UK Parliament.",
source: "election-results",
prefix: "",
suffix: "%",
raw: false,
absolute: false,
}),
],
},
FeatureGroup {
@ -1110,12 +1138,12 @@ pub fn bounds_for(name: &str) -> Option<&'static Bounds> {
/// The server will panic at startup if the data contains groups not in this list or vice versa.
pub const POI_GROUP_ORDER: &[&str] = &[
"Public Transport",
"Groceries",
"Leisure",
"Education",
"Health",
"Emergency Services",
"Other",
"Groceries",
"Local Businesses",
"Culture",
"Services",

View file

@ -37,6 +37,38 @@ use tracing_subscriber::EnvFilter;
use state::{AppState, SharedState};
#[cfg(target_os = "linux")]
fn resident_memory_kib() -> Option<u64> {
let status = std::fs::read_to_string("/proc/self/status").ok()?;
status.lines().find_map(|line| {
line.strip_prefix("VmRSS:")?
.split_whitespace()
.next()?
.parse()
.ok()
})
}
#[cfg(target_os = "linux")]
fn trim_allocator(label: &'static str) {
let before = resident_memory_kib();
let trimmed = unsafe { libc::malloc_trim(0) };
let after = resident_memory_kib();
if let (Some(before), Some(after)) = (before, after) {
info!(
label,
trimmed = trimmed != 0,
rss_before_mib = format_args!("{:.1}", before as f64 / 1024.0),
rss_after_mib = format_args!("{:.1}", after as f64 / 1024.0),
released_mib = format_args!("{:.1}", before.saturating_sub(after) as f64 / 1024.0),
"Allocator trim"
);
}
}
#[cfg(not(target_os = "linux"))]
fn trim_allocator(_label: &'static str) {}
#[derive(Parser)]
#[command(
name = "perfect-postcode",
@ -165,10 +197,8 @@ async fn main() -> anyhow::Result<()> {
cli.properties.display(),
cli.postcode_features.display(),
);
let property_data = data::PropertyData::load(
&cli.properties,
&cli.postcode_features,
)?;
let property_data = data::PropertyData::load(&cli.properties, &cli.postcode_features)?;
trim_allocator("property data load");
info!(
rows = property_data.lat.len(),
features = property_data.num_features,
@ -197,6 +227,7 @@ async fn main() -> anyhow::Result<()> {
info!("Loading POI data from {}", poi_path.display());
let poi_data = data::POIData::load(&poi_path)?;
trim_allocator("poi data load");
info!(pois = poi_data.lat.len(), "POI data loaded");
info!("Building POI spatial grid index");
@ -209,6 +240,7 @@ async fn main() -> anyhow::Result<()> {
}
info!("Loading place data from {}", places_path.display());
let place_data = data::PlaceData::load(places_path)?;
trim_allocator("place data load");
info!(places = place_data.name.len(), "Place data loaded");
// Load postcode boundaries
@ -224,6 +256,7 @@ async fn main() -> anyhow::Result<()> {
postcodes_path.display()
);
let postcode_data = data::PostcodeData::load(postcodes_path)?;
trim_allocator("postcode boundary load");
info!(
postcodes = postcode_data.postcodes.len(),
"Postcode boundaries loaded"
@ -450,7 +483,10 @@ async fn main() -> anyhow::Result<()> {
"/api/postcode-properties",
get(routes::get_postcode_properties),
)
.route("/api/screenshot", get(routes::get_screenshot))
.route(
"/api/screenshot",
get(routes::get_screenshot).layer(ConcurrencyLimitLayer::new(3)),
)
.route(
"/api/export",
get(routes::get_export).layer(ConcurrencyLimitLayer::new(3)),

View file

@ -281,6 +281,7 @@ pub fn build_system_prompt(
- \"cheap\" / \"affordable\" = lower price range. \"expensive\" = higher price range.\n\
- \"low crime\" / \"safe\" = low values on Serious crime and Minor crime summary features. \
\"quiet\" = low Noise (dB). \"green\" / \"near parks\" = high Number of parks within 1km.\n\
- \"good schools\" = Good+ school features. \"outstanding schools\" = Outstanding school features.\n\
- When the user says a number like \"under 400k\", interpret it as 400000.\n\
- When the user says \"3 bed\" or \"3 bedroom\", use Number of bedrooms & living rooms \
(note: this counts bedrooms + living rooms combined, so 3 bed ~ min 4).\n\
@ -424,6 +425,16 @@ pub fn build_system_prompt(
.to_string(),
);
parts.push(
"\nUser: \"quiet area with outstanding schools\"\n\
Output: {\"numeric_filters\": [\
{\"name\": \"Noise (dB)\", \"bound\": \"max\", \"value\": 55}, \
{\"name\": \"Outstanding primary schools within 2km\", \"bound\": \"min\", \"value\": 1}, \
{\"name\": \"Outstanding secondary schools within 2km\", \"bound\": \"min\", \"value\": 1}], \
\"enum_filters\": [], \"travel_time_filters\": [], \"notes\": \"\"}"
.to_string(),
);
parts.push(
"\nUser: \"3 bed flat under 300k with fast broadband near the beach\"\n\
Output: {\"numeric_filters\": [\

View file

@ -14,6 +14,7 @@ use tracing::{info, warn};
use crate::auth::OptionalUser;
use crate::consts::NAN_U16;
use crate::data::QuantRef;
use crate::features::INTEGER_BIN_FEATURES;
use crate::licensing::check_license_bounds;
use crate::parsing::{parse_field_indices, parse_filters, require_bounds, row_passes_filters};
use crate::routes::{fetch_screenshot_bytes, FeatureInfo};
@ -315,6 +316,11 @@ pub async fn get_export(
})
.collect();
let integer_feature_indices: FxHashSet<usize> = INTEGER_BIN_FEATURES
.iter()
.filter_map(|name| state.feature_name_to_index.get(*name).copied())
.collect();
// Build Excel number formats per feature index for unit display
let mut feat_num_fmts: FxHashMap<usize, Format> = FxHashMap::default();
for &feat_idx in &all_feature_indices {
@ -324,6 +330,8 @@ pub async fn get_export(
}
let num_fmt_str = if !prefix.is_empty() {
format!("\"{}\"#,##0", prefix)
} else if integer_feature_indices.contains(&feat_idx) {
format!("#,##0\"{}\"", suffix)
} else {
format!("#,##0.0\"{}\"", suffix)
};
@ -488,7 +496,11 @@ pub async fn get_export(
} else {
let fc = agg.finite_counts[feat_idx];
if fc > 0 {
let mean = (agg.sums[feat_idx] / fc as f64 * 100.0).round() / 100.0;
let mean = if integer_feature_indices.contains(&feat_idx) {
(agg.sums[feat_idx] / fc as f64).round()
} else {
(agg.sums[feat_idx] / fc as f64 * 100.0).round() / 100.0
};
if let Some(fmt) = feat_num_fmts.get(&feat_idx) {
sheet
.write_number_with_format(row, col, mean, fmt)

View file

@ -85,13 +85,14 @@ pub async fn get_filter_counts(
let has_travel = !travel_entries.is_empty();
let (pc_interner, pc_keys) = state.data.postcode_parts();
let rows = state.grid.query(south, west, north, east);
let row_count = rows.len();
let row_count = state.grid.count_in_bounds(south, west, north, east);
let mut total_passing: u32 = 0;
let mut impacts = vec![0u32; num_total_filters];
for row_idx in rows {
state
.grid
.for_each_in_bounds(south, west, north, east, |row_idx| {
let row = row_idx as usize;
let base = row * num_features;
let mut fail_count: u32 = 0;
@ -157,7 +158,7 @@ pub async fn get_filter_counts(
1 => impacts[fail_index] += 1,
_ => {}
}
}
});
// Map filter indices back to feature/travel names
let mut impact_map: FxHashMap<String, u32> = FxHashMap::default();

View file

@ -1,4 +1,5 @@
use std::sync::Arc;
use std::collections::HashSet;
use std::sync::{Arc, LazyLock, Mutex};
use axum::extract::{Path, State};
use axum::http::StatusCode;
@ -7,9 +8,39 @@ use axum::{Extension, Json};
use serde::{Deserialize, Serialize};
use tracing::{info, warn};
use crate::auth::OptionalUser;
use crate::auth::{OptionalUser, PocketBaseUser};
use crate::pocketbase::get_superuser_token;
use crate::state::SharedState;
use crate::state::{AppState, SharedState};
static INVITE_REDEMPTIONS_IN_PROGRESS: LazyLock<Mutex<HashSet<String>>> =
LazyLock::new(|| Mutex::new(HashSet::new()));
struct InviteRedemptionGuard {
code: String,
}
impl InviteRedemptionGuard {
fn acquire(code: &str) -> Option<Self> {
let mut in_progress = INVITE_REDEMPTIONS_IN_PROGRESS
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
if !in_progress.insert(code.to_string()) {
return None;
}
Some(Self {
code: code.to_string(),
})
}
}
impl Drop for InviteRedemptionGuard {
fn drop(&mut self) {
let mut in_progress = INVITE_REDEMPTIONS_IN_PROGRESS
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
in_progress.remove(&self.code);
}
}
#[derive(Serialize)]
struct InviteResponse {
@ -87,6 +118,207 @@ fn generate_invite_code() -> String {
chars.into_iter().collect()
}
fn current_unix_secs_string() -> String {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
.to_string()
}
async fn lookup_unused_invite(
state: &AppState,
pb_url: &str,
token: &str,
code: &str,
) -> Result<Option<serde_json::Value>, Response> {
let filter = format!("code=\"{}\" && used_by_id=\"\"", code);
let lookup_url = format!(
"{pb_url}/api/collections/invites/records?filter={}&perPage=1",
urlencoding::encode(&filter)
);
let res = match state
.http_client
.get(&lookup_url)
.header("Authorization", format!("Bearer {token}"))
.send()
.await
{
Ok(resp) => resp,
Err(err) => {
warn!("Failed to look up invite: {err}");
return Err(StatusCode::BAD_GATEWAY.into_response());
}
};
if !res.status().is_success() {
let status = res.status();
let text = res.text().await.unwrap_or_default();
warn!("PocketBase invite lookup failed ({status}): {text}");
return Err(StatusCode::BAD_GATEWAY.into_response());
}
let body: serde_json::Value = match res.json().await {
Ok(value) => value,
Err(err) => {
warn!("Failed to parse invite lookup response: {err}");
return Err(StatusCode::BAD_GATEWAY.into_response());
}
};
Ok(body["items"]
.as_array()
.and_then(|arr| arr.first())
.cloned())
}
async fn mark_invite_used(
state: &AppState,
pb_url: &str,
token: &str,
invite_id: &str,
user_id: &str,
) -> Result<(), Response> {
let resp = match state
.http_client
.patch(format!(
"{pb_url}/api/collections/invites/records/{invite_id}"
))
.header("Authorization", format!("Bearer {token}"))
.json(&serde_json::json!({
"used_by_id": user_id,
"used_at": current_unix_secs_string(),
}))
.send()
.await
{
Ok(resp) => resp,
Err(err) => {
warn!("Failed to mark invite as used: {err}");
return Err(StatusCode::BAD_GATEWAY.into_response());
}
};
if !resp.status().is_success() {
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
warn!("PocketBase invite usage update failed ({status}): {text}");
return Err(StatusCode::BAD_GATEWAY.into_response());
}
Ok(())
}
async fn grant_license_for_invite(
state: &AppState,
pb_url: &str,
token: &str,
user_id: &str,
) -> Result<(), Response> {
let update_url = format!("{pb_url}/api/collections/users/records/{user_id}");
let resp = match state
.http_client
.patch(&update_url)
.header("Authorization", format!("Bearer {token}"))
.json(&serde_json::json!({ "subscription": "licensed" }))
.send()
.await
{
Ok(resp) => resp,
Err(err) => {
warn!("Failed to update user subscription for admin invite: {err}");
return Err(StatusCode::BAD_GATEWAY.into_response());
}
};
if !resp.status().is_success() {
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
warn!("PocketBase user subscription update failed ({status}): {text}");
return Err(StatusCode::BAD_GATEWAY.into_response());
}
state.token_cache.invalidate_by_user_id(user_id);
Ok(())
}
async fn create_referral_checkout(
state: &AppState,
user: &PocketBaseUser,
) -> Result<String, Response> {
let count = match super::pricing::count_licensed_users(state).await {
Ok(count) => count,
Err(err) => {
warn!("Failed to count licensed users for invite checkout: {err}");
return Err(StatusCode::SERVICE_UNAVAILABLE.into_response());
}
};
let price_pence = super::pricing::price_for_count(count);
let public_url = &state.public_url;
let success_url = format!("{public_url}/pricing?license_success=1");
let cancel_url = format!("{public_url}/pricing");
let form_params = vec![
("mode", "payment".to_string()),
(
"line_items[0][price_data][unit_amount]",
price_pence.to_string(),
),
("line_items[0][price_data][currency]", "gbp".to_string()),
(
"line_items[0][price_data][product_data][name]",
"Perfect Postcodes Lifetime License".to_string(),
),
("line_items[0][quantity]", "1".to_string()),
("success_url", success_url),
("cancel_url", cancel_url),
("client_reference_id", user.id.clone()),
("customer_email", user.email.clone()),
(
"discounts[0][coupon]",
state.stripe_referral_coupon_id.clone(),
),
];
let stripe_res = state
.http_client
.post("https://api.stripe.com/v1/checkout/sessions")
.basic_auth(&state.stripe_secret_key, None::<&str>)
.form(&form_params)
.send()
.await;
match stripe_res {
Ok(resp) if resp.status().is_success() => {
let stripe_body: serde_json::Value = match resp.json().await {
Ok(value) => value,
Err(err) => {
warn!("Failed to parse Stripe checkout response: {err}");
return Err(StatusCode::BAD_GATEWAY.into_response());
}
};
let checkout_url = stripe_body["url"].as_str().unwrap_or_default().to_string();
if checkout_url.is_empty() {
warn!("Stripe checkout response did not include a URL");
return Err(StatusCode::BAD_GATEWAY.into_response());
}
Ok(checkout_url)
}
Ok(resp) => {
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
warn!("Failed to create Stripe checkout for referral invite ({status}): {text}");
Err(StatusCode::BAD_GATEWAY.into_response())
}
Err(err) => {
warn!("Stripe request error for referral invite: {err}");
Err(StatusCode::BAD_GATEWAY.into_response())
}
}
}
/// Create an invite. Admins create "admin" invites (free license) by default,
/// but can explicitly request "referral" type. Licensed non-admin users always create "referral" invites (30% off).
pub async fn post_invites(
@ -319,154 +551,80 @@ pub async fn post_redeem_invite(
}
};
// Look up invite
let filter = format!("code=\"{}\" && used_by_id=\"\"", req.code);
let lookup_url = format!(
"{pb_url}/api/collections/invites/records?filter={}&perPage=1",
urlencoding::encode(&filter)
);
let _redemption_guard = match InviteRedemptionGuard::acquire(&req.code) {
Some(guard) => guard,
None => {
return (
StatusCode::CONFLICT,
"Invite redemption is already in progress",
)
.into_response()
}
};
let res = match state
.http_client
.get(&lookup_url)
.header("Authorization", format!("Bearer {token}"))
.send()
.await
{
Ok(r) => r,
Err(err) => {
warn!("Failed to look up invite: {err}");
let invite = match lookup_unused_invite(&state, pb_url, &token, &req.code).await {
Ok(Some(invite)) => invite,
Ok(None) => {
return (StatusCode::NOT_FOUND, "Invalid or already used invite code").into_response()
}
Err(response) => return response,
};
let invite_id = match invite["id"].as_str().filter(|id| !id.is_empty()) {
Some(id) => id,
None => {
warn!(code = %req.code, "Invite lookup returned record without id");
return StatusCode::BAD_GATEWAY.into_response();
}
};
let invite_type = match invite["invite_type"].as_str() {
Some("admin") => "admin",
Some("referral") => "referral",
Some(other) => {
warn!(code = %req.code, invite_type = other, "Invite has unsupported type");
return StatusCode::BAD_GATEWAY.into_response();
}
None => {
warn!(code = %req.code, "Invite lookup returned record without invite_type");
return StatusCode::BAD_GATEWAY.into_response();
}
};
let body: serde_json::Value = match res.json().await {
Ok(v) => v,
Err(_) => return StatusCode::BAD_GATEWAY.into_response(),
};
let invite = match body["items"].as_array().and_then(|arr| arr.first()) {
Some(inv) => inv.clone(),
None => {
return (StatusCode::NOT_FOUND, "Invalid or already used invite code").into_response()
}
};
let invite_id = invite["id"].as_str().unwrap_or("");
let invite_type = invite["invite_type"].as_str().unwrap_or("");
// Mark invite as used
let now = {
let dur = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default();
dur.as_secs().to_string()
};
let _ = state
.http_client
.patch(format!(
"{pb_url}/api/collections/invites/records/{invite_id}"
))
.header("Authorization", format!("Bearer {token}"))
.json(&serde_json::json!({
"used_by_id": user.id,
"used_at": now,
}))
.send()
.await;
if invite_type == "admin" {
// Grant license directly
let update_url = format!("{pb_url}/api/collections/users/records/{}", user.id);
let res = state
.http_client
.patch(&update_url)
.header("Authorization", format!("Bearer {token}"))
.json(&serde_json::json!({ "subscription": "licensed" }))
.send()
.await;
if let Err(response) = grant_license_for_invite(&state, pb_url, &token, &user.id).await {
return response;
}
match res {
Ok(resp) if resp.status().is_success() => {
state.token_cache.invalidate_by_user_id(&user.id);
info!(user_id = %user.id, code = %req.code, "Admin invite redeemed — user licensed");
Json(RedeemResponse {
if let Err(response) = mark_invite_used(&state, pb_url, &token, invite_id, &user.id).await {
return response;
}
info!(user_id = %user.id, code = %req.code, "Admin invite redeemed; user licensed");
return Json(RedeemResponse {
result: "licensed".to_string(),
checkout_url: None,
})
.into_response()
}
_ => {
warn!("Failed to update user subscription for admin invite");
StatusCode::BAD_GATEWAY.into_response()
}
}
} else {
// Referral invite — create discounted checkout with dynamic pricing
let count = match super::pricing::count_licensed_users(&state).await {
Ok(c) => c,
Err(err) => {
warn!("Failed to count licensed users for invite checkout: {err}");
return StatusCode::SERVICE_UNAVAILABLE.into_response();
.into_response();
}
let checkout_url = match create_referral_checkout(&state, &user).await {
Ok(url) => url,
Err(response) => return response,
};
let price_pence = super::pricing::price_for_count(count);
let secret_key = &state.stripe_secret_key;
let public_url = &state.public_url;
let success_url = format!("{public_url}/pricing?license_success=1");
let cancel_url = format!("{public_url}/pricing");
if let Err(response) = mark_invite_used(&state, pb_url, &token, invite_id, &user.id).await {
return response;
}
let form_params = vec![
("mode", "payment".to_string()),
(
"line_items[0][price_data][unit_amount]",
price_pence.to_string(),
),
("line_items[0][price_data][currency]", "gbp".to_string()),
(
"line_items[0][price_data][product_data][name]",
"Perfect Postcodes Lifetime License".to_string(),
),
("line_items[0][quantity]", "1".to_string()),
("success_url", success_url),
("cancel_url", cancel_url),
("client_reference_id", user.id.clone()),
("customer_email", user.email.clone()),
(
"discounts[0][coupon]",
state.stripe_referral_coupon_id.clone(),
),
];
let stripe_res = state
.http_client
.post("https://api.stripe.com/v1/checkout/sessions")
.basic_auth(secret_key, None::<&str>)
.form(&form_params)
.send()
.await;
match stripe_res {
Ok(resp) if resp.status().is_success() => {
let stripe_body: serde_json::Value = resp.json().await.unwrap_or_default();
let checkout_url = stripe_body["url"].as_str().unwrap_or_default().to_string();
info!(user_id = %user.id, code = %req.code, "Referral invite redeemed — checkout created");
info!(user_id = %user.id, code = %req.code, "Referral invite redeemed; checkout created");
Json(RedeemResponse {
result: "checkout".to_string(),
checkout_url: Some(checkout_url),
})
.into_response()
}
_ => {
warn!("Failed to create Stripe checkout for referral invite");
StatusCode::BAD_GATEWAY.into_response()
}
}
}
}
/// List invites. Admins see all invites; licensed users see only their own.
/// List invites. Users only see invites they created, including admins.
pub async fn get_invites(
State(shared): State<Arc<SharedState>>,
Extension(user): Extension<OptionalUser>,
@ -487,16 +645,9 @@ pub async fn get_invites(
}
};
let filter = if user.is_admin {
String::new()
} else {
format!("created_by=\"{}\"", user.id)
};
let filter = format!("created_by=\"{}\"", user.id);
let mut url = format!("{pb_url}/api/collections/invites/records?sort=-created&perPage=200");
if !filter.is_empty() {
url.push_str(&format!("&filter={}", urlencoding::encode(&filter)));
}
let res = match state
.http_client

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