Compare commits
9 commits
349a6c1d53
...
05a1f316e1
| Author | SHA1 | Date | |
|---|---|---|---|
| 05a1f316e1 | |||
| cd34ee693f | |||
| 90c47afe17 | |||
| d4dde21ad2 | |||
| 0bae902e08 | |||
| 6077a17a83 | |||
| 14db8b4a05 | |||
| a98c54c5b8 | |||
| 8614acdfae |
104 changed files with 8013 additions and 5631 deletions
|
|
@ -11,8 +11,8 @@ concurrency:
|
||||||
cancel-in-progress: true
|
cancel-in-progress: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
python:
|
check:
|
||||||
name: Python (lint + test)
|
name: Check
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
@ -20,70 +20,27 @@ jobs:
|
||||||
- name: Install uv
|
- name: Install uv
|
||||||
run: curl -LsSf https://astral.sh/uv/install.sh | sh
|
run: curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install Python dependencies
|
||||||
run: uv sync
|
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
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install frontend dependencies
|
||||||
|
working-directory: frontend
|
||||||
run: npm ci
|
run: npm ci
|
||||||
|
|
||||||
- name: ESLint
|
- name: Install screenshot service dependencies
|
||||||
run: npm run lint
|
working-directory: screenshot
|
||||||
|
run: npm ci
|
||||||
- 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
|
|
||||||
|
|
||||||
- uses: https://github.com/dtolnay/rust-toolchain@stable
|
- uses: https://github.com/dtolnay/rust-toolchain@stable
|
||||||
with:
|
with:
|
||||||
components: clippy, rustfmt
|
components: clippy, rustfmt
|
||||||
|
|
||||||
- name: Clippy
|
|
||||||
run: cargo clippy -- -D warnings
|
|
||||||
|
|
||||||
- name: Format check
|
|
||||||
run: cargo fmt --check
|
|
||||||
|
|
||||||
- name: Install cargo-machete
|
- name: Install cargo-machete
|
||||||
run: cargo install cargo-machete
|
run: cargo install cargo-machete
|
||||||
|
|
||||||
- name: Unused dependencies check
|
- name: Run checks
|
||||||
run: cargo machete
|
run: ./check.sh
|
||||||
|
|
||||||
- name: Tests
|
|
||||||
run: cargo test
|
|
||||||
|
|
|
||||||
94
.github/workflows/ci.yml
vendored
94
.github/workflows/ci.yml
vendored
|
|
@ -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
|
|
||||||
72
.github/workflows/docker-publish.yml
vendored
72
.github/workflows/docker-publish.yml
vendored
|
|
@ -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
430
CLAUDE.md
|
|
@ -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 4–12 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
|
|
||||||
|
|
@ -19,9 +19,11 @@ ARCGIS := $(DATA_DIR)/arcgis_data.parquet
|
||||||
PRICE_PAID := $(DATA_DIR)/price-paid-complete.parquet
|
PRICE_PAID := $(DATA_DIR)/price-paid-complete.parquet
|
||||||
IOD := $(DATA_DIR)/IoD2025_Scores.parquet
|
IOD := $(DATA_DIR)/IoD2025_Scores.parquet
|
||||||
POIS_RAW := $(DATA_DIR)/uk_pois.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
|
POIS_FILTERED := $(DATA_DIR)/filtered_uk_pois.parquet
|
||||||
POI_PROXIMITY := $(DATA_DIR)/poi_proximity.parquet
|
POI_PROXIMITY := $(DATA_DIR)/poi_proximity.parquet
|
||||||
EPC_PP := $(DATA_DIR)/epc_pp.parquet
|
EPC_PP := $(DATA_DIR)/epc_pp.parquet
|
||||||
|
POSTCODES_RAW := $(DATA_DIR)/gb-postcodes-v5
|
||||||
POSTCODES_PQ := $(DATA_DIR)/postcode.parquet
|
POSTCODES_PQ := $(DATA_DIR)/postcode.parquet
|
||||||
PROPERTIES_PQ := $(DATA_DIR)/properties.parquet
|
PROPERTIES_PQ := $(DATA_DIR)/properties.parquet
|
||||||
MERGE_STAMP := $(DATA_DIR)/.merge_done
|
MERGE_STAMP := $(DATA_DIR)/.merge_done
|
||||||
|
|
@ -62,7 +64,7 @@ PMTILES_VERSION := 1.22.3
|
||||||
|
|
||||||
.PHONY: prepare merge tiles \
|
.PHONY: prepare merge tiles \
|
||||||
download-arcgis download-price-paid download-deprivation download-ethnicity \
|
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-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 \
|
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 \
|
transform-pois transform-epc-pp transform-crime transform-poi-proximity \
|
||||||
|
|
@ -78,9 +80,10 @@ download-deprivation: $(IOD)
|
||||||
download-ethnicity: $(ETHNICITY)
|
download-ethnicity: $(ETHNICITY)
|
||||||
download-naptan: $(NAPTAN)
|
download-naptan: $(NAPTAN)
|
||||||
download-pois: $(POIS_RAW)
|
download-pois: $(POIS_RAW)
|
||||||
|
download-grocery-retail-points: $(GROCERY_RETAIL_POINTS)
|
||||||
download-ofsted: $(OFSTED)
|
download-ofsted: $(OFSTED)
|
||||||
download-broadband: $(BROADBAND)
|
download-broadband: $(BROADBAND)
|
||||||
download-postcodes: $(POSTCODES)
|
download-postcodes: $(POSTCODES_RAW)
|
||||||
download-rental-prices: $(RENTAL)
|
download-rental-prices: $(RENTAL)
|
||||||
download-noise: $(NOISE)
|
download-noise: $(NOISE)
|
||||||
download-inspire: $(INSPIRE_STAMP)
|
download-inspire: $(INSPIRE_STAMP)
|
||||||
|
|
@ -148,13 +151,16 @@ $(PBF):
|
||||||
$(POIS_RAW): $(PBF) $(ENGLAND_BOUNDARY)
|
$(POIS_RAW): $(PBF) $(ENGLAND_BOUNDARY)
|
||||||
uv run python -m pipeline.download.pois --output $@ --pbf $(PBF) --boundary $(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):
|
$(OFSTED):
|
||||||
uv run python -m pipeline.download.ofsted --output $@
|
uv run python -m pipeline.download.ofsted --output $@
|
||||||
|
|
||||||
$(BROADBAND):
|
$(BROADBAND):
|
||||||
uv run python -m pipeline.download.broadband --output $@
|
uv run python -m pipeline.download.broadband --output $@
|
||||||
|
|
||||||
$(POSTCODES):
|
$(POSTCODES_RAW):
|
||||||
uv run python -m pipeline.download.postcodes --output $@
|
uv run python -m pipeline.download.postcodes --output $@
|
||||||
|
|
||||||
$(NOISE): $(ARCGIS)
|
$(NOISE): $(ARCGIS)
|
||||||
|
|
@ -204,8 +210,8 @@ $(RM_OUTCODES): $(MERGE_STAMP)
|
||||||
|
|
||||||
# ── Transforms ────────────────────────────────────────────────────────────────
|
# ── Transforms ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
$(POIS_FILTERED): $(POIS_RAW) $(NAPTAN) $(ENGLAND_BOUNDARY)
|
$(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) --output $@
|
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)
|
$(EPC_PP): $(PRICE_PAID) $(EPC)
|
||||||
uv run python -m pipeline.transform.join_epc_pp --epc $(EPC) --price-paid $(PRICE_PAID) --output $@
|
uv run python -m pipeline.transform.join_epc_pp --epc $(EPC) --price-paid $(PRICE_PAID) --output $@
|
||||||
|
|
|
||||||
185
README.md
185
README.md
|
|
@ -1,95 +1,174 @@
|
||||||
# Property Map
|
# Property Map
|
||||||
|
|
||||||
## Area
|
Interactive UK property intelligence map. The app combines transaction, EPC,
|
||||||
uv run python scripts/remove_bg.py house-og.png 200 house.png
|
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
|
The public product is branded as Perfect Postcodes, while this repository is
|
||||||
- nature / greenery within 5 mins walk
|
still named `property-map`.
|
||||||
8. current listings
|
|
||||||
|
|
||||||
|
## What Is In Here
|
||||||
|
|
||||||
rightmove:
|
- `frontend/` - React 18, TypeScript, Tailwind, MapLibre, and deck.gl. The app
|
||||||
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>'
|
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
|
The Rust server expects these files or directories to exist:
|
||||||
- https://propertydata.co.uk/videos/quick-overview
|
|
||||||
- https://osdatahub.os.uk/data/downloads/open
|
|
||||||
|
|
||||||
|
```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
|
Most data can be downloaded or generated through `Makefile.data`. Some inputs
|
||||||
unzip data/d29f0314840ef7dcbb5cde66e383fe08059dab5a.zip -d data/crime/
|
are deliberately manual:
|
||||||
rm data/d29f0314840ef7dcbb5cde66e383fe08059dab5a.zip
|
|
||||||
|
|
||||||
|
- `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/
|
Build the main property datasets with:
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
- stripe
|
|
||||||
|
|
||||||
|
|
||||||
- Why hexagons?
|
|
||||||
- Why the price tag?
|
|
||||||
- contact support
|
|
||||||
-
|
|
||||||
|
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv sync
|
||||||
make -f Makefile.data prepare
|
make -f Makefile.data prepare
|
||||||
make -f Makefile.data tiles
|
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.
|
||||||
|
|
|
||||||
133
Taskfile.yml
133
Taskfile.yml
|
|
@ -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
40
check.sh
Executable 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
|
||||||
|
)
|
||||||
1833
frontend/package-lock.json
generated
1833
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -6,6 +6,7 @@
|
||||||
"build": "webpack --mode production && node scripts/prerender.mjs",
|
"build": "webpack --mode production && node scripts/prerender.mjs",
|
||||||
"build:no-prerender": "webpack --mode production",
|
"build:no-prerender": "webpack --mode production",
|
||||||
"prerender": "node scripts/prerender.mjs",
|
"prerender": "node scripts/prerender.mjs",
|
||||||
|
"test": "vitest run --environment jsdom",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"lint": "eslint src --ext .ts,.tsx",
|
"lint": "eslint src --ext .ts,.tsx",
|
||||||
"lint:fix": "eslint src --ext .ts,.tsx --fix",
|
"lint:fix": "eslint src --ext .ts,.tsx --fix",
|
||||||
|
|
@ -39,6 +40,7 @@
|
||||||
"@babel/preset-react": "^7.28.5",
|
"@babel/preset-react": "^7.28.5",
|
||||||
"@babel/preset-typescript": "^7.28.5",
|
"@babel/preset-typescript": "^7.28.5",
|
||||||
"@pmmmwh/react-refresh-webpack-plugin": "^0.6.2",
|
"@pmmmwh/react-refresh-webpack-plugin": "^0.6.2",
|
||||||
|
"@testing-library/react": "^16.3.2",
|
||||||
"@types/react": "^18.2.0",
|
"@types/react": "^18.2.0",
|
||||||
"@types/react-dom": "^18.2.0",
|
"@types/react-dom": "^18.2.0",
|
||||||
"@typescript-eslint/eslint-plugin": "^7.0.0",
|
"@typescript-eslint/eslint-plugin": "^7.0.0",
|
||||||
|
|
@ -53,6 +55,7 @@
|
||||||
"favicons": "^7.2.0",
|
"favicons": "^7.2.0",
|
||||||
"favicons-webpack-plugin": "^6.0.1",
|
"favicons-webpack-plugin": "^6.0.1",
|
||||||
"html-webpack-plugin": "^5.6.0",
|
"html-webpack-plugin": "^5.6.0",
|
||||||
|
"jsdom": "^29.1.1",
|
||||||
"mini-css-extract-plugin": "^2.9.0",
|
"mini-css-extract-plugin": "^2.9.0",
|
||||||
"postcss": "^8.4.0",
|
"postcss": "^8.4.0",
|
||||||
"postcss-loader": "^8.0.0",
|
"postcss-loader": "^8.0.0",
|
||||||
|
|
@ -63,6 +66,7 @@
|
||||||
"tailwindcss": "^3.4.0",
|
"tailwindcss": "^3.4.0",
|
||||||
"ts-loader": "^9.5.0",
|
"ts-loader": "^9.5.0",
|
||||||
"typescript": "^5.4.0",
|
"typescript": "^5.4.0",
|
||||||
|
"vitest": "^4.1.5",
|
||||||
"webpack": "^5.90.0",
|
"webpack": "^5.90.0",
|
||||||
"webpack-cli": "^5.1.0",
|
"webpack-cli": "^5.1.0",
|
||||||
"webpack-dev-server": "^5.0.0"
|
"webpack-dev-server": "^5.0.0"
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useState, useEffect, useRef } from 'react';
|
import { useState, useEffect, useRef, type ReactNode } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useFadeInRef } from '../../hooks/useFadeIn';
|
import { useFadeInRef } from '../../hooks/useFadeIn';
|
||||||
import HexCanvas from './HexCanvas';
|
import HexCanvas from './HexCanvas';
|
||||||
|
|
@ -7,6 +7,386 @@ import { TickerValue } from '../ui/TickerValue';
|
||||||
import { LogoIcon } from '../ui/icons/LogoIcon';
|
import { LogoIcon } from '../ui/icons/LogoIcon';
|
||||||
import { trackEvent } from '../../lib/analytics';
|
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]">
|
||||||
|
✓
|
||||||
|
</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">✓</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({
|
export default function HomePage({
|
||||||
onOpenDashboard,
|
onOpenDashboard,
|
||||||
onOpenPricing: _onOpenPricing,
|
onOpenPricing: _onOpenPricing,
|
||||||
|
|
@ -63,89 +443,91 @@ export default function HomePage({
|
||||||
{/* Hero */}
|
{/* 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">
|
<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'} />
|
<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="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="absolute bottom-0 right-1/4 w-[400px] h-[300px] bg-teal-600/[0.03] rounded-full blur-[100px] pointer-events-none" />
|
<div className="grid lg:grid-cols-[1fr_0.9fr] gap-8 lg:gap-12 items-center">
|
||||||
<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 className="max-w-4xl">
|
||||||
<div>
|
<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] tracking-tight">
|
<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>
|
{t('home.heroTitle1')}{' '}
|
||||||
.
|
<span className="text-teal-400">{t('home.heroTitle2')}</span>.
|
||||||
<br />
|
<br />
|
||||||
{t('home.heroTitle3')}
|
{t('home.heroTitle3')}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-base md:text-lg text-warm-300 mb-6 leading-relaxed max-w-xl">
|
<p className="text-base md:text-lg text-warm-300 mb-6 leading-relaxed max-w-xl">
|
||||||
{t('home.heroSubtitle')}
|
{t('home.heroSubtitle')}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-base md:text-lg text-warm-400 mb-8 max-w-xl">
|
<p className="text-base md:text-lg text-warm-400 mb-8 max-w-xl">
|
||||||
{t('home.heroDescription')}
|
{t('home.heroDescription')}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center gap-3 sm:gap-4 mb-10">
|
<div className="flex flex-col sm:flex-row sm:items-center gap-3 sm:gap-4 mb-10">
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
trackEvent('CTA Click', { location: 'hero', label: 'explore_map' });
|
trackEvent('CTA Click', { location: 'hero', label: 'explore_map' });
|
||||||
onOpenDashboard();
|
onOpenDashboard();
|
||||||
}}
|
}}
|
||||||
className="px-7 py-3.5 bg-coral-500 text-white rounded-lg font-semibold hover:bg-coral-600 transition-colors text-base shadow-lg shadow-coral-500/25 text-center"
|
className="px-7 py-3.5 bg-coral-500 text-white rounded-lg font-semibold hover:bg-coral-600 transition-colors text-base shadow-lg shadow-coral-500/25 text-center"
|
||||||
>
|
>
|
||||||
{t('home.exploreTheMap')}
|
{t('home.exploreTheMap')}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
trackEvent('CTA Click', { location: 'hero', label: 'see_difference' });
|
trackEvent('CTA Click', { location: 'hero', label: 'see_difference' });
|
||||||
const target = document.getElementById('comparison');
|
const target = document.getElementById('how-it-works');
|
||||||
if (!target) return;
|
if (!target) return;
|
||||||
const scroller = target.closest('.overflow-y-auto') as HTMLElement | null;
|
const scroller = target.closest('.overflow-y-auto') as HTMLElement | null;
|
||||||
if (!scroller) return;
|
if (!scroller) return;
|
||||||
const start = scroller.scrollTop;
|
const start = scroller.scrollTop;
|
||||||
const end =
|
const end =
|
||||||
start +
|
start +
|
||||||
target.getBoundingClientRect().top -
|
target.getBoundingClientRect().top -
|
||||||
scroller.getBoundingClientRect().top -
|
scroller.getBoundingClientRect().top -
|
||||||
48;
|
48;
|
||||||
const distance = end - start;
|
const distance = end - start;
|
||||||
const duration = 1200;
|
const duration = 1200;
|
||||||
let startTime: number;
|
let startTime: number;
|
||||||
const step = (time: number) => {
|
const step = (time: number) => {
|
||||||
if (!startTime) startTime = time;
|
if (!startTime) startTime = time;
|
||||||
const p = Math.min((time - startTime) / duration, 1);
|
const p = Math.min((time - startTime) / duration, 1);
|
||||||
const ease = p < 0.5 ? 4 * p * p * p : 1 - Math.pow(-2 * p + 2, 3) / 2;
|
const ease = p < 0.5 ? 4 * p * p * p : 1 - Math.pow(-2 * p + 2, 3) / 2;
|
||||||
scroller.scrollTop = start + distance * ease;
|
scroller.scrollTop = start + distance * ease;
|
||||||
if (p < 1) requestAnimationFrame(step);
|
if (p < 1) requestAnimationFrame(step);
|
||||||
};
|
};
|
||||||
requestAnimationFrame(step);
|
requestAnimationFrame(step);
|
||||||
}}
|
}}
|
||||||
className="px-[26px] py-[12px] border-2 border-teal-400 text-teal-400 rounded-lg font-semibold hover:bg-teal-400/10 transition-colors text-base text-center"
|
className="px-[26px] py-[12px] border-2 border-teal-400 text-teal-400 rounded-lg font-semibold hover:bg-teal-400/10 transition-colors text-base text-center"
|
||||||
>
|
>
|
||||||
{t('home.seeTheDifference')}
|
{t('home.seeTheDifference')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap gap-x-8 sm:gap-x-12 gap-y-4 pt-3 border-t border-white/10">
|
|
||||||
<div>
|
|
||||||
<div className="text-2xl md:text-3xl font-bold text-white">
|
|
||||||
<TickerValue text="13M" active={statsActive} />
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-warm-400">{t('home.statProperties')}</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="flex flex-wrap gap-x-8 sm:gap-x-12 gap-y-4 pt-3 border-t border-white/10">
|
||||||
<div className="text-2xl md:text-3xl font-bold text-white">
|
<div>
|
||||||
<TickerValue text="56" active={statsActive} />
|
<div className="text-2xl md:text-3xl font-bold text-white">
|
||||||
|
<TickerValue text="13M" active={statsActive} />
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-warm-400">{t('home.statProperties')}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-warm-400">{t('home.statFilters')}</div>
|
<div>
|
||||||
</div>
|
<div className="text-2xl md:text-3xl font-bold text-white">
|
||||||
<div>
|
<TickerValue text="56" active={statsActive} />
|
||||||
<div className="text-2xl md:text-3xl font-bold text-white">
|
</div>
|
||||||
{t('home.statEvery')}
|
<div className="text-sm text-warm-400">{t('home.statFilters')}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-2xl md:text-3xl font-bold text-white">
|
||||||
|
{t('home.statEvery')}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-warm-400">{t('home.statPostcodeInEngland')}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-warm-400">{t('home.statPostcodeInEngland')}</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<HeroProductShowcase />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Our philosophy */}
|
{/* 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">
|
<h2 className="text-2xl md:text-3xl font-bold text-navy-950 dark:text-warm-100 mb-6">
|
||||||
{t('home.ourPhilosophy')}
|
{t('home.ourPhilosophy')}
|
||||||
</h2>
|
</h2>
|
||||||
|
|
@ -155,8 +537,34 @@ export default function HomePage({
|
||||||
</div>
|
</div>
|
||||||
</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) */}
|
{/* 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 ref={whyRef} className="fade-in-section">
|
||||||
<div className="grid lg:grid-cols-[2fr_3fr] gap-8 lg:gap-12 items-start">
|
<div className="grid lg:grid-cols-[2fr_3fr] gap-8 lg:gap-12 items-start">
|
||||||
{/* Left: How to use it */}
|
{/* Left: How to use it */}
|
||||||
|
|
@ -277,8 +685,8 @@ export default function HomePage({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* The real cost CTA */}
|
{/* The real cost CTA */}
|
||||||
<div className="max-w-4xl mx-auto px-6 pt-12 md:pt-20 pb-12">
|
<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">
|
<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">
|
<h2 className="text-2xl md:text-3xl font-bold text-navy-950 dark:text-warm-100 mb-4 leading-snug">
|
||||||
{t('home.ctaTitle')}
|
{t('home.ctaTitle')}
|
||||||
</h2>
|
</h2>
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,11 @@ const DATA_SOURCE_DEFS: DataSourceDef[] = [
|
||||||
url: 'https://download.geofabrik.de/europe/great-britain-latest.osm.pbf',
|
url: 'https://download.geofabrik.de/europe/great-britain-latest.osm.pbf',
|
||||||
license: 'Open Data Commons Open Database License (ODbL)',
|
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',
|
id: 'os-open-greenspace',
|
||||||
url: 'https://osdatahub.os.uk/downloads/open/OpenGreenspace',
|
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'],
|
crime: ['learnPage.dsCrimeName', 'learnPage.dsCrimeOrigin', 'learnPage.dsCrimeUse'],
|
||||||
'osm-pois': ['learnPage.dsOsmName', 'learnPage.dsOsmOrigin', 'learnPage.dsOsmUse'],
|
'osm-pois': ['learnPage.dsOsmName', 'learnPage.dsOsmOrigin', 'learnPage.dsOsmUse'],
|
||||||
|
'geolytix-retail-points': [
|
||||||
|
'learnPage.dsGeolytixRetailName',
|
||||||
|
'learnPage.dsGeolytixRetailOrigin',
|
||||||
|
'learnPage.dsGeolytixRetailUse',
|
||||||
|
],
|
||||||
'os-open-greenspace': [
|
'os-open-greenspace': [
|
||||||
'learnPage.dsGreenspaceName',
|
'learnPage.dsGreenspaceName',
|
||||||
'learnPage.dsGreenspaceOrigin',
|
'learnPage.dsGreenspaceOrigin',
|
||||||
|
|
@ -228,7 +238,6 @@ export default function LearnPage() {
|
||||||
{ question: t('learnPage.faqPricing1Q'), answer: t('learnPage.faqPricing1A') },
|
{ question: t('learnPage.faqPricing1Q'), answer: t('learnPage.faqPricing1A') },
|
||||||
{ question: t('learnPage.faqPricing2Q'), answer: t('learnPage.faqPricing2A') },
|
{ question: t('learnPage.faqPricing2Q'), answer: t('learnPage.faqPricing2A') },
|
||||||
{ question: t('learnPage.faqPricing3Q'), answer: t('learnPage.faqPricing3A') },
|
{ question: t('learnPage.faqPricing3Q'), answer: t('learnPage.faqPricing3A') },
|
||||||
{ question: t('learnPage.faqPricing4Q'), answer: t('learnPage.faqPricing4A') },
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -9,16 +9,21 @@ import type {
|
||||||
} from '../../types';
|
} from '../../types';
|
||||||
import type { TravelTimeEntry } from '../../hooks/useTravelTime';
|
import type { TravelTimeEntry } from '../../hooks/useTravelTime';
|
||||||
import type { HexagonLocation } from '../../lib/external-search';
|
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 { 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 { DualHistogram, LoadingSkeleton } from './DualHistogram';
|
||||||
import EnumBarChart from './EnumBarChart';
|
import EnumBarChart from './EnumBarChart';
|
||||||
import StackedBarChart from './StackedBarChart';
|
import StackedBarChart from './StackedBarChart';
|
||||||
import StackedEnumChart from './StackedEnumChart';
|
import StackedEnumChart from './StackedEnumChart';
|
||||||
import PriceHistoryChart from './PriceHistoryChart';
|
import PriceHistoryChart from './PriceHistoryChart';
|
||||||
import ExternalSearchLinks from './ExternalSearchLinks';
|
import ExternalSearchLinks from './ExternalSearchLinks';
|
||||||
import { InfoIcon } from '../ui/icons';
|
import { FilterIcon, InfoIcon } from '../ui/icons';
|
||||||
import { CollapsibleGroupHeader } from '../ui/CollapsibleGroupHeader';
|
import { CollapsibleGroupHeader } from '../ui/CollapsibleGroupHeader';
|
||||||
import { FeatureInfoPopup } from '../ui/FeatureInfoPopup';
|
import { FeatureInfoPopup } from '../ui/FeatureInfoPopup';
|
||||||
import { EmptyState } from '../ui/EmptyState';
|
import { EmptyState } from '../ui/EmptyState';
|
||||||
|
|
@ -35,14 +40,26 @@ interface AreaPaneProps {
|
||||||
isPostcode?: boolean;
|
isPostcode?: boolean;
|
||||||
postcodeData?: PostcodeFeature | null;
|
postcodeData?: PostcodeFeature | null;
|
||||||
onViewProperties: () => void;
|
onViewProperties: () => void;
|
||||||
|
onClearFilters?: () => void;
|
||||||
hexagonLocation: HexagonLocation | null;
|
hexagonLocation: HexagonLocation | null;
|
||||||
filters: FeatureFilters;
|
filters: FeatureFilters;
|
||||||
|
unfilteredCount?: number | null;
|
||||||
onNavigateToSource?: (slug: string, featureName: string) => void;
|
onNavigateToSource?: (slug: string, featureName: string) => void;
|
||||||
travelTimeEntries?: TravelTimeEntry[];
|
travelTimeEntries?: TravelTimeEntry[];
|
||||||
isGroupExpanded: (name: string) => boolean;
|
isGroupExpanded: (name: string) => boolean;
|
||||||
onToggleGroup: (name: string) => void;
|
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({
|
export default function AreaPane({
|
||||||
stats,
|
stats,
|
||||||
globalFeatures,
|
globalFeatures,
|
||||||
|
|
@ -51,8 +68,10 @@ export default function AreaPane({
|
||||||
isPostcode = false,
|
isPostcode = false,
|
||||||
postcodeData,
|
postcodeData,
|
||||||
onViewProperties,
|
onViewProperties,
|
||||||
|
onClearFilters,
|
||||||
hexagonLocation,
|
hexagonLocation,
|
||||||
filters,
|
filters,
|
||||||
|
unfilteredCount,
|
||||||
onNavigateToSource,
|
onNavigateToSource,
|
||||||
travelTimeEntries,
|
travelTimeEntries,
|
||||||
isGroupExpanded,
|
isGroupExpanded,
|
||||||
|
|
@ -60,6 +79,8 @@ export default function AreaPane({
|
||||||
}: AreaPaneProps) {
|
}: AreaPaneProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const propertyCount = isPostcode && postcodeData ? postcodeData.properties.count : stats?.count;
|
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 featureGroups = useMemo(() => groupFeaturesByCategory(globalFeatures), [globalFeatures]);
|
||||||
const [infoFeature, setInfoFeature] = useState<FeatureMeta | null>(null);
|
const [infoFeature, setInfoFeature] = useState<FeatureMeta | null>(null);
|
||||||
|
|
||||||
|
|
@ -119,8 +140,36 @@ export default function AreaPane({
|
||||||
? t('common.postcode').toLowerCase()
|
? t('common.postcode').toLowerCase()
|
||||||
: t('common.area').toLowerCase(),
|
: t('common.area').toLowerCase(),
|
||||||
})}
|
})}
|
||||||
{Object.keys(filters).length > 0 ? t('areaPane.matchingFilters') : ''}
|
|
||||||
</p>
|
</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 && (
|
{stats && stats.count > 0 && (
|
||||||
<button
|
<button
|
||||||
onClick={onViewProperties}
|
onClick={onViewProperties}
|
||||||
|
|
@ -149,7 +198,7 @@ export default function AreaPane({
|
||||||
) : stats ? (
|
) : stats ? (
|
||||||
<div>
|
<div>
|
||||||
{hexagonLocation && <StreetViewEmbed location={hexagonLocation} />}
|
{hexagonLocation && <StreetViewEmbed location={hexagonLocation} />}
|
||||||
<HistogramLegend />
|
{stats.count > 0 && <HistogramLegend />}
|
||||||
{stats.price_history &&
|
{stats.price_history &&
|
||||||
(() => {
|
(() => {
|
||||||
const uniqueYears = new Set(stats.price_history.map((p) => Math.floor(p.year)));
|
const uniqueYears = new Set(stats.price_history.map((p) => Math.floor(p.year)));
|
||||||
|
|
@ -190,148 +239,170 @@ export default function AreaPane({
|
||||||
{expanded && (
|
{expanded && (
|
||||||
<div className="px-3 py-2 space-y-3">
|
<div className="px-3 py-2 space-y-3">
|
||||||
{stackedCharts?.map((chart) => {
|
{stackedCharts?.map((chart) => {
|
||||||
const segments = chart.components
|
const segments = chart.components
|
||||||
.map((name) => ({
|
.map((name) => ({
|
||||||
name,
|
name,
|
||||||
value: numericByName.get(name)?.mean ?? 0,
|
value: numericByName.get(name)?.mean ?? 0,
|
||||||
}))
|
}))
|
||||||
.filter((s) => s.value > 0);
|
.filter((s) => s.value > 0);
|
||||||
|
|
||||||
const aggregateStats = chart.feature
|
const isPercentageComposition = chart.unit === '%' && !chart.feature;
|
||||||
? numericByName.get(chart.feature)
|
const displaySegments = isPercentageComposition
|
||||||
: undefined;
|
? normalizePercentageSegments(segments)
|
||||||
const total = aggregateStats
|
: segments;
|
||||||
? aggregateStats.mean
|
|
||||||
: segments.reduce((sum, s) => sum + s.value, 0);
|
|
||||||
|
|
||||||
// Use rateFeature (e.g. per-1k) for display if available
|
const aggregateStats = chart.feature
|
||||||
const rateStats = chart.rateFeature
|
? numericByName.get(chart.feature)
|
||||||
? numericByName.get(chart.rateFeature)
|
: undefined;
|
||||||
: undefined;
|
const total = aggregateStats
|
||||||
const displayValue = rateStats ? rateStats.mean : total;
|
? aggregateStats.mean
|
||||||
|
: displaySegments.reduce((sum, s) => sum + s.value, 0);
|
||||||
|
|
||||||
// Use rateFeature for info popup and national average when available
|
// Use rateFeature (e.g. per-1k) for display if available
|
||||||
const infoFeatureName = chart.rateFeature ?? chart.feature;
|
const rateStats = chart.rateFeature
|
||||||
const featureMeta = infoFeatureName
|
? numericByName.get(chart.rateFeature)
|
||||||
? globalFeatureByName.get(infoFeatureName)
|
: undefined;
|
||||||
: undefined;
|
const displayValue = isPercentageComposition
|
||||||
|
? 100
|
||||||
|
: rateStats
|
||||||
|
? rateStats.mean
|
||||||
|
: total;
|
||||||
|
|
||||||
const globalMean =
|
// Use rateFeature for info popup and national average when available
|
||||||
featureMeta?.histogram
|
const infoFeatureName = chart.rateFeature ?? chart.feature;
|
||||||
? calculateHistogramMean(featureMeta.histogram)
|
const featureMeta = infoFeatureName
|
||||||
: undefined;
|
? globalFeatureByName.get(infoFeatureName)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
if (total === 0) return null;
|
const globalMean = featureMeta?.histogram
|
||||||
|
? calculateHistogramMean(featureMeta.histogram)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
return (
|
if (total === 0) return null;
|
||||||
<div
|
|
||||||
key={ts(chart.label)}
|
return (
|
||||||
className="bg-warm-50 dark:bg-warm-800 rounded p-2"
|
<div
|
||||||
>
|
key={ts(chart.label)}
|
||||||
<div className="flex justify-between items-baseline mb-1.5">
|
className="bg-warm-50 dark:bg-warm-800 rounded p-2"
|
||||||
{featureMeta ? (
|
>
|
||||||
<FeatureLabel
|
<div className="flex justify-between items-baseline mb-1.5">
|
||||||
feature={{ ...featureMeta, name: ts(chart.label) }}
|
{featureMeta ? (
|
||||||
onShowInfo={setInfoFeature}
|
<FeatureLabel
|
||||||
className="mr-2"
|
feature={{ ...featureMeta, name: ts(chart.label) }}
|
||||||
/>
|
onShowInfo={setInfoFeature}
|
||||||
) : (
|
className="mr-2"
|
||||||
<span className="text-xs text-warm-700 dark:text-warm-300 truncate mr-2">
|
/>
|
||||||
{ts(chart.label)}
|
) : (
|
||||||
</span>
|
<span className="text-xs text-warm-700 dark:text-warm-300 truncate mr-2">
|
||||||
)}
|
{ts(chart.label)}
|
||||||
<div className="text-right shrink-0">
|
</span>
|
||||||
<span className="text-xs font-semibold text-teal-700 dark:text-teal-400 whitespace-nowrap">
|
)}
|
||||||
{formatValue(displayValue)}
|
<div className="text-right shrink-0">
|
||||||
{chart.unit ? ` ${chart.unit}` : ''}
|
<span className="text-xs font-semibold text-teal-700 dark:text-teal-400 whitespace-nowrap">
|
||||||
</span>
|
{formatValue(displayValue)}
|
||||||
{globalMean != null && (
|
{chart.unit ? ` ${chart.unit}` : ''}
|
||||||
<div className="text-[10px] text-warm-400 dark:text-warm-500 whitespace-nowrap">
|
</span>
|
||||||
{t('areaPane.nationalAvg')}: {formatValue(globalMean)}
|
{globalMean != null && (
|
||||||
</div>
|
<div className="text-[10px] text-warm-400 dark:text-warm-500 whitespace-nowrap">
|
||||||
)}
|
{t('areaPane.nationalAvg')}: {formatValue(globalMean)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
<StackedBarChart segments={segments} total={total} />
|
|
||||||
</div>
|
</div>
|
||||||
);
|
</div>
|
||||||
})}
|
<StackedBarChart
|
||||||
|
segments={displaySegments}
|
||||||
|
total={total}
|
||||||
|
colorMap={
|
||||||
|
chart.label === 'Political vote share'
|
||||||
|
? PARTY_FEATURE_COLORS
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
{(() => {
|
{(() => {
|
||||||
const stackedFeatureNames = new Set<string>(
|
const stackedFeatureNames = new Set<string>(
|
||||||
stackedCharts?.flatMap((c) =>
|
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
|
return group.features
|
||||||
.filter((f) => !stackedFeatureNames.has(f.name) && !stackedEnumFeatureNames.has(f.name))
|
.filter(
|
||||||
.map((feature) => {
|
(f) =>
|
||||||
const numericStats = numericByName.get(feature.name);
|
!stackedFeatureNames.has(f.name) &&
|
||||||
const enumStats = enumByName.get(feature.name);
|
!stackedEnumFeatureNames.has(f.name)
|
||||||
|
)
|
||||||
|
.map((feature) => {
|
||||||
|
const numericStats = numericByName.get(feature.name);
|
||||||
|
const enumStats = enumByName.get(feature.name);
|
||||||
|
|
||||||
if (numericStats) {
|
if (numericStats) {
|
||||||
const globalFeature = globalFeatureByName.get(feature.name);
|
const globalFeature = globalFeatureByName.get(feature.name);
|
||||||
const globalHistogram = globalFeature?.histogram;
|
const globalHistogram = globalFeature?.histogram;
|
||||||
const globalMean = globalHistogram
|
const globalMean = globalHistogram
|
||||||
? calculateHistogramMean(globalHistogram)
|
? calculateHistogramMean(globalHistogram)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={feature.name}
|
key={feature.name}
|
||||||
className="bg-warm-50 dark:bg-warm-800 rounded p-2"
|
className="bg-warm-50 dark:bg-warm-800 rounded p-2"
|
||||||
>
|
>
|
||||||
<div className="flex justify-between items-baseline">
|
<div className="flex justify-between items-baseline">
|
||||||
<FeatureLabel
|
<FeatureLabel
|
||||||
feature={feature}
|
feature={feature}
|
||||||
onShowInfo={setInfoFeature}
|
onShowInfo={setInfoFeature}
|
||||||
className="mr-2"
|
className="mr-2"
|
||||||
/>
|
|
||||||
<span className="text-xs font-semibold text-teal-700 dark:text-teal-400 whitespace-nowrap">
|
|
||||||
{formatValue(numericStats.mean, feature)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{numericStats.histogram &&
|
|
||||||
(globalHistogram ? (
|
|
||||||
<DualHistogram
|
|
||||||
localCounts={numericStats.histogram.counts}
|
|
||||||
globalCounts={globalHistogram.counts}
|
|
||||||
p1={numericStats.histogram.p1}
|
|
||||||
p99={numericStats.histogram.p99}
|
|
||||||
globalMean={globalMean}
|
|
||||||
formatLabel={(v) => formatFilterValue(v, feature.raw)}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<DualHistogram
|
|
||||||
localCounts={numericStats.histogram.counts}
|
|
||||||
globalCounts={numericStats.histogram.counts}
|
|
||||||
p1={numericStats.histogram.p1}
|
|
||||||
p99={numericStats.histogram.p99}
|
|
||||||
formatLabel={(v) => formatFilterValue(v, feature.raw)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (enumStats) {
|
|
||||||
const globalFeature = globalFeatureByName.get(feature.name);
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={feature.name}
|
|
||||||
className="bg-warm-50 dark:bg-warm-800 rounded p-2"
|
|
||||||
>
|
|
||||||
<FeatureLabel feature={feature} onShowInfo={setInfoFeature} />
|
|
||||||
<EnumBarChart
|
|
||||||
counts={enumStats.counts}
|
|
||||||
globalCounts={globalFeature?.counts}
|
|
||||||
featureName={feature.name}
|
|
||||||
/>
|
/>
|
||||||
|
<span className="text-xs font-semibold text-teal-700 dark:text-teal-400 whitespace-nowrap">
|
||||||
|
{formatValue(numericStats.mean, feature)}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
{numericStats.histogram &&
|
||||||
}
|
(globalHistogram ? (
|
||||||
|
<DualHistogram
|
||||||
|
localCounts={numericStats.histogram.counts}
|
||||||
|
globalCounts={globalHistogram.counts}
|
||||||
|
p1={numericStats.histogram.p1}
|
||||||
|
p99={numericStats.histogram.p99}
|
||||||
|
globalMean={globalMean}
|
||||||
|
formatLabel={(v) => formatFilterValue(v, feature.raw)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<DualHistogram
|
||||||
|
localCounts={numericStats.histogram.counts}
|
||||||
|
globalCounts={numericStats.histogram.counts}
|
||||||
|
p1={numericStats.histogram.p1}
|
||||||
|
p99={numericStats.histogram.p99}
|
||||||
|
formatLabel={(v) => formatFilterValue(v, feature.raw)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return null;
|
if (enumStats) {
|
||||||
});
|
const globalFeature = globalFeatureByName.get(feature.name);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={feature.name}
|
||||||
|
className="bg-warm-50 dark:bg-warm-800 rounded p-2"
|
||||||
|
>
|
||||||
|
<FeatureLabel feature={feature} onShowInfo={setInfoFeature} />
|
||||||
|
<EnumBarChart
|
||||||
|
counts={enumStats.counts}
|
||||||
|
globalCounts={globalFeature?.counts}
|
||||||
|
featureName={feature.name}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
});
|
||||||
})()}
|
})()}
|
||||||
{stackedEnumCharts?.map((chart) => {
|
{stackedEnumCharts?.map((chart) => {
|
||||||
const featureMeta = chart.feature
|
const featureMeta = chart.feature
|
||||||
|
|
|
||||||
|
|
@ -13,9 +13,7 @@ export default function EnumBarChart({
|
||||||
const localTotal = entries.reduce((sum, [, c]) => sum + c, 0);
|
const localTotal = entries.reduce((sum, [, c]) => sum + c, 0);
|
||||||
|
|
||||||
// When global counts are available, normalize both to percentages for comparison
|
// When global counts are available, normalize both to percentages for comparison
|
||||||
const globalTotal = globalCounts
|
const globalTotal = globalCounts ? Object.values(globalCounts).reduce((sum, c) => sum + c, 0) : 0;
|
||||||
? Object.values(globalCounts).reduce((sum, c) => sum + c, 0)
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
const hasGlobal = globalCounts && globalTotal > 0;
|
const hasGlobal = globalCounts && globalTotal > 0;
|
||||||
|
|
||||||
|
|
@ -61,9 +59,14 @@ export default function EnumBarChart({
|
||||||
)}
|
)}
|
||||||
<div
|
<div
|
||||||
className={
|
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>
|
</div>
|
||||||
<span className="w-8 text-warm-500 dark:text-warm-400 text-right shrink-0">
|
<span className="w-8 text-warm-500 dark:text-warm-400 text-right shrink-0">
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,19 @@ import {
|
||||||
type TravelTimeEntry,
|
type TravelTimeEntry,
|
||||||
travelFieldKey,
|
travelFieldKey,
|
||||||
} from '../../hooks/useTravelTime';
|
} 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({
|
function EditableLabel({
|
||||||
value,
|
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 {
|
interface FiltersProps {
|
||||||
features: FeatureMeta[];
|
features: FeatureMeta[];
|
||||||
filters: FeatureFilters;
|
filters: FeatureFilters;
|
||||||
|
|
@ -214,6 +444,7 @@ interface FiltersProps {
|
||||||
onClearAll: () => void;
|
onClearAll: () => void;
|
||||||
onSaveSearch?: (name: string) => Promise<void>;
|
onSaveSearch?: (name: string) => Promise<void>;
|
||||||
savingSearch?: boolean;
|
savingSearch?: boolean;
|
||||||
|
destinationDropdownPortal?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default memo(function Filters({
|
export default memo(function Filters({
|
||||||
|
|
@ -255,17 +486,57 @@ export default memo(function Filters({
|
||||||
onClearAll,
|
onClearAll,
|
||||||
onSaveSearch,
|
onSaveSearch,
|
||||||
savingSearch,
|
savingSearch,
|
||||||
|
destinationDropdownPortal = true,
|
||||||
}: FiltersProps) {
|
}: FiltersProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const availableFeatures = useMemo(
|
const defaultSchoolFeatureName = useMemo(() => getDefaultSchoolFeatureName(features), [features]);
|
||||||
() => features.filter((f) => !enabledFeatures.has(f.name)),
|
const schoolMeta = useMemo(() => getSchoolFilterMeta(features), [features]);
|
||||||
[features, enabledFeatures]
|
const schoolFilterItems = useMemo(() => {
|
||||||
);
|
return Object.keys(filters)
|
||||||
const enabledFeatureList = useMemo(
|
.filter(isSchoolFilterName)
|
||||||
() => features.filter((f) => enabledFeatures.has(f.name)),
|
.map((name) => {
|
||||||
[features, enabledFeatures]
|
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 containerRef = useRef<HTMLDivElement>(null);
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
@ -279,10 +550,24 @@ export default memo(function Filters({
|
||||||
|
|
||||||
const handleAddAndScroll = useCallback(
|
const handleAddAndScroll = useCallback(
|
||||||
(name: string) => {
|
(name: string) => {
|
||||||
|
if (name === SCHOOL_FILTER_NAME) {
|
||||||
|
if (!defaultSchoolFeatureName) return;
|
||||||
|
pendingScrollRef.current = SCHOOL_FILTER_NAME;
|
||||||
|
onAddFilter(SCHOOL_FILTER_NAME);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
pendingScrollRef.current = name;
|
pendingScrollRef.current = name;
|
||||||
onAddFilter(name);
|
onAddFilter(name);
|
||||||
},
|
},
|
||||||
[onAddFilter]
|
[defaultSchoolFeatureName, onAddFilter]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleRemoveSchoolFilter = useCallback(
|
||||||
|
(name: string) => {
|
||||||
|
onRemoveFilter(name);
|
||||||
|
},
|
||||||
|
[onRemoveFilter]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleAddTravelTimeAndScroll = useCallback(
|
const handleAddTravelTimeAndScroll = useCallback(
|
||||||
|
|
@ -455,6 +740,59 @@ export default memo(function Filters({
|
||||||
|
|
||||||
<div className="px-2 py-1 space-y-1">
|
<div className="px-2 py-1 space-y-1">
|
||||||
{enabledFeatureList.map((feature, featureIdx) => {
|
{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') {
|
if (feature.type === 'enum') {
|
||||||
const selectedValues = (filters[feature.name] as string[]) || [];
|
const selectedValues = (filters[feature.name] as string[]) || [];
|
||||||
const allValues = feature.values || [];
|
const allValues = feature.values || [];
|
||||||
|
|
@ -483,6 +821,7 @@ export default memo(function Filters({
|
||||||
onToggleBest={() => onTravelTimeToggleBest(index)}
|
onToggleBest={() => onTravelTimeToggleBest(index)}
|
||||||
onRemove={() => onTravelTimeRemoveEntry(index)}
|
onRemove={() => onTravelTimeRemoveEntry(index)}
|
||||||
filterImpact={filterImpacts?.[travelFieldKey(entry)]}
|
filterImpact={filterImpacts?.[travelFieldKey(entry)]}
|
||||||
|
destinationDropdownPortal={destinationDropdownPortal}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
@ -587,6 +926,7 @@ export default memo(function Filters({
|
||||||
onToggleBest={() => onTravelTimeToggleBest(index)}
|
onToggleBest={() => onTravelTimeToggleBest(index)}
|
||||||
onRemove={() => onTravelTimeRemoveEntry(index)}
|
onRemove={() => onTravelTimeRemoveEntry(index)}
|
||||||
filterImpact={filterImpacts?.[travelFieldKey(entry)]}
|
filterImpact={filterImpacts?.[travelFieldKey(entry)]}
|
||||||
|
destinationDropdownPortal={destinationDropdownPortal}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
@ -604,6 +944,7 @@ export default memo(function Filters({
|
||||||
<FeatureActions
|
<FeatureActions
|
||||||
feature={feature}
|
feature={feature}
|
||||||
isPinned={isPinned}
|
isPinned={isPinned}
|
||||||
|
isPreviewing={isActive}
|
||||||
onTogglePin={onTogglePin}
|
onTogglePin={onTogglePin}
|
||||||
onShowInfo={setActiveInfoFeature}
|
onShowInfo={setActiveInfoFeature}
|
||||||
onRemove={onRemoveFilter}
|
onRemove={onRemoveFilter}
|
||||||
|
|
@ -688,6 +1029,7 @@ export default memo(function Filters({
|
||||||
onToggleBest={() => onTravelTimeToggleBest(index)}
|
onToggleBest={() => onTravelTimeToggleBest(index)}
|
||||||
onRemove={() => onTravelTimeRemoveEntry(index)}
|
onRemove={() => onTravelTimeRemoveEntry(index)}
|
||||||
filterImpact={filterImpacts?.[travelFieldKey(entry)]}
|
filterImpact={filterImpacts?.[travelFieldKey(entry)]}
|
||||||
|
destinationDropdownPortal={destinationDropdownPortal}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
@ -721,10 +1063,20 @@ export default memo(function Filters({
|
||||||
<div className="md:flex-1 md:min-h-0 md:overflow-y-auto">
|
<div className="md:flex-1 md:min-h-0 md:overflow-y-auto">
|
||||||
<FeatureBrowser
|
<FeatureBrowser
|
||||||
availableFeatures={availableFeatures}
|
availableFeatures={availableFeatures}
|
||||||
allFeatures={features}
|
allFeatures={[...features, schoolMeta]}
|
||||||
pinnedFeature={pinnedFeature}
|
pinnedFeature={
|
||||||
|
pinnedFeature && isSchoolFilterName(pinnedFeature)
|
||||||
|
? SCHOOL_FILTER_NAME
|
||||||
|
: pinnedFeature
|
||||||
|
}
|
||||||
onAddFilter={handleAddAndScroll}
|
onAddFilter={handleAddAndScroll}
|
||||||
onTogglePin={onTogglePin}
|
onTogglePin={(name) => {
|
||||||
|
if (name === SCHOOL_FILTER_NAME) {
|
||||||
|
if (defaultSchoolFeatureName) onTogglePin(defaultSchoolFeatureName);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onTogglePin(name);
|
||||||
|
}}
|
||||||
onNavigateToSource={onNavigateToSource}
|
onNavigateToSource={onNavigateToSource}
|
||||||
openInfoFeature={openInfoFeature}
|
openInfoFeature={openInfoFeature}
|
||||||
onClearOpenInfoFeature={onClearOpenInfoFeature}
|
onClearOpenInfoFeature={onClearOpenInfoFeature}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next';
|
||||||
import type { FeatureFilters, FeatureMeta } from '../../types';
|
import type { FeatureFilters, FeatureMeta } from '../../types';
|
||||||
import { formatValue } from '../../lib/format';
|
import { formatValue } from '../../lib/format';
|
||||||
import { ts } from '../../i18n/server';
|
import { ts } from '../../i18n/server';
|
||||||
|
import { SCHOOL_FILTER_NAME, getSchoolBackendFeatureName } from '../../lib/school-filter';
|
||||||
|
|
||||||
interface HoverCardData {
|
interface HoverCardData {
|
||||||
count: number;
|
count: number;
|
||||||
|
|
@ -41,14 +42,18 @@ export default memo(function HoverCard({
|
||||||
|
|
||||||
// Show stats for active filters (up to 4)
|
// Show stats for active filters (up to 4)
|
||||||
for (const name of activeFilterNames.slice(0, 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;
|
if (val == null || typeof val !== 'number') continue;
|
||||||
const meta = featureMap.get(name);
|
const meta = featureMap.get(backendName);
|
||||||
if (meta?.type === 'enum' && meta.values) {
|
if (meta?.type === 'enum' && meta.values) {
|
||||||
const label = meta.values[Math.round(val)];
|
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 {
|
} else {
|
||||||
results.push({ name, value: formatValue(val, meta) });
|
results.push({
|
||||||
|
name: backendName === name ? name : SCHOOL_FILTER_NAME,
|
||||||
|
value: formatValue(val, meta),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -34,10 +34,12 @@ const ZOOM_FOR_TYPE: Record<string, number> = {
|
||||||
export default function LocationSearch({
|
export default function LocationSearch({
|
||||||
onFlyTo,
|
onFlyTo,
|
||||||
onLocationSearched,
|
onLocationSearched,
|
||||||
|
onCurrentLocationFound,
|
||||||
onMouseEnter,
|
onMouseEnter,
|
||||||
}: {
|
}: {
|
||||||
onFlyTo: (lat: number, lng: number, zoom: number) => void;
|
onFlyTo: (lat: number, lng: number, zoom: number) => void;
|
||||||
onLocationSearched?: (postcode: SearchedLocation | null) => void;
|
onLocationSearched?: (postcode: SearchedLocation | null) => void;
|
||||||
|
onCurrentLocationFound?: (lat: number, lng: number) => void;
|
||||||
onMouseEnter?: () => void;
|
onMouseEnter?: () => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
@ -131,27 +133,8 @@ export default function LocationSearch({
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
const { latitude, longitude } = position.coords;
|
const { latitude, longitude } = position.coords;
|
||||||
const res = await fetch(
|
onFlyTo(latitude, longitude, 17);
|
||||||
`/api/nearest-postcode?lat=${latitude}&lng=${longitude}`,
|
onCurrentLocationFound?.(latitude, 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,
|
|
||||||
});
|
|
||||||
search.clear();
|
search.clear();
|
||||||
if (isMobile) setExpanded(false);
|
if (isMobile) setExpanded(false);
|
||||||
} catch {
|
} catch {
|
||||||
|
|
@ -159,7 +142,7 @@ export default function LocationSearch({
|
||||||
} finally {
|
} finally {
|
||||||
setLocating(false);
|
setLocating(false);
|
||||||
}
|
}
|
||||||
}, [onFlyTo, onLocationSearched, isMobile, search, t]);
|
}, [onFlyTo, onCurrentLocationFound, isMobile, search, t]);
|
||||||
|
|
||||||
// Mobile collapsed state: search icon + locate button
|
// Mobile collapsed state: search icon + locate button
|
||||||
if (isMobile && !expanded) {
|
if (isMobile && !expanded) {
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,12 @@ import type {
|
||||||
Bounds,
|
Bounds,
|
||||||
} from '../../types';
|
} from '../../types';
|
||||||
|
|
||||||
import { zoomToResolution, getBoundsFromViewState, getMapStyle } from '../../lib/map-utils';
|
import {
|
||||||
|
zoomToResolution,
|
||||||
|
getBoundsFromViewState,
|
||||||
|
getMapStyle,
|
||||||
|
getPoiIconUrl,
|
||||||
|
} from '../../lib/map-utils';
|
||||||
import {
|
import {
|
||||||
INITIAL_VIEW_STATE,
|
INITIAL_VIEW_STATE,
|
||||||
MAP_MIN_ZOOM,
|
MAP_MIN_ZOOM,
|
||||||
|
|
@ -56,6 +61,8 @@ interface MapProps {
|
||||||
filters?: FeatureFilters;
|
filters?: FeatureFilters;
|
||||||
selectedPostcodeGeometry?: PostcodeGeometry | null;
|
selectedPostcodeGeometry?: PostcodeGeometry | null;
|
||||||
onLocationSearched?: (location: SearchedLocation | null) => void;
|
onLocationSearched?: (location: SearchedLocation | null) => void;
|
||||||
|
onCurrentLocationFound?: (lat: number, lng: number) => void;
|
||||||
|
currentLocation?: { lat: number; lng: number } | null;
|
||||||
bounds?: Bounds | null;
|
bounds?: Bounds | null;
|
||||||
hideLegend?: boolean;
|
hideLegend?: boolean;
|
||||||
travelTimeEntries?: TravelTimeEntry[];
|
travelTimeEntries?: TravelTimeEntry[];
|
||||||
|
|
@ -114,6 +121,8 @@ export default memo(function Map({
|
||||||
filters = {},
|
filters = {},
|
||||||
selectedPostcodeGeometry,
|
selectedPostcodeGeometry,
|
||||||
onLocationSearched,
|
onLocationSearched,
|
||||||
|
onCurrentLocationFound,
|
||||||
|
currentLocation,
|
||||||
bounds: viewportBounds,
|
bounds: viewportBounds,
|
||||||
hideLegend = false,
|
hideLegend = false,
|
||||||
travelTimeEntries = EMPTY_TRAVEL_ENTRIES,
|
travelTimeEntries = EMPTY_TRAVEL_ENTRIES,
|
||||||
|
|
@ -225,6 +234,7 @@ export default memo(function Map({
|
||||||
onHexagonHover,
|
onHexagonHover,
|
||||||
theme,
|
theme,
|
||||||
selectedPostcodeGeometry,
|
selectedPostcodeGeometry,
|
||||||
|
currentLocation,
|
||||||
bounds: viewportBounds,
|
bounds: viewportBounds,
|
||||||
travelTimeEntries,
|
travelTimeEntries,
|
||||||
});
|
});
|
||||||
|
|
@ -307,6 +317,7 @@ export default memo(function Map({
|
||||||
<LocationSearch
|
<LocationSearch
|
||||||
onFlyTo={handleFlyTo}
|
onFlyTo={handleFlyTo}
|
||||||
onLocationSearched={onLocationSearched}
|
onLocationSearched={onLocationSearched}
|
||||||
|
onCurrentLocationFound={onCurrentLocationFound}
|
||||||
onMouseEnter={handleMouseLeave}
|
onMouseEnter={handleMouseLeave}
|
||||||
/>
|
/>
|
||||||
{!hideLegend &&
|
{!hideLegend &&
|
||||||
|
|
@ -389,7 +400,14 @@ export default memo(function Map({
|
||||||
) : (
|
) : (
|
||||||
<div className="px-3 py-2">
|
<div className="px-3 py-2">
|
||||||
<div className="flex items-center gap-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>
|
||||||
<div className="font-semibold dark:text-warm-100">{popupInfo.name}</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">
|
<div className="flex items-center gap-1.5 text-xs text-warm-500 dark:text-warm-400">
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,10 @@ import { useTranslation } from 'react-i18next';
|
||||||
import { formatValue } from '../../lib/format';
|
import { formatValue } from '../../lib/format';
|
||||||
import { ts } from '../../i18n/server';
|
import { ts } from '../../i18n/server';
|
||||||
import {
|
import {
|
||||||
FEATURE_GRADIENT,
|
|
||||||
DENSITY_GRADIENT,
|
DENSITY_GRADIENT,
|
||||||
DENSITY_GRADIENT_DARK,
|
DENSITY_GRADIENT_DARK,
|
||||||
getEnumPaletteForFeature,
|
getEnumPaletteForFeature,
|
||||||
|
getFeatureGradient,
|
||||||
} from '../../lib/consts';
|
} from '../../lib/consts';
|
||||||
import { gradientToCss } from '../../lib/utils';
|
import { gradientToCss } from '../../lib/utils';
|
||||||
import { CloseIcon } from '../ui/icons/CloseIcon';
|
import { CloseIcon } from '../ui/icons/CloseIcon';
|
||||||
|
|
@ -95,7 +95,9 @@ export default function MapLegend({
|
||||||
const enumPalette = getEnumPaletteForFeature(featureName ?? null, enumValues);
|
const enumPalette = getEnumPaletteForFeature(featureName ?? null, enumValues);
|
||||||
const densityGradient = theme === 'dark' ? DENSITY_GRADIENT_DARK : DENSITY_GRADIENT;
|
const densityGradient = theme === 'dark' ? DENSITY_GRADIENT_DARK : DENSITY_GRADIENT;
|
||||||
const gradientStyle =
|
const gradientStyle =
|
||||||
mode === 'density' ? gradientToCss(densityGradient) : gradientToCss(FEATURE_GRADIENT);
|
mode === 'density'
|
||||||
|
? gradientToCss(densityGradient)
|
||||||
|
: gradientToCss(getFeatureGradient(featureName));
|
||||||
|
|
||||||
const fmt = raw ? { raw: true } : undefined;
|
const fmt = raw ? { raw: true } : undefined;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
|
import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { cellToLatLng } from 'h3-js';
|
||||||
import type {
|
import type {
|
||||||
FeatureMeta,
|
FeatureMeta,
|
||||||
FeatureFilters,
|
FeatureFilters,
|
||||||
|
|
@ -16,8 +17,9 @@ import POIPane from './POIPane';
|
||||||
import { PropertiesPane } from './PropertiesPane';
|
import { PropertiesPane } from './PropertiesPane';
|
||||||
import AreaPane from './AreaPane';
|
import AreaPane from './AreaPane';
|
||||||
import MobileDrawer from './MobileDrawer';
|
import MobileDrawer from './MobileDrawer';
|
||||||
|
import MobileBottomSheet from './MobileBottomSheet';
|
||||||
import MapLegend from './MapLegend';
|
import MapLegend from './MapLegend';
|
||||||
import { TabButton } from '../ui/TabButton';
|
import { MapPageSelectionPane } from './MapPageSelectionPane';
|
||||||
import { useMapData } from '../../hooks/useMapData';
|
import { useMapData } from '../../hooks/useMapData';
|
||||||
import { usePOIData } from '../../hooks/usePOIData';
|
import { usePOIData } from '../../hooks/usePOIData';
|
||||||
import { useFilters } from '../../hooks/useFilters';
|
import { useFilters } from '../../hooks/useFilters';
|
||||||
|
|
@ -40,9 +42,9 @@ import { useFilterCounts } from '../../hooks/useFilterCounts';
|
||||||
import { ts } from '../../i18n/server';
|
import { ts } from '../../i18n/server';
|
||||||
import { trackEvent } from '../../lib/analytics';
|
import { trackEvent } from '../../lib/analytics';
|
||||||
import { INITIAL_VIEW_STATE } from '../../lib/consts';
|
import { INITIAL_VIEW_STATE } from '../../lib/consts';
|
||||||
|
import { getSchoolBackendFeatureName } from '../../lib/school-filter';
|
||||||
import { useLicense } from '../../hooks/useLicense';
|
import { useLicense } from '../../hooks/useLicense';
|
||||||
import UpgradeModal from '../ui/UpgradeModal';
|
import UpgradeModal from '../ui/UpgradeModal';
|
||||||
import { CloseIcon } from '../ui/icons/CloseIcon';
|
|
||||||
import { SpinnerIcon } from '../ui/icons/SpinnerIcon';
|
import { SpinnerIcon } from '../ui/icons/SpinnerIcon';
|
||||||
import { MapPinIcon } from '../ui/icons/MapPinIcon';
|
import { MapPinIcon } from '../ui/icons/MapPinIcon';
|
||||||
import { BookmarkIcon } from '../ui/icons/BookmarkIcon';
|
import { BookmarkIcon } from '../ui/icons/BookmarkIcon';
|
||||||
|
|
@ -116,15 +118,10 @@ export default function MapPage({
|
||||||
|
|
||||||
const [leftPaneWidth, leftPaneHandlers] = usePaneResize(384, 200, 0.45, 'left');
|
const [leftPaneWidth, leftPaneHandlers] = usePaneResize(384, 200, 0.45, 'left');
|
||||||
const [rightPaneWidth, rightPaneHandlers] = usePaneResize(384, 200, 0.45, 'right');
|
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 [mobileDrawerOpen, setMobileDrawerOpen] = useState(false);
|
||||||
const [poiPaneOpen, setPoiPaneOpen] = useState(false);
|
const [poiPaneOpen, setPoiPaneOpen] = useState(false);
|
||||||
|
const [currentLocation, setCurrentLocation] = useState<{ lat: number; lng: number } | null>(null);
|
||||||
|
|
||||||
const [showBookmarkToast, setShowBookmarkToast] = useState(false);
|
const [showBookmarkToast, setShowBookmarkToast] = useState(false);
|
||||||
const bookmarkToastDismissed = useRef(localStorage.getItem('bookmark_toast_dismissed') === '1');
|
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(() => {
|
const handleClearAll = useCallback(() => {
|
||||||
|
|
@ -304,6 +308,7 @@ export default function MapPage({
|
||||||
loadingProperties,
|
loadingProperties,
|
||||||
areaStats,
|
areaStats,
|
||||||
loadingAreaStats,
|
loadingAreaStats,
|
||||||
|
unfilteredAreaCount,
|
||||||
hoveredHexagon,
|
hoveredHexagon,
|
||||||
rightPaneTab,
|
rightPaneTab,
|
||||||
setRightPaneTab,
|
setRightPaneTab,
|
||||||
|
|
@ -315,25 +320,38 @@ export default function MapPage({
|
||||||
handleCloseSelection,
|
handleCloseSelection,
|
||||||
selectedPostcodeGeometry,
|
selectedPostcodeGeometry,
|
||||||
handleLocationSearch,
|
handleLocationSearch,
|
||||||
|
handleCurrentLocationSearch,
|
||||||
} = useHexagonSelection({
|
} = useHexagonSelection({
|
||||||
filters,
|
filters,
|
||||||
features,
|
features,
|
||||||
resolution: mapData.resolution,
|
resolution: mapData.resolution,
|
||||||
|
usePostcodeView: mapData.usePostcodeView,
|
||||||
journeyDest,
|
journeyDest,
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleLocationSearchResult = useCallback(
|
const handleLocationSearchResult = useCallback(
|
||||||
(result: SearchedLocation | null) => {
|
(result: SearchedLocation | null) => {
|
||||||
if (result) {
|
if (result) {
|
||||||
|
setCurrentLocation(null);
|
||||||
handleLocationSearch(result.postcode, result.geometry, result.latitude, result.longitude);
|
handleLocationSearch(result.postcode, result.geometry, result.latitude, result.longitude);
|
||||||
if (isMobile) setMobileDrawerOpen(true);
|
if (isMobile) setMobileDrawerOpen(true);
|
||||||
} else {
|
} else {
|
||||||
|
setCurrentLocation(null);
|
||||||
handleCloseSelection();
|
handleCloseSelection();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[handleLocationSearch, handleCloseSelection, isMobile]
|
[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(() => {
|
const handleZoomToFreeZone = useCallback(() => {
|
||||||
mapFlyToRef.current?.(
|
mapFlyToRef.current?.(
|
||||||
INITIAL_VIEW_STATE.latitude,
|
INITIAL_VIEW_STATE.latitude,
|
||||||
|
|
@ -428,20 +446,19 @@ export default function MapPage({
|
||||||
const [lon, lat] = postcodeFeature.properties.centroid;
|
const [lon, lat] = postcodeFeature.properties.centroid;
|
||||||
return { lat, lon, resolution: mapData.resolution, postcode: hexId, isPostcode: true };
|
return { lat, lon, resolution: mapData.resolution, postcode: hexId, isPostcode: true };
|
||||||
} else {
|
} else {
|
||||||
// For hexagons, get lat/lon from hexagon data; central postcode comes from stats
|
if (!hexId) return null;
|
||||||
const hex = hexId ? mapData.data.find((d) => d.h3 === hexId) : null;
|
const [lat, lon] = cellToLatLng(hexId);
|
||||||
if (!hex || typeof hex.lat !== 'number' || typeof hex.lon !== 'number') return null;
|
|
||||||
return {
|
return {
|
||||||
lat: hex.lat as number,
|
lat,
|
||||||
lon: hex.lon as number,
|
lon,
|
||||||
resolution: mapData.resolution,
|
resolution: selectedHexagon?.resolution ?? mapData.resolution,
|
||||||
postcode: areaStats?.central_postcode,
|
postcode: areaStats?.central_postcode,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
selectedHexagon?.id,
|
selectedHexagon?.id,
|
||||||
|
selectedHexagon?.resolution,
|
||||||
selectedHexagon?.type,
|
selectedHexagon?.type,
|
||||||
mapData.data,
|
|
||||||
mapData.postcodeData,
|
mapData.postcodeData,
|
||||||
mapData.resolution,
|
mapData.resolution,
|
||||||
areaStats?.central_postcode,
|
areaStats?.central_postcode,
|
||||||
|
|
@ -487,10 +504,17 @@ export default function MapPage({
|
||||||
}, [mapData.licenseRequired]);
|
}, [mapData.licenseRequired]);
|
||||||
|
|
||||||
const densityLabel = t('mapLegend.historicalMatches');
|
const densityLabel = t('mapLegend.historicalMatches');
|
||||||
|
const hasActiveFilters = Object.keys(filters).length > 0 || activeEntries.length > 0;
|
||||||
|
|
||||||
const mobileLegendMeta = useMemo(
|
const mobileLegendMeta = useMemo(() => {
|
||||||
() => (viewFeature ? features.find((f) => f.name === viewFeature) || null : null),
|
const featureName = viewFeature
|
||||||
[viewFeature, features]
|
? (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 mobileDensityRange = useMemo((): [number, number] => {
|
||||||
const items = mapData.usePostcodeView ? mapData.postcodeData : mapData.data;
|
const items = mapData.usePostcodeView ? mapData.postcodeData : mapData.data;
|
||||||
|
|
@ -573,7 +597,7 @@ export default function MapPage({
|
||||||
usePostcodeView={mapData.usePostcodeView}
|
usePostcodeView={mapData.usePostcodeView}
|
||||||
pois={[]}
|
pois={[]}
|
||||||
onViewChange={mapData.handleViewChange}
|
onViewChange={mapData.handleViewChange}
|
||||||
viewFeature={viewFeature}
|
viewFeature={mapViewFeature}
|
||||||
colorRange={mapData.colorRange}
|
colorRange={mapData.colorRange}
|
||||||
filterRange={filterRange}
|
filterRange={filterRange}
|
||||||
viewSource={viewSource}
|
viewSource={viewSource}
|
||||||
|
|
@ -607,8 +631,10 @@ export default function MapPage({
|
||||||
: null
|
: null
|
||||||
}
|
}
|
||||||
onViewProperties={handleViewPropertiesFromArea}
|
onViewProperties={handleViewPropertiesFromArea}
|
||||||
|
onClearFilters={hasActiveFilters ? handleClearAll : undefined}
|
||||||
hexagonLocation={hexagonLocation}
|
hexagonLocation={hexagonLocation}
|
||||||
filters={filters}
|
filters={filters}
|
||||||
|
unfilteredCount={unfilteredAreaCount}
|
||||||
travelTimeEntries={activeEntries}
|
travelTimeEntries={activeEntries}
|
||||||
isGroupExpanded={isAreaGroupExpanded}
|
isGroupExpanded={isAreaGroupExpanded}
|
||||||
onToggleGroup={toggleAreaGroup}
|
onToggleGroup={toggleAreaGroup}
|
||||||
|
|
@ -639,7 +665,7 @@ export default function MapPage({
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
const renderFilters = () => (
|
const renderFilters = (options?: { destinationDropdownPortal?: boolean }) => (
|
||||||
<Filters
|
<Filters
|
||||||
features={features}
|
features={features}
|
||||||
filters={filters}
|
filters={filters}
|
||||||
|
|
@ -678,12 +704,71 @@ export default function MapPage({
|
||||||
onClearAll={handleClearAll}
|
onClearAll={handleClearAll}
|
||||||
onSaveSearch={onSaveSearch}
|
onSaveSearch={onSaveSearch}
|
||||||
savingSearch={savingSearch}
|
savingSearch={savingSearch}
|
||||||
|
destinationDropdownPortal={options?.destinationDropdownPortal}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const renderMobileLegend = () => {
|
||||||
|
if (mapViewFeature && mapData.colorRange) {
|
||||||
|
if (mapViewFeature.startsWith('tt_')) {
|
||||||
|
return (
|
||||||
|
<MapLegend
|
||||||
|
featureLabel={t('travel.travelTime', {
|
||||||
|
mode: modes.label(
|
||||||
|
mapViewFeature.split('_')[1] as 'car' | 'bicycle' | 'walking' | 'transit'
|
||||||
|
),
|
||||||
|
})}
|
||||||
|
range={mapData.colorRange}
|
||||||
|
showCancel={viewSource === 'eye'}
|
||||||
|
onCancel={handleCancelPin}
|
||||||
|
mode="feature"
|
||||||
|
theme={theme}
|
||||||
|
inline
|
||||||
|
suffix=" min"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mobileLegendMeta) {
|
||||||
|
return (
|
||||||
|
<MapLegend
|
||||||
|
featureLabel={
|
||||||
|
viewSource === 'eye'
|
||||||
|
? t('mapLegend.previewing', { name: ts(mobileLegendMeta.name) })
|
||||||
|
: ts(mobileLegendMeta.name)
|
||||||
|
}
|
||||||
|
range={mapData.colorRange}
|
||||||
|
showCancel={viewSource === 'eye'}
|
||||||
|
onCancel={handleCancelPin}
|
||||||
|
mode="feature"
|
||||||
|
enumValues={mobileLegendMeta.type === 'enum' ? mobileLegendMeta.values : undefined}
|
||||||
|
featureName={mobileLegendMeta.name}
|
||||||
|
theme={theme}
|
||||||
|
inline
|
||||||
|
raw={mobileLegendMeta.raw}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MapLegend
|
||||||
|
featureLabel={densityLabel}
|
||||||
|
range={mobileDensityRange}
|
||||||
|
showCancel={false}
|
||||||
|
onCancel={handleCancelPin}
|
||||||
|
mode="density"
|
||||||
|
theme={theme}
|
||||||
|
inline
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
if (isMobile) {
|
if (isMobile) {
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 flex flex-col overflow-hidden relative touch-pan-y">
|
<div className="flex-1 overflow-hidden relative">
|
||||||
{initialLoading && (
|
{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="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">
|
<div className="flex flex-col items-center gap-4">
|
||||||
|
|
@ -695,17 +780,14 @@ export default function MapPage({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div
|
<div className="absolute inset-0">
|
||||||
ref={mobileMapRef}
|
|
||||||
className="relative overflow-hidden"
|
|
||||||
>
|
|
||||||
<Map
|
<Map
|
||||||
data={mapData.data}
|
data={mapData.data}
|
||||||
postcodeData={mapData.postcodeData}
|
postcodeData={mapData.postcodeData}
|
||||||
usePostcodeView={mapData.usePostcodeView}
|
usePostcodeView={mapData.usePostcodeView}
|
||||||
pois={pois}
|
pois={pois}
|
||||||
onViewChange={mapData.handleViewChange}
|
onViewChange={mapData.handleViewChange}
|
||||||
viewFeature={viewFeature}
|
viewFeature={mapViewFeature}
|
||||||
colorRange={mapData.colorRange}
|
colorRange={mapData.colorRange}
|
||||||
filterRange={filterRange}
|
filterRange={filterRange}
|
||||||
viewSource={viewSource}
|
viewSource={viewSource}
|
||||||
|
|
@ -721,94 +803,45 @@ export default function MapPage({
|
||||||
filters={filters}
|
filters={filters}
|
||||||
selectedPostcodeGeometry={selectedPostcodeGeometry}
|
selectedPostcodeGeometry={selectedPostcodeGeometry}
|
||||||
onLocationSearched={handleLocationSearchResult}
|
onLocationSearched={handleLocationSearchResult}
|
||||||
|
onCurrentLocationFound={handleCurrentLocationFound}
|
||||||
|
currentLocation={currentLocation}
|
||||||
bounds={mapData.bounds}
|
bounds={mapData.bounds}
|
||||||
hideLegend
|
hideLegend
|
||||||
travelTimeEntries={entries}
|
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>
|
||||||
|
|
||||||
<div
|
{mapData.loading && (
|
||||||
className="relative z-10 py-2 -my-2 cursor-row-resize touch-none group"
|
<div className="absolute inset-0 z-10 flex items-center justify-center pointer-events-none">
|
||||||
{...mobileResizeHandlers}
|
<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" />
|
||||||
<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">
|
<span className="text-sm font-medium text-warm-700 dark:text-warm-200">
|
||||||
<div className="flex flex-row gap-1.5">
|
Loading...
|
||||||
<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" />
|
</span>
|
||||||
<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>
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
<div className="flex-1 min-h-0 bg-white dark:bg-warm-900 overflow-hidden flex flex-col">
|
<button
|
||||||
{viewFeature && mapData.colorRange ? (
|
onClick={() => setPoiPaneOpen((p) => !p)}
|
||||||
viewFeature.startsWith('tt_') ? (
|
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'}`}
|
||||||
<MapLegend
|
aria-label={t('poiPane.pointsOfInterest')}
|
||||||
featureLabel={t('travel.travelTime', {
|
>
|
||||||
mode: modes.label(
|
<MapPinIcon className="w-5 h-5" />
|
||||||
viewFeature.split('_')[1] as 'car' | 'bicycle' | 'walking' | 'transit'
|
</button>
|
||||||
),
|
|
||||||
})}
|
{poiPaneOpen && (
|
||||||
range={mapData.colorRange}
|
<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">
|
||||||
showCancel={viewSource === 'eye'}
|
{renderPOIPane()}
|
||||||
onCancel={handleCancelPin}
|
</div>
|
||||||
mode="feature"
|
)}
|
||||||
theme={theme}
|
|
||||||
inline
|
<MobileBottomSheet
|
||||||
suffix=" min"
|
activeCount={Object.keys(filters).length + entries.length}
|
||||||
/>
|
legend={renderMobileLegend()}
|
||||||
) : mobileLegendMeta ? (
|
>
|
||||||
<MapLegend
|
{renderFilters({ destinationDropdownPortal: false })}
|
||||||
featureLabel={
|
</MobileBottomSheet>
|
||||||
viewSource === 'eye'
|
|
||||||
? t('mapLegend.previewing', { name: ts(mobileLegendMeta.name) })
|
|
||||||
: ts(mobileLegendMeta.name)
|
|
||||||
}
|
|
||||||
range={mapData.colorRange}
|
|
||||||
showCancel={viewSource === 'eye'}
|
|
||||||
onCancel={handleCancelPin}
|
|
||||||
mode="feature"
|
|
||||||
enumValues={mobileLegendMeta.type === 'enum' ? mobileLegendMeta.values : undefined}
|
|
||||||
featureName={mobileLegendMeta.name}
|
|
||||||
theme={theme}
|
|
||||||
inline
|
|
||||||
raw={mobileLegendMeta.raw}
|
|
||||||
/>
|
|
||||||
) : null
|
|
||||||
) : (
|
|
||||||
<MapLegend
|
|
||||||
featureLabel={densityLabel}
|
|
||||||
range={mobileDensityRange}
|
|
||||||
showCancel={false}
|
|
||||||
onCancel={handleCancelPin}
|
|
||||||
mode="density"
|
|
||||||
theme={theme}
|
|
||||||
inline
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<div className="flex-1 min-h-0">{renderFilters()}</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{mobileDrawerOpen && selectedHexagon && (
|
{mobileDrawerOpen && selectedHexagon && (
|
||||||
<MobileDrawer
|
<MobileDrawer
|
||||||
|
|
@ -891,7 +924,7 @@ export default function MapPage({
|
||||||
usePostcodeView={mapData.usePostcodeView}
|
usePostcodeView={mapData.usePostcodeView}
|
||||||
pois={pois}
|
pois={pois}
|
||||||
onViewChange={mapData.handleViewChange}
|
onViewChange={mapData.handleViewChange}
|
||||||
viewFeature={viewFeature}
|
viewFeature={mapViewFeature}
|
||||||
colorRange={mapData.colorRange}
|
colorRange={mapData.colorRange}
|
||||||
filterRange={filterRange}
|
filterRange={filterRange}
|
||||||
viewSource={viewSource}
|
viewSource={viewSource}
|
||||||
|
|
@ -907,10 +940,12 @@ export default function MapPage({
|
||||||
filters={filters}
|
filters={filters}
|
||||||
selectedPostcodeGeometry={selectedPostcodeGeometry}
|
selectedPostcodeGeometry={selectedPostcodeGeometry}
|
||||||
onLocationSearched={handleLocationSearchResult}
|
onLocationSearched={handleLocationSearchResult}
|
||||||
|
onCurrentLocationFound={handleCurrentLocationFound}
|
||||||
|
currentLocation={currentLocation}
|
||||||
bounds={mapData.bounds}
|
bounds={mapData.bounds}
|
||||||
travelTimeEntries={entries}
|
travelTimeEntries={entries}
|
||||||
densityLabel={densityLabel}
|
densityLabel={densityLabel}
|
||||||
totalCount={filterCounts.total || undefined}
|
totalCount={hasActiveFilters ? filterCounts.total : undefined}
|
||||||
/>
|
/>
|
||||||
{mapData.loading && (
|
{mapData.loading && (
|
||||||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
||||||
|
|
@ -940,47 +975,16 @@ export default function MapPage({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{selectedHexagon && (
|
{selectedHexagon && (
|
||||||
<div
|
<MapPageSelectionPane
|
||||||
data-tutorial="right-pane"
|
width={rightPaneWidth}
|
||||||
className="flex bg-white dark:bg-navy-950 shadow-lg z-10"
|
resizeHandlers={rightPaneHandlers}
|
||||||
style={{ width: rightPaneWidth }}
|
tab={rightPaneTab}
|
||||||
>
|
onAreaTabClick={() => setRightPaneTab('area')}
|
||||||
<div
|
onPropertiesTabClick={handlePropertiesTabClick}
|
||||||
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"
|
onClose={handleCloseSelection}
|
||||||
{...rightPaneHandlers}
|
renderAreaPane={renderAreaPane}
|
||||||
>
|
renderPropertiesPane={renderPropertiesPane}
|
||||||
<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')}
|
|
||||||
/>
|
|
||||||
<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}
|
{bookmarkToast}
|
||||||
|
|
|
||||||
72
frontend/src/components/map/MapPageSelectionPane.tsx
Normal file
72
frontend/src/components/map/MapPageSelectionPane.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
189
frontend/src/components/map/MobileBottomSheet.tsx
Normal file
189
frontend/src/components/map/MobileBottomSheet.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next';
|
||||||
import { ts } from '../../i18n/server';
|
import { ts } from '../../i18n/server';
|
||||||
import { useCollapsibleGroups } from '../../hooks/useCollapsibleGroups';
|
import { useCollapsibleGroups } from '../../hooks/useCollapsibleGroups';
|
||||||
import { trackEvent } from '../../lib/analytics';
|
import { trackEvent } from '../../lib/analytics';
|
||||||
|
import { POI_CATEGORY_LOGOS } from '../../lib/consts';
|
||||||
import type { POICategoryGroup } from '../../types';
|
import type { POICategoryGroup } from '../../types';
|
||||||
import InfoPopup from '../ui/InfoPopup';
|
import InfoPopup from '../ui/InfoPopup';
|
||||||
import { SearchInput } from '../ui/SearchInput';
|
import { SearchInput } from '../ui/SearchInput';
|
||||||
|
|
@ -186,15 +187,30 @@ export default function POIPane({
|
||||||
{!isCollapsed && (
|
{!isCollapsed && (
|
||||||
<div className="px-3 py-2">
|
<div className="px-3 py-2">
|
||||||
<PillGroup>
|
<PillGroup>
|
||||||
{group.categories.map((category) => (
|
{group.categories.map((category) => {
|
||||||
<PillToggle
|
const logo = POI_CATEGORY_LOGOS[category];
|
||||||
key={category}
|
return (
|
||||||
label={ts(category)}
|
<PillToggle
|
||||||
active={selectedCategories.has(category)}
|
key={category}
|
||||||
onClick={() => toggleCategory(category)}
|
label={ts(category)}
|
||||||
size="xs"
|
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>
|
</PillGroup>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -316,7 +316,6 @@ function PropertyCard({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { SEGMENT_COLORS } from '../../lib/consts';
|
import { SEGMENT_COLORS } from '../../lib/consts';
|
||||||
import { formatValue } from '../../lib/format';
|
import { formatValue, roundedPercentages } from '../../lib/format';
|
||||||
|
|
||||||
interface Segment {
|
interface Segment {
|
||||||
name: string;
|
name: string;
|
||||||
|
|
@ -30,6 +30,15 @@ function shortenLabel(name: string): string {
|
||||||
|
|
||||||
export default function StackedBarChart({ segments, total, colorMap }: StackedBarChartProps) {
|
export default function StackedBarChart({ segments, total, colorMap }: StackedBarChartProps) {
|
||||||
const sortedSegments = useMemo(() => [...segments].sort((a, b) => b.value - a.value), [segments]);
|
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) {
|
if (total === 0) {
|
||||||
return <div className="text-xs text-warm-400 dark:text-warm-500 italic">No data</div>;
|
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:
|
backgroundColor:
|
||||||
colorMap?.[segment.name] ?? SEGMENT_COLORS[i % SEGMENT_COLORS.length],
|
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)}%)`}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import type { EnumFeatureStats } from '../../types';
|
import type { EnumFeatureStats } from '../../types';
|
||||||
|
import { roundedPercentages } from '../../lib/format';
|
||||||
|
|
||||||
interface StackedEnumChartProps {
|
interface StackedEnumChartProps {
|
||||||
components: { label: string; stats: EnumFeatureStats }[];
|
components: { label: string; stats: EnumFeatureStats }[];
|
||||||
|
|
@ -30,7 +31,9 @@ export default function StackedEnumChart({
|
||||||
return (
|
return (
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
{visibleRows.map(({ label, stats }) => {
|
{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 (
|
return (
|
||||||
<div key={label} className="flex items-center gap-2 text-xs">
|
<div key={label} className="flex items-center gap-2 text-xs">
|
||||||
|
|
@ -39,7 +42,7 @@ export default function StackedEnumChart({
|
||||||
</span>
|
</span>
|
||||||
<div className="flex-1 flex h-3.5 rounded overflow-hidden bg-warm-200 dark:bg-warm-700">
|
<div className="flex-1 flex h-3.5 rounded overflow-hidden bg-warm-200 dark:bg-warm-700">
|
||||||
{valueOrder.map((value, i) => {
|
{valueOrder.map((value, i) => {
|
||||||
const count = stats.counts[value] ?? 0;
|
const count = counts[i];
|
||||||
const pct = (count / total) * 100;
|
const pct = (count / total) * 100;
|
||||||
if (pct < 0.5) return null;
|
if (pct < 0.5) return null;
|
||||||
return (
|
return (
|
||||||
|
|
@ -50,7 +53,7 @@ export default function StackedEnumChart({
|
||||||
width: `${pct}%`,
|
width: `${pct}%`,
|
||||||
backgroundColor: valueColors[i],
|
backgroundColor: valueColors[i],
|
||||||
}}
|
}}
|
||||||
title={`${value}: ${count} (${pct.toFixed(0)}%)`}
|
title={`${value}: ${count} (${roundedPcts[i]}%)`}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import type { HexagonLocation } from '../../lib/external-search';
|
import type { HexagonLocation } from '../../lib/external-search';
|
||||||
import { apiUrl, logNonAbortError } from '../../lib/api';
|
import { apiUrl, logNonAbortError } from '../../lib/api';
|
||||||
|
import { CloseIcon, ExpandIcon } from '../ui/icons';
|
||||||
|
|
||||||
interface StreetViewEmbedProps {
|
interface StreetViewEmbedProps {
|
||||||
location: HexagonLocation;
|
location: HexagonLocation;
|
||||||
|
|
@ -13,6 +15,7 @@ export default function StreetViewEmbed({ location }: StreetViewEmbedProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [status, setStatus] = useState<Status>('loading');
|
const [status, setStatus] = useState<Status>('loading');
|
||||||
const [panoId, setPanoId] = useState<string | null>(null);
|
const [panoId, setPanoId] = useState<string | null>(null);
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setStatus('loading');
|
setStatus('loading');
|
||||||
|
|
@ -47,31 +50,107 @@ export default function StreetViewEmbed({ location }: StreetViewEmbedProps) {
|
||||||
return () => controller.abort();
|
return () => controller.abort();
|
||||||
}, [location.lat, location.lon]);
|
}, [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;
|
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 (
|
return (
|
||||||
<div>
|
<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">
|
<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">
|
||||||
{t('streetView.title')}
|
<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>
|
||||||
<div className="px-3 py-2">
|
<div className="px-3 py-2">
|
||||||
<div className="rounded overflow-hidden border border-warm-200 dark:border-warm-700">
|
<div className="rounded overflow-hidden border border-warm-200 dark:border-warm-700">
|
||||||
{status === 'loading' ? (
|
{status === 'loading' || !panoUrl ? (
|
||||||
<div
|
<div
|
||||||
className="w-full animate-pulse bg-warm-200 dark:bg-warm-700"
|
className="w-full animate-pulse bg-warm-200 dark:bg-warm-700"
|
||||||
style={{ height: 240 }}
|
style={{ height: 240 }}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<iframe
|
<iframe
|
||||||
|
title={t('streetView.title')}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
style={{ height: 240, border: 0 }}
|
style={{ height: 240, border: 0 }}
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
referrerPolicy="no-referrer-when-downgrade"
|
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>
|
||||||
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@ interface TravelTimeCardProps {
|
||||||
onToggleBest: () => void;
|
onToggleBest: () => void;
|
||||||
onRemove: () => void;
|
onRemove: () => void;
|
||||||
filterImpact?: number;
|
filterImpact?: number;
|
||||||
|
destinationDropdownPortal?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TravelTimeCard({
|
export function TravelTimeCard({
|
||||||
|
|
@ -51,6 +52,7 @@ export function TravelTimeCard({
|
||||||
onToggleBest,
|
onToggleBest,
|
||||||
onRemove,
|
onRemove,
|
||||||
filterImpact,
|
filterImpact,
|
||||||
|
destinationDropdownPortal = true,
|
||||||
}: TravelTimeCardProps) {
|
}: TravelTimeCardProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const modes = useTranslatedModes();
|
const modes = useTranslatedModes();
|
||||||
|
|
@ -90,10 +92,10 @@ export function TravelTimeCard({
|
||||||
{slug && (
|
{slug && (
|
||||||
<IconButton
|
<IconButton
|
||||||
onClick={onTogglePin}
|
onClick={onTogglePin}
|
||||||
active={isPinned}
|
active={isPinned || isActive}
|
||||||
title={isPinned ? t('travel.stopPreviewing') : t('travel.previewOnMap')}
|
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>
|
||||||
)}
|
)}
|
||||||
<IconButton onClick={() => onRemove()} title={t('travel.removeTravelTime')}>
|
<IconButton onClick={() => onRemove()} title={t('travel.removeTravelTime')}>
|
||||||
|
|
@ -110,6 +112,7 @@ export function TravelTimeCard({
|
||||||
value={label || undefined}
|
value={label || undefined}
|
||||||
onClear={() => onSetDestination('', '', 0, 0)}
|
onClear={() => onSetDestination('', '', 0, 0)}
|
||||||
placeholder={t('travel.selectDestination')}
|
placeholder={t('travel.selectDestination')}
|
||||||
|
portal={destinationDropdownPortal}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Best-case toggle — transit only, shown when destination is set */}
|
{/* Best-case toggle — transit only, shown when destination is set */}
|
||||||
|
|
|
||||||
|
|
@ -340,11 +340,11 @@ export default function PricingPage({
|
||||||
{license.error}
|
{license.error}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<p className="text-center text-xs text-warm-400 dark:text-warm-500 mt-2">
|
{isFree && (
|
||||||
{isFree
|
<p className="text-center text-xs text-warm-400 dark:text-warm-500 mt-2">
|
||||||
? t('pricingPage.noCreditCard')
|
{t('pricingPage.noCreditCard')}
|
||||||
: t('pricingPage.moneyBackGuarantee')}
|
</p>
|
||||||
</p>
|
)}
|
||||||
</>
|
</>
|
||||||
) : isFilled ? (
|
) : 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">
|
<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">
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ interface DestinationDropdownProps {
|
||||||
onClear?: () => void;
|
onClear?: () => void;
|
||||||
value?: string;
|
value?: string;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
|
portal?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DestinationDropdown({
|
export function DestinationDropdown({
|
||||||
|
|
@ -23,6 +24,7 @@ export function DestinationDropdown({
|
||||||
onClear,
|
onClear,
|
||||||
value,
|
value,
|
||||||
placeholder,
|
placeholder,
|
||||||
|
portal = true,
|
||||||
}: DestinationDropdownProps) {
|
}: DestinationDropdownProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
|
@ -32,7 +34,7 @@ export function DestinationDropdown({
|
||||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
const listRef = useRef<HTMLDivElement>(null);
|
const listRef = useRef<HTMLDivElement>(null);
|
||||||
const pos = useDropdownPosition(containerRef, open);
|
const pos = useDropdownPosition(containerRef, open && portal);
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
if (!filter) return destinations;
|
if (!filter) return destinations;
|
||||||
|
|
@ -212,7 +214,12 @@ export function DestinationDropdown({
|
||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import { IconButton } from './IconButton';
|
||||||
interface FeatureActionsProps {
|
interface FeatureActionsProps {
|
||||||
feature: FeatureMeta;
|
feature: FeatureMeta;
|
||||||
isPinned: boolean;
|
isPinned: boolean;
|
||||||
|
isPreviewing?: boolean;
|
||||||
onTogglePin: (name: string) => void;
|
onTogglePin: (name: string) => void;
|
||||||
onShowInfo?: (feature: FeatureMeta) => void;
|
onShowInfo?: (feature: FeatureMeta) => void;
|
||||||
onRemove?: (name: string) => void;
|
onRemove?: (name: string) => void;
|
||||||
|
|
@ -14,11 +15,14 @@ interface FeatureActionsProps {
|
||||||
export function FeatureActions({
|
export function FeatureActions({
|
||||||
feature,
|
feature,
|
||||||
isPinned,
|
isPinned,
|
||||||
|
isPreviewing = false,
|
||||||
onTogglePin,
|
onTogglePin,
|
||||||
onShowInfo,
|
onShowInfo,
|
||||||
onRemove,
|
onRemove,
|
||||||
onAdd,
|
onAdd,
|
||||||
}: FeatureActionsProps) {
|
}: FeatureActionsProps) {
|
||||||
|
const isEyeActive = isPinned || isPreviewing;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-0.5 shrink-0">
|
<div className="flex items-center gap-0.5 shrink-0">
|
||||||
{feature.detail && onShowInfo && (
|
{feature.detail && onShowInfo && (
|
||||||
|
|
@ -29,10 +33,10 @@ export function FeatureActions({
|
||||||
<IconButton
|
<IconButton
|
||||||
onClick={() => onTogglePin(feature.name)}
|
onClick={() => onTogglePin(feature.name)}
|
||||||
title={isPinned ? 'Unpin colour view' : 'Colour map by this feature'}
|
title={isPinned ? 'Unpin colour view' : 'Colour map by this feature'}
|
||||||
active={isPinned}
|
active={isEyeActive}
|
||||||
size="md"
|
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>
|
</IconButton>
|
||||||
{onAdd && (
|
{onAdd && (
|
||||||
<button
|
<button
|
||||||
|
|
|
||||||
|
|
@ -72,9 +72,9 @@ export default function MobileMenu({
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Backdrop */}
|
{/* 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 */}
|
{/* 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">
|
<div className="flex items-center justify-between px-4 h-12 border-b border-navy-700">
|
||||||
<span className="font-semibold">{t('mobileMenu.menu')}</span>
|
<span className="font-semibold">{t('mobileMenu.menu')}</span>
|
||||||
<button
|
<button
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,10 @@
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
interface PillToggleProps {
|
interface PillToggleProps {
|
||||||
label: string;
|
label: string;
|
||||||
active: boolean;
|
active: boolean;
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
|
icon?: ReactNode;
|
||||||
/** Visual hint for partial selection (e.g. some children selected) */
|
/** Visual hint for partial selection (e.g. some children selected) */
|
||||||
indeterminate?: boolean;
|
indeterminate?: boolean;
|
||||||
size?: 'sm' | 'xs';
|
size?: 'sm' | 'xs';
|
||||||
|
|
@ -11,6 +14,7 @@ export function PillToggle({
|
||||||
label,
|
label,
|
||||||
active,
|
active,
|
||||||
onClick,
|
onClick,
|
||||||
|
icon,
|
||||||
indeterminate,
|
indeterminate,
|
||||||
size = 'sm',
|
size = 'sm',
|
||||||
}: PillToggleProps) {
|
}: PillToggleProps) {
|
||||||
|
|
@ -26,8 +30,9 @@ export function PillToggle({
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClick}
|
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}
|
{label}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
20
frontend/src/components/ui/icons/ExpandIcon.tsx
Normal file
20
frontend/src/components/ui/icons/ExpandIcon.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -7,6 +7,7 @@ export { ChevronIcon } from './ChevronIcon';
|
||||||
export { ClipboardIcon } from './ClipboardIcon';
|
export { ClipboardIcon } from './ClipboardIcon';
|
||||||
export { CloseIcon } from './CloseIcon';
|
export { CloseIcon } from './CloseIcon';
|
||||||
export { DownloadIcon } from './DownloadIcon';
|
export { DownloadIcon } from './DownloadIcon';
|
||||||
|
export { ExpandIcon } from './ExpandIcon';
|
||||||
export { EyeIcon } from './EyeIcon';
|
export { EyeIcon } from './EyeIcon';
|
||||||
export { FilterIcon } from './FilterIcon';
|
export { FilterIcon } from './FilterIcon';
|
||||||
export { GoogleIcon } from './GoogleIcon';
|
export { GoogleIcon } from './GoogleIcon';
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,7 @@
|
||||||
import { useCallback, useRef, useState, useMemo, useEffect } from 'react';
|
import { useCallback, useRef, useState, useMemo, useEffect } from 'react';
|
||||||
import { H3HexagonLayer } from '@deck.gl/geo-layers';
|
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 { cellToBoundary } from 'h3-js';
|
||||||
import Supercluster from 'supercluster';
|
|
||||||
import type { PickingInfo } from '@deck.gl/core';
|
import type { PickingInfo } from '@deck.gl/core';
|
||||||
import type {
|
import type {
|
||||||
HexagonData,
|
HexagonData,
|
||||||
|
|
@ -16,16 +15,12 @@ import type {
|
||||||
import {
|
import {
|
||||||
DENSITY_GRADIENT,
|
DENSITY_GRADIENT,
|
||||||
DENSITY_GRADIENT_DARK,
|
DENSITY_GRADIENT_DARK,
|
||||||
POI_GROUP_COLORS,
|
|
||||||
POI_DEFAULT_COLOR,
|
|
||||||
MINOR_POI_CATEGORIES,
|
|
||||||
MINOR_POI_ZOOM_THRESHOLD,
|
|
||||||
POI_CLUSTER_RADIUS,
|
|
||||||
POI_CLUSTER_MAX_ZOOM,
|
|
||||||
getEnumPaletteForFeature,
|
getEnumPaletteForFeature,
|
||||||
|
getFeatureGradient,
|
||||||
} from '../lib/consts';
|
} from '../lib/consts';
|
||||||
import { emojiToTwemojiUrl, getFeatureFillColor } from '../lib/map-utils';
|
import { getFeatureFillColor } from '../lib/map-utils';
|
||||||
import type { TravelTimeEntry } from './useTravelTime';
|
import type { TravelTimeEntry } from './useTravelTime';
|
||||||
|
import { usePoiLayers } from './usePoiLayers';
|
||||||
import { MarchingAntsExtension } from '../lib/MarchingAntsExtension';
|
import { MarchingAntsExtension } from '../lib/MarchingAntsExtension';
|
||||||
import { PieHexExtension } from '../lib/PieHexExtension';
|
import { PieHexExtension } from '../lib/PieHexExtension';
|
||||||
|
|
||||||
|
|
@ -45,29 +40,11 @@ interface UseDeckLayersProps {
|
||||||
onHexagonHover: (h3: string | null, x?: number, y?: number) => void;
|
onHexagonHover: (h3: string | null, x?: number, y?: number) => void;
|
||||||
theme: 'light' | 'dark';
|
theme: 'light' | 'dark';
|
||||||
selectedPostcodeGeometry?: PostcodeGeometry | null;
|
selectedPostcodeGeometry?: PostcodeGeometry | null;
|
||||||
|
currentLocation?: { lat: number; lng: number } | null;
|
||||||
bounds?: Bounds | null;
|
bounds?: Bounds | null;
|
||||||
travelTimeEntries?: TravelTimeEntry[];
|
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. */
|
/** Normalize a distribution count array to [0..1] ratios, padded to 10 values. */
|
||||||
function distToRatios(dist: unknown): number[] {
|
function distToRatios(dist: unknown): number[] {
|
||||||
if (!Array.isArray(dist) || dist.length === 0) return [1, 0, 0, 0, 0, 0, 0, 0, 0, 0];
|
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,
|
onHexagonHover,
|
||||||
theme,
|
theme,
|
||||||
selectedPostcodeGeometry,
|
selectedPostcodeGeometry,
|
||||||
|
currentLocation,
|
||||||
bounds: viewportBounds,
|
bounds: viewportBounds,
|
||||||
travelTimeEntries = [],
|
travelTimeEntries = [],
|
||||||
}: UseDeckLayersProps) {
|
}: UseDeckLayersProps) {
|
||||||
const [popupInfo, setPopupInfo] = useState<PopupInfo | null>(null);
|
|
||||||
const [hoverPosition, setHoverPosition] = useState<{ x: number; y: number } | null>(null);
|
const [hoverPosition, setHoverPosition] = useState<{ x: number; y: number } | null>(null);
|
||||||
const [hoveredPostcode, setHoveredPostcode] = useState<string | null>(null);
|
const [hoveredPostcode, setHoveredPostcode] = useState<string | null>(null);
|
||||||
|
|
||||||
|
|
@ -114,6 +91,7 @@ export function useDeckLayers({
|
||||||
|
|
||||||
const isDark = theme === 'dark';
|
const isDark = theme === 'dark';
|
||||||
const densityGradient = isDark ? DENSITY_GRADIENT_DARK : DENSITY_GRADIENT;
|
const densityGradient = isDark ? DENSITY_GRADIENT_DARK : DENSITY_GRADIENT;
|
||||||
|
const { poiLayers, popupInfo, clearPopupInfo } = usePoiLayers({ pois, zoom, isDark });
|
||||||
|
|
||||||
// --- Refs for deck.gl accessors ---
|
// --- Refs for deck.gl accessors ---
|
||||||
const viewFeatureRef = useRef(viewFeature);
|
const viewFeatureRef = useRef(viewFeature);
|
||||||
|
|
@ -126,6 +104,8 @@ export function useDeckLayers({
|
||||||
isDarkRef.current = isDark;
|
isDarkRef.current = isDark;
|
||||||
const densityGradientRef = useRef(densityGradient);
|
const densityGradientRef = useRef(densityGradient);
|
||||||
densityGradientRef.current = densityGradient;
|
densityGradientRef.current = densityGradient;
|
||||||
|
const featureGradientRef = useRef(getFeatureGradient(viewFeature));
|
||||||
|
featureGradientRef.current = getFeatureGradient(viewFeature);
|
||||||
const selectedHexagonIdRef = useRef(selectedHexagonId);
|
const selectedHexagonIdRef = useRef(selectedHexagonId);
|
||||||
selectedHexagonIdRef.current = selectedHexagonId;
|
selectedHexagonIdRef.current = selectedHexagonId;
|
||||||
const hoveredHexagonIdRef = useRef(hoveredHexagonId);
|
const hoveredHexagonIdRef = useRef(hoveredHexagonId);
|
||||||
|
|
@ -148,9 +128,7 @@ export function useDeckLayers({
|
||||||
: 0;
|
: 0;
|
||||||
|
|
||||||
// Per-feature color palette (uses overrides when defined)
|
// Per-feature color palette (uses overrides when defined)
|
||||||
const enumPaletteRef = useRef(
|
const enumPaletteRef = useRef(getEnumPaletteForFeature(viewFeature, colorFeatureMeta?.values));
|
||||||
getEnumPaletteForFeature(viewFeature, colorFeatureMeta?.values)
|
|
||||||
);
|
|
||||||
enumPaletteRef.current = getEnumPaletteForFeature(viewFeature, colorFeatureMeta?.values);
|
enumPaletteRef.current = getEnumPaletteForFeature(viewFeature, colorFeatureMeta?.values);
|
||||||
|
|
||||||
const countRange = useMemo(() => {
|
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
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
const handlePostcodeClick = useCallback((info: PickingInfo<any>) => {
|
const handlePostcodeClick = useCallback((info: PickingInfo<any>) => {
|
||||||
const pc = info.object?.properties?.postcode;
|
const pc = info.object?.properties?.postcode;
|
||||||
|
|
@ -380,7 +312,10 @@ export function useDeckLayers({
|
||||||
0,
|
0,
|
||||||
densityGradientRef.current,
|
densityGradientRef.current,
|
||||||
dark,
|
dark,
|
||||||
255
|
255,
|
||||||
|
0,
|
||||||
|
undefined,
|
||||||
|
featureGradientRef.current
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -399,7 +334,8 @@ export function useDeckLayers({
|
||||||
dark,
|
dark,
|
||||||
255,
|
255,
|
||||||
enumCountRef.current,
|
enumCountRef.current,
|
||||||
enumPaletteRef.current
|
enumPaletteRef.current,
|
||||||
|
featureGradientRef.current
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -481,7 +417,10 @@ export function useDeckLayers({
|
||||||
0,
|
0,
|
||||||
densityGradientRef.current,
|
densityGradientRef.current,
|
||||||
dark,
|
dark,
|
||||||
180
|
180,
|
||||||
|
0,
|
||||||
|
undefined,
|
||||||
|
featureGradientRef.current
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -501,7 +440,8 @@ export function useDeckLayers({
|
||||||
dark,
|
dark,
|
||||||
180,
|
180,
|
||||||
enumCountRef.current,
|
enumCountRef.current,
|
||||||
enumPaletteRef.current
|
enumPaletteRef.current,
|
||||||
|
featureGradientRef.current
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -576,148 +516,6 @@ export function useDeckLayers({
|
||||||
[postcodeData, theme]
|
[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
|
// Marching ants highlight layer for selected hexagon or postcode
|
||||||
const marchingAntsLayer = useMemo(() => {
|
const marchingAntsLayer = useMemo(() => {
|
||||||
let geometry: PostcodeGeometry | null = null;
|
let geometry: PostcodeGeometry | null = null;
|
||||||
|
|
@ -748,10 +546,25 @@ export function useDeckLayers({
|
||||||
});
|
});
|
||||||
}, [selectedPostcodeGeometry, selectedHexagonId, marchTime]);
|
}, [selectedPostcodeGeometry, selectedHexagonId, marchTime]);
|
||||||
|
|
||||||
const poiLayers = useMemo(
|
const currentLocationLayer = useMemo(() => {
|
||||||
() => [poiShadowLayer, poiBackgroundLayer, poiIconLayer, clusterCircleLayer, clusterTextLayer],
|
if (!currentLocation) return null;
|
||||||
[poiShadowLayer, poiBackgroundLayer, poiIconLayer, clusterCircleLayer, clusterTextLayer]
|
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(() => {
|
const layers = useMemo(() => {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
|
@ -761,6 +574,7 @@ export function useDeckLayers({
|
||||||
: [postcodeLayer, ...poiLayers]
|
: [postcodeLayer, ...poiLayers]
|
||||||
: [hexLayer, ...poiLayers];
|
: [hexLayer, ...poiLayers];
|
||||||
if (marchingAntsLayer) baseLayers.push(marchingAntsLayer);
|
if (marchingAntsLayer) baseLayers.push(marchingAntsLayer);
|
||||||
|
if (currentLocationLayer) baseLayers.push(currentLocationLayer);
|
||||||
return baseLayers;
|
return baseLayers;
|
||||||
}, [
|
}, [
|
||||||
usePostcodeView,
|
usePostcodeView,
|
||||||
|
|
@ -770,16 +584,15 @@ export function useDeckLayers({
|
||||||
postcodeLabelsLayer,
|
postcodeLabelsLayer,
|
||||||
poiLayers,
|
poiLayers,
|
||||||
marchingAntsLayer,
|
marchingAntsLayer,
|
||||||
|
currentLocationLayer,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const handleMouseLeave = useCallback(() => {
|
const handleMouseLeave = useCallback(() => {
|
||||||
setHoverPosition(null);
|
setHoverPosition(null);
|
||||||
setHoveredPostcode(null);
|
setHoveredPostcode(null);
|
||||||
setPopupInfo(null);
|
clearPopupInfo();
|
||||||
onHexagonHoverRef.current(null);
|
onHexagonHoverRef.current(null);
|
||||||
}, []);
|
}, [clearPopupInfo]);
|
||||||
|
|
||||||
const clearPopupInfo = useCallback(() => setPopupInfo(null), []);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
layers,
|
layers,
|
||||||
|
|
|
||||||
|
|
@ -1,25 +1,65 @@
|
||||||
import { useCallback, useLayoutEffect, useState } from 'react';
|
import { useCallback, useLayoutEffect, useRef, useState } from 'react';
|
||||||
import type React from 'react';
|
import type React from 'react';
|
||||||
|
|
||||||
export function useDropdownPosition(anchorRef: React.RefObject<HTMLElement | null>, open: boolean) {
|
export function useDropdownPosition(anchorRef: React.RefObject<HTMLElement | null>, open: boolean) {
|
||||||
const [pos, setPos] = useState<{ top: number; left: number; width: number } | null>(null);
|
const [pos, setPos] = useState<{ top: number; left: number; width: number } | null>(null);
|
||||||
|
const posRef = useRef(pos);
|
||||||
|
posRef.current = pos;
|
||||||
|
|
||||||
const update = useCallback(() => {
|
const update = useCallback(() => {
|
||||||
if (!anchorRef.current) return;
|
if (!anchorRef.current) return;
|
||||||
const rect = anchorRef.current.getBoundingClientRect();
|
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]);
|
}, [anchorRef]);
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
update();
|
const vv = window.visualViewport;
|
||||||
window.addEventListener('scroll', update, true);
|
let raf = 0;
|
||||||
window.addEventListener('resize', update);
|
let frame = 0;
|
||||||
return () => {
|
const anchor = anchorRef.current;
|
||||||
window.removeEventListener('scroll', update, true);
|
|
||||||
window.removeEventListener('resize', update);
|
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;
|
return pos;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,13 @@
|
||||||
import { useState, useCallback, useMemo, useRef, useEffect } from 'react';
|
import { useState, useCallback, useMemo, useRef, useEffect } from 'react';
|
||||||
import type { FeatureMeta, FeatureFilters } from '../types';
|
import type { FeatureMeta, FeatureFilters } from '../types';
|
||||||
import { trackEvent } from '../lib/analytics';
|
import { trackEvent } from '../lib/analytics';
|
||||||
|
import {
|
||||||
|
SCHOOL_FILTER_NAME,
|
||||||
|
createSchoolFilterKey,
|
||||||
|
getDefaultSchoolFeatureName,
|
||||||
|
getSchoolFilterKeyId,
|
||||||
|
normalizeSchoolFilters,
|
||||||
|
} from '../lib/school-filter';
|
||||||
|
|
||||||
interface UseFiltersOptions {
|
interface UseFiltersOptions {
|
||||||
initialFilters: FeatureFilters;
|
initialFilters: FeatureFilters;
|
||||||
|
|
@ -8,7 +15,9 @@ interface UseFiltersOptions {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useFilters({ initialFilters, features }: 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 [activeFeature, setActiveFeature] = useState<string | null>(null);
|
||||||
const [dragValue, setDragValue] = useState<[number, number] | null>(null);
|
const [dragValue, setDragValue] = useState<[number, number] | null>(null);
|
||||||
const [pinnedFeature, setPinnedFeature] = useState<string | 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 dragActiveRef = useRef<string | null>(null);
|
||||||
const dragValueRef = useRef<[number, number] | null>(null);
|
const dragValueRef = useRef<[number, number] | null>(null);
|
||||||
const undoStackRef = useRef<FeatureFilters[]>([]);
|
const undoStackRef = useRef<FeatureFilters[]>([]);
|
||||||
|
const schoolFilterIdRef = useRef(1);
|
||||||
|
|
||||||
const enabledFeatures = useMemo(() => new Set(Object.keys(filters)), [filters]);
|
const enabledFeatures = useMemo(() => new Set(Object.keys(filters)), [filters]);
|
||||||
|
|
||||||
|
|
@ -33,11 +43,31 @@ export function useFilters({ initialFilters, features }: UseFiltersOptions) {
|
||||||
const handleAddFilter = useCallback(
|
const handleAddFilter = useCallback(
|
||||||
(name: string) => {
|
(name: string) => {
|
||||||
const meta = features.find((f) => f.name === name);
|
const meta = features.find((f) => f.name === name);
|
||||||
if (!meta) return;
|
if (name !== SCHOOL_FILTER_NAME && !meta) return;
|
||||||
trackEvent('Filter Add', { feature: name });
|
trackEvent('Filter Add', { feature: name });
|
||||||
setFilters((prev) => {
|
setFilters((prev) => {
|
||||||
undoStackRef.current.push(prev);
|
undoStackRef.current.push(prev);
|
||||||
if (undoStackRef.current.length > 50) undoStackRef.current.shift();
|
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) {
|
if (meta.type === 'enum' && meta.values) {
|
||||||
return { ...prev, [name]: [...meta.values!] };
|
return { ...prev, [name]: [...meta.values!] };
|
||||||
} else if (meta.type === 'numeric' && meta.histogram) {
|
} else if (meta.type === 'numeric' && meta.histogram) {
|
||||||
|
|
@ -75,9 +105,27 @@ export function useFilters({ initialFilters, features }: UseFiltersOptions) {
|
||||||
if (Array.isArray(value) && value.length === 0) {
|
if (Array.isArray(value) && value.length === 0) {
|
||||||
const next = { ...prev };
|
const next = { ...prev };
|
||||||
delete next[name];
|
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);
|
const meta = features.find((f) => f.name === name);
|
||||||
if (meta?.type === 'enum') return;
|
if (meta?.type === 'enum') return;
|
||||||
pendingDragRef.current = name;
|
pendingDragRef.current = name;
|
||||||
|
setActiveFeature(name);
|
||||||
},
|
},
|
||||||
[features]
|
[features]
|
||||||
);
|
);
|
||||||
|
|
@ -112,8 +161,9 @@ export function useFilters({ initialFilters, features }: UseFiltersOptions) {
|
||||||
|
|
||||||
const handleDragEnd = useCallback(() => {
|
const handleDragEnd = useCallback(() => {
|
||||||
if (pendingDragRef.current) {
|
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;
|
pendingDragRef.current = null;
|
||||||
|
setActiveFeature(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const af = dragActiveRef.current;
|
const af = dragActiveRef.current;
|
||||||
|
|
@ -131,6 +181,7 @@ export function useFilters({ initialFilters, features }: UseFiltersOptions) {
|
||||||
const handleDragEndNoCommit = useCallback((): [number, number] | null => {
|
const handleDragEndNoCommit = useCallback((): [number, number] | null => {
|
||||||
if (pendingDragRef.current) {
|
if (pendingDragRef.current) {
|
||||||
pendingDragRef.current = null;
|
pendingDragRef.current = null;
|
||||||
|
setActiveFeature(null);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const dv = dragValueRef.current;
|
const dv = dragValueRef.current;
|
||||||
|
|
@ -142,7 +193,7 @@ export function useFilters({ initialFilters, features }: UseFiltersOptions) {
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleSetFilters = useCallback((newFilters: FeatureFilters) => {
|
const handleSetFilters = useCallback((newFilters: FeatureFilters) => {
|
||||||
setFilters(newFilters);
|
setFilters(normalizeSchoolFilters(newFilters));
|
||||||
setActiveFeature(null);
|
setActiveFeature(null);
|
||||||
setDragValue(null);
|
setDragValue(null);
|
||||||
setPinnedFeature(null);
|
setPinnedFeature(null);
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { useState, useCallback, useEffect, useMemo, useRef } from 'react';
|
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 { trackEvent } from '../lib/analytics';
|
||||||
import type {
|
import type {
|
||||||
FeatureMeta,
|
FeatureMeta,
|
||||||
|
|
@ -11,10 +11,13 @@ import type {
|
||||||
} from '../types';
|
} from '../types';
|
||||||
import { buildFilterString, apiUrl, assertOk, logNonAbortError, authHeaders } from '../lib/api';
|
import { buildFilterString, apiUrl, assertOk, logNonAbortError, authHeaders } from '../lib/api';
|
||||||
|
|
||||||
|
const CURRENT_LOCATION_HEX_RESOLUTION = 12;
|
||||||
|
|
||||||
interface SelectedHexagon {
|
interface SelectedHexagon {
|
||||||
id: string;
|
id: string;
|
||||||
type: 'hexagon' | 'postcode';
|
type: 'hexagon' | 'postcode';
|
||||||
resolution: number;
|
resolution: number;
|
||||||
|
lockedResolution?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface JourneyDest {
|
interface JourneyDest {
|
||||||
|
|
@ -22,10 +25,18 @@ interface JourneyDest {
|
||||||
slug: string;
|
slug: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface PostcodeLookupResponse {
|
||||||
|
postcode: string;
|
||||||
|
latitude: number;
|
||||||
|
longitude: number;
|
||||||
|
geometry: PostcodeGeometry;
|
||||||
|
}
|
||||||
|
|
||||||
interface UseHexagonSelectionOptions {
|
interface UseHexagonSelectionOptions {
|
||||||
filters: FeatureFilters;
|
filters: FeatureFilters;
|
||||||
features: FeatureMeta[];
|
features: FeatureMeta[];
|
||||||
resolution: number;
|
resolution: number;
|
||||||
|
usePostcodeView: boolean;
|
||||||
/** First transit destination — used to pick the best central_postcode for journey display. */
|
/** First transit destination — used to pick the best central_postcode for journey display. */
|
||||||
journeyDest?: JourneyDest | null;
|
journeyDest?: JourneyDest | null;
|
||||||
}
|
}
|
||||||
|
|
@ -34,6 +45,7 @@ export function useHexagonSelection({
|
||||||
filters,
|
filters,
|
||||||
features,
|
features,
|
||||||
resolution,
|
resolution,
|
||||||
|
usePostcodeView,
|
||||||
journeyDest,
|
journeyDest,
|
||||||
}: UseHexagonSelectionOptions) {
|
}: UseHexagonSelectionOptions) {
|
||||||
const [selectedHexagon, setSelectedHexagon] = useState<SelectedHexagon | null>(null);
|
const [selectedHexagon, setSelectedHexagon] = useState<SelectedHexagon | null>(null);
|
||||||
|
|
@ -42,6 +54,7 @@ export function useHexagonSelection({
|
||||||
const [propertiesOffset, setPropertiesOffset] = useState(0);
|
const [propertiesOffset, setPropertiesOffset] = useState(0);
|
||||||
const [loadingProperties, setLoadingProperties] = useState(false);
|
const [loadingProperties, setLoadingProperties] = useState(false);
|
||||||
const [areaStats, setAreaStats] = useState<HexagonStatsResponse | null>(null);
|
const [areaStats, setAreaStats] = useState<HexagonStatsResponse | null>(null);
|
||||||
|
const [unfilteredAreaCount, setUnfilteredAreaCount] = useState<number | null>(null);
|
||||||
const [loadingAreaStats, setLoadingAreaStats] = useState(false);
|
const [loadingAreaStats, setLoadingAreaStats] = useState(false);
|
||||||
const [hoveredHexagon, setHoveredHexagon] = useState<string | null>(null);
|
const [hoveredHexagon, setHoveredHexagon] = useState<string | null>(null);
|
||||||
const [rightPaneTab, setRightPaneTab] = useState<'properties' | 'area'>('area');
|
const [rightPaneTab, setRightPaneTab] = useState<'properties' | 'area'>('area');
|
||||||
|
|
@ -50,12 +63,18 @@ export function useHexagonSelection({
|
||||||
);
|
);
|
||||||
|
|
||||||
const fetchHexagonStats = useCallback(
|
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({
|
const params = new URLSearchParams({
|
||||||
h3,
|
h3,
|
||||||
resolution: res.toString(),
|
resolution: res.toString(),
|
||||||
});
|
});
|
||||||
const filterStr = buildFilterString(filters, features);
|
const filterStr = includeFilters ? buildFilterString(filters, features) : '';
|
||||||
if (filterStr) params.append('filters', filterStr);
|
if (filterStr) params.append('filters', filterStr);
|
||||||
if (fields) {
|
if (fields) {
|
||||||
params.set('fields', fields.join(';;'));
|
params.set('fields', fields.join(';;'));
|
||||||
|
|
@ -72,9 +91,9 @@ export function useHexagonSelection({
|
||||||
);
|
);
|
||||||
|
|
||||||
const fetchPostcodeStats = useCallback(
|
const fetchPostcodeStats = useCallback(
|
||||||
async (postcode: string, signal?: AbortSignal) => {
|
async (postcode: string, signal?: AbortSignal, includeFilters = true) => {
|
||||||
const params = new URLSearchParams({ postcode });
|
const params = new URLSearchParams({ postcode });
|
||||||
const filterStr = buildFilterString(filters, features);
|
const filterStr = includeFilters ? buildFilterString(filters, features) : '';
|
||||||
if (filterStr) params.append('filters', filterStr);
|
if (filterStr) params.append('filters', filterStr);
|
||||||
const response = await fetch(apiUrl('postcode-stats', params), authHeaders({ signal }));
|
const response = await fetch(apiUrl('postcode-stats', params), authHeaders({ signal }));
|
||||||
assertOk(response, 'postcode-stats');
|
assertOk(response, 'postcode-stats');
|
||||||
|
|
@ -83,6 +102,47 @@ export function useHexagonSelection({
|
||||||
[filters, features]
|
[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(
|
const fetchHexagonProperties = useCallback(
|
||||||
async (h3: string, res: number, offset = 0) => {
|
async (h3: string, res: number, offset = 0) => {
|
||||||
setLoadingProperties(true);
|
setLoadingProperties(true);
|
||||||
|
|
@ -156,33 +216,42 @@ export function useHexagonSelection({
|
||||||
setSelectedHexagon(null);
|
setSelectedHexagon(null);
|
||||||
setProperties([]);
|
setProperties([]);
|
||||||
setAreaStats(null);
|
setAreaStats(null);
|
||||||
|
setUnfilteredAreaCount(null);
|
||||||
setSelectedPostcodeGeometry(null);
|
setSelectedPostcodeGeometry(null);
|
||||||
} else {
|
} else {
|
||||||
const type = isPostcode ? 'postcode' : 'hexagon';
|
const type: SelectedHexagon['type'] = isPostcode ? 'postcode' : 'hexagon';
|
||||||
|
const selection = { id, type, resolution };
|
||||||
trackEvent('Hexagon Click', { type });
|
trackEvent('Hexagon Click', { type });
|
||||||
setSelectedHexagon({ id, type, resolution });
|
setSelectedHexagon(selection);
|
||||||
setSelectedPostcodeGeometry(isPostcode && geometry ? geometry : null);
|
setSelectedPostcodeGeometry(isPostcode && geometry ? geometry : null);
|
||||||
setProperties([]);
|
setProperties([]);
|
||||||
setPropertiesTotal(0);
|
setPropertiesTotal(0);
|
||||||
setPropertiesOffset(0);
|
setPropertiesOffset(0);
|
||||||
|
setUnfilteredAreaCount(null);
|
||||||
setRightPaneTab('area');
|
setRightPaneTab('area');
|
||||||
|
|
||||||
if (isPostcode) {
|
if (isPostcode) {
|
||||||
setLoadingAreaStats(true);
|
setLoadingAreaStats(true);
|
||||||
fetchPostcodeStats(id)
|
fetchPostcodeStats(id)
|
||||||
.then((stats) => setAreaStats(stats))
|
.then((stats) => {
|
||||||
|
setAreaStats(stats);
|
||||||
|
refreshUnfilteredAreaCount(selection, stats.count);
|
||||||
|
})
|
||||||
.catch((error) => logNonAbortError('Failed to fetch postcode stats', error))
|
.catch((error) => logNonAbortError('Failed to fetch postcode stats', error))
|
||||||
.finally(() => setLoadingAreaStats(false));
|
.finally(() => setLoadingAreaStats(false));
|
||||||
} else {
|
} else {
|
||||||
setLoadingAreaStats(true);
|
setLoadingAreaStats(true);
|
||||||
fetchHexagonStats(id, resolution)
|
fetchHexagonStats(id, resolution)
|
||||||
.then((stats) => setAreaStats(stats))
|
.then((stats) => {
|
||||||
|
setAreaStats(stats);
|
||||||
|
refreshUnfilteredAreaCount(selection, stats.count);
|
||||||
|
})
|
||||||
.catch((error) => logNonAbortError('Failed to fetch area stats', error))
|
.catch((error) => logNonAbortError('Failed to fetch area stats', error))
|
||||||
.finally(() => setLoadingAreaStats(false));
|
.finally(() => setLoadingAreaStats(false));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[selectedHexagon, resolution, fetchHexagonStats, fetchPostcodeStats]
|
[selectedHexagon, resolution, fetchHexagonStats, fetchPostcodeStats, refreshUnfilteredAreaCount]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleHexagonHover = useCallback((h3: string | null) => {
|
const handleHexagonHover = useCallback((h3: string | null) => {
|
||||||
|
|
@ -232,11 +301,111 @@ export function useHexagonSelection({
|
||||||
setSelectedHexagon(null);
|
setSelectedHexagon(null);
|
||||||
setProperties([]);
|
setProperties([]);
|
||||||
setAreaStats(null);
|
setAreaStats(null);
|
||||||
|
setUnfilteredAreaCount(null);
|
||||||
setSelectedPostcodeGeometry(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
|
// Re-fetch stats when filters change while a hexagon is selected
|
||||||
const filterStr = useMemo(() => buildFilterString(filters, features), [filters, features]);
|
|
||||||
const prevFilterStr = useRef(filterStr);
|
const prevFilterStr = useRef(filterStr);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -261,19 +430,14 @@ export function useHexagonSelection({
|
||||||
fetchStats
|
fetchStats
|
||||||
.then((stats) => {
|
.then((stats) => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
if (stats.count === 0) {
|
setAreaStats(stats);
|
||||||
setSelectedHexagon(null);
|
refreshUnfilteredAreaCount(selectedHexagon, stats.count);
|
||||||
setAreaStats(null);
|
// Re-fetch properties if the properties tab is active and the filtered area still has matches.
|
||||||
setSelectedPostcodeGeometry(null);
|
if (rightPaneTab === 'properties' && stats.count > 0) {
|
||||||
} else {
|
if (selectedHexagon.type === 'postcode') {
|
||||||
setAreaStats(stats);
|
fetchPostcodeProperties(selectedHexagon.id, 0);
|
||||||
// Re-fetch properties if the properties tab is active
|
} else {
|
||||||
if (rightPaneTab === 'properties') {
|
fetchHexagonProperties(selectedHexagon.id, selectedHexagon.resolution, 0);
|
||||||
if (selectedHexagon.type === 'postcode') {
|
|
||||||
fetchPostcodeProperties(selectedHexagon.id, 0);
|
|
||||||
} else {
|
|
||||||
fetchHexagonProperties(selectedHexagon.id, selectedHexagon.resolution, 0);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
@ -296,6 +460,7 @@ export function useHexagonSelection({
|
||||||
rightPaneTab,
|
rightPaneTab,
|
||||||
fetchHexagonProperties,
|
fetchHexagonProperties,
|
||||||
fetchPostcodeProperties,
|
fetchPostcodeProperties,
|
||||||
|
refreshUnfilteredAreaCount,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const handleLocationSearch = useCallback(
|
const handleLocationSearch = useCallback(
|
||||||
|
|
@ -304,6 +469,7 @@ export function useHexagonSelection({
|
||||||
setProperties([]);
|
setProperties([]);
|
||||||
setPropertiesTotal(0);
|
setPropertiesTotal(0);
|
||||||
setPropertiesOffset(0);
|
setPropertiesOffset(0);
|
||||||
|
setUnfilteredAreaCount(null);
|
||||||
setRightPaneTab('area');
|
setRightPaneTab('area');
|
||||||
setLoadingAreaStats(true);
|
setLoadingAreaStats(true);
|
||||||
|
|
||||||
|
|
@ -311,18 +477,22 @@ export function useHexagonSelection({
|
||||||
fetchPostcodeStats(postcode)
|
fetchPostcodeStats(postcode)
|
||||||
.then(async (stats) => {
|
.then(async (stats) => {
|
||||||
if (stats.count > 0) {
|
if (stats.count > 0) {
|
||||||
setSelectedHexagon({ id: postcode, type: 'postcode', resolution });
|
const selection = { id: postcode, type: 'postcode' as const, resolution };
|
||||||
|
setSelectedHexagon(selection);
|
||||||
setSelectedPostcodeGeometry(geometry);
|
setSelectedPostcodeGeometry(geometry);
|
||||||
setAreaStats(stats);
|
setAreaStats(stats);
|
||||||
|
refreshUnfilteredAreaCount(selection, stats.count);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// No properties in this postcode — fall back to hexagons
|
// No properties in this postcode — fall back to hexagons
|
||||||
if (lat == null || lng == null) {
|
if (lat == null || lng == null) {
|
||||||
// No coordinates available, show empty postcode anyway
|
// 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);
|
setSelectedPostcodeGeometry(geometry);
|
||||||
setAreaStats(stats);
|
setAreaStats(stats);
|
||||||
|
refreshUnfilteredAreaCount(selection, stats.count);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -332,9 +502,11 @@ export function useHexagonSelection({
|
||||||
const h3 = latLngToCell(lat, lng, res);
|
const h3 = latLngToCell(lat, lng, res);
|
||||||
const hexStats = await fetchHexagonStats(h3, res);
|
const hexStats = await fetchHexagonStats(h3, res);
|
||||||
if (hexStats.count > 1) {
|
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);
|
setSelectedPostcodeGeometry(null);
|
||||||
setAreaStats(hexStats);
|
setAreaStats(hexStats);
|
||||||
|
refreshUnfilteredAreaCount(selection, hexStats.count);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -342,14 +514,47 @@ export function useHexagonSelection({
|
||||||
// Even the coarsest hexagon has ≤1 property — show whatever the finest has
|
// Even the coarsest hexagon has ≤1 property — show whatever the finest has
|
||||||
const h3 = latLngToCell(lat, lng, 9);
|
const h3 = latLngToCell(lat, lng, 9);
|
||||||
const fallbackStats = await fetchHexagonStats(h3, 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);
|
setSelectedPostcodeGeometry(null);
|
||||||
setAreaStats(fallbackStats);
|
setAreaStats(fallbackStats);
|
||||||
|
refreshUnfilteredAreaCount(selection, fallbackStats.count);
|
||||||
})
|
})
|
||||||
.catch((error) => logNonAbortError('Failed to fetch postcode stats', error))
|
.catch((error) => logNonAbortError('Failed to fetch postcode stats', error))
|
||||||
.finally(() => setLoadingAreaStats(false));
|
.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 {
|
return {
|
||||||
|
|
@ -359,6 +564,7 @@ export function useHexagonSelection({
|
||||||
loadingProperties,
|
loadingProperties,
|
||||||
areaStats,
|
areaStats,
|
||||||
loadingAreaStats,
|
loadingAreaStats,
|
||||||
|
unfilteredAreaCount,
|
||||||
hoveredHexagon,
|
hoveredHexagon,
|
||||||
rightPaneTab,
|
rightPaneTab,
|
||||||
setRightPaneTab,
|
setRightPaneTab,
|
||||||
|
|
@ -370,5 +576,6 @@ export function useHexagonSelection({
|
||||||
handleCloseSelection,
|
handleCloseSelection,
|
||||||
selectedPostcodeGeometry,
|
selectedPostcodeGeometry,
|
||||||
handleLocationSearch,
|
handleLocationSearch,
|
||||||
|
handleCurrentLocationSearch,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import {
|
||||||
authHeaders,
|
authHeaders,
|
||||||
isAbortError,
|
isAbortError,
|
||||||
} from '../lib/api';
|
} from '../lib/api';
|
||||||
|
import { getSchoolBackendFeatureName } from '../lib/school-filter';
|
||||||
import { POSTCODE_ZOOM_THRESHOLD } from '../lib/consts';
|
import { POSTCODE_ZOOM_THRESHOLD } from '../lib/consts';
|
||||||
import { COLOR_RANGE_LOW_PERCENTILE, COLOR_RANGE_HIGH_PERCENTILE } from '../lib/consts';
|
import { COLOR_RANGE_LOW_PERCENTILE, COLOR_RANGE_HIGH_PERCENTILE } from '../lib/consts';
|
||||||
import { type TravelTimeEntry } from './useTravelTime';
|
import { type TravelTimeEntry } from './useTravelTime';
|
||||||
|
|
@ -74,11 +75,16 @@ export function useMapData({
|
||||||
const prevBoundsRef = useRef<string>('');
|
const prevBoundsRef = useRef<string>('');
|
||||||
|
|
||||||
const usePostcodeView = zoom >= POSTCODE_ZOOM_THRESHOLD;
|
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)
|
// Determine if the current viewFeature is an enum (for enum_dist param)
|
||||||
const viewFeatureIsEnum = useMemo(
|
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(
|
const buildFilterParam = useCallback(
|
||||||
|
|
@ -130,17 +136,18 @@ export function useMapData({
|
||||||
const filtersStr = buildFilterString(filters, features, activeFeature);
|
const filtersStr = buildFilterString(filters, features, activeFeature);
|
||||||
const boundsStr = `${bounds.south},${bounds.west},${bounds.north},${bounds.east}`;
|
const boundsStr = `${bounds.south},${bounds.west},${bounds.north},${bounds.east}`;
|
||||||
const isTravelTimeDrag = activeFeature.startsWith('tt_');
|
const isTravelTimeDrag = activeFeature.startsWith('tt_');
|
||||||
|
const dataActiveFeature = getSchoolBackendFeatureName(activeFeature) ?? activeFeature;
|
||||||
const dragTravelParam = isTravelTimeDrag ? buildTravelParam(activeFeature) : travelParam;
|
const dragTravelParam = isTravelTimeDrag ? buildTravelParam(activeFeature) : travelParam;
|
||||||
// Travel time fields are computed from the travel param, not regular feature columns.
|
// 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.
|
// 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) {
|
if (usePostcodeView) {
|
||||||
const params = new URLSearchParams({ bounds: boundsStr });
|
const params = new URLSearchParams({ bounds: boundsStr });
|
||||||
if (filtersStr) params.set('filters', filtersStr);
|
if (filtersStr) params.set('filters', filtersStr);
|
||||||
params.set('fields', fieldsParam);
|
params.set('fields', fieldsParam);
|
||||||
if (dragTravelParam) params.set('travel', dragTravelParam);
|
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 }))
|
fetch(apiUrl('postcodes', params), authHeaders({ signal: dragAbortRef.current.signal }))
|
||||||
.then((res) => res.json())
|
.then((res) => res.json())
|
||||||
|
|
@ -158,7 +165,7 @@ export function useMapData({
|
||||||
if (filtersStr) params.set('filters', filtersStr);
|
if (filtersStr) params.set('filters', filtersStr);
|
||||||
params.set('fields', fieldsParam);
|
params.set('fields', fieldsParam);
|
||||||
if (dragTravelParam) params.set('travel', dragTravelParam);
|
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 }))
|
fetch(apiUrl('hexagons', params), authHeaders({ signal: dragAbortRef.current.signal }))
|
||||||
.then((res) => res.json())
|
.then((res) => res.json())
|
||||||
|
|
@ -185,7 +192,7 @@ export function useMapData({
|
||||||
usePostcodeView,
|
usePostcodeView,
|
||||||
travelParam,
|
travelParam,
|
||||||
buildTravelParam,
|
buildTravelParam,
|
||||||
viewFeature,
|
dataViewFeature,
|
||||||
viewFeatureIsEnum,
|
viewFeatureIsEnum,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|
@ -211,11 +218,14 @@ export function useMapData({
|
||||||
if (usePostcodeView) {
|
if (usePostcodeView) {
|
||||||
const params = new URLSearchParams({ bounds: boundsStr });
|
const params = new URLSearchParams({ bounds: boundsStr });
|
||||||
if (filtersStr) params.set('filters', filtersStr);
|
if (filtersStr) params.set('filters', filtersStr);
|
||||||
params.set('fields', viewFeature && !viewFeature.startsWith('tt_') ? viewFeature : '');
|
params.set(
|
||||||
|
'fields',
|
||||||
|
dataViewFeature && !dataViewFeature.startsWith('tt_') ? dataViewFeature : ''
|
||||||
|
);
|
||||||
if (travelParam) {
|
if (travelParam) {
|
||||||
params.set('travel', 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(
|
const res = await fetch(
|
||||||
apiUrl('postcodes', params),
|
apiUrl('postcodes', params),
|
||||||
authHeaders({
|
authHeaders({
|
||||||
|
|
@ -242,11 +252,14 @@ export function useMapData({
|
||||||
bounds: boundsStr,
|
bounds: boundsStr,
|
||||||
});
|
});
|
||||||
if (filtersStr) params.set('filters', filtersStr);
|
if (filtersStr) params.set('filters', filtersStr);
|
||||||
params.set('fields', viewFeature && !viewFeature.startsWith('tt_') ? viewFeature : '');
|
params.set(
|
||||||
|
'fields',
|
||||||
|
dataViewFeature && !dataViewFeature.startsWith('tt_') ? dataViewFeature : ''
|
||||||
|
);
|
||||||
if (travelParam) {
|
if (travelParam) {
|
||||||
params.set('travel', 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(
|
const res = await fetch(
|
||||||
apiUrl('hexagons', params),
|
apiUrl('hexagons', params),
|
||||||
authHeaders({
|
authHeaders({
|
||||||
|
|
@ -294,7 +307,7 @@ export function useMapData({
|
||||||
bounds,
|
bounds,
|
||||||
filters,
|
filters,
|
||||||
buildFilterParam,
|
buildFilterParam,
|
||||||
viewFeature,
|
dataViewFeature,
|
||||||
viewFeatureIsEnum,
|
viewFeatureIsEnum,
|
||||||
usePostcodeView,
|
usePostcodeView,
|
||||||
travelParam,
|
travelParam,
|
||||||
|
|
@ -311,12 +324,12 @@ export function useMapData({
|
||||||
// Always uses rawData/postcodeData (not drag preview data) so the color
|
// Always uses rawData/postcodeData (not drag preview data) so the color
|
||||||
// scale stays stable while dragging a filter slider.
|
// scale stays stable while dragging a filter slider.
|
||||||
const dataRange = useMemo((): [number, number] | null => {
|
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) {
|
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;
|
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)
|
if (lat < bounds.south || lat > bounds.north || lng < bounds.west || lng > bounds.east)
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const val = feat.properties[`avg_${viewFeature}`];
|
const val = feat.properties[`avg_${dataViewFeature}`];
|
||||||
if (typeof val === 'number' && !isNaN(val)) vals.push(val);
|
if (typeof val === 'number' && !isNaN(val)) vals.push(val);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -341,7 +354,7 @@ export function useMapData({
|
||||||
if (lat < bounds.south || lat > bounds.north || lon < bounds.west || lon > bounds.east)
|
if (lat < bounds.south || lat > bounds.north || lon < bounds.west || lon > bounds.east)
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const val = item[`avg_${viewFeature}`];
|
const val = item[`avg_${dataViewFeature}`];
|
||||||
if (typeof val === 'number' && !isNaN(val)) vals.push(val);
|
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_LOW_PERCENTILE),
|
||||||
percentile(vals, COLOR_RANGE_HIGH_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
|
// Color range for the legend and hex coloring
|
||||||
const colorRange = useMemo((): [number, number] | null => {
|
const colorRange = useMemo((): [number, number] | null => {
|
||||||
if (!viewFeature) return null;
|
if (!dataViewFeature) return null;
|
||||||
|
|
||||||
// Travel time keys: use dataRange directly (no FeatureMeta)
|
// Travel time keys: use dataRange directly (no FeatureMeta)
|
||||||
if (viewFeature.startsWith('tt_')) {
|
if (dataViewFeature.startsWith('tt_')) {
|
||||||
return dataRange;
|
return dataRange;
|
||||||
}
|
}
|
||||||
|
|
||||||
const meta = features.find((f) => f.name === viewFeature);
|
const meta = features.find((f) => f.name === dataViewFeature);
|
||||||
if (!meta) return null;
|
if (!meta) return null;
|
||||||
if (meta.type === 'enum' && meta.values && meta.values.length > 0) {
|
if (meta.type === 'enum' && meta.values && meta.values.length > 0) {
|
||||||
return [0, meta.values.length - 1];
|
return [0, meta.values.length - 1];
|
||||||
|
|
@ -371,7 +384,7 @@ export function useMapData({
|
||||||
if (dataRange) return dataRange;
|
if (dataRange) return dataRange;
|
||||||
if (meta.min != null && meta.max != null) return [meta.min, meta.max];
|
if (meta.min != null && meta.max != null) return [meta.min, meta.max];
|
||||||
return null;
|
return null;
|
||||||
}, [viewFeature, features, dataRange]);
|
}, [dataViewFeature, features, dataRange]);
|
||||||
|
|
||||||
const handleViewChange = useCallback(
|
const handleViewChange = useCallback(
|
||||||
({
|
({
|
||||||
|
|
|
||||||
150
frontend/src/hooks/usePoiLayers.test.ts
Normal file
150
frontend/src/hooks/usePoiLayers.test.ts
Normal 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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
238
frontend/src/hooks/usePoiLayers.ts
Normal file
238
frontend/src/hooks/usePoiLayers.ts
Normal 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 };
|
||||||
|
}
|
||||||
68
frontend/src/hooks/useTravelTime.test.ts
Normal file
68
frontend/src/hooks/useTravelTime.test.ts
Normal 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]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -11,7 +11,7 @@ export const MODE_LABELS: Record<TransportMode, string> = {
|
||||||
car: 'Car',
|
car: 'Car',
|
||||||
bicycle: 'Bicycle',
|
bicycle: 'Bicycle',
|
||||||
walking: 'Walking',
|
walking: 'Walking',
|
||||||
transit: 'Transit',
|
transit: 'Public Transport',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const MODE_DESCRIPTIONS: Record<TransportMode, string> = {
|
export const MODE_DESCRIPTIONS: Record<TransportMode, string> = {
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ const descriptions: Record<string, Record<string, string>> = {
|
||||||
'Estimated current price': 'Estimation du prix actuel ajusté à l’inflation',
|
'Estimated current price': 'Estimation du prix actuel ajusté à l’inflation',
|
||||||
'Price per sqm': 'Prix de vente divisé par la surface totale',
|
'Price per sqm': 'Prix de vente divisé par la surface totale',
|
||||||
'Est. price per sqm': 'Prix actuel estimé 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',
|
'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',
|
'Number of bedrooms & living rooms': 'Nombre de pièces habitables selon le diagnostic EPC',
|
||||||
'Construction year': 'Année de construction estimée selon l’EPC',
|
'Construction year': 'Année de construction estimée selon l’EPC',
|
||||||
|
|
@ -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',
|
'Écoles primaires notées Bien ou Excellent par Ofsted dans un rayon de 5 km',
|
||||||
'Good+ secondary schools within 5km':
|
'Good+ secondary schools within 5km':
|
||||||
'Collèges/lycées notés Bien ou Excellent par Ofsted dans un rayon de 5 km',
|
'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':
|
'Education, Skills and Training Score':
|
||||||
'Score de qualité éducative du secteur (plus élevé = meilleur)',
|
'Score de qualité éducative du secteur (plus élevé = meilleur)',
|
||||||
'Income Score (rate)': 'Taux de précarité de revenu, inversé (plus élevé = moins précaire)',
|
'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':
|
'% Mixed':
|
||||||
'Pourcentage de la population se déclarant Métisse ou de plusieurs groupes ethniques',
|
'Pourcentage de la population se déclarant Métisse ou de plusieurs groupes ethniques',
|
||||||
'% Other': 'Pourcentage de la population se déclarant d’un autre groupe ethnique',
|
'% Other': 'Pourcentage de la population se déclarant d’un autre groupe ethnique',
|
||||||
'Winning party':
|
|
||||||
'Parti vainqueur dans la circonscription lors des élections générales de 2024',
|
|
||||||
'Voter turnout (%)':
|
'Voter turnout (%)':
|
||||||
'Pourcentage d’électeurs inscrits ayant voté aux élections générales de 2024',
|
'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',
|
'% Labour': 'Part des voix travaillistes aux élections générales de 2024',
|
||||||
'% Conservative': 'Part des voix conservatrices 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',
|
'% 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',
|
'Estimated current price': 'Inflationsbereinigter Schätzwert der Immobilie',
|
||||||
'Price per sqm': 'Verkaufspreis geteilt durch die Gesamtfläche',
|
'Price per sqm': 'Verkaufspreis geteilt durch die Gesamtfläche',
|
||||||
'Est. price per sqm': 'Geschätzter aktueller Preis 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',
|
'Total floor area (sqm)': 'Wohnfläche laut EPC-Gutachten',
|
||||||
'Number of bedrooms & living rooms': 'Anzahl bewohnbarer Räume laut EPC-Gutachten',
|
'Number of bedrooms & living rooms': 'Anzahl bewohnbarer Räume laut EPC-Gutachten',
|
||||||
'Construction year': 'Geschätztes Baujahr laut EPC',
|
'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',
|
'Von Ofsted mit Gut oder Hervorragend bewertete Grundschulen im Umkreis von 5 km',
|
||||||
'Good+ secondary schools within 5km':
|
'Good+ secondary schools within 5km':
|
||||||
'Von Ofsted mit Gut oder Hervorragend bewertete weiterführende Schulen im Umkreis von 5 km',
|
'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)',
|
'Education, Skills and Training Score': 'Bildungsqualitätsscore der Gegend (höher = besser)',
|
||||||
'Income Score (rate)':
|
'Income Score (rate)':
|
||||||
'Einkommensbenachteiligungsrate, invertiert (höher = weniger benachteiligt)',
|
'Einkommensbenachteiligungsrate, invertiert (höher = weniger benachteiligt)',
|
||||||
|
|
@ -168,12 +180,8 @@ const descriptions: Record<string, Record<string, string>> = {
|
||||||
'% Mixed':
|
'% Mixed':
|
||||||
'Anteil der Bevölkerung, der sich als gemischt oder mehreren ethnischen Gruppen zugehörig identifiziert',
|
'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',
|
'% Other': 'Anteil der Bevölkerung, der sich einer anderen ethnischen Gruppe zuordnet',
|
||||||
'Winning party':
|
|
||||||
'Siegreiche Partei im Wahlkreis bei der Parlamentswahl 2024',
|
|
||||||
'Voter turnout (%)':
|
'Voter turnout (%)':
|
||||||
'Anteil der registrierten Wähler, die bei der Parlamentswahl 2024 gewählt haben',
|
'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',
|
'% Labour': 'Labour-Stimmenanteil bei der Parlamentswahl 2024',
|
||||||
'% Conservative': 'Stimmenanteil der Konservativen bei der Parlamentswahl 2024',
|
'% Conservative': 'Stimmenanteil der Konservativen bei der Parlamentswahl 2024',
|
||||||
'% Liberal Democrat': 'Stimmenanteil der Liberaldemokraten 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': '经通胀调整后的当前估计价值',
|
'Estimated current price': '经通胀调整后的当前估计价值',
|
||||||
'Price per sqm': '售价除以总建筑面积',
|
'Price per sqm': '售价除以总建筑面积',
|
||||||
'Est. price per sqm': '估计当前价格除以总建筑面积',
|
'Est. price per sqm': '估计当前价格除以总建筑面积',
|
||||||
'Estimated monthly rent': '当地私人租赁的中位月租',
|
'Estimated monthly rent': '当地私人租赁的平均月租',
|
||||||
'Total floor area (sqm)': 'EPC评估的室内建筑面积',
|
'Total floor area (sqm)': 'EPC评估的室内建筑面积',
|
||||||
'Number of bedrooms & living rooms': 'EPC评估的宜居房间数',
|
'Number of bedrooms & living rooms': 'EPC评估的宜居房间数',
|
||||||
'Construction year': 'EPC评估的建造年份',
|
'Construction year': 'EPC评估的建造年份',
|
||||||
|
|
@ -210,6 +218,10 @@ const descriptions: Record<string, Record<string, string>> = {
|
||||||
'Good+ secondary schools within 2km': 'Ofsted评为良好或优秀的2公里内中学',
|
'Good+ secondary schools within 2km': 'Ofsted评为良好或优秀的2公里内中学',
|
||||||
'Good+ primary schools within 5km': 'Ofsted评为良好或优秀的5公里内小学',
|
'Good+ primary schools within 5km': 'Ofsted评为良好或优秀的5公里内小学',
|
||||||
'Good+ secondary 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': '当地教育质量得分(越高越好)',
|
'Education, Skills and Training Score': '当地教育质量得分(越高越好)',
|
||||||
'Income Score (rate)': '收入贫困率,反向指标(越高越不贫困)',
|
'Income Score (rate)': '收入贫困率,反向指标(越高越不贫困)',
|
||||||
'Employment Score (rate)': '就业贫困率,反向指标(越高越不贫困)',
|
'Employment Score (rate)': '就业贫困率,反向指标(越高越不贫困)',
|
||||||
|
|
@ -242,9 +254,7 @@ const descriptions: Record<string, Record<string, string>> = {
|
||||||
'% East Asian': '东亚裔人口比例',
|
'% East Asian': '东亚裔人口比例',
|
||||||
'% Mixed': '混血或多族裔人口比例',
|
'% Mixed': '混血或多族裔人口比例',
|
||||||
'% Other': '其他族裔人口比例',
|
'% Other': '其他族裔人口比例',
|
||||||
'Winning party': '2024年大选中该选区获胜的政党',
|
|
||||||
'Voter turnout (%)': '2024年大选中登记选民的投票率',
|
'Voter turnout (%)': '2024年大选中登记选民的投票率',
|
||||||
'Majority (%)': '2024年大选中获胜者的得票优势占有效票的百分比',
|
|
||||||
'% Labour': '2024年大选中工党得票率',
|
'% Labour': '2024年大选中工党得票率',
|
||||||
'% Conservative': '2024年大选中保守党得票率',
|
'% Conservative': '2024年大选中保守党得票率',
|
||||||
'% Liberal Democrat': '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',
|
'Estimated current price': 'Inflációval korrigált becsült jelenlegi érték',
|
||||||
'Price per sqm': 'Eladási ár osztva az összes alapterülettel',
|
'Price per sqm': 'Eladási ár osztva az összes alapterülettel',
|
||||||
'Est. price per sqm': 'Becsült jelenlegi á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',
|
'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',
|
'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',
|
'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',
|
'Ofsted által Jó vagy Kiváló minősítésű általános iskolák 5 km-en belül',
|
||||||
'Good+ secondary schools within 5km':
|
'Good+ secondary schools within 5km':
|
||||||
'Ofsted által Jó vagy Kiváló minősítésű középiskolák 5 km-en belül',
|
'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':
|
'Education, Skills and Training Score':
|
||||||
'A környék oktatási minőségi pontszáma (magasabb = jobb)',
|
'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)',
|
'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',
|
'% 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',
|
'% 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',
|
'% 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 (%)':
|
'Voter turnout (%)':
|
||||||
'A regisztrált választók szavazási aránya a 2024-es parlamenti választáson',
|
'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',
|
'% 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',
|
'% 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',
|
'% Liberal Democrat': 'A Liberális Demokraták szavazataránya a 2024-es parlamenti választáson',
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ export const details: Record<string, Record<string, string>> = {
|
||||||
'Est. price per sqm':
|
'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.",
|
"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':
|
'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)':
|
'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.",
|
"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':
|
'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.",
|
"É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':
|
'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.",
|
"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':
|
'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.",
|
"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)':
|
'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).",
|
"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':
|
'% 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).",
|
"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 (%)':
|
'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.",
|
"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':
|
'% 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':
|
'% 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':
|
'% 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':
|
'% 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':
|
'% 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':
|
'% 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 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.",
|
"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':
|
'Number of parks within 1km':
|
||||||
|
|
@ -154,7 +158,7 @@ export const details: Record<string, Record<string, string>> = {
|
||||||
'Est. price per sqm':
|
'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.',
|
'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':
|
'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)':
|
'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.',
|
'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':
|
'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.',
|
'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':
|
'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.',
|
'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':
|
'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.',
|
'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)':
|
'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).',
|
'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':
|
'% 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).',
|
'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 (%)':
|
'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.',
|
'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':
|
'% Labour':
|
||||||
'Prozentsatz der gültigen Stimmen für die Labour Party im Wahlkreis dieser Postleitzahl bei der britischen Parlamentswahl im Juli 2024.',
|
'Prozentsatz der gültigen Stimmen für die Labour Party im Wahlkreis dieser Postleitzahl bei der britischen Parlamentswahl im Juli 2024.',
|
||||||
'% Conservative':
|
'% Conservative':
|
||||||
|
|
@ -290,7 +298,7 @@ export const details: Record<string, Record<string, string>> = {
|
||||||
'Est. price per sqm':
|
'Est. price per sqm':
|
||||||
'用经通胀调整的估算当前价格(含装修溢价)除以EPC证书中的总建筑面积计算得出。与历史成交价格每平方米相比,提供更为最新的单位面积价格对比。',
|
'用经通胀调整的估算当前价格(含装修溢价)除以EPC证书中的总建筑面积计算得出。与历史成交价格每平方米相比,提供更为最新的单位面积价格对比。',
|
||||||
'Estimated monthly rent':
|
'Estimated monthly rent':
|
||||||
'来自ONS私人租赁市场摘要统计(2022年10月至2023年9月)的月租金中位数,按地方政府和卧室数量匹配。基于估价署租赁数据。',
|
'来自ONS私人租赁价格指数(PIPR)的平均月租金,按地方政府和卧室数量匹配。',
|
||||||
'Total floor area (sqm)':
|
'Total floor area (sqm)':
|
||||||
'在能源性能证书(EPC)评估期间测量的总可用建筑面积(平方米)。包括所有可居住房间,但不含车库、附属建筑和外部区域。',
|
'在能源性能证书(EPC)评估期间测量的总可用建筑面积(平方米)。包括所有可居住房间,但不含车库、附属建筑和外部区域。',
|
||||||
'Number of bedrooms & living rooms':
|
'Number of bedrooms & living rooms':
|
||||||
|
|
@ -317,6 +325,14 @@ export const details: Record<string, Record<string, string>> = {
|
||||||
'5km范围内Ofsted评级为"Good"或"Outstanding"的公立小学数量。尚未接受评估的学校不计入。',
|
'5km范围内Ofsted评级为"Good"或"Outstanding"的公立小学数量。尚未接受评估的学校不计入。',
|
||||||
'Good+ secondary schools within 5km':
|
'Good+ secondary schools within 5km':
|
||||||
'5km范围内Ofsted评级为"Good"或"Outstanding"的公立中学数量。尚未接受评估的学校不计入。',
|
'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':
|
'Education, Skills and Training Score':
|
||||||
'来自英格兰剥夺指数(取反后越高越好)。涵盖学校成绩、高等教育入学率、成人学历和英语水平。分数越高表示剥夺程度越低。',
|
'来自英格兰剥夺指数(取反后越高越好)。涵盖学校成绩、高等教育入学率、成人学历和英语水平。分数越高表示剥夺程度越低。',
|
||||||
'Income Score (rate)':
|
'Income Score (rate)':
|
||||||
|
|
@ -379,22 +395,14 @@ export const details: Record<string, Record<string, string>> = {
|
||||||
'来自2021年Census。地方政府人口中认同为<E5908C><E4B8BA>血或多种族群体(白人与黑人加勒比裔、白人与黑人非洲裔、白人与亚洲裔,或其他混血或多种族背景)的百分比。',
|
'来自2021年Census。地方政府人口中认同为<E5908C><E4B8BA>血或多种族群体(白人与黑人加勒比裔、白人与黑人非洲裔、白人与亚洲裔,或其他混血或多种族背景)的百分比。',
|
||||||
'% Other':
|
'% Other':
|
||||||
'来自2021年Census。地方政府人口中认同为其他族裔群体(阿拉伯人或其他未被主要类别涵盖的族裔)的百分比。',
|
'来自2021年Census。地方政府人口中认同为其他族裔群体(阿拉伯人或其他未被主要类别涵盖的族裔)的百分比。',
|
||||||
'Winning party':
|
|
||||||
'在2024年7月英国大选中,该邮编所属选区得票最多的政党。基于英国议会公布的简单多数制选举结果。选区根据2023年边界委员会审查进行了重新划分。',
|
|
||||||
'Voter turnout (%)':
|
'Voter turnout (%)':
|
||||||
'2024年7月英国大选中投出有效选票的登记选民比例。计算方式为有效票数除以选民总数。较高的投票率通常与较富裕地区和竞争更激烈的选举相关。',
|
'2024年7月英国大选中投出有效选票的登记选民比例。计算方式为有效票数除以选民总数。较高的投票率通常与较富裕地区和竞争更激烈的选举相关。',
|
||||||
'Majority (%)':
|
|
||||||
'获胜候选人与第二名之间的票数差距,以有效投票总数的百分比表示。小的多数票表示边缘选区(竞争激烈);大的多数票表示安全席位。数据来自英国议会公布的2024年7月大选结果。',
|
|
||||||
'% Labour':
|
'% Labour':
|
||||||
'2024年7月英国大选中,该邮编所属选区投给工党的有效选票百分比。包括所有工党候选人的选票。',
|
'2024年7月英国大选中,该邮编所属选区投给工党的有效选票百分比。包括所有工党候选人的选票。',
|
||||||
'% Conservative':
|
'% Conservative': '2024年7月英国大选中,该邮编所属选区投给保守党的有效选票百分比。',
|
||||||
'2024年7月英国大选中,该邮编所属选区投给保守党的有效选票百分比。',
|
'% Liberal Democrat': '2024年7月英国大选中,该邮编所属选区投给自由民主党的有效选票百分比。',
|
||||||
'% Liberal Democrat':
|
'% Reform UK': '2024年7月英国大选中,该邮编所属选区投给英国改革党的有效选票百分比。',
|
||||||
'2024年7月英国大选中,该邮编所属选区投给自由民主党的有效选票百分比。',
|
'% Green': '2024年7月英国大选中,该邮编所属选区投给绿党的有效选票百分比。',
|
||||||
'% Reform UK':
|
|
||||||
'2024年7月英国大选中,该邮编所属选区投给英国改革党的有效选票百分比。',
|
|
||||||
'% Green':
|
|
||||||
'2024年7月英国大选中,该邮编所属选区投给绿党的有效选票百分比。',
|
|
||||||
'% Other parties':
|
'% Other parties':
|
||||||
'该选区中投给工党、保守党、自由民主党、英国改革党和绿党以外政党的有效选票百分比。包括独立候选人、议长和小型政党。',
|
'该选区中投给工党、保守党、自由民主党、英国改革党和绿党以外政党的有效选票百分比。包括独立候选人、议长和小型政党。',
|
||||||
'Distance to nearest park (km)':
|
'Distance to nearest park (km)':
|
||||||
|
|
@ -424,7 +432,7 @@ export const details: Record<string, Record<string, string>> = {
|
||||||
'Est. price per sqm':
|
'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.',
|
'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':
|
'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)':
|
'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.',
|
'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':
|
'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.',
|
'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':
|
'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.',
|
'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':
|
'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.',
|
'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)':
|
'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.',
|
'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':
|
'% 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.',
|
'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 (%)':
|
'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.',
|
'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':
|
'% 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.',
|
'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':
|
'% Conservative':
|
||||||
|
|
|
||||||
|
|
@ -94,7 +94,7 @@ const de: Translations = {
|
||||||
free: 'Kostenlos',
|
free: 'Kostenlos',
|
||||||
once: '/einmalig',
|
once: '/einmalig',
|
||||||
freeForEarly: 'Kostenlos für Frühnutzer. Keine Kreditkarte erforderlich.',
|
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...',
|
redirecting: 'Weiterleitung...',
|
||||||
claimFreeAccess: 'Kostenlosen Zugang sichern',
|
claimFreeAccess: 'Kostenlosen Zugang sichern',
|
||||||
upgradeFor: 'Upgrade für {{price}}',
|
upgradeFor: 'Upgrade für {{price}}',
|
||||||
|
|
@ -259,6 +259,16 @@ const de: Translations = {
|
||||||
areaStatistics: 'Gebietsstatistiken',
|
areaStatistics: 'Gebietsstatistiken',
|
||||||
statsFor: 'Statistiken für alle Immobilien in diesem {{type}}',
|
statsFor: 'Statistiken für alle Immobilien in diesem {{type}}',
|
||||||
matchingFilters: ', die allen aktiven Filtern entsprechen',
|
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',
|
viewProperties: '{{count}} Immobilien ansehen',
|
||||||
priceHistory: 'Preisentwicklung',
|
priceHistory: 'Preisentwicklung',
|
||||||
journeysFrom: 'Verbindungen ab {{label}}',
|
journeysFrom: 'Verbindungen ab {{label}}',
|
||||||
|
|
@ -283,6 +293,8 @@ const de: Translations = {
|
||||||
// ── Street View ────────────────────────────────────
|
// ── Street View ────────────────────────────────────
|
||||||
streetView: {
|
streetView: {
|
||||||
title: 'Street View',
|
title: 'Street View',
|
||||||
|
openLarge: 'Street View größer öffnen',
|
||||||
|
expandedTitle: 'Vergrößerte Street View',
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── POI Pane ───────────────────────────────────────
|
// ── POI Pane ───────────────────────────────────────
|
||||||
|
|
@ -320,47 +332,105 @@ const de: Translations = {
|
||||||
|
|
||||||
// ── Home Page ──────────────────────────────────────
|
// ── Home Page ──────────────────────────────────────
|
||||||
home: {
|
home: {
|
||||||
heroTitle1: 'Maximaler',
|
heroEyebrow: 'Für Käufer, die fragen: „Wo soll ich überhaupt suchen?“',
|
||||||
heroTitle2: 'Wert',
|
heroTitle1: 'Finden Sie die Postleitzahlen',
|
||||||
heroTitle3: 'Minimale Kompromisse.',
|
heroTitle2: 'die zu Ihrem Leben passen',
|
||||||
|
heroTitle3: 'Nicht nur die Gegenden, die Sie schon kennen.',
|
||||||
heroSubtitle:
|
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:
|
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.',
|
'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: 'Karte entdecken',
|
exploreTheMap: 'Passende Postleitzahlen finden',
|
||||||
seeTheDifference: 'Den Unterschied sehen',
|
seeTheDifference: 'So funktioniert es',
|
||||||
statProperties: 'Immobilien',
|
showcaseHeader: 'Produktvorschau',
|
||||||
statFilters: 'Filter',
|
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',
|
statEvery: 'Jede',
|
||||||
statPostcodeInEngland: 'Postleitzahl in England',
|
statPostcodeInEngland: 'Postleitzahl in England',
|
||||||
ourPhilosophy: 'Unsere Philosophie',
|
ourPhilosophy: 'Beginnen Sie mit Ihrem Leben, nicht mit einer Postleitzahl',
|
||||||
philosophyP1:
|
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:
|
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',
|
howToUseIt: 'So funktioniert es',
|
||||||
howStep1Title: 'Lege deine Muss-Kriterien fest',
|
howStep1Title: 'Beschreiben Sie das Leben, das Sie brauchen',
|
||||||
howStep1Desc: 'Budget, Pendelweg, Schulen — die Karte zeigt nur, was passt.',
|
howStep1Desc: 'Budget, Pendelzeit, Immobilientyp, Schulen, Sicherheit, Platz und Alltag.',
|
||||||
howStep2Title: 'Entdecke Gebiete und versteckte Perlen',
|
howStep2Title: 'Passende Postleitzahlen anzeigen',
|
||||||
howStep2Desc: 'Zoom rein, schau dir Details und Kann-Kriterien an.',
|
howStep2Desc: 'Die Karte markiert Orte, die Ihre Filter erfüllen, auch unbekanntere Gegenden.',
|
||||||
howStep3Title: 'Einzelne Postleitzahlen erkunden',
|
howStep3Title: 'Die Belege prüfen',
|
||||||
howStep3Desc: 'Sieh einzelne Immobilien, Verkaufspreise, Wohnflächen und vergleiche.',
|
howStep3Desc:
|
||||||
howStep4Title: 'Engere Auswahl mit Zuversicht',
|
'Prüfen Sie Verkaufspreise, Wohnfläche, EPC, Straßenlärm, Breitband, Kriminalität und Schulen.',
|
||||||
|
howStep4Title: 'Shortlist vor der Listingsuche',
|
||||||
howStep4Desc:
|
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',
|
othersVs: 'Andere vs',
|
||||||
checkMyPostcode: '„Meine Postleitzahl prüfen“',
|
checkMyPostcode: 'Immobilienportale',
|
||||||
areaGuides: 'Gebietsratgeber',
|
areaGuides: 'Postleitzahl-Berichte',
|
||||||
compSearchWithout: 'Suchen, ohne zuerst ein Gebiet auszuwählen',
|
compSearchWithout: 'Gegenden entdecken, bevor Sie die Namen kennen',
|
||||||
compSearchWithoutSub: '(starte mit Bedürfnissen, nicht mit einem Ort)',
|
compSearchWithoutSub: '(erst Anforderungen, dann Ort)',
|
||||||
compAreaData: 'Gebietsdaten',
|
compAreaData: 'Nachbarschaftsdaten auf Postleitzahl-Ebene',
|
||||||
compAreaDataSub: '(Kriminalität, Schulen, Lärm, Breitband)',
|
compAreaDataSub: '(Kriminalität, Schulen, Lärm, Breitband, Ausstattung)',
|
||||||
compPropertyData: 'Immobilienspezifische Daten',
|
compPropertyData: 'Historie auf Immobilienebene',
|
||||||
compPropertyDataSub: '(Preis, EPC, Wohnfläche)',
|
compPropertyDataSub: '(Verkaufspreise, EPC, Wohnfläche, Schätzwert)',
|
||||||
compFilters: '56 kombinierbare Filter an einem Ort',
|
compFilters: '56 Filter, die zusammenarbeiten',
|
||||||
compFiltersSub: '(alle Einblicke, eine interaktive Karte)',
|
compFiltersSub: '(nicht eine Postleitzahl oder ein Listing nach dem anderen)',
|
||||||
ctaTitle: 'Mach aus deiner größten Investition deine klügste Entscheidung.',
|
ctaTitle: 'Hören Sie auf zu raten, wo Sie kaufen sollen.',
|
||||||
ctaDescription: 'Das verdient die richtigen Werkzeuge — überlass es nicht dem Zufall.',
|
ctaDescription:
|
||||||
|
'Erstellen Sie eine Shortlist von Postleitzahlen, die zu Ihrem echten Leben passen, und prüfen Sie sie dann vor Ort.',
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── Pricing Page ───────────────────────────────────
|
// ── Pricing Page ───────────────────────────────────
|
||||||
|
|
@ -382,7 +452,7 @@ const de: Translations = {
|
||||||
getStarted: 'Jetzt starten',
|
getStarted: 'Jetzt starten',
|
||||||
getStartedPrice: 'Jetzt starten — {{price}}',
|
getStartedPrice: 'Jetzt starten — {{price}}',
|
||||||
noCreditCard: 'Keine Kreditkarte erforderlich',
|
noCreditCard: 'Keine Kreditkarte erforderlich',
|
||||||
moneyBackGuarantee: '30 Tage Geld-zurück-Garantie',
|
|
||||||
soldOut: 'Ausverkauft',
|
soldOut: 'Ausverkauft',
|
||||||
upcoming: 'Demnächst',
|
upcoming: 'Demnächst',
|
||||||
failedToLoad: 'Preise konnten nicht geladen werden. Bitte später erneut versuchen.',
|
failedToLoad: 'Preise konnten nicht geladen werden. Bitte später erneut versuchen.',
|
||||||
|
|
@ -446,6 +516,10 @@ const de: Translations = {
|
||||||
dsOsmOrigin: 'OpenStreetMap contributors / Geofabrik',
|
dsOsmOrigin: 'OpenStreetMap contributors / Geofabrik',
|
||||||
dsOsmUse:
|
dsOsmUse:
|
||||||
'Sehenswürdigkeiten und Einrichtungen wie Geschäfte, Restaurants, Gesundheitseinrichtungen, Freizeit, Tourismus und mehr in ganz Großbritannien.',
|
'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, Sainsbury’s, Asda, Morrisons, Aldi, Lidl, Co-op, M&S, Iceland und Spar.',
|
||||||
dsGreenspaceName: 'OS Open Greenspace',
|
dsGreenspaceName: 'OS Open Greenspace',
|
||||||
dsGreenspaceOrigin: 'Ordnance Survey',
|
dsGreenspaceOrigin: 'Ordnance Survey',
|
||||||
dsGreenspaceUse:
|
dsGreenspaceUse:
|
||||||
|
|
@ -477,7 +551,7 @@ const de: Translations = {
|
||||||
dsElectionName: 'Ergebnisse der Parlamentswahl 2024',
|
dsElectionName: 'Ergebnisse der Parlamentswahl 2024',
|
||||||
dsElectionOrigin: 'Britisches Parlament',
|
dsElectionOrigin: 'Britisches Parlament',
|
||||||
dsElectionUse:
|
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
|
// FAQ section titles
|
||||||
faqFindingTitle: 'Ihr Gebiet finden',
|
faqFindingTitle: 'Ihr Gebiet finden',
|
||||||
faqCommuteTitle: 'Pendelweg und Reisezeit',
|
faqCommuteTitle: 'Pendelweg und Reisezeit',
|
||||||
|
|
@ -562,9 +636,7 @@ const de: Translations = {
|
||||||
faqPricing3Q: 'Was kann ich mit der kostenlosen Version nutzen?',
|
faqPricing3Q: 'Was kann ich mit der kostenlosen Version nutzen?',
|
||||||
faqPricing3A:
|
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.',
|
'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
|
// FAQ items — Tips and Tricks
|
||||||
faqTips1Q: 'Wie nutze ich den KI-Filter, anstatt Filter einzeln hinzuzufügen?',
|
faqTips1Q: 'Wie nutze ich den KI-Filter, anstatt Filter einzeln hinzuzufügen?',
|
||||||
faqTips1A:
|
faqTips1A:
|
||||||
|
|
@ -744,6 +816,12 @@ const de: Translations = {
|
||||||
'Good+ secondary schools within 2km': 'Gute+ weiterführende Schulen im Umkreis von 2 km',
|
'Good+ secondary schools within 2km': 'Gute+ weiterführende Schulen im Umkreis von 2 km',
|
||||||
'Good+ primary schools within 5km': 'Gute+ Grundschulen im Umkreis von 5 km',
|
'Good+ 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',
|
'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',
|
'Education, Skills and Training Score': 'Score für Bildung, Kompetenzen und Ausbildung',
|
||||||
|
|
||||||
// ─ Feature names (Deprivation) ─
|
// ─ Feature names (Deprivation) ─
|
||||||
|
|
@ -786,9 +864,7 @@ const de: Translations = {
|
||||||
'% Other': '% Sonstige',
|
'% Other': '% Sonstige',
|
||||||
|
|
||||||
// ─ Feature names (Politics) ─
|
// ─ Feature names (Politics) ─
|
||||||
'Winning party': 'Siegreiche Partei',
|
|
||||||
'Voter turnout (%)': 'Wahlbeteiligung (%)',
|
'Voter turnout (%)': 'Wahlbeteiligung (%)',
|
||||||
'Majority (%)': 'Mehrheit (%)',
|
|
||||||
'% Labour': '% Labour',
|
'% Labour': '% Labour',
|
||||||
'% Conservative': '% Conservative',
|
'% Conservative': '% Conservative',
|
||||||
'% Liberal Democrat': '% Liberal Democrat',
|
'% Liberal Democrat': '% Liberal Democrat',
|
||||||
|
|
@ -806,12 +882,6 @@ const de: Translations = {
|
||||||
'Max available download speed (Mbps)': 'Max. verfügbare Downloadgeschwindigkeit (Mbps)',
|
'Max available download speed (Mbps)': 'Max. verfügbare Downloadgeschwindigkeit (Mbps)',
|
||||||
|
|
||||||
// ─ Enum values ─
|
// ─ Enum values ─
|
||||||
Labour: 'Labour',
|
|
||||||
Conservative: 'Conservative',
|
|
||||||
'Liberal Democrat': 'Liberal Democrat',
|
|
||||||
'Reform UK': 'Reform UK',
|
|
||||||
Green: 'Grüne',
|
|
||||||
'Other parties': 'Sonstige Parteien',
|
|
||||||
Detached: 'Freistehend',
|
Detached: 'Freistehend',
|
||||||
'Semi-Detached': 'Doppelhaushälfte',
|
'Semi-Detached': 'Doppelhaushälfte',
|
||||||
Terraced: 'Reihenhaus',
|
Terraced: 'Reihenhaus',
|
||||||
|
|
@ -826,6 +896,7 @@ const de: Translations = {
|
||||||
'Serious crime': 'Schwere Straftaten',
|
'Serious crime': 'Schwere Straftaten',
|
||||||
'Minor crime': 'Leichte Straftaten',
|
'Minor crime': 'Leichte Straftaten',
|
||||||
'Ethnic composition': 'Ethnische Zusammensetzung',
|
'Ethnic composition': 'Ethnische Zusammensetzung',
|
||||||
|
'Political vote share': 'Stimmenverteilung',
|
||||||
|
|
||||||
// ─ POI group names ─
|
// ─ POI group names ─
|
||||||
'Public Transport': 'Öffentlicher Nahverkehr',
|
'Public Transport': 'Öffentlicher Nahverkehr',
|
||||||
|
|
|
||||||
|
|
@ -70,7 +70,7 @@ const en = {
|
||||||
logIn: 'Log in',
|
logIn: 'Log in',
|
||||||
createAccount: 'Create account',
|
createAccount: 'Create account',
|
||||||
resetPassword: 'Reset password',
|
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',
|
continueWithGoogle: 'Continue with Google',
|
||||||
email: 'Email',
|
email: 'Email',
|
||||||
emailPlaceholder: 'you@example.com',
|
emailPlaceholder: 'you@example.com',
|
||||||
|
|
@ -86,13 +86,13 @@ const en = {
|
||||||
|
|
||||||
// ── Upgrade Modal ──────────────────────────────────
|
// ── Upgrade Modal ──────────────────────────────────
|
||||||
upgrade: {
|
upgrade: {
|
||||||
title: 'See all of England',
|
title: 'Find every matching postcode',
|
||||||
description:
|
description:
|
||||||
"You’re currently exploring the demo area. Get lifetime access to every postcode, every filter, every neighbourhood. One payment, forever.",
|
'You’re currently exploring the demo area. Get lifetime access to every postcode, every filter, and every neighbourhood in England. One payment, forever.',
|
||||||
free: 'Free',
|
free: 'Free',
|
||||||
once: '/once',
|
once: '/once',
|
||||||
freeForEarly: 'Free for early adopters. No credit card required.',
|
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...',
|
redirecting: 'Redirecting...',
|
||||||
claimFreeAccess: 'Claim free access',
|
claimFreeAccess: 'Claim free access',
|
||||||
upgradeFor: 'Upgrade for {{price}}',
|
upgradeFor: 'Upgrade for {{price}}',
|
||||||
|
|
@ -115,7 +115,7 @@ const en = {
|
||||||
|
|
||||||
// ── License Success ────────────────────────────────
|
// ── License Success ────────────────────────────────
|
||||||
licenseSuccess: {
|
licenseSuccess: {
|
||||||
title: "You’re in.",
|
title: 'You’re in.',
|
||||||
subtitle: 'Your lifetime access is now active.',
|
subtitle: 'Your lifetime access is now active.',
|
||||||
description: 'Full access to every feature, every postcode, across all of England.',
|
description: 'Full access to every feature, every postcode, across all of England.',
|
||||||
startExploring: 'Start exploring',
|
startExploring: 'Start exploring',
|
||||||
|
|
@ -128,7 +128,7 @@ const en = {
|
||||||
findingPerfectPostcode: 'Finding the Perfect Postcode',
|
findingPerfectPostcode: 'Finding the Perfect Postcode',
|
||||||
addFiltersHint: 'Add filters below to narrow the map to areas that match your criteria',
|
addFiltersHint: 'Add filters below to narrow the map to areas that match your criteria',
|
||||||
upgradePrompt:
|
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.',
|
oneTimeLifetime: 'One-time payment, lifetime access.',
|
||||||
upgradeToFullMap: 'Upgrade to full map',
|
upgradeToFullMap: 'Upgrade to full map',
|
||||||
chooseFilters: 'Choose the filters that matter to you. The map updates as you go.',
|
chooseFilters: 'Choose the filters that matter to you. The map updates as you go.',
|
||||||
|
|
@ -184,7 +184,7 @@ const en = {
|
||||||
modeCar: 'Car',
|
modeCar: 'Car',
|
||||||
modeBicycle: 'Bicycle',
|
modeBicycle: 'Bicycle',
|
||||||
modeWalking: 'Walking',
|
modeWalking: 'Walking',
|
||||||
modeTransit: 'Transit',
|
modeTransit: 'Public Transport',
|
||||||
modeCarDesc: 'Drive time via the fastest road route',
|
modeCarDesc: 'Drive time via the fastest road route',
|
||||||
modeBicycleDesc: 'Cycling time using bike-friendly routes',
|
modeBicycleDesc: 'Cycling time using bike-friendly routes',
|
||||||
modeWalkingDesc: 'Walking time along pedestrian paths and pavements',
|
modeWalkingDesc: 'Walking time along pedestrian paths and pavements',
|
||||||
|
|
@ -204,19 +204,19 @@ const en = {
|
||||||
|
|
||||||
// ── AI Filter ──────────────────────────────────────
|
// ── AI Filter ──────────────────────────────────────
|
||||||
aiFilter: {
|
aiFilter: {
|
||||||
describeIdealArea: 'Describe your ideal area with AI',
|
describeIdealArea: 'Describe where you want to live',
|
||||||
aiSearch: 'AI Search',
|
aiSearch: 'AI Search',
|
||||||
describeHint: "describe what you’re looking for",
|
describeHint: 'describe what you’re looking for',
|
||||||
placeholder: 'e.g. quiet area, under £400k, near good schools...',
|
placeholder: 'e.g. 2-bed under £525k, 45 mins to work, quiet...',
|
||||||
example1: 'House 40 mins from Bank in a low crime area',
|
example1: '2-bed under £525k, 45 mins to work',
|
||||||
example2: 'Flats around good primary schools not too far from Manchester',
|
example2: 'Family areas near good schools under £650k',
|
||||||
example3: 'Best ex-council houses under 200k',
|
example3: 'More space with a sane commute',
|
||||||
analysing: 'Analysing your query...',
|
analysing: 'Analysing your query...',
|
||||||
searchingDestinations: 'Searching for destinations...',
|
searchingDestinations: 'Searching for destinations...',
|
||||||
generatingFilters: 'Generating filters...',
|
generatingFilters: 'Generating filters...',
|
||||||
refiningResults: 'Refining results...',
|
refiningResults: 'Refining results...',
|
||||||
weeklyLimitReached:
|
weeklyLimitReached:
|
||||||
"You’ve reached the weekly AI usage limit. It will reset automatically next week.",
|
'You’ve reached the weekly AI usage limit. It will reset automatically next week.',
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── Map Legend ─────────────────────────────────────
|
// ── Map Legend ─────────────────────────────────────
|
||||||
|
|
@ -256,6 +256,15 @@ const en = {
|
||||||
areaStatistics: 'Area Statistics',
|
areaStatistics: 'Area Statistics',
|
||||||
statsFor: 'Stats for all properties in this {{type}}',
|
statsFor: 'Stats for all properties in this {{type}}',
|
||||||
matchingFilters: ' matching all active filters',
|
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',
|
viewProperties: 'View {{count}} Properties',
|
||||||
priceHistory: 'Price History',
|
priceHistory: 'Price History',
|
||||||
journeysFrom: 'Journeys from {{label}}',
|
journeysFrom: 'Journeys from {{label}}',
|
||||||
|
|
@ -280,6 +289,8 @@ const en = {
|
||||||
// ── Street View ────────────────────────────────────
|
// ── Street View ────────────────────────────────────
|
||||||
streetView: {
|
streetView: {
|
||||||
title: 'Street View',
|
title: 'Street View',
|
||||||
|
openLarge: 'Open Street View larger',
|
||||||
|
expandedTitle: 'Expanded Street View',
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── POI Pane ───────────────────────────────────────
|
// ── POI Pane ───────────────────────────────────────
|
||||||
|
|
@ -287,7 +298,7 @@ const en = {
|
||||||
pois: 'POIs',
|
pois: 'POIs',
|
||||||
pointsOfInterest: 'Points of Interest',
|
pointsOfInterest: 'Points of Interest',
|
||||||
poiDescription:
|
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...',
|
searchCategories: 'Search categories...',
|
||||||
dataSourceInfo: 'Data source info',
|
dataSourceInfo: 'Data source info',
|
||||||
},
|
},
|
||||||
|
|
@ -317,55 +328,113 @@ const en = {
|
||||||
|
|
||||||
// ── Home Page ──────────────────────────────────────
|
// ── Home Page ──────────────────────────────────────
|
||||||
home: {
|
home: {
|
||||||
heroTitle1: 'Maximum',
|
heroEyebrow: 'For buyers asking “where should I even look?”',
|
||||||
heroTitle2: 'Value',
|
heroTitle1: 'Find the postcodes',
|
||||||
heroTitle3: 'Minimum Compromise.',
|
heroTitle2: 'that fit your life',
|
||||||
heroSubtitle: 'House hunting? Make your biggest investment your smartest move.',
|
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:
|
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.',
|
'Set your budget, commute, schools, safety, noise, broadband, and lifestyle needs. Perfect Postcode scans England’s postcodes and reveals the places that actually fit, including areas you would never have typed into a listing portal.',
|
||||||
exploreTheMap: 'Explore the map',
|
exploreTheMap: 'Find my matching postcodes',
|
||||||
seeTheDifference: 'See the difference',
|
seeTheDifference: 'See how it works',
|
||||||
statProperties: 'properties',
|
showcaseHeader: 'Product showcase',
|
||||||
statFilters: 'filters',
|
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',
|
statEvery: 'Every',
|
||||||
statPostcodeInEngland: 'postcode in England',
|
statPostcodeInEngland: 'postcode in England',
|
||||||
ourPhilosophy: 'Our philosophy',
|
ourPhilosophy: 'Start with your life, not a postcode',
|
||||||
philosophyP1:
|
philosophyP1:
|
||||||
"On Rightmove, you pick an area first, then hope it’s 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:
|
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',
|
howToUseIt: 'How to use it',
|
||||||
howStep1Title: 'Set your must-haves',
|
howStep1Title: 'Describe the life you need',
|
||||||
howStep1Desc: 'Budget, commute, schools — the map shows only what qualifies.',
|
howStep1Desc: 'Budget, commute, property type, schools, safety, space, and daily essentials.',
|
||||||
howStep2Title: 'Explore areas and discover hidden gems',
|
howStep2Title: 'Reveal matching postcodes',
|
||||||
howStep2Desc: 'Zoom in, dig into details and nice to haves.',
|
howStep2Desc:
|
||||||
howStep3Title: 'Drill into postcodes',
|
'The map highlights the places that pass your filters, including unfamiliar areas.',
|
||||||
howStep3Desc: 'See individual properties, sale prices, floor area, and compare.',
|
howStep3Title: 'Check the evidence',
|
||||||
howStep4Title: 'Shortlist with confidence',
|
howStep3Desc:
|
||||||
howStep4Desc:
|
'Inspect sold prices, floor area, EPC, road noise, broadband, crime, and schools.',
|
||||||
'Every area on your list meets your actual criteria — not just what was listed that week.',
|
howStep4Title: 'Shortlist before you browse listings',
|
||||||
|
howStep4Desc: 'Take a better search area to Rightmove, Zoopla, agents, and viewings.',
|
||||||
othersVs: 'Others vs',
|
othersVs: 'Others vs',
|
||||||
checkMyPostcode: '“Check my postcode”',
|
checkMyPostcode: 'Listing portals',
|
||||||
areaGuides: 'Area guides',
|
areaGuides: 'Postcode reports',
|
||||||
compSearchWithout: 'Search without choosing an area first',
|
compSearchWithout: 'Discover areas before you know their names',
|
||||||
compSearchWithoutSub: '(start with needs, not a location)',
|
compSearchWithoutSub: '(requirements first, location second)',
|
||||||
compAreaData: 'Area data',
|
compAreaData: 'Postcode-level neighbourhood evidence',
|
||||||
compAreaDataSub: '(crime, schools, noise, broadband)',
|
compAreaDataSub: '(crime, schools, noise, broadband, amenities)',
|
||||||
compPropertyData: 'Property-specific data',
|
compPropertyData: 'Property-level history',
|
||||||
compPropertyDataSub: '(price, EPC, floor area)',
|
compPropertyDataSub: '(sold prices, EPC, floor area, estimated value)',
|
||||||
compFilters: '56 combinable filters in one place',
|
compFilters: '56 filters working together',
|
||||||
compFiltersSub: '(all insights, one interactive map)',
|
compFiltersSub: '(not one postcode or one listing at a time)',
|
||||||
ctaTitle: 'Make your biggest investment your smartest move.',
|
ctaTitle: 'Stop guessing where to buy.',
|
||||||
ctaDescription: "This deserves proper tools behind it, don’t leave it to luck.",
|
ctaDescription:
|
||||||
|
'Build a shortlist of postcodes that fit your actual life, then test them in person.',
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── Pricing Page ───────────────────────────────────
|
// ── Pricing Page ───────────────────────────────────
|
||||||
pricingPage: {
|
pricingPage: {
|
||||||
title: 'Early access pricing',
|
title: 'Buy with a better search area',
|
||||||
subtitle: 'Pay once, access forever. The earlier you join, the less you pay.',
|
subtitle:
|
||||||
|
'Lifetime access to the map that helps you find where to look before you book viewings.',
|
||||||
costContext:
|
costContext:
|
||||||
"Buying a home costs £10k+ in stamp duty, £1,500 in solicitor fees, £500 for a survey. Get the wrong area and you’re stuck with a long commute, bad schools, or a road you didn’t know about.",
|
'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 home survey. Far more useful.',
|
lessThanSurvey: 'Less than a survey. Useful before you even choose an area.',
|
||||||
currentTier: 'Current tier',
|
currentTier: 'Current tier',
|
||||||
firstNUsers: 'First {{count}} users',
|
firstNUsers: 'First {{count}} users',
|
||||||
everyoneAfter: 'Everyone after',
|
everyoneAfter: 'Everyone after',
|
||||||
|
|
@ -378,15 +447,15 @@ const en = {
|
||||||
getStarted: 'Get started',
|
getStarted: 'Get started',
|
||||||
getStartedPrice: 'Get started - {{price}}',
|
getStartedPrice: 'Get started - {{price}}',
|
||||||
noCreditCard: 'No credit card required',
|
noCreditCard: 'No credit card required',
|
||||||
moneyBackGuarantee: '30-day money-back guarantee',
|
|
||||||
soldOut: 'Sold out',
|
soldOut: 'Sold out',
|
||||||
upcoming: 'Upcoming',
|
upcoming: 'Upcoming',
|
||||||
failedToLoad: 'Failed to load pricing. Please try again later.',
|
failedToLoad: 'Failed to load pricing. Please try again later.',
|
||||||
feat1: '56 data layers across England',
|
feat1: '56 filters across England',
|
||||||
feat2: 'Every postcode scored and filterable',
|
feat2: 'Every postcode searchable from your needs',
|
||||||
feat3: 'Unlimited map exploration and exports',
|
feat3: 'Unlimited map exploration, saved searches and exports',
|
||||||
feat4: 'Multiple decades of historical price data',
|
feat4: '13M historical transactions and price context',
|
||||||
feat5: 'Crime, schools, transport, broadband and more',
|
feat5: 'Commute, schools, crime, noise, broadband and more',
|
||||||
feat6: 'All future data updates included',
|
feat6: 'All future data updates included',
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
@ -398,7 +467,7 @@ const en = {
|
||||||
dataSourcesIntro:
|
dataSourcesIntro:
|
||||||
'This application combines {{count}} open datasets covering property prices, energy performance, transport, demographics, crime, environment, and more.',
|
'This application combines {{count}} open datasets covering property prices, energy performance, transport, demographics, crime, environment, and more.',
|
||||||
faqIntro:
|
faqIntro:
|
||||||
"Whether you’re buying, renting, or just exploring, here’s how Perfect Postcode helps you find the right area.",
|
'Whether you’re buying in London, comparing commuter towns, or sanity-checking an unfamiliar postcode, here’s how Perfect Postcode helps you work out where to look.',
|
||||||
supportIntro: 'Have a question? Check our FAQ or reach out to us directly.',
|
supportIntro: 'Have a question? Check our FAQ or reach out to us directly.',
|
||||||
source: 'Source:',
|
source: 'Source:',
|
||||||
optOut: 'Opt out of public disclosure',
|
optOut: 'Opt out of public disclosure',
|
||||||
|
|
@ -440,6 +509,10 @@ const en = {
|
||||||
dsOsmOrigin: 'OpenStreetMap contributors / Geofabrik',
|
dsOsmOrigin: 'OpenStreetMap contributors / Geofabrik',
|
||||||
dsOsmUse:
|
dsOsmUse:
|
||||||
'Points of interest covering shops, restaurants, healthcare, leisure, tourism, and more across Great Britain.',
|
'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, Sainsbury’s, Asda, Morrisons, Aldi, Lidl, Co-op, M&S, Iceland, and Spar.',
|
||||||
dsGreenspaceName: 'OS Open Greenspace',
|
dsGreenspaceName: 'OS Open Greenspace',
|
||||||
dsGreenspaceOrigin: 'Ordnance Survey',
|
dsGreenspaceOrigin: 'Ordnance Survey',
|
||||||
dsGreenspaceUse:
|
dsGreenspaceUse:
|
||||||
|
|
@ -471,7 +544,7 @@ const en = {
|
||||||
dsElectionName: '2024 General Election Results',
|
dsElectionName: '2024 General Election Results',
|
||||||
dsElectionOrigin: 'UK Parliament',
|
dsElectionOrigin: 'UK Parliament',
|
||||||
dsElectionUse:
|
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
|
// FAQ section titles
|
||||||
faqFindingTitle: 'Finding Your Area',
|
faqFindingTitle: 'Finding Your Area',
|
||||||
faqCommuteTitle: 'Commute and Travel',
|
faqCommuteTitle: 'Commute and Travel',
|
||||||
|
|
@ -483,29 +556,29 @@ const en = {
|
||||||
faqPricingTitle: 'Pricing and Access',
|
faqPricingTitle: 'Pricing and Access',
|
||||||
faqTipsTitle: 'Tips and Tricks',
|
faqTipsTitle: 'Tips and Tricks',
|
||||||
// FAQ items — Finding Your Area
|
// FAQ items — Finding Your Area
|
||||||
faqFinding1Q: "I don’t even know which areas to look at. Can this help?",
|
faqFinding1Q: 'I don’t even know which areas to look at. Can this help?',
|
||||||
faqFinding1A:
|
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.',
|
'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: "I’m moving somewhere I’ve never been. How do I even start?",
|
faqFinding2Q: 'I’m moving somewhere I’ve never been. How do I even start?',
|
||||||
faqFinding2A:
|
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?',
|
faqFinding3Q: 'How do I find areas that tick all my boxes at once?',
|
||||||
faqFinding3A:
|
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.',
|
'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
|
// FAQ items — Commute and Travel
|
||||||
faqCommute1Q: 'Can I see how long my commute would actually be from different areas?',
|
faqCommute1Q: 'Can I see how long my commute would actually be from different areas?',
|
||||||
faqCommute1A:
|
faqCommute1A:
|
||||||
"Set your workplace as a destination and we’ll colour every postcode by journey time, whether that’s by car, bike, or public transport. Filter to your max commute and the rest disappears.",
|
'Set your workplace as a destination and we’ll colour every postcode by journey time, whether that’s by car, bike, or public transport. Filter to your max commute and the rest disappears.',
|
||||||
faqCommute2Q: 'How is that better than checking Google Maps?',
|
faqCommute2Q: 'How is that better than checking Google Maps?',
|
||||||
faqCommute2A:
|
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.',
|
'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
|
// FAQ items — Budget and Value
|
||||||
faqBudget1Q: 'How do I find areas where I get the most space for my money?',
|
faqBudget1Q: 'How do I find areas where I get the most space for my money?',
|
||||||
faqBudget1A:
|
faqBudget1A:
|
||||||
"Filter by price per sqm and you’ll 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.",
|
'Filter by price per sqm and you’ll 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 isn’t cheap for a reason?",
|
faqBudget2Q: 'How do I make sure a cheap area isn’t cheap for a reason?',
|
||||||
faqBudget2A:
|
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, you’ve found genuine value, not just a low price with trade-offs you haven’t 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, you’ve found genuine value, not just a low price with trade-offs you haven’t spotted yet.',
|
||||||
// FAQ items — Safety and Neighbourhood
|
// FAQ items — Safety and Neighbourhood
|
||||||
faqSafety1Q: 'How can I check if an area is safe before I move there?',
|
faqSafety1Q: 'How can I check if an area is safe before I move there?',
|
||||||
faqSafety1A:
|
faqSafety1A:
|
||||||
|
|
@ -513,7 +586,7 @@ const en = {
|
||||||
faqSafety2Q:
|
faqSafety2Q:
|
||||||
'I keep finding flats that look great online, then the area turns out to be rough.',
|
'I keep finding flats that look great online, then the area turns out to be rough.',
|
||||||
faqSafety2A:
|
faqSafety2A:
|
||||||
"That’s 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.",
|
'That’s 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
|
// FAQ items — Families and Schools
|
||||||
faqFamilies1Q: 'Can I find areas with good schools AND low crime in one search?',
|
faqFamilies1Q: 'Can I find areas with good schools AND low crime in one search?',
|
||||||
faqFamilies1A:
|
faqFamilies1A:
|
||||||
|
|
@ -522,7 +595,7 @@ const en = {
|
||||||
faqFamilies2A:
|
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.',
|
'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
|
// FAQ items — Environment and Quality of Life
|
||||||
faqEnv1Q: "Can I find energy-efficient homes that aren’t on a noisy road?",
|
faqEnv1Q: 'Can I find energy-efficient homes that aren’t on a noisy road?',
|
||||||
faqEnv1A:
|
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.',
|
'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?',
|
faqEnv2Q: 'Does it show flood or subsidence risk?',
|
||||||
|
|
@ -534,34 +607,32 @@ const en = {
|
||||||
// FAQ items — Why Perfect Postcode
|
// FAQ items — Why Perfect Postcode
|
||||||
faqWhy1Q: 'I already use Rightmove. What does this add?',
|
faqWhy1Q: 'I already use Rightmove. What does this add?',
|
||||||
faqWhy1A:
|
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.',
|
'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: "Can’t I just research all this myself for free?",
|
faqWhy2Q: 'Can’t I just research all this myself for free?',
|
||||||
faqWhy2A:
|
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?',
|
faqWhy3Q: 'Where does the data actually come from?',
|
||||||
faqWhy3A:
|
faqWhy3A:
|
||||||
"Every dataset comes from official UK government sources: Land Registry, the EPC register, ONS, Ofsted, Ofcom, data.police.uk, and Defra. We don’t 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 don’t scrape estate agents or make anything up. You can verify any record against the original source.',
|
||||||
// FAQ items — Pricing and Access
|
// FAQ items — Pricing and Access
|
||||||
faqPricing1Q: 'Is it really worth paying for a property search tool?',
|
faqPricing1Q: 'Is it really worth paying for a property search tool?',
|
||||||
faqPricing1A:
|
faqPricing1A:
|
||||||
"Buying a home is likely the biggest purchase you’ll 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 you’ll 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?',
|
faqPricing2Q: 'Is this a subscription?',
|
||||||
faqPricing2A:
|
faqPricing2A:
|
||||||
"No. One-time payment, yours forever. Use it intensively during your search, come back whenever you’re curious about a new area, and it’s still there if you ever move again.",
|
'No. One-time payment, yours forever. Use it intensively during your search, come back whenever you’re curious about a new area, and it’s still there if you ever move again.',
|
||||||
faqPricing3Q: 'What can I access on the free tier?',
|
faqPricing3Q: 'What can I access on the free tier?',
|
||||||
faqPricing3A:
|
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.',
|
'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 you’re not satisfied, email support@perfect-postcode.co.uk within 30 days for a full refund.',
|
|
||||||
// FAQ items — Tips and Tricks
|
// FAQ items — Tips and Tricks
|
||||||
faqTips1Q: 'How do I use the AI filter instead of adding filters one by one?',
|
faqTips1Q: 'How do I use the AI filter instead of adding filters one by one?',
|
||||||
faqTips1A:
|
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?',
|
faqTips2Q: 'Can I save a search and come back to it later?',
|
||||||
faqTips2A:
|
faqTips2A:
|
||||||
'Hit the save button and everything is captured: your filters, zoom level, and which data layer you’re colouring by. Pick up exactly where you left off or share the link with your partner.',
|
'Hit the save button and everything is captured: your filters, zoom level, and which data layer you’re colouring by. Pick up exactly where you left off or share the link with your partner.',
|
||||||
faqTips3Q: "Can I export the data I’m looking at?",
|
faqTips3Q: 'Can I export the data I’m looking at?',
|
||||||
faqTips3A:
|
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.',
|
'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 ────────────────────────────────────
|
// ── Invite Page ────────────────────────────────────
|
||||||
invitePage: {
|
invitePage: {
|
||||||
youreInvited: "You’re invited!",
|
youreInvited: 'You’re invited!',
|
||||||
specialOffer: 'Special offer!',
|
specialOffer: 'Special offer!',
|
||||||
invitedByFree: '{{name}} has invited you to get free lifetime access.',
|
invitedByFree: '{{name}} has invited you to get free lifetime access.',
|
||||||
invitedByDiscount: '{{name}} has shared a 30% discount on lifetime access.',
|
invitedByDiscount: '{{name}} has shared a 30% discount on lifetime access.',
|
||||||
genericFreeInvite: 'You have been invited to get free lifetime access.',
|
genericFreeInvite: 'You have been invited to get free lifetime access.',
|
||||||
genericDiscount: 'A friend has shared a 30% discount on lifetime access.',
|
genericDiscount: 'A friend has shared a 30% discount on lifetime access.',
|
||||||
exploreEvery: 'Explore every neighbourhood in England',
|
exploreEvery: 'Find postcodes that fit your life',
|
||||||
propertyInfo: 'Property prices, energy ratings, crime stats, school ratings and more',
|
propertyInfo: 'Prices, commute, schools, crime, noise, broadband, EPC and more',
|
||||||
invalidInvite: 'Invalid invite',
|
invalidInvite: 'Invalid invite',
|
||||||
inviteAlreadyUsed: 'Invite already used',
|
inviteAlreadyUsed: 'Invite already used',
|
||||||
inviteAlreadyUsedDesc: 'This invite link has already been redeemed.',
|
inviteAlreadyUsedDesc: 'This invite link has already been redeemed.',
|
||||||
|
|
@ -674,13 +745,13 @@ const en = {
|
||||||
tutorial: {
|
tutorial: {
|
||||||
step1Title: 'Tell the map what matters',
|
step1Title: 'Tell the map what matters',
|
||||||
step1Content:
|
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',
|
step2Title: 'Or just describe it',
|
||||||
step2Content:
|
step2Content:
|
||||||
'Type what you want in plain English, like "quiet area near good schools under £400k", and we’ll set up the filters for you.',
|
'Type what you want in plain English, like "quiet area near good schools under £400k", and we’ll set up the filters for you.',
|
||||||
step3Title: 'Explore what’s out there',
|
step3Title: 'Explore what’s out there',
|
||||||
step3Content:
|
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',
|
step4Title: 'Jump to a location',
|
||||||
step4Content: 'Search for any place or postcode to fly straight there.',
|
step4Content: 'Search for any place or postcode to fly straight there.',
|
||||||
step5Title: 'Dig into the details',
|
step5Title: 'Dig into the details',
|
||||||
|
|
@ -731,6 +802,10 @@ const en = {
|
||||||
'Good+ secondary schools within 2km': 'Good+ secondary schools within 2km',
|
'Good+ secondary schools within 2km': 'Good+ secondary schools within 2km',
|
||||||
'Good+ primary schools within 5km': 'Good+ primary schools within 5km',
|
'Good+ primary schools within 5km': 'Good+ primary schools within 5km',
|
||||||
'Good+ secondary schools within 5km': 'Good+ secondary 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',
|
'Education, Skills and Training Score': 'Education, Skills and Training Score',
|
||||||
|
|
||||||
// ─ Feature names (Deprivation) ─
|
// ─ Feature names (Deprivation) ─
|
||||||
|
|
@ -771,9 +846,7 @@ const en = {
|
||||||
'% Other': '% Other',
|
'% Other': '% Other',
|
||||||
|
|
||||||
// ─ Feature names (Politics) ─
|
// ─ Feature names (Politics) ─
|
||||||
'Winning party': 'Winning party',
|
|
||||||
'Voter turnout (%)': 'Voter turnout (%)',
|
'Voter turnout (%)': 'Voter turnout (%)',
|
||||||
'Majority (%)': 'Majority (%)',
|
|
||||||
'% Labour': '% Labour',
|
'% Labour': '% Labour',
|
||||||
'% Conservative': '% Conservative',
|
'% Conservative': '% Conservative',
|
||||||
'% Liberal Democrat': '% Liberal Democrat',
|
'% Liberal Democrat': '% Liberal Democrat',
|
||||||
|
|
@ -791,12 +864,6 @@ const en = {
|
||||||
'Max available download speed (Mbps)': 'Max available download speed (Mbps)',
|
'Max available download speed (Mbps)': 'Max available download speed (Mbps)',
|
||||||
|
|
||||||
// ─ Enum values ─
|
// ─ Enum values ─
|
||||||
Labour: 'Labour',
|
|
||||||
Conservative: 'Conservative',
|
|
||||||
'Liberal Democrat': 'Liberal Democrat',
|
|
||||||
'Reform UK': 'Reform UK',
|
|
||||||
Green: 'Green',
|
|
||||||
'Other parties': 'Other parties',
|
|
||||||
Detached: 'Detached',
|
Detached: 'Detached',
|
||||||
'Semi-Detached': 'Semi-Detached',
|
'Semi-Detached': 'Semi-Detached',
|
||||||
Terraced: 'Terraced',
|
Terraced: 'Terraced',
|
||||||
|
|
@ -811,6 +878,7 @@ const en = {
|
||||||
'Serious crime': 'Serious crime',
|
'Serious crime': 'Serious crime',
|
||||||
'Minor crime': 'Minor crime',
|
'Minor crime': 'Minor crime',
|
||||||
'Ethnic composition': 'Ethnic composition',
|
'Ethnic composition': 'Ethnic composition',
|
||||||
|
'Political vote share': 'Political vote share',
|
||||||
|
|
||||||
// ─ POI group names ─
|
// ─ POI group names ─
|
||||||
'Public Transport': 'Public Transport',
|
'Public Transport': 'Public Transport',
|
||||||
|
|
|
||||||
|
|
@ -89,17 +89,17 @@ const fr: Translations = {
|
||||||
|
|
||||||
// ── Upgrade Modal ──────────────────────────────────
|
// ── Upgrade Modal ──────────────────────────────────
|
||||||
upgrade: {
|
upgrade: {
|
||||||
title: "Découvrez toute l’Angleterre",
|
title: 'Découvrez toute l’Angleterre',
|
||||||
description:
|
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.',
|
'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',
|
free: 'Gratuit',
|
||||||
once: '/unique',
|
once: '/unique',
|
||||||
freeForEarly: 'Gratuit pour les premiers utilisateurs. Aucune carte bancaire requise.',
|
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...',
|
redirecting: 'Redirection...',
|
||||||
claimFreeAccess: "Réclamer l’accès gratuit",
|
claimFreeAccess: 'Réclamer l’accès gratuit',
|
||||||
upgradeFor: 'Passer à la version complète pour {{price}}',
|
upgradeFor: 'Passer à la version complète pour {{price}}',
|
||||||
registerAndUpgrade: "S’inscrire et passer à la version complète",
|
registerAndUpgrade: 'S’inscrire et passer à la version complète',
|
||||||
alreadyHaveAccount: 'Vous avez déjà un compte ? Connectez-vous',
|
alreadyHaveAccount: 'Vous avez déjà un compte ? Connectez-vous',
|
||||||
continueWithDemo: 'Continuer avec la démo',
|
continueWithDemo: 'Continuer avec la démo',
|
||||||
checkoutFailed: 'Échec du paiement',
|
checkoutFailed: 'Échec du paiement',
|
||||||
|
|
@ -118,10 +118,10 @@ const fr: Translations = {
|
||||||
|
|
||||||
// ── License Success ────────────────────────────────
|
// ── License Success ────────────────────────────────
|
||||||
licenseSuccess: {
|
licenseSuccess: {
|
||||||
title: "C’est fait.",
|
title: 'C’est fait.',
|
||||||
subtitle: 'Votre accès à vie est maintenant actif.',
|
subtitle: 'Votre accès à vie est maintenant actif.',
|
||||||
description:
|
description:
|
||||||
"Accès complet à chaque fonctionnalité, chaque code postal, dans toute l’Angleterre.",
|
'Accès complet à chaque fonctionnalité, chaque code postal, dans toute l’Angleterre.',
|
||||||
startExploring: 'Commencer à explorer',
|
startExploring: 'Commencer à explorer',
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
@ -133,7 +133,7 @@ const fr: Translations = {
|
||||||
addFiltersHint:
|
addFiltersHint:
|
||||||
'Ajoutez des filtres ci-dessous pour restreindre la carte aux zones correspondant à vos critères',
|
'Ajoutez des filtres ci-dessous pour restreindre la carte aux zones correspondant à vos critères',
|
||||||
upgradePrompt:
|
upgradePrompt:
|
||||||
"Voir la criminalité, les écoles, le bruit, le débit internet et plus de 50 filtres dans toute l’Angleterre.",
|
'Voir la criminalité, les écoles, le bruit, le débit internet et plus de 50 filtres dans toute l’Angleterre.',
|
||||||
oneTimeLifetime: 'Paiement unique, accès à vie.',
|
oneTimeLifetime: 'Paiement unique, accès à vie.',
|
||||||
upgradeToFullMap: 'Passer à la carte complète',
|
upgradeToFullMap: 'Passer à la carte complète',
|
||||||
chooseFilters:
|
chooseFilters:
|
||||||
|
|
@ -168,7 +168,7 @@ const fr: Translations = {
|
||||||
step5Desc: '(restaurants, parcs, débit internet)',
|
step5Desc: '(restaurants, parcs, débit internet)',
|
||||||
step6Title: 'Énergie',
|
step6Title: 'Énergie',
|
||||||
step6Desc: '(classements DPE, isolation, coûts de chauffage)',
|
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 d’options.",
|
tip: 'Astuce : si rien ne correspond, assouplissez un critère à la fois pour voir quel compromis ouvre le plus d’options.',
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── Travel Time ────────────────────────────────────
|
// ── Travel Time ────────────────────────────────────
|
||||||
|
|
@ -179,9 +179,9 @@ const fr: Translations = {
|
||||||
bestCase: 'Meilleur cas',
|
bestCase: 'Meilleur cas',
|
||||||
bestCaseTitle: 'Meilleur temps de trajet',
|
bestCaseTitle: 'Meilleur temps de trajet',
|
||||||
bestCaseDesc:
|
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 l’heure 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 l’heure de départ.',
|
||||||
previewOnMap: 'Aperçu sur la carte',
|
previewOnMap: 'Aperçu sur la carte',
|
||||||
stopPreviewing: "Arrêter l’aperçu",
|
stopPreviewing: 'Arrêter l’aperçu',
|
||||||
removeTravelTime: 'Supprimer le temps de trajet',
|
removeTravelTime: 'Supprimer le temps de trajet',
|
||||||
addTravelTime: 'Ajouter le temps de trajet en {{mode}}',
|
addTravelTime: 'Ajouter le temps de trajet en {{mode}}',
|
||||||
clearDestination: 'Effacer la destination',
|
clearDestination: 'Effacer la destination',
|
||||||
|
|
@ -263,6 +263,16 @@ const fr: Translations = {
|
||||||
areaStatistics: 'Statistiques de la zone',
|
areaStatistics: 'Statistiques de la zone',
|
||||||
statsFor: 'Statistiques pour toutes les propriétés de ce/cette {{type}}',
|
statsFor: 'Statistiques pour toutes les propriétés de ce/cette {{type}}',
|
||||||
matchingFilters: ' correspondant à tous les filtres actifs',
|
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é n’a é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',
|
viewProperties: 'Voir {{count}} propriétés',
|
||||||
priceHistory: 'Historique des prix',
|
priceHistory: 'Historique des prix',
|
||||||
journeysFrom: 'Trajets depuis {{label}}',
|
journeysFrom: 'Trajets depuis {{label}}',
|
||||||
|
|
@ -287,14 +297,16 @@ const fr: Translations = {
|
||||||
// ── Street View ────────────────────────────────────
|
// ── Street View ────────────────────────────────────
|
||||||
streetView: {
|
streetView: {
|
||||||
title: 'Street View',
|
title: 'Street View',
|
||||||
|
openLarge: 'Ouvrir Street View en grand',
|
||||||
|
expandedTitle: 'Street View agrandi',
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── POI Pane ───────────────────────────────────────
|
// ── POI Pane ───────────────────────────────────────
|
||||||
poiPane: {
|
poiPane: {
|
||||||
pois: 'POI',
|
pois: 'POI',
|
||||||
pointsOfInterest: "Points d’intérêt",
|
pointsOfInterest: 'Points d’intérêt',
|
||||||
poiDescription:
|
poiDescription:
|
||||||
"Données issues d’OpenStreetMap. 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 d’OpenStreetMap. 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...',
|
searchCategories: 'Rechercher des catégories...',
|
||||||
dataSourceInfo: 'Informations sur la source',
|
dataSourceInfo: 'Informations sur la source',
|
||||||
},
|
},
|
||||||
|
|
@ -313,7 +325,7 @@ const fr: Translations = {
|
||||||
lookupFailed: 'Échec de la recherche',
|
lookupFailed: 'Échec de la recherche',
|
||||||
searchLabel: 'Rechercher des lieux ou codes postaux',
|
searchLabel: 'Rechercher des lieux ou codes postaux',
|
||||||
locateMe: 'Aller à ma position',
|
locateMe: 'Aller à ma position',
|
||||||
geolocationUnsupported: "La géolocalisation n’est pas prise en charge par votre navigateur",
|
geolocationUnsupported: 'La géolocalisation n’est pas prise en charge par votre navigateur',
|
||||||
geolocationFailed: 'Impossible de déterminer votre position',
|
geolocationFailed: 'Impossible de déterminer votre position',
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
@ -324,48 +336,107 @@ const fr: Translations = {
|
||||||
|
|
||||||
// ── Home Page ──────────────────────────────────────
|
// ── Home Page ──────────────────────────────────────
|
||||||
home: {
|
home: {
|
||||||
heroTitle1: 'Valeur',
|
heroEyebrow: 'Pour les acheteurs qui se demandent « où chercher ? »',
|
||||||
heroTitle2: 'Maximale',
|
heroTitle1: 'Trouvez les codes postaux',
|
||||||
heroTitle3: 'Compromis Minimum.',
|
heroTitle2: 'qui correspondent à votre vie',
|
||||||
|
heroTitle3: 'Pas seulement les quartiers que vous connaissez déjà.',
|
||||||
heroSubtitle:
|
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, l’Angleterre compte trop de lieux pour les rechercher un par un.',
|
||||||
heroDescription:
|
heroDescription:
|
||||||
"Tant d’options — 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.",
|
'Définissez votre budget, trajet, écoles, sécurité, bruit, débit internet et style de vie. Perfect Postcode analyse les codes postaux d’Angleterre et révèle les lieux qui correspondent vraiment, y compris ceux que vous n’auriez jamais cherchés sur un portail immobilier.',
|
||||||
exploreTheMap: 'Explorer la carte',
|
exploreTheMap: 'Trouver mes codes postaux',
|
||||||
seeTheDifference: 'Voir la différence',
|
seeTheDifference: 'Voir comment ça marche',
|
||||||
statProperties: 'propriétés',
|
showcaseHeader: 'Aperçu du produit',
|
||||||
statFilters: 'filtres',
|
showcaseContext: 'Recherche d’acheteur 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 jusqu’au 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 n’aviez 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 d’espace',
|
||||||
|
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',
|
statEvery: 'Chaque',
|
||||||
statPostcodeInEngland: "code postal d’Angleterre",
|
statPostcodeInEngland: 'code postal d’Angleterre',
|
||||||
ourPhilosophy: 'Notre philosophie',
|
ourPhilosophy: 'Commencez par votre vie, pas par un code postal',
|
||||||
philosophyP1:
|
philosophyP1:
|
||||||
"Sur Rightmove, vous choisissez d’abord une zone, puis vous espérez qu’elle convient. Vous finissez par croiser statistiques de criminalité, rapports scolaires et tests de débit sur une dizaine d’onglets, un code postal à la fois.",
|
'La plupart des sites immobiliers demandent où vous voulez vivre. À Londres, c’est particulièrement difficile, mais le même problème existe partout en Angleterre : les acheteurs partent des quelques lieux qu’ils connaissent, puis vérifient séparément trajets, écoles, criminalité, Street View, débit internet et prix vendus.',
|
||||||
philosophyP2:
|
philosophyP2:
|
||||||
"Nous inversons la logique. Dites-nous ce qu’il vous faut (budget, trajet, écoles, sécurité) et nous vous montrons chaque zone d’Angleterre 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 d’abord, puis allez tester l’ambiance.',
|
||||||
|
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 l’utiliser',
|
howToUseIt: 'Comment l’utiliser',
|
||||||
howStep1Title: 'Définissez vos indispensables',
|
howStep1Title: 'Décrivez la vie dont vous avez besoin',
|
||||||
howStep1Desc: 'Budget, trajet, écoles — la carte n’affiche que ce qui correspond.',
|
howStep1Desc:
|
||||||
howStep2Title: 'Explorez les zones et découvrez des pépites cachées',
|
'Budget, trajet, type de bien, écoles, sécurité, surface et essentiels du quotidien.',
|
||||||
howStep2Desc: 'Zoomez, examinez les détails et les critères secondaires.',
|
howStep2Title: 'Révélez les codes postaux compatibles',
|
||||||
howStep3Title: 'Plongez dans les codes postaux',
|
howStep2Desc:
|
||||||
|
'La carte met en évidence les lieux qui passent vos filtres, y compris les zones moins connues.',
|
||||||
|
howStep3Title: 'Vérifiez les preuves',
|
||||||
howStep3Desc:
|
howStep3Desc:
|
||||||
'Consultez les propriétés individuelles, les prix de vente, la surface et comparez.',
|
'Consultez prix vendus, surface, DPE, bruit routier, débit internet, criminalité et écoles.',
|
||||||
howStep4Title: 'Constituez votre sélection en toute confiance',
|
howStep4Title: 'Faites votre sélection avant les annonces',
|
||||||
howStep4Desc:
|
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',
|
othersVs: 'Les autres vs',
|
||||||
checkMyPostcode: '« Vérifier mon code postal »',
|
checkMyPostcode: 'Portails d’annonces',
|
||||||
areaGuides: 'Guides de quartier',
|
areaGuides: 'Rapports de code postal',
|
||||||
compSearchWithout: "Rechercher sans d’abord choisir une zone",
|
compSearchWithout: 'Découvrir des zones avant d’en connaître le nom',
|
||||||
compSearchWithoutSub: "(partir de ses besoins, pas d’un lieu)",
|
compSearchWithoutSub: '(besoins d’abord, lieu ensuite)',
|
||||||
compAreaData: 'Données de la zone',
|
compAreaData: 'Preuves au niveau du code postal',
|
||||||
compAreaDataSub: '(criminalité, écoles, bruit, débit internet)',
|
compAreaDataSub: '(criminalité, écoles, bruit, débit internet, services)',
|
||||||
compPropertyData: 'Données par propriété',
|
compPropertyData: 'Historique par propriété',
|
||||||
compPropertyDataSub: '(prix, DPE, surface)',
|
compPropertyDataSub: '(prix vendus, DPE, surface, valeur estimée)',
|
||||||
compFilters: '56 filtres combinables en un seul endroit',
|
compFilters: '56 filtres qui fonctionnent ensemble',
|
||||||
compFiltersSub: '(toutes les informations, une seule carte interactive)',
|
compFiltersSub: '(pas un code postal ou une annonce à la fois)',
|
||||||
ctaTitle: 'Faites de votre plus gros investissement votre meilleure décision.',
|
ctaTitle: 'Arrêtez de deviner où acheter.',
|
||||||
ctaDescription: 'Un tel enjeu mérite de vrais outils, ne laissez pas la chance décider.',
|
ctaDescription:
|
||||||
|
'Construisez une sélection de codes postaux adaptés à votre vraie vie, puis allez les tester sur place.',
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── Pricing Page ───────────────────────────────────
|
// ── Pricing Page ───────────────────────────────────
|
||||||
|
|
@ -373,8 +444,8 @@ const fr: Translations = {
|
||||||
title: 'Tarifs early access',
|
title: 'Tarifs early access',
|
||||||
subtitle: 'Payez une fois, accédez pour toujours. Plus vous rejoignez tôt, moins vous payez.',
|
subtitle: 'Payez une fois, accédez pour toujours. Plus vous rejoignez tôt, moins vous payez.',
|
||||||
costContext:
|
costContext:
|
||||||
"L’achat d’un 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 l’existence.",
|
'L’achat d’un 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 l’existence.',
|
||||||
lessThanSurvey: "Moins cher qu’une expertise immobilière. Bien plus utile.",
|
lessThanSurvey: 'Moins cher qu’une expertise immobilière. Bien plus utile.',
|
||||||
currentTier: 'Palier actuel',
|
currentTier: 'Palier actuel',
|
||||||
firstNUsers: '{{count}} premiers utilisateurs',
|
firstNUsers: '{{count}} premiers utilisateurs',
|
||||||
everyoneAfter: 'Tous les suivants',
|
everyoneAfter: 'Tous les suivants',
|
||||||
|
|
@ -387,11 +458,11 @@ const fr: Translations = {
|
||||||
getStarted: 'Commencer',
|
getStarted: 'Commencer',
|
||||||
getStartedPrice: 'Commencer - {{price}}',
|
getStartedPrice: 'Commencer - {{price}}',
|
||||||
noCreditCard: 'Aucune carte bancaire requise',
|
noCreditCard: 'Aucune carte bancaire requise',
|
||||||
moneyBackGuarantee: 'Garantie satisfait ou remboursé sous 30 jours',
|
|
||||||
soldOut: 'Épuisé',
|
soldOut: 'Épuisé',
|
||||||
upcoming: 'À venir',
|
upcoming: 'À venir',
|
||||||
failedToLoad: 'Échec du chargement des tarifs. Veuillez réessayer plus tard.',
|
failedToLoad: 'Échec du chargement des tarifs. Veuillez réessayer plus tard.',
|
||||||
feat1: "56 couches de données à travers l’Angleterre",
|
feat1: '56 couches de données à travers l’Angleterre',
|
||||||
feat2: 'Chaque code postal noté et filtrable',
|
feat2: 'Chaque code postal noté et filtrable',
|
||||||
feat3: 'Exploration de la carte et exportations illimitées',
|
feat3: 'Exploration de la carte et exportations illimitées',
|
||||||
feat4: 'Plusieurs décennies de données historiques de prix',
|
feat4: 'Plusieurs décennies de données historiques de prix',
|
||||||
|
|
@ -450,6 +521,10 @@ const fr: Translations = {
|
||||||
dsOsmOrigin: 'OpenStreetMap contributors / Geofabrik',
|
dsOsmOrigin: 'OpenStreetMap contributors / Geofabrik',
|
||||||
dsOsmUse:
|
dsOsmUse:
|
||||||
'Points d’intérêt couvrant commerces, restaurants, santé, loisirs, tourisme et plus à travers la Grande-Bretagne.',
|
'Points d’inté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, Sainsbury’s, Asda, Morrisons, Aldi, Lidl, Co-op, M&S, Iceland et Spar.',
|
||||||
dsGreenspaceName: 'OS Open Greenspace',
|
dsGreenspaceName: 'OS Open Greenspace',
|
||||||
dsGreenspaceOrigin: 'Ordnance Survey',
|
dsGreenspaceOrigin: 'Ordnance Survey',
|
||||||
dsGreenspaceUse:
|
dsGreenspaceUse:
|
||||||
|
|
@ -481,7 +556,7 @@ const fr: Translations = {
|
||||||
dsElectionName: 'Résultats des élections générales 2024',
|
dsElectionName: 'Résultats des élections générales 2024',
|
||||||
dsElectionOrigin: 'Parlement britannique',
|
dsElectionOrigin: 'Parlement britannique',
|
||||||
dsElectionUse:
|
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
|
// FAQ section titles
|
||||||
faqFindingTitle: 'Trouver votre quartier',
|
faqFindingTitle: 'Trouver votre quartier',
|
||||||
faqCommuteTitle: 'Trajet et déplacements',
|
faqCommuteTitle: 'Trajet et déplacements',
|
||||||
|
|
@ -565,9 +640,7 @@ const fr: Translations = {
|
||||||
faqPricing3Q: 'Que puis-je faire avec la version gratuite ?',
|
faqPricing3Q: 'Que puis-je faire avec la version gratuite ?',
|
||||||
faqPricing3A:
|
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 l’Angleterre, il faut l’accès à vie.',
|
'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 l’Angleterre, il faut l’accè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
|
// FAQ items — Tips and Tricks
|
||||||
faqTips1Q: 'Comment utiliser le filtre IA au lieu d’ajouter les filtres un par un ?',
|
faqTips1Q: 'Comment utiliser le filtre IA au lieu d’ajouter les filtres un par un ?',
|
||||||
faqTips1A:
|
faqTips1A:
|
||||||
|
|
@ -615,16 +688,16 @@ const fr: Translations = {
|
||||||
|
|
||||||
// ── Invites Page ───────────────────────────────────
|
// ── Invites Page ───────────────────────────────────
|
||||||
invitesPage: {
|
invitesPage: {
|
||||||
inviteLinksLicensed: "Les liens d’invitation sont disponibles pour les utilisateurs licenciés.",
|
inviteLinksLicensed: 'Les liens d’invitation sont disponibles pour les utilisateurs licenciés.',
|
||||||
inviteAdminLabel: 'Inviter des amis (100% de réduction)',
|
inviteAdminLabel: 'Inviter des amis (100% de réduction)',
|
||||||
inviteReferralLabel: 'Inviter des amis (30% de réduction)',
|
inviteReferralLabel: 'Inviter des amis (30% de réduction)',
|
||||||
generateFreeInvite: "Générer un lien d’invitation gratuit",
|
generateFreeInvite: 'Générer un lien d’invitation gratuit',
|
||||||
generateReferralLink: 'Générer un lien de parrainage',
|
generateReferralLink: 'Générer un lien de parrainage',
|
||||||
copyInviteLink: "Copier le lien d’invitation",
|
copyInviteLink: 'Copier le lien d’invitation',
|
||||||
adminInvitesTitle: 'Invitations admin (100% de réduction)',
|
adminInvitesTitle: 'Invitations admin (100% de réduction)',
|
||||||
referralInvitesTitle: 'Invitations de parrainage (30% de réduction)',
|
referralInvitesTitle: 'Invitations de parrainage (30% de réduction)',
|
||||||
yourInviteLinks: "Vos liens d’invitation",
|
yourInviteLinks: 'Vos liens d’invitation',
|
||||||
noInvitesYet: "Aucune invitation générée pour l’instant",
|
noInvitesYet: 'Aucune invitation générée pour l’instant',
|
||||||
link: 'Lien',
|
link: 'Lien',
|
||||||
status: 'Statut',
|
status: 'Statut',
|
||||||
created: 'Créé',
|
created: 'Créé',
|
||||||
|
|
@ -637,26 +710,26 @@ const fr: Translations = {
|
||||||
youreInvited: 'Vous êtes invité !',
|
youreInvited: 'Vous êtes invité !',
|
||||||
specialOffer: 'Offre spéciale !',
|
specialOffer: 'Offre spéciale !',
|
||||||
invitedByFree: '{{name}} vous invite à obtenir un accès à vie gratuit.',
|
invitedByFree: '{{name}} vous invite à obtenir un accès à vie gratuit.',
|
||||||
invitedByDiscount: "{{name}} vous fait bénéficier d’une réduction de 30% sur l’accès à vie.",
|
invitedByDiscount: '{{name}} vous fait bénéficier d’une réduction de 30% sur l’accès à vie.',
|
||||||
genericFreeInvite: 'Vous avez été invité à obtenir un accès à vie gratuit.',
|
genericFreeInvite: 'Vous avez été invité à obtenir un accès à vie gratuit.',
|
||||||
genericDiscount: "Un ami vous fait bénéficier d’une réduction de 30% sur l’accès à vie.",
|
genericDiscount: 'Un ami vous fait bénéficier d’une réduction de 30% sur l’accès à vie.',
|
||||||
exploreEvery: "Explorez chaque quartier d’Angleterre",
|
exploreEvery: 'Explorez chaque quartier d’Angleterre',
|
||||||
propertyInfo:
|
propertyInfo:
|
||||||
'Prix immobiliers, classements énergétiques, statistiques de criminalité, notes des écoles et plus encore',
|
'Prix immobiliers, classements énergétiques, statistiques de criminalité, notes des écoles et plus encore',
|
||||||
invalidInvite: 'Invitation invalide',
|
invalidInvite: 'Invitation invalide',
|
||||||
inviteAlreadyUsed: 'Invitation déjà utilisée',
|
inviteAlreadyUsed: 'Invitation déjà utilisée',
|
||||||
inviteAlreadyUsedDesc: "Ce lien d’invitation a déjà été utilisé.",
|
inviteAlreadyUsedDesc: 'Ce lien d’invitation a déjà été utilisé.',
|
||||||
invalidInviteLink: "Lien d’invitation invalide",
|
invalidInviteLink: 'Lien d’invitation invalide',
|
||||||
invalidInviteLinkDesc: "Ce lien d’invitation est invalide ou a expiré.",
|
invalidInviteLinkDesc: 'Ce lien d’invitation est invalide ou a expiré.',
|
||||||
licenseActivated: 'Licence activée !',
|
licenseActivated: 'Licence activée !',
|
||||||
fullAccessGranted: 'Vous avez désormais un accès complet à Perfect Postcode.',
|
fullAccessGranted: 'Vous avez désormais un accès complet à Perfect Postcode.',
|
||||||
activating: 'Activation...',
|
activating: 'Activation...',
|
||||||
activateLicense: 'Activer la licence',
|
activateLicense: 'Activer la licence',
|
||||||
claimDiscount: 'Réclamer la réduction',
|
claimDiscount: 'Réclamer la réduction',
|
||||||
registerToClaim: "S’inscrire pour réclamer",
|
registerToClaim: 'S’inscrire pour réclamer',
|
||||||
youAlreadyHaveLicense: 'Vous avez déjà une licence',
|
youAlreadyHaveLicense: 'Vous avez déjà une licence',
|
||||||
accountHasFullAccess: 'Votre compte dispose déjà d’un accès complet.',
|
accountHasFullAccess: 'Votre compte dispose déjà d’un accès complet.',
|
||||||
failedToValidate: "Échec de la validation du lien d’invitation",
|
failedToValidate: 'Échec de la validation du lien d’invitation',
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── Map Page ───────────────────────────────────────
|
// ── 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+ 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+ 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',
|
'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',
|
'Education, Skills and Training Score': 'Score éducation, compétences et formation',
|
||||||
|
|
||||||
// ─ Feature names (Deprivation) ─
|
// ─ Feature names (Deprivation) ─
|
||||||
|
|
@ -787,9 +864,7 @@ const fr: Translations = {
|
||||||
'% Other': '% Autres',
|
'% Other': '% Autres',
|
||||||
|
|
||||||
// ─ Feature names (Politics) ─
|
// ─ Feature names (Politics) ─
|
||||||
'Winning party': 'Parti vainqueur',
|
|
||||||
'Voter turnout (%)': 'Participation électorale (%)',
|
'Voter turnout (%)': 'Participation électorale (%)',
|
||||||
'Majority (%)': 'Majorité (%)',
|
|
||||||
'% Labour': '% Travaillistes',
|
'% Labour': '% Travaillistes',
|
||||||
'% Conservative': '% Conservateurs',
|
'% Conservative': '% Conservateurs',
|
||||||
'% Liberal Democrat': '% Libéraux-démocrates',
|
'% Liberal Democrat': '% Libéraux-démocrates',
|
||||||
|
|
@ -807,12 +882,6 @@ const fr: Translations = {
|
||||||
'Max available download speed (Mbps)': 'Débit descendant max. disponible (Mbps)',
|
'Max available download speed (Mbps)': 'Débit descendant max. disponible (Mbps)',
|
||||||
|
|
||||||
// ─ Enum values ─
|
// ─ Enum values ─
|
||||||
Labour: 'Travailliste',
|
|
||||||
Conservative: 'Conservateur',
|
|
||||||
'Liberal Democrat': 'Libéral-démocrate',
|
|
||||||
'Reform UK': 'Reform UK',
|
|
||||||
Green: 'Vert',
|
|
||||||
'Other parties': 'Autres partis',
|
|
||||||
Detached: 'Individuelle',
|
Detached: 'Individuelle',
|
||||||
'Semi-Detached': 'Jumelée',
|
'Semi-Detached': 'Jumelée',
|
||||||
Terraced: 'Mitoyenne',
|
Terraced: 'Mitoyenne',
|
||||||
|
|
@ -827,6 +896,7 @@ const fr: Translations = {
|
||||||
'Serious crime': 'Crimes graves',
|
'Serious crime': 'Crimes graves',
|
||||||
'Minor crime': 'Délits mineurs',
|
'Minor crime': 'Délits mineurs',
|
||||||
'Ethnic composition': 'Composition ethnique',
|
'Ethnic composition': 'Composition ethnique',
|
||||||
|
'Political vote share': 'Répartition des voix',
|
||||||
|
|
||||||
// ─ POI group names ─
|
// ─ POI group names ─
|
||||||
'Public Transport': 'Transports en commun',
|
'Public Transport': 'Transports en commun',
|
||||||
|
|
|
||||||
|
|
@ -95,8 +95,7 @@ const hu: Translations = {
|
||||||
free: 'Ingyenes',
|
free: 'Ingyenes',
|
||||||
once: '/egyszeri',
|
once: '/egyszeri',
|
||||||
freeForEarly: 'Ingyenes a korai felhasználóknak. Nem szükséges bankkartya.',
|
freeForEarly: 'Ingyenes a korai felhasználóknak. Nem szükséges bankkartya.',
|
||||||
oneTimePayment:
|
oneTimePayment: 'Egyszeri fizetés. Élethosszig tartó hozzáférés.',
|
||||||
'Egyszeri fizetés. Élethosszig tartó hozzáférés. 30 napos pénzvisszatérítési garancia.',
|
|
||||||
redirecting: 'Átirányítás...',
|
redirecting: 'Átirányítás...',
|
||||||
claimFreeAccess: 'Ingyenes hozzáférés igénylése',
|
claimFreeAccess: 'Ingyenes hozzáférés igénylése',
|
||||||
upgradeFor: 'Frissítés {{price}} áron',
|
upgradeFor: 'Frissítés {{price}} áron',
|
||||||
|
|
@ -258,6 +257,15 @@ const hu: Translations = {
|
||||||
areaStatistics: 'Területi statisztikák',
|
areaStatistics: 'Területi statisztikák',
|
||||||
statsFor: 'Statisztikák a(z) {{type}} összes ingatlanáról',
|
statsFor: 'Statisztikák a(z) {{type}} összes ingatlanáról',
|
||||||
matchingFilters: ' az összes aktív szűrőnek megfelelően',
|
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',
|
viewProperties: '{{count}} ingatlan megtekintése',
|
||||||
priceHistory: 'Ártörténet',
|
priceHistory: 'Ártörténet',
|
||||||
journeysFrom: 'Utazások innen: {{label}}',
|
journeysFrom: 'Utazások innen: {{label}}',
|
||||||
|
|
@ -282,6 +290,8 @@ const hu: Translations = {
|
||||||
// ── Street View ────────────────────────────────────
|
// ── Street View ────────────────────────────────────
|
||||||
streetView: {
|
streetView: {
|
||||||
title: 'Utcakép',
|
title: 'Utcakép',
|
||||||
|
openLarge: 'Utcakép megnyitása nagyobb méretben',
|
||||||
|
expandedTitle: 'Nagyított utcakép',
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── POI Pane ───────────────────────────────────────
|
// ── POI Pane ───────────────────────────────────────
|
||||||
|
|
@ -319,47 +329,105 @@ const hu: Translations = {
|
||||||
|
|
||||||
// ── Home Page ──────────────────────────────────────
|
// ── Home Page ──────────────────────────────────────
|
||||||
home: {
|
home: {
|
||||||
heroTitle1: 'Maximális',
|
heroEyebrow: 'Vevőknek, akik azt kérdezik: „hol is kezdjem?”',
|
||||||
heroTitle2: 'Érték',
|
heroTitle1: 'Találd meg az irányítószámokat',
|
||||||
heroTitle3: 'Minimális kompromisszum.',
|
heroTitle2: 'amelyek illenek az életedhez',
|
||||||
heroSubtitle: 'Ingatlant keresel? Legyen a legnagyobb befektetésed a legokosabb döntésed.',
|
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:
|
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.',
|
'Á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: 'Térkép felfedezése',
|
exploreTheMap: 'Megfelelő irányítószámok keresése',
|
||||||
seeTheDifference: 'Nézd meg a különbséget',
|
seeTheDifference: 'Így működik',
|
||||||
statProperties: 'ingatlan',
|
showcaseHeader: 'Termékbemutató',
|
||||||
statFilters: 'szűrő',
|
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',
|
statEvery: 'Minden',
|
||||||
statPostcodeInEngland: 'irányítószám Angliában',
|
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:
|
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:
|
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',
|
howToUseIt: 'Hogyan használd',
|
||||||
howStep1Title: 'Állítsd be a feltételeidet',
|
howStep1Title: 'Írd le, milyen életre van szükséged',
|
||||||
howStep1Desc: 'Költségvetés, ingazás, iskolák — a térkép csak a megfelelőket mutatja.',
|
howStep1Desc:
|
||||||
howStep2Title: 'Fedezz fel területeket és rejtett kincseket',
|
'Költségvetés, ingázás, ingatlantípus, iskolák, biztonság, tér és napi szükségletek.',
|
||||||
howStep2Desc: 'Nagyíts rá, mélyedj el a részletekben és a pluszokban.',
|
howStep2Title: 'Fedd fel a megfelelő irányítószámokat',
|
||||||
howStep3Title: 'Vizsgáld meg az 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:
|
howStep3Desc:
|
||||||
'Nézd meg az egyes ingatlanokat, eladási árakat, alapterületet, és hasonlítsd össze.',
|
'Nézd meg az eladási árakat, alapterületet, EPC-t, zajt, internetet, bűnözést és iskolákat.',
|
||||||
howStep4Title: 'Válassz magabiztosan',
|
howStep4Title: 'Szűkíts listát hirdetések előtt',
|
||||||
howStep4Desc:
|
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.',
|
othersVs: 'Mások vs.',
|
||||||
checkMyPostcode: '“Irányítószám ellenőrzése”',
|
checkMyPostcode: 'Ingatlanportálok',
|
||||||
areaGuides: 'Területi útmutatók',
|
areaGuides: 'Irányítószám-riportok',
|
||||||
compSearchWithout: 'Keresés terület előzetes kiválasztása nélkül',
|
compSearchWithout: 'Területek felfedezése a nevük ismerete előtt',
|
||||||
compSearchWithoutSub: '(igényekből indulj, nem helyszínből)',
|
compSearchWithoutSub: '(előbb igények, aztán helyszín)',
|
||||||
compAreaData: 'Területi adatok',
|
compAreaData: 'Irányítószám-szintű környékadatok',
|
||||||
compAreaDataSub: '(bűnözés, iskolák, zaj, szélessáv)',
|
compAreaDataSub: '(bűnözés, iskolák, zaj, internet, szolgáltatások)',
|
||||||
compPropertyData: 'Ingatlanspecifikus adatok',
|
compPropertyData: 'Ingatlanszintű előzmények',
|
||||||
compPropertyDataSub: '(ár, EPC, alapterület)',
|
compPropertyDataSub: '(eladási árak, EPC, alapterület, becsült érték)',
|
||||||
compFilters: '56 kombinálható szűrő egy helyen',
|
compFilters: '56 együtt működő szűrő',
|
||||||
compFiltersSub: '(minden információ, egy interaktív térkép)',
|
compFiltersSub: '(nem egy irányítószám vagy hirdetés egyszerre)',
|
||||||
ctaTitle: 'Legyen a legnagyobb befektetésed a legokosabb döntésed.',
|
ctaTitle: 'Ne találgasd, hol vegyél.',
|
||||||
ctaDescription: 'Ez megfelelő eszközöket érdemel, ne bízd a szerencsére.',
|
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 ───────────────────────────────────
|
// ── Pricing Page ───────────────────────────────────
|
||||||
|
|
@ -382,7 +450,7 @@ const hu: Translations = {
|
||||||
getStarted: 'Kezdjük el',
|
getStarted: 'Kezdjük el',
|
||||||
getStartedPrice: 'Kezdjük el – {{price}}',
|
getStartedPrice: 'Kezdjük el – {{price}}',
|
||||||
noCreditCard: 'Nem szükséges bankkartya',
|
noCreditCard: 'Nem szükséges bankkartya',
|
||||||
moneyBackGuarantee: '30 napos pénzvisszatérítési garancia',
|
|
||||||
soldOut: 'Elfogyott',
|
soldOut: 'Elfogyott',
|
||||||
upcoming: 'Következő',
|
upcoming: 'Következő',
|
||||||
failedToLoad: 'Nem sikerült betölteni az árakat. Kérjük, próbáld újra később.',
|
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',
|
dsOsmOrigin: 'OpenStreetMap contributors / Geofabrik',
|
||||||
dsOsmUse:
|
dsOsmUse:
|
||||||
'Érdekes pontok, beleértve üzleteket, éttermeket, egészségügyet, szabadidőt, turizmust és még sok mást Nagy-Britanniában.',
|
'É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, Sainsbury’s, Asda, Morrisons, Aldi, Lidl, Co-op, M&S, Iceland és Spar láncokkal.',
|
||||||
dsGreenspaceName: 'OS Open Greenspace',
|
dsGreenspaceName: 'OS Open Greenspace',
|
||||||
dsGreenspaceOrigin: 'Ordnance Survey',
|
dsGreenspaceOrigin: 'Ordnance Survey',
|
||||||
dsGreenspaceUse:
|
dsGreenspaceUse:
|
||||||
|
|
@ -476,7 +548,7 @@ const hu: Translations = {
|
||||||
dsElectionName: '2024-es parlamenti választási eredmények',
|
dsElectionName: '2024-es parlamenti választási eredmények',
|
||||||
dsElectionOrigin: 'Egyesült Királyság Parlamentje',
|
dsElectionOrigin: 'Egyesült Királyság Parlamentje',
|
||||||
dsElectionUse:
|
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
|
// FAQ section titles
|
||||||
faqFindingTitle: 'Területed megtalálása',
|
faqFindingTitle: 'Területed megtalálása',
|
||||||
faqCommuteTitle: 'Ingazás és utazás',
|
faqCommuteTitle: 'Ingazás és utazás',
|
||||||
|
|
@ -557,9 +629,7 @@ const hu: Translations = {
|
||||||
faqPricing3Q: 'Mit érhetek el az ingyenes szinten?',
|
faqPricing3Q: 'Mit érhetek el az ingyenes szinten?',
|
||||||
faqPricing3A:
|
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.',
|
'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
|
// FAQ items — Tips and Tricks
|
||||||
faqTips1Q: 'Hogyan használjam az AI szűrőt a szűrők egyenkénti hozzáadása helyett?',
|
faqTips1Q: 'Hogyan használjam az AI szűrőt a szűrők egyenkénti hozzáadása helyett?',
|
||||||
faqTips1A:
|
faqTips1A:
|
||||||
|
|
@ -740,6 +810,10 @@ const hu: Translations = {
|
||||||
'Good+ secondary schools within 2km': 'Jó+ középiskolák 2 km-en belül',
|
'Good+ secondary schools within 2km': 'Jó+ középiskolák 2 km-en belül',
|
||||||
'Good+ primary schools within 5km': 'Jó+ általános iskolák 5 km-en belül',
|
'Good+ 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',
|
'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',
|
'Education, Skills and Training Score': 'Oktatás, készségek és képzés pontszám',
|
||||||
|
|
||||||
// ─ Feature names (Deprivation) ─
|
// ─ Feature names (Deprivation) ─
|
||||||
|
|
@ -780,9 +854,7 @@ const hu: Translations = {
|
||||||
'% Other': '% egyéb',
|
'% Other': '% egyéb',
|
||||||
|
|
||||||
// ─ Feature names (Politics) ─
|
// ─ Feature names (Politics) ─
|
||||||
'Winning party': 'Győztes párt',
|
|
||||||
'Voter turnout (%)': 'Választási részvétel (%)',
|
'Voter turnout (%)': 'Választási részvétel (%)',
|
||||||
'Majority (%)': 'Többség (%)',
|
|
||||||
'% Labour': '% Munkáspárt',
|
'% Labour': '% Munkáspárt',
|
||||||
'% Conservative': '% Konzervatív',
|
'% Conservative': '% Konzervatív',
|
||||||
'% Liberal Democrat': '% Liberális Demokrata',
|
'% 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)',
|
'Max available download speed (Mbps)': 'Max elérhető letöltési sebesség (Mbps)',
|
||||||
|
|
||||||
// ─ Enum values ─
|
// ─ 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ó',
|
Detached: 'Különálló',
|
||||||
'Semi-Detached': 'Ikerház',
|
'Semi-Detached': 'Ikerház',
|
||||||
Terraced: 'Sorház',
|
Terraced: 'Sorház',
|
||||||
|
|
@ -820,6 +886,7 @@ const hu: Translations = {
|
||||||
'Serious crime': 'Súlyos bűncselekmény',
|
'Serious crime': 'Súlyos bűncselekmény',
|
||||||
'Minor crime': 'Kisebb bűncselekmény',
|
'Minor crime': 'Kisebb bűncselekmény',
|
||||||
'Ethnic composition': 'Etnikai összetétel',
|
'Ethnic composition': 'Etnikai összetétel',
|
||||||
|
'Political vote share': 'Szavazati megoszlás',
|
||||||
|
|
||||||
// ─ POI group names ─
|
// ─ POI group names ─
|
||||||
'Public Transport': 'Tömegközlekedés',
|
'Public Transport': 'Tömegközlekedés',
|
||||||
|
|
|
||||||
|
|
@ -93,7 +93,7 @@ const zh: Translations = {
|
||||||
free: '免费',
|
free: '免费',
|
||||||
once: '/一次性',
|
once: '/一次性',
|
||||||
freeForEarly: '早期用户免费。无需信用卡。',
|
freeForEarly: '早期用户免费。无需信用卡。',
|
||||||
oneTimePayment: '一次性付款。终身访问。30天无条件退款。',
|
oneTimePayment: '一次性付款。终身访问。',
|
||||||
redirecting: '跳转中...',
|
redirecting: '跳转中...',
|
||||||
claimFreeAccess: '领取免费访问权限',
|
claimFreeAccess: '领取免费访问权限',
|
||||||
upgradeFor: '升级仅需 {{price}}',
|
upgradeFor: '升级仅需 {{price}}',
|
||||||
|
|
@ -254,6 +254,14 @@ const zh: Translations = {
|
||||||
areaStatistics: '区域统计',
|
areaStatistics: '区域统计',
|
||||||
statsFor: '该{{type}}内所有房产的统计数据',
|
statsFor: '该{{type}}内所有房产的统计数据',
|
||||||
matchingFilters: ',满足所有当前筛选条件',
|
matchingFilters: ',满足所有当前筛选条件',
|
||||||
|
filtersAffectStats:
|
||||||
|
'左侧面板的筛选条件会应用到这里:数值、图表和房产数量都会使用 {{count}} 个当前筛选条件。',
|
||||||
|
noFiltersAffectStats:
|
||||||
|
'左侧面板的筛选条件会更新此面板:添加筛选条件后,这些值会按匹配的房产重新计算。',
|
||||||
|
noFilteredMatches: '该区域没有房产符合当前筛选条件。',
|
||||||
|
unfilteredAreaCount: '筛选前这里有 {{count}} 处房产;位置有效,但被筛选条件排除了。',
|
||||||
|
noUnfilteredAreaProperties: '筛选前该选定区域内也没有找到房产。',
|
||||||
|
relaxFiltersHint: '放宽或清除筛选条件即可查看该区域的房产。',
|
||||||
viewProperties: '查看 {{count}} 处房产',
|
viewProperties: '查看 {{count}} 处房产',
|
||||||
priceHistory: '价格历史',
|
priceHistory: '价格历史',
|
||||||
journeysFrom: '从 {{label}} 出发的路线',
|
journeysFrom: '从 {{label}} 出发的路线',
|
||||||
|
|
@ -278,6 +286,8 @@ const zh: Translations = {
|
||||||
// ── Street View ────────────────────────────────────
|
// ── Street View ────────────────────────────────────
|
||||||
streetView: {
|
streetView: {
|
||||||
title: '街景视图',
|
title: '街景视图',
|
||||||
|
openLarge: '放大打开街景视图',
|
||||||
|
expandedTitle: '放大的街景视图',
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── POI Pane ───────────────────────────────────────
|
// ── POI Pane ───────────────────────────────────────
|
||||||
|
|
@ -315,45 +325,96 @@ const zh: Translations = {
|
||||||
|
|
||||||
// ── Home Page ──────────────────────────────────────
|
// ── Home Page ──────────────────────────────────────
|
||||||
home: {
|
home: {
|
||||||
heroTitle1: '最大',
|
heroEyebrow: '适合正在问“我到底该看哪里?”的买家',
|
||||||
heroTitle2: '价值',
|
heroTitle1: '找到真正',
|
||||||
heroTitle3: '最小妥协。',
|
heroTitle2: '适合您生活的邮编',
|
||||||
heroSubtitle: '正在找房?让您最大的投资成为最明智的决定。',
|
heroTitle3: '不只局限于您已经知道的区域。',
|
||||||
|
heroSubtitle: '从伦敦街区到通勤城镇和英格兰各地城市,可研究的地方太多,无法一个个筛查。',
|
||||||
heroDescription:
|
heroDescription:
|
||||||
'选择太多,找到合适的可能让人不知所措。我们的交互式地图让一切变得简单:选择您的必要条件,立即看到符合的区域。',
|
'设定预算、通勤、学校、安全、噪音、宽带和生活方式需求。Perfect Postcode 会扫描英格兰的邮编,显示真正匹配的地方,包括您从未想过要在房源网站上搜索的区域。',
|
||||||
exploreTheMap: '探索地图',
|
exploreTheMap: '找到匹配的邮编',
|
||||||
seeTheDifference: '看看有何不同',
|
seeTheDifference: '查看使用方式',
|
||||||
statProperties: '处房产',
|
showcaseHeader: '产品展示',
|
||||||
statFilters: '项筛选条件',
|
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: '覆盖',
|
statEvery: '覆盖',
|
||||||
statPostcodeInEngland: '英格兰每个邮编',
|
statPostcodeInEngland: '英格兰每个邮编',
|
||||||
ourPhilosophy: '我们的理念',
|
ourPhilosophy: '从生活需求出发,而不是从邮编出发',
|
||||||
philosophyP1:
|
philosophyP1:
|
||||||
'在 Rightmove 上,您需要先选一个区域,然后期望它足够好。最终您不得不在十几个标签页中交叉对比犯罪数据、学校报告和宽带速度,一个邮编一个邮编地查。',
|
'大多数房产网站先问您想住哪里。在伦敦这个问题尤其困难,但英格兰各地都有同样的问题:买家通常只能从几个熟悉的地方开始,然后分别查询通勤、学校、犯罪率、街景、宽带和成交价。',
|
||||||
philosophyP2:
|
philosophyP2:
|
||||||
'我们反其道而行。告诉我们您的需求(预算、通勤、学校、安全),我们为您展示英格兰所有符合条件的区域。不用猜测,不浪费看房时间。',
|
'Perfect Postcode 反过来做搜索。告诉地图什么重要,它会显示符合条件的邮编,并解释为什么值得查看。先看数据,再去现场感受。',
|
||||||
|
streetTitle: '每条街都可能不同',
|
||||||
|
streetIntro:
|
||||||
|
'大的区域名称会掩盖关键细节:车站哪一侧、道路噪音、学校组合、真实通勤时间,以及类似房产的实际成交价。',
|
||||||
|
streetCard1Title: '发现您可能错过的区域',
|
||||||
|
streetCard1Body:
|
||||||
|
'根据您的条件找出匹配的邮编,而不是只依赖熟悉的地名、朋友推荐或“潜力区域”的宣传。',
|
||||||
|
streetCard2Title: '看房前先看清取舍',
|
||||||
|
streetCard2Body:
|
||||||
|
'在把周末花在看房之前,先比较价格、空间、通勤、安全、学校、宽带、噪音和能源评级。',
|
||||||
howToUseIt: '使用方法',
|
howToUseIt: '使用方法',
|
||||||
howStep1Title: '设定必要条件',
|
howStep1Title: '描述您需要的生活',
|
||||||
howStep1Desc: '预算、通勤、学校——地图只显示符合条件的区域。',
|
howStep1Desc: '预算、通勤、房产类型、学校、安全、空间和日常生活设施。',
|
||||||
howStep2Title: '探索区域,发现隐藏的好地方',
|
howStep2Title: '显示匹配的邮编',
|
||||||
howStep2Desc: '放大查看,深入了解细节和加分项。',
|
howStep2Desc: '地图会高亮通过筛选的地方,包括不熟悉的区域。',
|
||||||
howStep3Title: '深入邮编级别',
|
howStep3Title: '查看证据',
|
||||||
howStep3Desc: '查看单个房产、成交价、建筑面积,并进行比较。',
|
howStep3Desc: '查看成交价、建筑面积、EPC、道路噪音、宽带、犯罪率和学校。',
|
||||||
howStep4Title: '自信地列出候选名单',
|
howStep4Title: '先筛区域,再看房源',
|
||||||
howStep4Desc: '您名单上的每个区域都满足您的实际需求——而不只是当周恰好有房源。',
|
howStep4Desc: '带着更好的搜索区域去 Rightmove、Zoopla、中介和看房。',
|
||||||
othersVs: '其他平台 vs',
|
othersVs: '其他平台 vs',
|
||||||
checkMyPostcode: '"查查我的邮编"类网站',
|
checkMyPostcode: '房源门户',
|
||||||
areaGuides: '区域指南',
|
areaGuides: '邮编报告',
|
||||||
compSearchWithout: '无需先选区域即可搜索',
|
compSearchWithout: '在知道名称前先发现区域',
|
||||||
compSearchWithoutSub: '(从需求出发,而非地点)',
|
compSearchWithoutSub: '(先需求,后地点)',
|
||||||
compAreaData: '区域数据',
|
compAreaData: '邮编级社区证据',
|
||||||
compAreaDataSub: '(犯罪率、学校、噪音、宽带)',
|
compAreaDataSub: '(犯罪率、学校、噪音、宽带、设施)',
|
||||||
compPropertyData: '房产专属数据',
|
compPropertyData: '房产级历史记录',
|
||||||
compPropertyDataSub: '(价格、能源性能证书、建筑面积)',
|
compPropertyDataSub: '(成交价、EPC、面积、估值)',
|
||||||
compFilters: '56 项可组合筛选条件,尽在一处',
|
compFilters: '56 项联动筛选',
|
||||||
compFiltersSub: '(所有信息,一张交互式地图)',
|
compFiltersSub: '(不是一次查一个邮编或一个房源)',
|
||||||
ctaTitle: '让您最大的投资成为最明智的 决定。',
|
ctaTitle: '别再猜哪里值得买。',
|
||||||
ctaDescription: '这值得用专业的工具来做,别全靠运气。',
|
ctaDescription: '先建立符合真实生活需求的邮编候选名单,再去实地感受。',
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── Pricing Page ───────────────────────────────────
|
// ── Pricing Page ───────────────────────────────────
|
||||||
|
|
@ -375,7 +436,7 @@ const zh: Translations = {
|
||||||
getStarted: '立即开始',
|
getStarted: '立即开始',
|
||||||
getStartedPrice: '立即开始 - {{price}}',
|
getStartedPrice: '立即开始 - {{price}}',
|
||||||
noCreditCard: '无需信用卡',
|
noCreditCard: '无需信用卡',
|
||||||
moneyBackGuarantee: '30天无条件退款保证',
|
|
||||||
soldOut: '已售罄',
|
soldOut: '已售罄',
|
||||||
upcoming: '即将开放',
|
upcoming: '即将开放',
|
||||||
failedToLoad: '加载价格信息失败,请稍后重试。',
|
failedToLoad: '加载价格信息失败,请稍后重试。',
|
||||||
|
|
@ -433,6 +494,10 @@ const zh: Translations = {
|
||||||
dsOsmName: 'OpenStreetMap POIs',
|
dsOsmName: 'OpenStreetMap POIs',
|
||||||
dsOsmOrigin: 'OpenStreetMap contributors / Geofabrik',
|
dsOsmOrigin: 'OpenStreetMap contributors / Geofabrik',
|
||||||
dsOsmUse: '涵盖大不列颠地区的商店、餐厅、医疗、休闲、旅游等兴趣点。',
|
dsOsmUse: '涵盖大不列颠地区的商店、餐厅、医疗、休闲、旅游等兴趣点。',
|
||||||
|
dsGeolytixRetailName: 'GEOLYTIX Grocery Retail Points',
|
||||||
|
dsGeolytixRetailOrigin: 'GEOLYTIX',
|
||||||
|
dsGeolytixRetailUse:
|
||||||
|
'英国超市和便利店位置数据,包括 Waitrose、Tesco、Sainsbury’s、Asda、Morrisons、Aldi、Lidl、Co-op、M&S、Iceland 和 Spar 等连锁品牌。',
|
||||||
dsGreenspaceName: 'OS Open Greenspace',
|
dsGreenspaceName: 'OS Open Greenspace',
|
||||||
dsGreenspaceOrigin: 'Ordnance Survey',
|
dsGreenspaceOrigin: 'Ordnance Survey',
|
||||||
dsGreenspaceUse:
|
dsGreenspaceUse:
|
||||||
|
|
@ -462,7 +527,7 @@ const zh: Translations = {
|
||||||
dsElectionName: '2024年大选结果',
|
dsElectionName: '2024年大选结果',
|
||||||
dsElectionOrigin: '英国议会',
|
dsElectionOrigin: '英国议会',
|
||||||
dsElectionUse:
|
dsElectionUse:
|
||||||
'2024年7月英国大选的候选人级别结果。聚合到选区级别:获胜政党、投票率(%)和多数票(%)。通过NSPL邮编查询中的议会选区代码(pcon)关联到房产。',
|
'2024年7月英国大选的候选人级别结果。聚合到选区级别:投票率(%)和各政党得票率(%)。通过NSPL邮编查询中的议会选区代码(pcon)关联到房产。',
|
||||||
// FAQ section titles
|
// FAQ section titles
|
||||||
faqFindingTitle: '寻找理想区域',
|
faqFindingTitle: '寻找理想区域',
|
||||||
faqCommuteTitle: '通勤与出行',
|
faqCommuteTitle: '通勤与出行',
|
||||||
|
|
@ -541,9 +606,7 @@ const zh: Translations = {
|
||||||
faqPricing3Q: '免费版能用哪些功能?',
|
faqPricing3Q: '免费版能用哪些功能?',
|
||||||
faqPricing3A:
|
faqPricing3A:
|
||||||
'免费用户可以在演示区域(伦敦市中心,大约 1 至 2 区)内探索所有功能。要访问英格兰其他地区的数据,需要获取终身访问权限。',
|
'免费用户可以在演示区域(伦敦市中心,大约 1 至 2 区)内探索所有功能。要访问英格兰其他地区的数据,需要获取终身访问权限。',
|
||||||
faqPricing4Q: '可以退款吗?',
|
|
||||||
faqPricing4A:
|
|
||||||
'当然可以。我们提供 30 天退款保证。如果您不满意,请在 30 天内发送邮件至 support@perfect-postcode.co.uk 申请全额退款。',
|
|
||||||
// FAQ items — Tips and Tricks
|
// FAQ items — Tips and Tricks
|
||||||
faqTips1Q: '如何使用 AI 筛选功能,而不是逐个添加筛选条件?',
|
faqTips1Q: '如何使用 AI 筛选功能,而不是逐个添加筛选条件?',
|
||||||
faqTips1A:
|
faqTips1A:
|
||||||
|
|
@ -663,7 +726,7 @@ const zh: Translations = {
|
||||||
'设置预算、通勤上限、学校质量、犯罪门槛。您关心的一切。只有符合条件的区域会保持高亮。使用眼睛图标按任意特征着色。',
|
'设置预算、通勤上限、学校质量、犯罪门槛。您关心的一切。只有符合条件的区域会保持高亮。使用眼睛图标按任意特征着色。',
|
||||||
step2Title: '或者直接描述',
|
step2Title: '或者直接描述',
|
||||||
step2Content:
|
step2Content:
|
||||||
'用中文输入您的需求,例如“安静的地区,靠近好学校,£400k 以下”,我们会为您设置筛选。',
|
'用中文输入您的需求,例如“安静的地区,靠近好学校,£40万 以下”,我们会为您设置筛选。',
|
||||||
step3Title: '探索现有住宅',
|
step3Title: '探索现有住宅',
|
||||||
step3Content:
|
step3Content:
|
||||||
'在英格兰各地平移和缩放。点击任何彩色区域查看犯罪、学校、价格、宽带、噪音等信息。',
|
'在英格兰各地平移和缩放。点击任何彩色区域查看犯罪、学校、价格、宽带、噪音等信息。',
|
||||||
|
|
@ -714,6 +777,10 @@ const zh: Translations = {
|
||||||
'Good+ secondary schools within 2km': '2公里内良好+中学数量',
|
'Good+ secondary schools within 2km': '2公里内良好+中学数量',
|
||||||
'Good+ primary schools within 5km': '5公里内良好+小学数量',
|
'Good+ primary schools within 5km': '5公里内良好+小学数量',
|
||||||
'Good+ secondary 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': '教育、技能和培训得分',
|
'Education, Skills and Training Score': '教育、技能和培训得分',
|
||||||
|
|
||||||
// ─ Feature names (Deprivation) ─
|
// ─ Feature names (Deprivation) ─
|
||||||
|
|
@ -754,9 +821,7 @@ const zh: Translations = {
|
||||||
'% Other': '% 其他',
|
'% Other': '% 其他',
|
||||||
|
|
||||||
// ─ Feature names (Politics) ─
|
// ─ Feature names (Politics) ─
|
||||||
'Winning party': '获胜政党',
|
|
||||||
'Voter turnout (%)': '投票率(%)',
|
'Voter turnout (%)': '投票率(%)',
|
||||||
'Majority (%)': '多数票(%)',
|
|
||||||
'% Labour': '% 工党',
|
'% Labour': '% 工党',
|
||||||
'% Conservative': '% 保守党',
|
'% Conservative': '% 保守党',
|
||||||
'% Liberal Democrat': '% 自由民主党',
|
'% Liberal Democrat': '% 自由民主党',
|
||||||
|
|
@ -773,12 +838,6 @@ const zh: Translations = {
|
||||||
'Max available download speed (Mbps)': '最大可用下载速度(Mbps)',
|
'Max available download speed (Mbps)': '最大可用下载速度(Mbps)',
|
||||||
|
|
||||||
// ─ Enum values ─
|
// ─ Enum values ─
|
||||||
Labour: '工党',
|
|
||||||
Conservative: '保守党',
|
|
||||||
'Liberal Democrat': '自由民主党',
|
|
||||||
'Reform UK': '英国改革党',
|
|
||||||
Green: '绿党',
|
|
||||||
'Other parties': '其他政党',
|
|
||||||
Detached: '独立式住宅',
|
Detached: '独立式住宅',
|
||||||
'Semi-Detached': '半独立式住宅',
|
'Semi-Detached': '半独立式住宅',
|
||||||
Terraced: '联排住宅',
|
Terraced: '联排住宅',
|
||||||
|
|
@ -793,6 +852,7 @@ const zh: Translations = {
|
||||||
'Serious crime': '严重犯罪',
|
'Serious crime': '严重犯罪',
|
||||||
'Minor crime': '轻微犯罪',
|
'Minor crime': '轻微犯罪',
|
||||||
'Ethnic composition': '族裔组成',
|
'Ethnic composition': '族裔组成',
|
||||||
|
'Political vote share': '政党得票率',
|
||||||
|
|
||||||
// ─ POI group names ─
|
// ─ POI group names ─
|
||||||
'Public Transport': '公共交通',
|
'Public Transport': '公共交通',
|
||||||
|
|
|
||||||
|
|
@ -79,6 +79,28 @@ h3 {
|
||||||
transform: translateY(0);
|
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 */
|
/* Cereal aside — hover to reveal */
|
||||||
@keyframes cereal-wobble {
|
@keyframes cereal-wobble {
|
||||||
0%,
|
0%,
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,8 @@
|
||||||
<meta name="theme-color" content="#fafaf9" media="(prefers-color-scheme: light)" />
|
<meta name="theme-color" content="#fafaf9" media="(prefers-color-scheme: light)" />
|
||||||
<meta name="theme-color" content="#0a0e1a" media="(prefers-color-scheme: dark)" />
|
<meta name="theme-color" content="#0a0e1a" media="(prefers-color-scheme: dark)" />
|
||||||
<meta name="referrer" content="no-referrer" />
|
<meta name="referrer" content="no-referrer" />
|
||||||
<title>Perfect Postcode - Every neighbourhood in England</title>
|
<title>Perfect Postcode - Find where to buy before browsing listings</title>
|
||||||
<meta name="description" content="Explore property prices, energy ratings, crime stats, school ratings, and more across England on one interactive map." />
|
<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__" />
|
<meta name="x-og-placeholder" content="__PERFECT_POSTCODE_OG_TAGS__" />
|
||||||
<script>
|
<script>
|
||||||
(function() {
|
(function() {
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,7 @@ export class PieHexExtension extends LayerExtension {
|
||||||
return layer.id.endsWith('-fill');
|
return layer.id.endsWith('-fill');
|
||||||
}
|
}
|
||||||
|
|
||||||
getShaders(extension: any): any {
|
getShaders(this: any, extension: any): any {
|
||||||
if (!extension.isEnabled(this)) return null;
|
if (!extension.isEnabled(this)) return null;
|
||||||
return {
|
return {
|
||||||
modules: [
|
modules: [
|
||||||
|
|
@ -74,7 +74,7 @@ in vec4 vRatios0;
|
||||||
in vec4 vRatios1;
|
in vec4 vRatios1;
|
||||||
in vec2 vRatios2;
|
in vec2 vRatios2;
|
||||||
const vec3 pieColors[10] = vec3[10](
|
const vec3 pieColors[10] = vec3[10](
|
||||||
${this.paletteGlsl}
|
${extension.paletteGlsl}
|
||||||
);`,
|
);`,
|
||||||
'fs:DECKGL_FILTER_COLOR': `\
|
'fs:DECKGL_FILTER_COLOR': `\
|
||||||
{
|
{
|
||||||
|
|
|
||||||
84
frontend/src/lib/api.test.ts
Normal file
84
frontend/src/lib/api.test.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import type { FeatureMeta, FeatureFilters } from '../types';
|
import type { FeatureMeta, FeatureFilters } from '../types';
|
||||||
import { INITIAL_RETRY_MS, MAX_RETRY_MS } from './consts';
|
import { INITIAL_RETRY_MS, MAX_RETRY_MS } from './consts';
|
||||||
import pb from './pocketbase';
|
import pb from './pocketbase';
|
||||||
|
import { getSchoolBackendFeatureName } from './school-filter';
|
||||||
|
|
||||||
export function logNonAbortError(label: string, error: unknown): void {
|
export function logNonAbortError(label: string, error: unknown): void {
|
||||||
if (error instanceof Error && error.name === 'AbortError') {
|
if (error instanceof Error && error.name === 'AbortError') {
|
||||||
|
|
@ -82,8 +83,29 @@ export function buildFilterString(
|
||||||
): string {
|
): string {
|
||||||
const entries = Object.entries(filters);
|
const entries = Object.entries(filters);
|
||||||
if (entries.length === 0) return '';
|
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]) => {
|
.map(([name, value]) => {
|
||||||
const meta = features.find((f) => f.name === name);
|
const meta = features.find((f) => f.name === name);
|
||||||
if (meta?.type === 'enum') {
|
if (meta?.type === 'enum') {
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,46 @@ export const FEATURE_GRADIENT: { t: number; color: [number, number, number] }[]
|
||||||
{ t: 1, color: [142, 68, 173] },
|
{ 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) */
|
/** Number of properties gradient — light mode (cream → orange) */
|
||||||
export const DENSITY_GRADIENT: { t: number; color: [number, number, number] }[] = [
|
export const DENSITY_GRADIENT: { t: number; color: [number, number, number] }[] = [
|
||||||
{ t: 0, color: [255, 255, 255] },
|
{ 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 */
|
/** Default color for unknown POI groups */
|
||||||
export const POI_DEFAULT_COLOR: [number, number, number] = [107, 114, 128];
|
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 */
|
/** 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']);
|
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'],
|
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.
|
* Any value not listed falls back to ENUM_PALETTE by index.
|
||||||
*/
|
*/
|
||||||
export const ENUM_COLOR_OVERRIDES: Record<string, Record<string, [number, number, number]>> = {
|
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': {
|
'Property type': {
|
||||||
Detached: [249, 115, 22], // orange
|
Detached: [249, 115, 22], // orange
|
||||||
'Semi-Detached': [59, 130, 246], // blue
|
'Semi-Detached': [59, 130, 246], // blue
|
||||||
|
|
|
||||||
78
frontend/src/lib/external-search.test.ts
Normal file
78
frontend/src/lib/external-search.test.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -103,8 +103,7 @@ export function buildPropertySearchUrls({
|
||||||
|
|
||||||
const radiusMiles = isPostcode ? 0 : (H3_RADIUS_MILES[resolution] ?? 1);
|
const radiusMiles = isPostcode ? 0 : (H3_RADIUS_MILES[resolution] ?? 1);
|
||||||
|
|
||||||
const priceFilter =
|
const priceFilter = filters['Estimated current price'] ?? filters['Last known price'];
|
||||||
filters['Estimated current price'] ?? filters['Last known price'];
|
|
||||||
const minPrice =
|
const minPrice =
|
||||||
Array.isArray(priceFilter) && typeof priceFilter[0] === 'number' ? priceFilter[0] : undefined;
|
Array.isArray(priceFilter) && typeof priceFilter[0] === 'number' ? priceFilter[0] : undefined;
|
||||||
const maxPrice =
|
const maxPrice =
|
||||||
|
|
@ -122,6 +121,16 @@ export function buildPropertySearchUrls({
|
||||||
? (tenureFilter as string[])
|
? (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
|
// Rightmove — requires locationIdentifier from typeahead API
|
||||||
let rightmove: string | null = null;
|
let rightmove: string | null = null;
|
||||||
if (rightmoveLocationId) {
|
if (rightmoveLocationId) {
|
||||||
|
|
@ -134,6 +143,8 @@ export function buildPropertySearchUrls({
|
||||||
rmParams.set('minPrice', String(snapToAllowed(minPrice, RIGHTMOVE_PRICES, 'floor')));
|
rmParams.set('minPrice', String(snapToAllowed(minPrice, RIGHTMOVE_PRICES, 'floor')));
|
||||||
if (maxPrice !== undefined)
|
if (maxPrice !== undefined)
|
||||||
rmParams.set('maxPrice', String(snapToAllowed(maxPrice, RIGHTMOVE_PRICES, 'ceil')));
|
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) {
|
if (selectedTypes.length > 0) {
|
||||||
const rmTypes = [
|
const rmTypes = [
|
||||||
...new Set(
|
...new Set(
|
||||||
|
|
@ -161,6 +172,8 @@ export function buildPropertySearchUrls({
|
||||||
otmParams.set('min-price', String(snapToAllowed(minPrice, OTM_PRICES, 'floor')));
|
otmParams.set('min-price', String(snapToAllowed(minPrice, OTM_PRICES, 'floor')));
|
||||||
if (maxPrice !== undefined)
|
if (maxPrice !== undefined)
|
||||||
otmParams.set('max-price', String(snapToAllowed(maxPrice, OTM_PRICES, 'ceil')));
|
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) {
|
if (selectedTypes.length > 0) {
|
||||||
const otmTypes = [
|
const otmTypes = [
|
||||||
...new Set(selectedTypes.map((t) => PROPERTY_TYPE_MAP[t]?.onthemarket).filter(Boolean)),
|
...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')));
|
zParams.set('price_min', String(snapToAllowed(minPrice, ZOOPLA_PRICES, 'floor')));
|
||||||
if (maxPrice !== undefined)
|
if (maxPrice !== undefined)
|
||||||
zParams.set('price_max', String(snapToAllowed(maxPrice, ZOOPLA_PRICES, 'ceil')));
|
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) {
|
if (selectedTypes.length > 0) {
|
||||||
const zTypes = [
|
const zTypes = [
|
||||||
...new Set(selectedTypes.map((t) => PROPERTY_TYPE_MAP[t]?.zoopla).filter(Boolean)),
|
...new Set(selectedTypes.map((t) => PROPERTY_TYPE_MAP[t]?.zoopla).filter(Boolean)),
|
||||||
|
|
|
||||||
|
|
@ -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" />
|
<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': (
|
'Good+ primary schools within 2km': (
|
||||||
<>
|
<>
|
||||||
<path d="M4 19V9l8-6 8 6v10" />
|
<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" />
|
<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 ──────────────────────────────
|
// ── Deprivation ──────────────────────────────
|
||||||
'Income Score (rate)': (
|
'Income Score (rate)': (
|
||||||
|
|
|
||||||
42
frontend/src/lib/format.test.ts
Normal file
42
frontend/src/lib/format.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import i18n from 'i18next';
|
||||||
|
|
||||||
interface ValueFormat {
|
interface ValueFormat {
|
||||||
prefix?: string;
|
prefix?: string;
|
||||||
suffix?: string;
|
suffix?: string;
|
||||||
|
|
@ -5,10 +7,31 @@ interface ValueFormat {
|
||||||
raw?: boolean;
|
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 {
|
export function formatValue(value: number, fmt?: ValueFormat): string {
|
||||||
const p = fmt?.prefix ?? '';
|
const p = fmt?.prefix ?? '';
|
||||||
const s = fmt?.suffix ?? '';
|
const s = fmt?.suffix ?? '';
|
||||||
if (fmt?.raw) return `${p}${Math.round(value)}${s}`;
|
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_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 (Math.abs(value) >= 1_000) return `${p}${(value / 1_000).toFixed(1)}k${s}`;
|
||||||
if (Number.isInteger(value)) return `${p}${value.toLocaleString()}${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 {
|
export function formatFilterValue(value: number, raw?: boolean): string {
|
||||||
if (raw) return Math.round(value).toString();
|
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_000) return `${(value / 1_000_000).toFixed(1)}M`;
|
||||||
if (Math.abs(value) >= 1_000) return `${(value / 1_000).toFixed(1)}k`;
|
if (Math.abs(value) >= 1_000) return `${(value / 1_000).toFixed(1)}k`;
|
||||||
if (Number.isInteger(value)) return value.toString();
|
if (Number.isInteger(value)) return value.toString();
|
||||||
|
|
@ -31,14 +60,17 @@ export function parseInputValue(
|
||||||
let s = text.trim();
|
let s = text.trim();
|
||||||
if (opts?.prefix) s = s.replace(new RegExp(`^\\${opts.prefix}`), '');
|
if (opts?.prefix) s = s.replace(new RegExp(`^\\${opts.prefix}`), '');
|
||||||
if (opts?.suffix) s = s.replace(new RegExp(`${opts.suffix.trim()}$`), '');
|
if (opts?.suffix) s = s.replace(new RegExp(`${opts.suffix.trim()}$`), '');
|
||||||
s = s.trim().replace(/,/g, '');
|
s = s.trim().replace(/[,,]/g, '');
|
||||||
const m = s.match(/^(-?\d+\.?\d*)\s*([kKmM]?)$/);
|
const m = s.match(/^(-?\d+\.?\d*)\s*([kKmM万亿億]?)$/);
|
||||||
if (!m) return null;
|
if (!m) return null;
|
||||||
let val = parseFloat(m[1]);
|
let val = parseFloat(m[1]);
|
||||||
if (isNaN(val)) return null;
|
if (isNaN(val)) return null;
|
||||||
const unit = m[2].toLowerCase();
|
const unit = m[2];
|
||||||
if (unit === 'k') val *= 1_000;
|
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;
|
if (opts?.step) val = Math.round(val / opts.step) * opts.step;
|
||||||
return val;
|
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();
|
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 {
|
export function formatRelativeTime(isoDate: string): string {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||||
const i18n = require('../i18n').default as {
|
const i18n = require('../i18n').default as {
|
||||||
|
|
|
||||||
101
frontend/src/lib/map-utils.test.ts
Normal file
101
frontend/src/lib/map-utils.test.ts
Normal 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]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -9,6 +9,8 @@ import {
|
||||||
TWEMOJI_BASE,
|
TWEMOJI_BASE,
|
||||||
BUFFER_MULTIPLIER,
|
BUFFER_MULTIPLIER,
|
||||||
ENUM_PALETTE,
|
ENUM_PALETTE,
|
||||||
|
POI_CATEGORY_LOGOS,
|
||||||
|
type GradientStop,
|
||||||
} from './consts';
|
} from './consts';
|
||||||
const ROAD_OPACITY = 0.4;
|
const ROAD_OPACITY = 0.4;
|
||||||
|
|
||||||
|
|
@ -64,8 +66,6 @@ export function getMapStyle(theme: 'light' | 'dark'): StyleSpecification {
|
||||||
} as StyleSpecification;
|
} as StyleSpecification;
|
||||||
}
|
}
|
||||||
|
|
||||||
type GradientStop = { t: number; color: [number, number, number] };
|
|
||||||
|
|
||||||
// Oklab color space for perceptually uniform interpolation
|
// Oklab color space for perceptually uniform interpolation
|
||||||
function srgbToLinear(c: number): number {
|
function srgbToLinear(c: number): number {
|
||||||
const v = c / 255;
|
const v = c / 255;
|
||||||
|
|
@ -131,8 +131,11 @@ function interpolateGradient(t: number, gradient: GradientStop[]): [number, numb
|
||||||
return gradient[gradient.length - 1].color;
|
return gradient[gradient.length - 1].color;
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizedToColor(t: number): [number, number, number] {
|
function normalizedToColor(
|
||||||
return interpolateGradient(t, FEATURE_GRADIENT);
|
t: number,
|
||||||
|
gradient: GradientStop[] = FEATURE_GRADIENT
|
||||||
|
): [number, number, number] {
|
||||||
|
return interpolateGradient(t, gradient);
|
||||||
}
|
}
|
||||||
|
|
||||||
function countToColor(
|
function countToColor(
|
||||||
|
|
@ -194,6 +197,10 @@ export function emojiToTwemojiUrl(emoji: string): string {
|
||||||
return `${TWEMOJI_BASE}${hex}.png`;
|
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). */
|
/** Look up a discrete color from the enum palette by index (wraps if > palette size). */
|
||||||
export function enumIndexToColor(
|
export function enumIndexToColor(
|
||||||
index: number,
|
index: number,
|
||||||
|
|
@ -220,7 +227,8 @@ export function getFeatureFillColor(
|
||||||
isDark: boolean,
|
isDark: boolean,
|
||||||
alpha: number,
|
alpha: number,
|
||||||
enumCount: number = 0,
|
enumCount: number = 0,
|
||||||
enumPalette?: [number, number, number][]
|
enumPalette?: [number, number, number][],
|
||||||
|
featureGradient: GradientStop[] = FEATURE_GRADIENT
|
||||||
): [number, number, number, number] {
|
): [number, number, number, number] {
|
||||||
if (colorRange) {
|
if (colorRange) {
|
||||||
if (value == null)
|
if (value == null)
|
||||||
|
|
@ -244,9 +252,9 @@ export function getFeatureFillColor(
|
||||||
|
|
||||||
const range = colorRange[1] - colorRange[0];
|
const range = colorRange[1] - colorRange[0];
|
||||||
if (range === 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 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 [...rgb, alpha] as [number, number, number, number];
|
||||||
}
|
}
|
||||||
return [...countToColor(Math.max(0, Math.min(1, countNormalized)), densityGradient), alpha] as [
|
return [...countToColor(Math.max(0, Math.min(1, countNormalized)), densityGradient), alpha] as [
|
||||||
|
|
|
||||||
216
frontend/src/lib/school-filter.ts
Normal file
216
frontend/src/lib/school-filter.ts
Normal 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))];
|
||||||
|
}
|
||||||
118
frontend/src/lib/url-state.test.ts
Normal file
118
frontend/src/lib/url-state.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -5,10 +5,20 @@ import {
|
||||||
type TravelTimeEntry,
|
type TravelTimeEntry,
|
||||||
type TravelTimeInitial,
|
type TravelTimeInitial,
|
||||||
} from '../hooks/useTravelTime';
|
} 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 {
|
function parseFilters(params: URLSearchParams): FeatureFilters | undefined {
|
||||||
const filterParams = params.getAll('filter');
|
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 = {};
|
const filters: FeatureFilters = {};
|
||||||
for (const entry of filterParams) {
|
for (const entry of filterParams) {
|
||||||
|
|
@ -29,6 +39,27 @@ function parseFilters(params: URLSearchParams): FeatureFilters | undefined {
|
||||||
filters[name] = [rest];
|
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;
|
return Object.keys(filters).length > 0 ? filters : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -126,6 +157,16 @@ export function stateToParams(
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const [name, value] of Object.entries(filters)) {
|
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);
|
const meta = features.find((f) => f.name === name);
|
||||||
if (meta?.type === 'enum') {
|
if (meta?.type === 'enum') {
|
||||||
params.append('filter', `${name}:${(value as string[]).join('|')}`);
|
params.append('filter', `${name}:${(value as string[]).join('|')}`);
|
||||||
|
|
@ -170,13 +211,15 @@ export function summarizeParams(queryString: string): string {
|
||||||
const parts: string[] = [];
|
const parts: string[] = [];
|
||||||
|
|
||||||
const filterParams = params.getAll('filter');
|
const filterParams = params.getAll('filter');
|
||||||
if (filterParams.length > 0) {
|
const schoolParams = params.getAll('school');
|
||||||
|
if (filterParams.length > 0 || schoolParams.length > 0) {
|
||||||
const filterNames = filterParams
|
const filterNames = filterParams
|
||||||
.map((entry) => {
|
.map((entry) => {
|
||||||
const colonIdx = entry.indexOf(':');
|
const colonIdx = entry.indexOf(':');
|
||||||
return colonIdx > 0 ? entry.substring(0, colonIdx) : entry;
|
return colonIdx > 0 ? entry.substring(0, colonIdx) : entry;
|
||||||
})
|
})
|
||||||
.filter((n) => n);
|
.filter((n) => n);
|
||||||
|
for (let i = 0; i < schoolParams.length; i++) filterNames.push(SCHOOL_FILTER_NAME);
|
||||||
if (filterNames.length > 0) {
|
if (filterNames.length > 0) {
|
||||||
parts.push(
|
parts.push(
|
||||||
filterNames.length <= 2
|
filterNames.length <= 2
|
||||||
|
|
|
||||||
BIN
manual-data/fixed_broadband_coverage.zip
Normal file
BIN
manual-data/fixed_broadband_coverage.zip
Normal file
Binary file not shown.
|
|
@ -221,7 +221,7 @@ def main() -> None:
|
||||||
deleted = _delete_files(args.travel_times, bad_files)
|
deleted = _delete_files(args.travel_times, bad_files)
|
||||||
print(f"Deleted {deleted}/{len(bad_files)} files.")
|
print(f"Deleted {deleted}/{len(bad_files)} files.")
|
||||||
else:
|
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:
|
else:
|
||||||
print("\nNo corrupted files found.")
|
print("\nNo corrupted files found.")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,11 +5,26 @@ from pathlib import Path
|
||||||
|
|
||||||
from pipeline.utils import download, extract_zip
|
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:
|
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()}")
|
print(f"Columns: {df.collect_schema().names()}")
|
||||||
parquet_path.parent.mkdir(parents=True, exist_ok=True)
|
parquet_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
df.sink_parquet(parquet_path, compression="zstd")
|
df.sink_parquet(parquet_path, compression="zstd")
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,53 @@
|
||||||
import argparse
|
import argparse
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
import polars as pl
|
import polars as pl
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
from pipeline.utils import download, extract_zip
|
from pipeline.utils import download, extract_zip
|
||||||
|
|
||||||
# Ofcom Connected Nations 2025 - Fixed broadband performance (output area & local authority level)
|
# 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
|
# 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"
|
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:
|
def convert_to_parquet(extract_dir: Path, parquet_path: Path) -> None:
|
||||||
# Find CSV files in the extracted directory
|
# Find CSV files in the extracted directory
|
||||||
|
|
@ -51,7 +90,7 @@ def main() -> None:
|
||||||
extract_dir = cache / "extracted"
|
extract_dir = cache / "extracted"
|
||||||
extracted_again_dir = cache / "extracted-again"
|
extracted_again_dir = cache / "extracted-again"
|
||||||
|
|
||||||
download(PERFORMANCE_URL, zip_path)
|
_obtain_zip(zip_path)
|
||||||
extract_zip(zip_path, extract_dir)
|
extract_zip(zip_path, extract_dir)
|
||||||
extract_zip(
|
extract_zip(
|
||||||
extract_dir
|
extract_dir
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ import polars as pl
|
||||||
# One row per candidate per constituency — we aggregate to per-constituency stats.
|
# One row per candidate per constituency — we aggregate to per-constituency stats.
|
||||||
URL = "https://electionresults.parliament.uk/general-elections/6/candidacies.csv"
|
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".
|
# Only parties that won seats in England are kept; the rest become "Other parties".
|
||||||
PARTY_MAP = {
|
PARTY_MAP = {
|
||||||
"Labour": "Labour",
|
"Labour": "Labour",
|
||||||
|
|
@ -37,13 +37,9 @@ def download_and_convert(output_path: Path) -> None:
|
||||||
.alias("party_group"),
|
.alias("party_group"),
|
||||||
)
|
)
|
||||||
|
|
||||||
# ── Per-constituency winner stats ──
|
# ── Per-constituency turnout ──
|
||||||
winners = df.filter(pl.col("Candidate result position") == 1).select(
|
turnout = df.filter(pl.col("Candidate result position") == 1).select(
|
||||||
pl.col("Constituency geographic code").alias("pcon"),
|
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)
|
(pl.col("Election valid vote count") / pl.col("Electorate") * 100)
|
||||||
.round(1)
|
.round(1)
|
||||||
.alias("turnout_pct"),
|
.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"}
|
rename_map = {col: f"% {col}" for col in party_pct.columns if col != "pcon"}
|
||||||
party_pct = party_pct.rename(rename_map)
|
party_pct = party_pct.rename(rename_map)
|
||||||
|
|
||||||
# Join winner stats with party vote shares
|
# Join turnout with party vote shares
|
||||||
result = winners.join(party_pct, on="pcon", how="left")
|
result = turnout.join(party_pct, on="pcon", how="left")
|
||||||
|
|
||||||
print(f"Constituencies: {result.height}")
|
print(f"Constituencies: {result.height}")
|
||||||
print(f"Columns: {result.columns}")
|
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)
|
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
result.write_parquet(output_path, compression="zstd")
|
result.write_parquet(output_path, compression="zstd")
|
||||||
|
|
|
||||||
98
pipeline/download/geolytix_retail_points.py
Normal file
98
pipeline/download/geolytix_retail_points.py
Normal 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()
|
||||||
|
|
@ -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:
|
def download_naptan(output: Path) -> None:
|
||||||
output.parent.mkdir(parents=True, exist_ok=True)
|
output.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
|
@ -50,24 +136,12 @@ def download_naptan(output: Path) -> None:
|
||||||
)
|
)
|
||||||
|
|
||||||
before = len(df)
|
before = len(df)
|
||||||
|
df = deduplicate_naptan(df)
|
||||||
|
|
||||||
# Deduplicate: one record per name+category+locality
|
print(
|
||||||
# (merges entrances, bus stop pairs on opposite sides of the road, etc.)
|
f"Deduplicated {before:,} → {len(df):,} stops "
|
||||||
has_loc = df.filter(
|
"(by name+category+locality; tube stations by normalized station name)"
|
||||||
pl.col("locality").is_not_null() & (pl.col("locality") != "")
|
|
||||||
)
|
)
|
||||||
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)
|
df.write_parquet(output)
|
||||||
size_mb = output.stat().st_size / (1024 * 1024)
|
size_mb = output.stat().st_size / (1024 * 1024)
|
||||||
|
|
|
||||||
|
|
@ -5,9 +5,9 @@ from pathlib import Path
|
||||||
|
|
||||||
from pipeline.utils import download
|
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
|
# 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:
|
def convert_to_parquet(csv_path: Path, parquet_path: Path) -> None:
|
||||||
|
|
|
||||||
|
|
@ -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 argparse
|
||||||
import tempfile
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
import polars as pl
|
import polars as pl
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from pipeline.utils import download
|
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
|
# Local authority district codes in England
|
||||||
# (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
|
|
||||||
LA_PREFIXES = ("E06", "E07", "E08", "E09")
|
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:
|
# Table 1 layout: row 0 = title, row 1 = column headers, row 2+ = data.
|
||||||
"""Read one bedroom category sheet, extract LA-level median rents."""
|
# 40 columns in repeating blocks of 4 (index, monthly change, annual change,
|
||||||
df = pl.read_excel(xls_path, sheet_id=sheet_id)
|
# 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:
|
df = df.select(
|
||||||
# 0=LA Code, 1=Area Code, 2=Area Name, 3=Count, 4=Mean, 5=LQ, 6=Median, 7=UQ
|
pl.col("column_1").alias("time_period"),
|
||||||
# First 4 rows are headers (title, notes, bedroom label, column headers)
|
pl.col("column_2").alias("area_code"),
|
||||||
df = df.slice(4)
|
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"),
|
||||||
|
)
|
||||||
|
|
||||||
area_code_col = df.columns[1]
|
# Filter to English local authorities
|
||||||
median_col = df.columns[6]
|
df = df.filter(
|
||||||
|
pl.any_horizontal(
|
||||||
return (
|
pl.col("area_code").str.starts_with(p) for p in LA_PREFIXES
|
||||||
df.select(
|
|
||||||
pl.col(area_code_col).alias("area_code"),
|
|
||||||
pl.col(median_col).alias("median_monthly_rent"),
|
|
||||||
)
|
|
||||||
.filter(
|
|
||||||
pl.col("area_code").is_not_null()
|
|
||||||
& 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),
|
|
||||||
pl.lit(bedrooms).cast(pl.UInt8).alias("bedrooms"),
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# 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}")
|
||||||
|
|
||||||
def convert_to_parquet(xls_path: Path, parquet_path: Path) -> None:
|
# 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 = []
|
frames = []
|
||||||
for sheet_id, bedrooms in BEDROOM_SHEETS.items():
|
for col, bedrooms in [
|
||||||
df = _read_sheet(xls_path, sheet_id, bedrooms)
|
("rent_1bed", 0), # Studio (proxy)
|
||||||
print(f" Sheet {sheet_id} (bedrooms={bedrooms}): {df.height} rows")
|
("rent_1bed", 1),
|
||||||
frames.append(df)
|
("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"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
combined = pl.concat(frames)
|
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"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))
|
print(combined.head(10))
|
||||||
|
|
||||||
|
parquet_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
combined.write_parquet(parquet_path, compression="zstd")
|
combined.write_parquet(parquet_path, compression="zstd")
|
||||||
print(f"Saved to {parquet_path}")
|
print(f"Saved to {parquet_path}")
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
description="Download and convert ONS private rental market statistics"
|
description="Download ONS private rent monthly price statistics"
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--output", type=Path, required=True, help="Output parquet file path"
|
"--output", type=Path, required=True, help="Output parquet file path"
|
||||||
|
|
@ -127,9 +93,9 @@ def main() -> None:
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
with tempfile.TemporaryDirectory() as cache_dir:
|
with tempfile.TemporaryDirectory() as cache_dir:
|
||||||
xls_path = Path(cache_dir) / "rental_prices.xls"
|
xlsx_path = Path(cache_dir) / "pipr_monthly.xlsx"
|
||||||
download(URL, xls_path, timeout=60)
|
download(URL, xlsx_path, timeout=120)
|
||||||
convert_to_parquet(xls_path, args.output)
|
convert_to_parquet(xlsx_path, args.output)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|
|
||||||
41
pipeline/download/test_geolytix_retail_points.py
Normal file
41
pipeline/download/test_geolytix_retail_points.py
Normal 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()
|
||||||
71
pipeline/download/test_naptan.py
Normal file
71
pipeline/download/test_naptan.py
Normal 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
|
||||||
|
|
@ -19,6 +19,8 @@ Output directory: property-data/transit/
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
import csv
|
||||||
|
import io
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
|
|
@ -108,6 +110,30 @@ def download_bods_gtfs(output_dir: Path) -> Path:
|
||||||
return dest
|
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:
|
def clean_gtfs(src: Path, dst: Path) -> None:
|
||||||
"""Fix R5-incompatible entries in GTFS.
|
"""Fix R5-incompatible entries in GTFS.
|
||||||
|
|
||||||
|
|
@ -128,8 +154,7 @@ def clean_gtfs(src: Path, dst: Path) -> None:
|
||||||
dropped = 0
|
dropped = 0
|
||||||
with zin.open(info) as f:
|
with zin.open(info) as f:
|
||||||
header = f.readline()
|
header = f.readline()
|
||||||
header_str = header.decode("utf-8").strip()
|
cols = _parse_csv_line(header)
|
||||||
cols = header_str.split(",")
|
|
||||||
arr_idx = (
|
arr_idx = (
|
||||||
cols.index("arrival_time") if "arrival_time" in cols else -1
|
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)
|
tmp.write(header)
|
||||||
|
|
||||||
for line in f:
|
for line in f:
|
||||||
line_str = line.decode("utf-8", errors="replace").strip()
|
parts = _parse_csv_line(line)
|
||||||
if not line_str:
|
if not parts:
|
||||||
continue
|
continue
|
||||||
parts = line_str.split(",")
|
|
||||||
skip = False
|
skip = False
|
||||||
for idx in [arr_idx, dep_idx]:
|
for idx in [arr_idx, dep_idx]:
|
||||||
if 0 <= idx < len(parts):
|
if 0 <= idx < len(parts):
|
||||||
|
|
@ -171,12 +195,13 @@ def clean_gtfs(src: Path, dst: Path) -> None:
|
||||||
|
|
||||||
elif info.filename == "feed_info.txt":
|
elif info.filename == "feed_info.txt":
|
||||||
data = zin.read(info).decode("utf-8")
|
data = zin.read(info).decode("utf-8")
|
||||||
lines = data.strip().split("\n")
|
rows = list(csv.reader(io.StringIO(data)))
|
||||||
header_line = lines[0]
|
if not rows:
|
||||||
feed_cols = header_line.split(",")
|
zout.writestr("feed_info.txt", data)
|
||||||
fixed_lines = [header_line]
|
continue
|
||||||
for line in lines[1:]:
|
feed_cols = rows[0]
|
||||||
parts = line.split(",")
|
fixed_rows = [feed_cols]
|
||||||
|
for parts in rows[1:]:
|
||||||
for i, col_name in enumerate(feed_cols):
|
for i, col_name in enumerate(feed_cols):
|
||||||
if "end_date" in col_name.lower() and i < len(parts):
|
if "end_date" in col_name.lower() and i < len(parts):
|
||||||
date_val = parts[i].strip('"')
|
date_val = parts[i].strip('"')
|
||||||
|
|
@ -187,8 +212,8 @@ def clean_gtfs(src: Path, dst: Path) -> None:
|
||||||
print(
|
print(
|
||||||
f" feed_info: capped end_date {date_val} → 20991231"
|
f" feed_info: capped end_date {date_val} → 20991231"
|
||||||
)
|
)
|
||||||
fixed_lines.append(",".join(parts))
|
fixed_rows.append(parts)
|
||||||
zout.writestr("feed_info.txt", "\n".join(fixed_lines) + "\n")
|
zout.writestr("feed_info.txt", _format_csv_rows(fixed_rows))
|
||||||
else:
|
else:
|
||||||
zout.writestr(info, zin.read(info))
|
zout.writestr(info, zin.read(info))
|
||||||
|
|
||||||
|
|
@ -237,12 +262,11 @@ def convert_high_freq_to_frequency_based(
|
||||||
# Step 1: Find metro/tram route IDs
|
# Step 1: Find metro/tram route IDs
|
||||||
target_route_ids: set[str] = set()
|
target_route_ids: set[str] = set()
|
||||||
with zin.open("routes.txt") as f:
|
with zin.open("routes.txt") as f:
|
||||||
header = f.readline().decode("utf-8").strip()
|
cols = _parse_csv_line(f.readline())
|
||||||
cols = header.split(",")
|
|
||||||
route_id_idx = cols.index("route_id")
|
route_id_idx = cols.index("route_id")
|
||||||
rt_idx = cols.index("route_type")
|
rt_idx = cols.index("route_type")
|
||||||
for line in f:
|
for line in f:
|
||||||
parts = line.decode("utf-8", errors="replace").strip().split(",")
|
parts = _parse_csv_line(line)
|
||||||
if not parts:
|
if not parts:
|
||||||
continue
|
continue
|
||||||
route_type = parts[rt_idx].strip('"')
|
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
|
# Step 2: Map target trips to grouping keys
|
||||||
trip_group_key: dict[str, tuple[str, str, str]] = {}
|
trip_group_key: dict[str, tuple[str, str, str]] = {}
|
||||||
with zin.open("trips.txt") as f:
|
with zin.open("trips.txt") as f:
|
||||||
header = f.readline().decode("utf-8").strip()
|
cols = _parse_csv_line(f.readline())
|
||||||
cols = header.split(",")
|
|
||||||
trip_id_idx = cols.index("trip_id")
|
trip_id_idx = cols.index("trip_id")
|
||||||
route_id_idx = cols.index("route_id")
|
route_id_idx = cols.index("route_id")
|
||||||
dir_idx = cols.index("direction_id") if "direction_id" in cols else -1
|
dir_idx = cols.index("direction_id") if "direction_id" in cols else -1
|
||||||
service_idx = cols.index("service_id")
|
service_idx = cols.index("service_id")
|
||||||
for line in f:
|
for line in f:
|
||||||
parts = line.decode("utf-8", errors="replace").strip().split(",")
|
parts = _parse_csv_line(line)
|
||||||
if not parts:
|
if not parts:
|
||||||
continue
|
continue
|
||||||
route_id = parts[route_id_idx].strip('"')
|
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_dep: dict[str, int] = {}
|
||||||
trip_first_stop: dict[str, str] = {}
|
trip_first_stop: dict[str, str] = {}
|
||||||
with zin.open("stop_times.txt") as f:
|
with zin.open("stop_times.txt") as f:
|
||||||
header = f.readline().decode("utf-8").strip()
|
cols = _parse_csv_line(f.readline())
|
||||||
cols = header.split(",")
|
|
||||||
trip_id_idx = cols.index("trip_id")
|
trip_id_idx = cols.index("trip_id")
|
||||||
dep_idx = cols.index("departure_time")
|
dep_idx = cols.index("departure_time")
|
||||||
seq_idx = cols.index("stop_sequence")
|
seq_idx = cols.index("stop_sequence")
|
||||||
stop_id_idx = cols.index("stop_id")
|
stop_id_idx = cols.index("stop_id")
|
||||||
for line in f:
|
for line in f:
|
||||||
parts = line.decode("utf-8", errors="replace").strip().split(",")
|
parts = _parse_csv_line(line)
|
||||||
if not parts:
|
if not parts:
|
||||||
continue
|
continue
|
||||||
trip_id = parts[trip_id_idx].strip('"')
|
trip_id = parts[trip_id_idx].strip('"')
|
||||||
|
|
@ -361,8 +383,7 @@ def convert_high_freq_to_frequency_based(
|
||||||
if info.filename == "trips.txt":
|
if info.filename == "trips.txt":
|
||||||
with zin.open(info) as f:
|
with zin.open(info) as f:
|
||||||
header = f.readline()
|
header = f.readline()
|
||||||
header_str = header.decode("utf-8").strip()
|
cols = _parse_csv_line(header)
|
||||||
cols = header_str.split(",")
|
|
||||||
trip_id_idx = cols.index("trip_id")
|
trip_id_idx = cols.index("trip_id")
|
||||||
|
|
||||||
tmp = tempfile.NamedTemporaryFile(
|
tmp = tempfile.NamedTemporaryFile(
|
||||||
|
|
@ -370,9 +391,7 @@ def convert_high_freq_to_frequency_based(
|
||||||
)
|
)
|
||||||
tmp.write(header)
|
tmp.write(header)
|
||||||
for line in f:
|
for line in f:
|
||||||
parts = (
|
parts = _parse_csv_line(line)
|
||||||
line.decode("utf-8", errors="replace").strip().split(",")
|
|
||||||
)
|
|
||||||
if not parts:
|
if not parts:
|
||||||
continue
|
continue
|
||||||
if parts[trip_id_idx].strip('"') not in trips_to_remove:
|
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":
|
elif info.filename == "stop_times.txt":
|
||||||
with zin.open(info) as f:
|
with zin.open(info) as f:
|
||||||
header = f.readline()
|
header = f.readline()
|
||||||
header_str = header.decode("utf-8").strip()
|
cols = _parse_csv_line(header)
|
||||||
cols = header_str.split(",")
|
|
||||||
trip_id_idx = cols.index("trip_id")
|
trip_id_idx = cols.index("trip_id")
|
||||||
|
|
||||||
tmp = tempfile.NamedTemporaryFile(
|
tmp = tempfile.NamedTemporaryFile(
|
||||||
|
|
@ -393,9 +411,7 @@ def convert_high_freq_to_frequency_based(
|
||||||
)
|
)
|
||||||
tmp.write(header)
|
tmp.write(header)
|
||||||
for line in f:
|
for line in f:
|
||||||
parts = (
|
parts = _parse_csv_line(line)
|
||||||
line.decode("utf-8", errors="replace").strip().split(",")
|
|
||||||
)
|
|
||||||
if not parts:
|
if not parts:
|
||||||
continue
|
continue
|
||||||
if parts[trip_id_idx].strip('"') not in trips_to_remove:
|
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:
|
with zipfile.ZipFile(src, "r") as zin:
|
||||||
# Load valid stop IDs
|
# Load valid stop IDs
|
||||||
with zin.open("stops.txt") as f:
|
with zin.open("stops.txt") as f:
|
||||||
header = f.readline().decode("utf-8").strip()
|
cols = _parse_csv_line(f.readline())
|
||||||
stop_id_idx = header.split(",").index("stop_id")
|
stop_id_idx = cols.index("stop_id")
|
||||||
lat_idx = header.split(",").index("stop_lat")
|
|
||||||
for line in f:
|
for line in f:
|
||||||
parts = line.decode("utf-8", errors="replace").strip().split(",")
|
parts = _parse_csv_line(line)
|
||||||
if parts:
|
if parts:
|
||||||
stop_ids.add(parts[stop_id_idx])
|
stop_ids.add(parts[stop_id_idx])
|
||||||
|
|
||||||
# Find trips with backwards travel times
|
# Find trips with backwards travel times
|
||||||
with zin.open("stop_times.txt") as f:
|
with zin.open("stop_times.txt") as f:
|
||||||
st_header = f.readline().decode("utf-8").strip()
|
st_cols = _parse_csv_line(f.readline())
|
||||||
st_cols = st_header.split(",")
|
|
||||||
trip_id_idx = st_cols.index("trip_id")
|
trip_id_idx = st_cols.index("trip_id")
|
||||||
dep_idx = st_cols.index("departure_time")
|
dep_idx = st_cols.index("departure_time")
|
||||||
|
|
||||||
prev_trip = ""
|
prev_trip = ""
|
||||||
prev_dep_secs = -1
|
prev_dep_secs = -1
|
||||||
for line in f:
|
for line in f:
|
||||||
parts = line.decode("utf-8", errors="replace").strip().split(",")
|
parts = _parse_csv_line(line)
|
||||||
if not parts:
|
if not parts:
|
||||||
continue
|
continue
|
||||||
trip_id = parts[trip_id_idx].strip('"')
|
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":
|
if info.filename == "stop_times.txt":
|
||||||
with zin.open(info) as f:
|
with zin.open(info) as f:
|
||||||
header = f.readline()
|
header = f.readline()
|
||||||
header_str = header.decode("utf-8").strip()
|
cols = _parse_csv_line(header)
|
||||||
cols = header_str.split(",")
|
|
||||||
trip_id_idx = cols.index("trip_id")
|
trip_id_idx = cols.index("trip_id")
|
||||||
stop_id_idx = cols.index("stop_id")
|
stop_id_idx = cols.index("stop_id")
|
||||||
seq_idx = cols.index("stop_sequence")
|
seq_idx = cols.index("stop_sequence")
|
||||||
|
|
@ -614,10 +627,9 @@ def clean_national_rail_gtfs(src: Path, dst: Path) -> None:
|
||||||
prev_trip = ""
|
prev_trip = ""
|
||||||
seq_counter = 0
|
seq_counter = 0
|
||||||
for line in f:
|
for line in f:
|
||||||
line_str = line.decode("utf-8", errors="replace").strip()
|
parts = _parse_csv_line(line)
|
||||||
if not line_str:
|
if not parts:
|
||||||
continue
|
continue
|
||||||
parts = line_str.split(",")
|
|
||||||
trip_id = parts[trip_id_idx].strip('"')
|
trip_id = parts[trip_id_idx].strip('"')
|
||||||
stop_id = parts[stop_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):
|
if old_seq != str(seq_counter):
|
||||||
seqs_renumbered += 1
|
seqs_renumbered += 1
|
||||||
|
|
||||||
tmp.write((",".join(parts) + "\n").encode("utf-8"))
|
tmp.write(_format_csv_row(parts))
|
||||||
|
|
||||||
tmp.close()
|
tmp.close()
|
||||||
zout.write(tmp.name, "stop_times.txt")
|
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":
|
elif info.filename == "stops.txt":
|
||||||
with zin.open(info) as f:
|
with zin.open(info) as f:
|
||||||
header = f.readline()
|
header = f.readline()
|
||||||
header_str = header.decode("utf-8").strip()
|
cols = _parse_csv_line(header)
|
||||||
cols = header_str.split(",")
|
|
||||||
lat_idx = cols.index("stop_lat")
|
lat_idx = cols.index("stop_lat")
|
||||||
lon_idx = cols.index("stop_lon")
|
lon_idx = cols.index("stop_lon")
|
||||||
|
|
||||||
|
|
@ -671,10 +682,9 @@ def clean_national_rail_gtfs(src: Path, dst: Path) -> None:
|
||||||
tmp.write(header)
|
tmp.write(header)
|
||||||
|
|
||||||
for line in f:
|
for line in f:
|
||||||
line_str = line.decode("utf-8", errors="replace").strip()
|
parts = _parse_csv_line(line)
|
||||||
if not line_str:
|
if not parts:
|
||||||
continue
|
continue
|
||||||
parts = line_str.split(",")
|
|
||||||
try:
|
try:
|
||||||
lat = float(parts[lat_idx])
|
lat = float(parts[lat_idx])
|
||||||
# Fix bogus Irish CIE coordinates (South Atlantic)
|
# Fix bogus Irish CIE coordinates (South Atlantic)
|
||||||
|
|
@ -685,7 +695,7 @@ def clean_national_rail_gtfs(src: Path, dst: Path) -> None:
|
||||||
coords_fixed += 1
|
coords_fixed += 1
|
||||||
except ValueError:
|
except ValueError:
|
||||||
pass
|
pass
|
||||||
tmp.write((",".join(parts) + "\n").encode("utf-8"))
|
tmp.write(_format_csv_row(parts))
|
||||||
|
|
||||||
tmp.close()
|
tmp.close()
|
||||||
zout.write(tmp.name, "stops.txt")
|
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":
|
elif info.filename == "routes.txt":
|
||||||
with zin.open(info) as f:
|
with zin.open(info) as f:
|
||||||
header = f.readline()
|
header = f.readline()
|
||||||
header_str = header.decode("utf-8").strip()
|
cols = _parse_csv_line(header)
|
||||||
cols = header_str.split(",")
|
|
||||||
rt_idx = cols.index("route_type")
|
rt_idx = cols.index("route_type")
|
||||||
|
|
||||||
tmp = tempfile.NamedTemporaryFile(
|
tmp = tempfile.NamedTemporaryFile(
|
||||||
|
|
@ -704,14 +713,13 @@ def clean_national_rail_gtfs(src: Path, dst: Path) -> None:
|
||||||
tmp.write(header)
|
tmp.write(header)
|
||||||
|
|
||||||
for line in f:
|
for line in f:
|
||||||
line_str = line.decode("utf-8", errors="replace").strip()
|
parts = _parse_csv_line(line)
|
||||||
if not line_str:
|
if not parts:
|
||||||
continue
|
continue
|
||||||
parts = line_str.split(",")
|
|
||||||
if parts[rt_idx].strip('"') == "714":
|
if parts[rt_idx].strip('"') == "714":
|
||||||
parts[rt_idx] = "3"
|
parts[rt_idx] = "3"
|
||||||
route_types_fixed += 1
|
route_types_fixed += 1
|
||||||
tmp.write((",".join(parts) + "\n").encode("utf-8"))
|
tmp.write(_format_csv_row(parts))
|
||||||
|
|
||||||
tmp.close()
|
tmp.close()
|
||||||
zout.write(tmp.name, "routes.txt")
|
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
|
# Remove trips that have backwards travel times
|
||||||
with zin.open(info) as f:
|
with zin.open(info) as f:
|
||||||
header = f.readline()
|
header = f.readline()
|
||||||
header_str = header.decode("utf-8").strip()
|
cols = _parse_csv_line(header)
|
||||||
cols = header_str.split(",")
|
|
||||||
trip_id_idx = cols.index("trip_id")
|
trip_id_idx = cols.index("trip_id")
|
||||||
|
|
||||||
tmp = tempfile.NamedTemporaryFile(
|
tmp = tempfile.NamedTemporaryFile(
|
||||||
|
|
@ -731,10 +738,9 @@ def clean_national_rail_gtfs(src: Path, dst: Path) -> None:
|
||||||
tmp.write(header)
|
tmp.write(header)
|
||||||
|
|
||||||
for line in f:
|
for line in f:
|
||||||
line_str = line.decode("utf-8", errors="replace").strip()
|
parts = _parse_csv_line(line)
|
||||||
if not line_str:
|
if not parts:
|
||||||
continue
|
continue
|
||||||
parts = line_str.split(",")
|
|
||||||
if parts[trip_id_idx].strip('"') not in bad_trip_ids:
|
if parts[trip_id_idx].strip('"') not in bad_trip_ids:
|
||||||
tmp.write(line)
|
tmp.write(line)
|
||||||
|
|
||||||
|
|
@ -746,8 +752,7 @@ def clean_national_rail_gtfs(src: Path, dst: Path) -> None:
|
||||||
# Cap end_date year to 2099
|
# Cap end_date year to 2099
|
||||||
with zin.open(info) as f:
|
with zin.open(info) as f:
|
||||||
header = f.readline()
|
header = f.readline()
|
||||||
header_str = header.decode("utf-8").strip()
|
cols = _parse_csv_line(header)
|
||||||
cols = header_str.split(",")
|
|
||||||
end_idx = cols.index("end_date")
|
end_idx = cols.index("end_date")
|
||||||
|
|
||||||
tmp = tempfile.NamedTemporaryFile(
|
tmp = tempfile.NamedTemporaryFile(
|
||||||
|
|
@ -756,10 +761,9 @@ def clean_national_rail_gtfs(src: Path, dst: Path) -> None:
|
||||||
tmp.write(header)
|
tmp.write(header)
|
||||||
|
|
||||||
for line in f:
|
for line in f:
|
||||||
line_str = line.decode("utf-8", errors="replace").strip()
|
parts = _parse_csv_line(line)
|
||||||
if not line_str:
|
if not parts:
|
||||||
continue
|
continue
|
||||||
parts = line_str.split(",")
|
|
||||||
date_val = parts[end_idx].strip('"')
|
date_val = parts[end_idx].strip('"')
|
||||||
if len(date_val) == 8:
|
if len(date_val) == 8:
|
||||||
try:
|
try:
|
||||||
|
|
@ -768,7 +772,7 @@ def clean_national_rail_gtfs(src: Path, dst: Path) -> None:
|
||||||
parts[end_idx] = "20991231"
|
parts[end_idx] = "20991231"
|
||||||
except ValueError:
|
except ValueError:
|
||||||
pass
|
pass
|
||||||
tmp.write((",".join(parts) + "\n").encode("utf-8"))
|
tmp.write(_format_csv_row(parts))
|
||||||
|
|
||||||
tmp.close()
|
tmp.close()
|
||||||
zout.write(tmp.name, "calendar.txt")
|
zout.write(tmp.name, "calendar.txt")
|
||||||
|
|
|
||||||
|
|
@ -60,12 +60,14 @@ _AREA_COLUMNS = [
|
||||||
"Good+ secondary schools within 5km",
|
"Good+ secondary schools within 5km",
|
||||||
"Good+ primary schools within 2km",
|
"Good+ primary schools within 2km",
|
||||||
"Good+ secondary 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
|
# Demographics
|
||||||
"Median age",
|
"Median age",
|
||||||
# Politics
|
# Politics
|
||||||
"Winning party",
|
|
||||||
"Voter turnout (%)",
|
"Voter turnout (%)",
|
||||||
"Majority (%)",
|
|
||||||
"% Labour",
|
"% Labour",
|
||||||
"% Conservative",
|
"% Conservative",
|
||||||
"% Liberal Democrat",
|
"% Liberal Democrat",
|
||||||
|
|
@ -116,15 +118,19 @@ def _build(
|
||||||
|
|
||||||
arcgis = (
|
arcgis = (
|
||||||
pl.scan_parquet(arcgis_path)
|
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
|
.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(
|
.select(
|
||||||
pl.col("pcds").alias("postcode"),
|
pl.col("pcds").alias("postcode"),
|
||||||
"lat",
|
"lat",
|
||||||
pl.col("long").alias("lon"),
|
pl.col("long").alias("lon"),
|
||||||
"lsoa21",
|
pl.col("lsoa21cd").alias("lsoa21"),
|
||||||
"oa21",
|
pl.col("oa21cd").alias("oa21"),
|
||||||
"pcon",
|
pl.col("pcon24cd").alias("pcon"),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
wide = wide.join(arcgis, on="postcode", how="left")
|
wide = wide.join(arcgis, on="postcode", how="left")
|
||||||
|
|
@ -349,18 +355,20 @@ def _build(
|
||||||
"good_secondary_5km": "Good+ secondary schools within 5km",
|
"good_secondary_5km": "Good+ secondary schools within 5km",
|
||||||
"good_primary_2km": "Good+ primary schools within 2km",
|
"good_primary_2km": "Good+ primary schools within 2km",
|
||||||
"good_secondary_2km": "Good+ secondary 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)",
|
"max_download_speed": "Max available download speed (Mbps)",
|
||||||
"serious_crime_avg_yr": "Serious crime (avg/yr)",
|
"serious_crime_avg_yr": "Serious crime (avg/yr)",
|
||||||
"minor_crime_avg_yr": "Minor crime (avg/yr)",
|
"minor_crime_avg_yr": "Minor crime (avg/yr)",
|
||||||
"serious_crime_per_1k": "Serious crime per 1k residents (avg/yr)",
|
"serious_crime_per_1k": "Serious crime per 1k residents (avg/yr)",
|
||||||
"minor_crime_per_1k": "Minor 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)",
|
"floor_height": "Interior height (m)",
|
||||||
"was_council_house": "Former council house",
|
"was_council_house": "Former council house",
|
||||||
"median_age": "Median age",
|
"median_age": "Median age",
|
||||||
"winning_party": "Winning party",
|
|
||||||
"turnout_pct": "Voter turnout (%)",
|
"turnout_pct": "Voter turnout (%)",
|
||||||
"majority_pct": "Majority (%)",
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
"""Compute good-rated school proximity counts per postcode."""
|
"""Compute Ofsted-rated school proximity counts per postcode."""
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
@ -8,14 +8,16 @@ import polars as pl
|
||||||
from pipeline.utils.poi_counts import count_pois_per_postcode
|
from pipeline.utils.poi_counts import count_pois_per_postcode
|
||||||
|
|
||||||
SCHOOL_GROUPS = {
|
SCHOOL_GROUPS = {
|
||||||
"good_primary": ["good_primary"],
|
"good_primary": ["good_primary", "outstanding_primary"],
|
||||||
"good_secondary": ["good_secondary"],
|
"good_secondary": ["good_secondary", "outstanding_secondary"],
|
||||||
|
"outstanding_primary": ["outstanding_primary"],
|
||||||
|
"outstanding_secondary": ["outstanding_secondary"],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
parser = argparse.ArgumentParser(
|
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(
|
parser.add_argument(
|
||||||
"--ofsted", type=Path, required=True, help="Ofsted inspection parquet"
|
"--ofsted", type=Path, required=True, help="Ofsted inspection parquet"
|
||||||
|
|
@ -28,19 +30,36 @@ def main():
|
||||||
)
|
)
|
||||||
args = parser.parse_args()
|
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 1–4 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(
|
ofsted = pl.read_parquet(args.ofsted).filter(
|
||||||
pl.col("Ofsted phase").is_in(["Primary", "Secondary"])
|
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(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(
|
ofsted = ofsted.with_columns(
|
||||||
pl.when(pl.col("Ofsted phase") == "Primary")
|
pl.when(pl.col("Ofsted phase") == "Primary")
|
||||||
.then(pl.lit("good_primary"))
|
.then(
|
||||||
.otherwise(pl.lit("good_secondary"))
|
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")
|
.alias("category")
|
||||||
).select(
|
).select(
|
||||||
pl.col("Postcode").alias("postcode"),
|
pl.col("Postcode").alias("postcode"),
|
||||||
|
|
|
||||||
59
pipeline/transform/test_transform_poi.py
Normal file
59
pipeline/transform/test_transform_poi.py
Normal 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"]
|
||||||
|
|
@ -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(
|
def transform(
|
||||||
input_path: Path,
|
input_path: Path,
|
||||||
naptan_path: Path | None = None,
|
naptan_path: Path | None = None,
|
||||||
boundary_path: Path | None = None,
|
boundary_path: Path | None = None,
|
||||||
|
grocery_retail_points_path: Path | None = None,
|
||||||
) -> pl.LazyFrame:
|
) -> pl.LazyFrame:
|
||||||
lf = pl.scan_parquet(input_path)
|
lf = pl.scan_parquet(input_path)
|
||||||
|
|
||||||
|
|
@ -1123,7 +1204,14 @@ def transform(
|
||||||
pl.col("category").replace_strict(NAPTAN_EMOJIS).alias("emoji"),
|
pl.col("category").replace_strict(NAPTAN_EMOJIS).alias("emoji"),
|
||||||
pl.lit("Public Transport").alias("group"),
|
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():
|
def main():
|
||||||
|
|
@ -1142,12 +1230,22 @@ def main():
|
||||||
required=True,
|
required=True,
|
||||||
help="England boundary GeoJSON file",
|
help="England boundary GeoJSON file",
|
||||||
)
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--grocery-retail-points",
|
||||||
|
type=Path,
|
||||||
|
help="GEOLYTIX Grocery Retail Points parquet",
|
||||||
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--output", type=Path, required=True, help="Output filtered POIs parquet file"
|
"--output", type=Path, required=True, help="Output filtered POIs parquet file"
|
||||||
)
|
)
|
||||||
args = parser.parse_args()
|
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)
|
df.write_parquet(args.output)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,19 +10,19 @@ from scipy.spatial import cKDTree
|
||||||
def build_postcode_mapping(arcgis_path: Path) -> pl.DataFrame:
|
def build_postcode_mapping(arcgis_path: Path) -> pl.DataFrame:
|
||||||
"""Build a mapping from terminated England postcodes to their nearest active postcode.
|
"""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.
|
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 = (
|
active = (
|
||||||
arcgis.filter(pl.col("doterm").is_null())
|
arcgis.filter(pl.col("doterm").is_null())
|
||||||
.select("pcds", "oseast1m", "osnrth1m")
|
.select("pcds", "east1m", "north1m")
|
||||||
.collect()
|
.collect()
|
||||||
)
|
)
|
||||||
terminated = (
|
terminated = (
|
||||||
arcgis.filter(pl.col("doterm").is_not_null())
|
arcgis.filter(pl.col("doterm").is_not_null())
|
||||||
.select("pcds", "oseast1m", "osnrth1m")
|
.select("pcds", "east1m", "north1m")
|
||||||
.collect()
|
.collect()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -39,10 +39,10 @@ def build_postcode_mapping(arcgis_path: Path) -> pl.DataFrame:
|
||||||
)
|
)
|
||||||
|
|
||||||
active_coords = np.column_stack(
|
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_coords = np.column_stack(
|
||||||
[terminated["oseast1m"].to_numpy(), terminated["osnrth1m"].to_numpy()]
|
[terminated["east1m"].to_numpy(), terminated["north1m"].to_numpy()]
|
||||||
)
|
)
|
||||||
|
|
||||||
tree = cKDTree(active_coords)
|
tree = cKDTree(active_coords)
|
||||||
|
|
|
||||||
|
|
@ -26,10 +26,8 @@ dependencies = [
|
||||||
"pyproj>=3.7.2",
|
"pyproj>=3.7.2",
|
||||||
"pyshp>=2.3.0",
|
"pyshp>=2.3.0",
|
||||||
"folium>=0.20.0",
|
"folium>=0.20.0",
|
||||||
"flask",
|
|
||||||
"httpx",
|
"httpx",
|
||||||
"polars",
|
"polars",
|
||||||
"fake-useragent>=2.2.0",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.uv]
|
[tool.uv]
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
|
"test": "npm run build && node --test dist/**/*.test.js",
|
||||||
"start": "node dist/server.js",
|
"start": "node dist/server.js",
|
||||||
"dev": "tsc --watch & node --watch dist/server.js"
|
"dev": "tsc --watch & node --watch dist/server.js"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,14 @@
|
||||||
import express from 'express';
|
import express, { type Request, type Response } from 'express';
|
||||||
import { ScreenshotCache } from './cache.js';
|
import { ScreenshotCache } from './cache.js';
|
||||||
import { takeScreenshot, checkWebGL, closeBrowser, initialize } from './screenshot.js';
|
import { takeScreenshot, checkWebGL, closeBrowser, initialize } from './screenshot.js';
|
||||||
|
import { buildScreenshotRequest, ValidationError } from './validation.js';
|
||||||
|
|
||||||
const PORT = parseInt(process.env.PORT || '8002', 10);
|
const PORT = parseInt(process.env.PORT || '8002', 10);
|
||||||
const APP_URL = process.env.APP_URL;
|
const APP_URL = process.env.APP_URL;
|
||||||
const CACHE_DIR = process.env.CACHE_DIR;
|
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) {
|
if (!APP_URL) {
|
||||||
console.error('Error: APP_URL environment variable is required');
|
console.error('Error: APP_URL environment variable is required');
|
||||||
|
|
@ -18,6 +22,96 @@ if (!CACHE_DIR) {
|
||||||
|
|
||||||
const cache = new ScreenshotCache(CACHE_DIR);
|
const cache = new ScreenshotCache(CACHE_DIR);
|
||||||
const app = express();
|
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) => {
|
app.get('/health', (_req, res) => {
|
||||||
res.status(200).send('ok');
|
res.status(200).send('ok');
|
||||||
|
|
@ -33,28 +127,9 @@ app.get('/debug', async (_req, res) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get('/screenshot', async (req, res) => {
|
app.get('/screenshot', async (req, res) => {
|
||||||
|
let releaseSlot: (() => void) | null = null;
|
||||||
try {
|
try {
|
||||||
const qs = new URLSearchParams();
|
const { pagePath, qs } = buildScreenshotRequest(req.query as Record<string, unknown>);
|
||||||
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 : '/';
|
|
||||||
if (pagePath !== '/') qs.set('path', pagePath);
|
if (pagePath !== '/') qs.set('path', pagePath);
|
||||||
|
|
||||||
// Include auth status in cache key so authenticated screenshots
|
// Include auth status in cache key so authenticated screenshots
|
||||||
|
|
@ -75,6 +150,16 @@ app.get('/screenshot', async (req, res) => {
|
||||||
return;
|
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
|
// Build the URL for the frontend in screenshot mode
|
||||||
qs.set('screenshot', '1');
|
qs.set('screenshot', '1');
|
||||||
const url = `${APP_URL}${pagePath}?${qs}`;
|
const url = `${APP_URL}${pagePath}?${qs}`;
|
||||||
|
|
@ -90,8 +175,14 @@ app.get('/screenshot', async (req, res) => {
|
||||||
res.setHeader('X-Cache', 'MISS');
|
res.setHeader('X-Cache', 'MISS');
|
||||||
res.send(jpeg);
|
res.send(jpeg);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (err instanceof ValidationError) {
|
||||||
|
res.status(err.status).json({ error: err.message });
|
||||||
|
return;
|
||||||
|
}
|
||||||
console.error('Screenshot failed:', err);
|
console.error('Screenshot failed:', err);
|
||||||
res.status(500).json({ error: 'Screenshot failed' });
|
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(`Screenshot service listening on port ${PORT}`);
|
||||||
console.log(` APP_URL: ${APP_URL}`);
|
console.log(` APP_URL: ${APP_URL}`);
|
||||||
console.log(` CACHE_DIR: ${CACHE_DIR}`);
|
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.
|
// Pre-warm browser and populate network cache in background.
|
||||||
// The health endpoint is available immediately; screenshot requests
|
// The health endpoint is available immediately; screenshot requests
|
||||||
|
|
|
||||||
58
screenshot/src/validation.test.ts
Normal file
58
screenshot/src/validation.test.ts
Normal 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);
|
||||||
|
});
|
||||||
114
screenshot/src/validation.ts
Normal file
114
screenshot/src/validation.ts
Normal 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 };
|
||||||
|
}
|
||||||
|
|
@ -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
|
|
@ -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
|
|
@ -514,10 +514,7 @@ pub fn precompute_h3(lat: &[f32], lon: &[f32]) -> anyhow::Result<Vec<u64>> {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PropertyData {
|
impl PropertyData {
|
||||||
pub fn load(
|
pub fn load(properties_path: &Path, postcode_features_path: &Path) -> anyhow::Result<Self> {
|
||||||
properties_path: &Path,
|
|
||||||
postcode_features_path: &Path,
|
|
||||||
) -> anyhow::Result<Self> {
|
|
||||||
// Load postcode.parquet
|
// Load postcode.parquet
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
"Loading postcode features from {:?}",
|
"Loading postcode features from {:?}",
|
||||||
|
|
@ -643,11 +640,22 @@ impl PropertyData {
|
||||||
}
|
}
|
||||||
let df = combined
|
let df = combined
|
||||||
.lazy()
|
.lazy()
|
||||||
|
.filter(col("lat").is_not_null().and(col("lon").is_not_null()))
|
||||||
.select(select_exprs)
|
.select(select_exprs)
|
||||||
.collect()
|
.collect()
|
||||||
.context("Failed to select columns from combined data")?;
|
.context("Failed to select columns from combined data")?;
|
||||||
|
|
||||||
let row_count = df.height();
|
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");
|
tracing::info!(rows = row_count, "Combined data selected");
|
||||||
|
|
||||||
let lat_series = df
|
let lat_series = df
|
||||||
|
|
@ -659,8 +667,8 @@ impl PropertyData {
|
||||||
.f32()
|
.f32()
|
||||||
.context("Failed to read 'lat' as f32")?
|
.context("Failed to read 'lat' as f32")?
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|value| value.unwrap_or(0.0))
|
.map(|value| value.context("Missing 'lat' value after coordinate filter"))
|
||||||
.collect();
|
.collect::<anyhow::Result<Vec<_>>>()?;
|
||||||
|
|
||||||
let lon_series = df
|
let lon_series = df
|
||||||
.column("lon")
|
.column("lon")
|
||||||
|
|
@ -671,8 +679,14 @@ impl PropertyData {
|
||||||
.f32()
|
.f32()
|
||||||
.context("Failed to read 'lon' as f32")?
|
.context("Failed to read 'lon' as f32")?
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|value| value.unwrap_or(0.0))
|
.map(|value| value.context("Missing 'lon' value after coordinate filter"))
|
||||||
.collect();
|
.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");
|
tracing::info!("Extracting numeric feature columns");
|
||||||
let numeric_col_major: Vec<Vec<f32>> = numeric_names
|
let numeric_col_major: Vec<Vec<f32>> = numeric_names
|
||||||
|
|
@ -705,12 +719,19 @@ impl PropertyData {
|
||||||
})
|
})
|
||||||
.collect::<anyhow::Result<Vec<_>>>()?;
|
.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_min = Vec::with_capacity(num_features);
|
||||||
let mut quant_range = Vec::with_capacity(num_features);
|
let mut quant_range = Vec::with_capacity(num_features);
|
||||||
for stats in &numeric_feature_stats {
|
for (feat_idx, stats) in numeric_feature_stats.iter().enumerate() {
|
||||||
let min = stats.histogram.min;
|
let (min, max) = match features::bounds_for(numeric_names[feat_idx]) {
|
||||||
let max = stats.histogram.max;
|
Some(Bounds::Fixed { min, max }) => (*min, *max),
|
||||||
|
_ => (stats.histogram.min, stats.histogram.max),
|
||||||
|
};
|
||||||
quant_min.push(min);
|
quant_min.push(min);
|
||||||
quant_range.push(if max > min { max - min } else { 0.0 });
|
quant_range.push(if max > min { max - min } else { 0.0 });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -32,8 +32,7 @@ pub struct FeatureConfig {
|
||||||
|
|
||||||
/// Features whose histogram bins should be exactly 1 unit wide (one per integer).
|
/// Features whose histogram bins should be exactly 1 unit wide (one per integer).
|
||||||
/// p1/p99 are snapped to integer boundaries before binning.
|
/// p1/p99 are snapped to integer boundaries before binning.
|
||||||
pub const INTEGER_BIN_FEATURES: &[&str] =
|
pub const INTEGER_BIN_FEATURES: &[&str] = &["Number of bedrooms & living rooms"];
|
||||||
&["Number of bedrooms & living rooms"];
|
|
||||||
|
|
||||||
pub struct EnumFeatureConfig {
|
pub struct EnumFeatureConfig {
|
||||||
pub name: &'static str,
|
pub name: &'static str,
|
||||||
|
|
@ -143,8 +142,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
||||||
name: "Estimated monthly rent",
|
name: "Estimated monthly rent",
|
||||||
bounds: Bounds::Percentile { low: 2.0, high: 98.0 },
|
bounds: Bounds::Percentile { low: 2.0, high: 98.0 },
|
||||||
step: 25.0,
|
step: 25.0,
|
||||||
description: "Median monthly private rent for the local area",
|
description: "Mean 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.",
|
detail: "Mean monthly rental price from ONS Price Index of Private Rents (PIPR), matched by local authority and bedroom count.",
|
||||||
source: "ons-rental",
|
source: "ons-rental",
|
||||||
prefix: "£",
|
prefix: "£",
|
||||||
suffix: "/mo",
|
suffix: "/mo",
|
||||||
|
|
@ -302,6 +301,36 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
||||||
raw: false,
|
raw: false,
|
||||||
absolute: 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 {
|
Feature::Numeric(FeatureConfig {
|
||||||
name: "Good+ primary schools within 5km",
|
name: "Good+ primary schools within 5km",
|
||||||
bounds: Bounds::Fixed {
|
bounds: Bounds::Fixed {
|
||||||
|
|
@ -332,6 +361,36 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
||||||
raw: false,
|
raw: false,
|
||||||
absolute: 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 {
|
Feature::Numeric(FeatureConfig {
|
||||||
name: "Education, Skills and Training Score",
|
name: "Education, Skills and Training Score",
|
||||||
bounds: Bounds::Percentile {
|
bounds: Bounds::Percentile {
|
||||||
|
|
@ -826,21 +885,6 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
||||||
FeatureGroup {
|
FeatureGroup {
|
||||||
name: "Politics",
|
name: "Politics",
|
||||||
features: &[
|
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 {
|
Feature::Numeric(FeatureConfig {
|
||||||
name: "% Labour",
|
name: "% Labour",
|
||||||
bounds: Bounds::Fixed {
|
bounds: Bounds::Fixed {
|
||||||
|
|
@ -947,22 +991,6 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
||||||
raw: false,
|
raw: false,
|
||||||
absolute: 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 {
|
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.
|
/// The server will panic at startup if the data contains groups not in this list or vice versa.
|
||||||
pub const POI_GROUP_ORDER: &[&str] = &[
|
pub const POI_GROUP_ORDER: &[&str] = &[
|
||||||
"Public Transport",
|
"Public Transport",
|
||||||
|
"Groceries",
|
||||||
"Leisure",
|
"Leisure",
|
||||||
"Education",
|
"Education",
|
||||||
"Health",
|
"Health",
|
||||||
"Emergency Services",
|
"Emergency Services",
|
||||||
"Other",
|
"Other",
|
||||||
"Groceries",
|
|
||||||
"Local Businesses",
|
"Local Businesses",
|
||||||
"Culture",
|
"Culture",
|
||||||
"Services",
|
"Services",
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,38 @@ use tracing_subscriber::EnvFilter;
|
||||||
|
|
||||||
use state::{AppState, SharedState};
|
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)]
|
#[derive(Parser)]
|
||||||
#[command(
|
#[command(
|
||||||
name = "perfect-postcode",
|
name = "perfect-postcode",
|
||||||
|
|
@ -165,10 +197,8 @@ async fn main() -> anyhow::Result<()> {
|
||||||
cli.properties.display(),
|
cli.properties.display(),
|
||||||
cli.postcode_features.display(),
|
cli.postcode_features.display(),
|
||||||
);
|
);
|
||||||
let property_data = data::PropertyData::load(
|
let property_data = data::PropertyData::load(&cli.properties, &cli.postcode_features)?;
|
||||||
&cli.properties,
|
trim_allocator("property data load");
|
||||||
&cli.postcode_features,
|
|
||||||
)?;
|
|
||||||
info!(
|
info!(
|
||||||
rows = property_data.lat.len(),
|
rows = property_data.lat.len(),
|
||||||
features = property_data.num_features,
|
features = property_data.num_features,
|
||||||
|
|
@ -197,6 +227,7 @@ async fn main() -> anyhow::Result<()> {
|
||||||
|
|
||||||
info!("Loading POI data from {}", poi_path.display());
|
info!("Loading POI data from {}", poi_path.display());
|
||||||
let poi_data = data::POIData::load(&poi_path)?;
|
let poi_data = data::POIData::load(&poi_path)?;
|
||||||
|
trim_allocator("poi data load");
|
||||||
info!(pois = poi_data.lat.len(), "POI data loaded");
|
info!(pois = poi_data.lat.len(), "POI data loaded");
|
||||||
|
|
||||||
info!("Building POI spatial grid index");
|
info!("Building POI spatial grid index");
|
||||||
|
|
@ -209,6 +240,7 @@ async fn main() -> anyhow::Result<()> {
|
||||||
}
|
}
|
||||||
info!("Loading place data from {}", places_path.display());
|
info!("Loading place data from {}", places_path.display());
|
||||||
let place_data = data::PlaceData::load(places_path)?;
|
let place_data = data::PlaceData::load(places_path)?;
|
||||||
|
trim_allocator("place data load");
|
||||||
info!(places = place_data.name.len(), "Place data loaded");
|
info!(places = place_data.name.len(), "Place data loaded");
|
||||||
|
|
||||||
// Load postcode boundaries
|
// Load postcode boundaries
|
||||||
|
|
@ -224,6 +256,7 @@ async fn main() -> anyhow::Result<()> {
|
||||||
postcodes_path.display()
|
postcodes_path.display()
|
||||||
);
|
);
|
||||||
let postcode_data = data::PostcodeData::load(postcodes_path)?;
|
let postcode_data = data::PostcodeData::load(postcodes_path)?;
|
||||||
|
trim_allocator("postcode boundary load");
|
||||||
info!(
|
info!(
|
||||||
postcodes = postcode_data.postcodes.len(),
|
postcodes = postcode_data.postcodes.len(),
|
||||||
"Postcode boundaries loaded"
|
"Postcode boundaries loaded"
|
||||||
|
|
@ -450,7 +483,10 @@ async fn main() -> anyhow::Result<()> {
|
||||||
"/api/postcode-properties",
|
"/api/postcode-properties",
|
||||||
get(routes::get_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(
|
.route(
|
||||||
"/api/export",
|
"/api/export",
|
||||||
get(routes::get_export).layer(ConcurrencyLimitLayer::new(3)),
|
get(routes::get_export).layer(ConcurrencyLimitLayer::new(3)),
|
||||||
|
|
|
||||||
|
|
@ -281,6 +281,7 @@ pub fn build_system_prompt(
|
||||||
- \"cheap\" / \"affordable\" = lower price range. \"expensive\" = higher price range.\n\
|
- \"cheap\" / \"affordable\" = lower price range. \"expensive\" = higher price range.\n\
|
||||||
- \"low crime\" / \"safe\" = low values on Serious crime and Minor crime summary features. \
|
- \"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\
|
\"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 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 \
|
- 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\
|
(note: this counts bedrooms + living rooms combined, so 3 bed ~ min 4).\n\
|
||||||
|
|
@ -424,6 +425,16 @@ pub fn build_system_prompt(
|
||||||
.to_string(),
|
.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(
|
parts.push(
|
||||||
"\nUser: \"3 bed flat under 300k with fast broadband near the beach\"\n\
|
"\nUser: \"3 bed flat under 300k with fast broadband near the beach\"\n\
|
||||||
Output: {\"numeric_filters\": [\
|
Output: {\"numeric_filters\": [\
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ use tracing::{info, warn};
|
||||||
use crate::auth::OptionalUser;
|
use crate::auth::OptionalUser;
|
||||||
use crate::consts::NAN_U16;
|
use crate::consts::NAN_U16;
|
||||||
use crate::data::QuantRef;
|
use crate::data::QuantRef;
|
||||||
|
use crate::features::INTEGER_BIN_FEATURES;
|
||||||
use crate::licensing::check_license_bounds;
|
use crate::licensing::check_license_bounds;
|
||||||
use crate::parsing::{parse_field_indices, parse_filters, require_bounds, row_passes_filters};
|
use crate::parsing::{parse_field_indices, parse_filters, require_bounds, row_passes_filters};
|
||||||
use crate::routes::{fetch_screenshot_bytes, FeatureInfo};
|
use crate::routes::{fetch_screenshot_bytes, FeatureInfo};
|
||||||
|
|
@ -315,6 +316,11 @@ pub async fn get_export(
|
||||||
})
|
})
|
||||||
.collect();
|
.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
|
// Build Excel number formats per feature index for unit display
|
||||||
let mut feat_num_fmts: FxHashMap<usize, Format> = FxHashMap::default();
|
let mut feat_num_fmts: FxHashMap<usize, Format> = FxHashMap::default();
|
||||||
for &feat_idx in &all_feature_indices {
|
for &feat_idx in &all_feature_indices {
|
||||||
|
|
@ -324,6 +330,8 @@ pub async fn get_export(
|
||||||
}
|
}
|
||||||
let num_fmt_str = if !prefix.is_empty() {
|
let num_fmt_str = if !prefix.is_empty() {
|
||||||
format!("\"{}\"#,##0", prefix)
|
format!("\"{}\"#,##0", prefix)
|
||||||
|
} else if integer_feature_indices.contains(&feat_idx) {
|
||||||
|
format!("#,##0\"{}\"", suffix)
|
||||||
} else {
|
} else {
|
||||||
format!("#,##0.0\"{}\"", suffix)
|
format!("#,##0.0\"{}\"", suffix)
|
||||||
};
|
};
|
||||||
|
|
@ -488,7 +496,11 @@ pub async fn get_export(
|
||||||
} else {
|
} else {
|
||||||
let fc = agg.finite_counts[feat_idx];
|
let fc = agg.finite_counts[feat_idx];
|
||||||
if fc > 0 {
|
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) {
|
if let Some(fmt) = feat_num_fmts.get(&feat_idx) {
|
||||||
sheet
|
sheet
|
||||||
.write_number_with_format(row, col, mean, fmt)
|
.write_number_with_format(row, col, mean, fmt)
|
||||||
|
|
|
||||||
|
|
@ -85,79 +85,80 @@ pub async fn get_filter_counts(
|
||||||
let has_travel = !travel_entries.is_empty();
|
let has_travel = !travel_entries.is_empty();
|
||||||
let (pc_interner, pc_keys) = state.data.postcode_parts();
|
let (pc_interner, pc_keys) = state.data.postcode_parts();
|
||||||
|
|
||||||
let rows = state.grid.query(south, west, north, east);
|
let row_count = state.grid.count_in_bounds(south, west, north, east);
|
||||||
let row_count = rows.len();
|
|
||||||
|
|
||||||
let mut total_passing: u32 = 0;
|
let mut total_passing: u32 = 0;
|
||||||
let mut impacts = vec![0u32; num_total_filters];
|
let mut impacts = vec![0u32; num_total_filters];
|
||||||
|
|
||||||
for row_idx in rows {
|
state
|
||||||
let row = row_idx as usize;
|
.grid
|
||||||
let base = row * num_features;
|
.for_each_in_bounds(south, west, north, east, |row_idx| {
|
||||||
let mut fail_count: u32 = 0;
|
let row = row_idx as usize;
|
||||||
let mut fail_index: usize = 0;
|
let base = row * num_features;
|
||||||
|
let mut fail_count: u32 = 0;
|
||||||
|
let mut fail_index: usize = 0;
|
||||||
|
|
||||||
// Test numeric filters
|
// Test numeric filters
|
||||||
for (i, f) in parsed_filters.iter().enumerate() {
|
for (i, f) in parsed_filters.iter().enumerate() {
|
||||||
let raw = feature_data[base + f.feat_idx];
|
|
||||||
if raw == NAN_U16 || raw < f.min_u16 || raw > f.max_u16 {
|
|
||||||
fail_count += 1;
|
|
||||||
fail_index = i;
|
|
||||||
if fail_count > 1 {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test enum filters
|
|
||||||
if fail_count <= 1 {
|
|
||||||
for (i, f) in parsed_enum_filters.iter().enumerate() {
|
|
||||||
let raw = feature_data[base + f.feat_idx];
|
let raw = feature_data[base + f.feat_idx];
|
||||||
if raw == NAN_U16 || !f.allowed.contains(&raw) {
|
if raw == NAN_U16 || raw < f.min_u16 || raw > f.max_u16 {
|
||||||
fail_count += 1;
|
fail_count += 1;
|
||||||
fail_index = parsed_filters.len() + i;
|
fail_index = i;
|
||||||
if fail_count > 1 {
|
if fail_count > 1 {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Test travel time filters
|
// Test enum filters
|
||||||
if fail_count <= 1 && has_travel {
|
if fail_count <= 1 {
|
||||||
let postcode = pc_interner.resolve(&pc_keys[row]);
|
for (i, f) in parsed_enum_filters.iter().enumerate() {
|
||||||
for (slot, &ti) in travel_filter_indices.iter().enumerate() {
|
let raw = feature_data[base + f.feat_idx];
|
||||||
let entry = &travel_entries[ti];
|
if raw == NAN_U16 || !f.allowed.contains(&raw) {
|
||||||
let minutes = travel_data[ti].get(postcode).map(|r| {
|
fail_count += 1;
|
||||||
if entry.use_best {
|
fail_index = parsed_filters.len() + i;
|
||||||
r.best_minutes.unwrap_or(r.minutes)
|
if fail_count > 1 {
|
||||||
} else {
|
break;
|
||||||
r.minutes
|
}
|
||||||
}
|
|
||||||
});
|
|
||||||
let passes = match (minutes, entry.filter_min, entry.filter_max) {
|
|
||||||
(Some(mins), Some(fmin), Some(fmax)) => {
|
|
||||||
(mins as f32) >= fmin && (mins as f32) <= fmax
|
|
||||||
}
|
|
||||||
(None, Some(_), Some(_)) => false,
|
|
||||||
_ => true,
|
|
||||||
};
|
|
||||||
if !passes {
|
|
||||||
fail_count += 1;
|
|
||||||
fail_index = num_regular + slot;
|
|
||||||
if fail_count > 1 {
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
match fail_count {
|
// Test travel time filters
|
||||||
0 => total_passing += 1,
|
if fail_count <= 1 && has_travel {
|
||||||
1 => impacts[fail_index] += 1,
|
let postcode = pc_interner.resolve(&pc_keys[row]);
|
||||||
_ => {}
|
for (slot, &ti) in travel_filter_indices.iter().enumerate() {
|
||||||
}
|
let entry = &travel_entries[ti];
|
||||||
}
|
let minutes = travel_data[ti].get(postcode).map(|r| {
|
||||||
|
if entry.use_best {
|
||||||
|
r.best_minutes.unwrap_or(r.minutes)
|
||||||
|
} else {
|
||||||
|
r.minutes
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let passes = match (minutes, entry.filter_min, entry.filter_max) {
|
||||||
|
(Some(mins), Some(fmin), Some(fmax)) => {
|
||||||
|
(mins as f32) >= fmin && (mins as f32) <= fmax
|
||||||
|
}
|
||||||
|
(None, Some(_), Some(_)) => false,
|
||||||
|
_ => true,
|
||||||
|
};
|
||||||
|
if !passes {
|
||||||
|
fail_count += 1;
|
||||||
|
fail_index = num_regular + slot;
|
||||||
|
if fail_count > 1 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
match fail_count {
|
||||||
|
0 => total_passing += 1,
|
||||||
|
1 => impacts[fail_index] += 1,
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Map filter indices back to feature/travel names
|
// Map filter indices back to feature/travel names
|
||||||
let mut impact_map: FxHashMap<String, u32> = FxHashMap::default();
|
let mut impact_map: FxHashMap<String, u32> = FxHashMap::default();
|
||||||
|
|
|
||||||
|
|
@ -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::extract::{Path, State};
|
||||||
use axum::http::StatusCode;
|
use axum::http::StatusCode;
|
||||||
|
|
@ -7,9 +8,39 @@ use axum::{Extension, Json};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use tracing::{info, warn};
|
use tracing::{info, warn};
|
||||||
|
|
||||||
use crate::auth::OptionalUser;
|
use crate::auth::{OptionalUser, PocketBaseUser};
|
||||||
use crate::pocketbase::get_superuser_token;
|
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)]
|
#[derive(Serialize)]
|
||||||
struct InviteResponse {
|
struct InviteResponse {
|
||||||
|
|
@ -87,6 +118,207 @@ fn generate_invite_code() -> String {
|
||||||
chars.into_iter().collect()
|
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,
|
/// 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).
|
/// but can explicitly request "referral" type. Licensed non-admin users always create "referral" invites (30% off).
|
||||||
pub async fn post_invites(
|
pub async fn post_invites(
|
||||||
|
|
@ -319,154 +551,80 @@ pub async fn post_redeem_invite(
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Look up invite
|
let _redemption_guard = match InviteRedemptionGuard::acquire(&req.code) {
|
||||||
let filter = format!("code=\"{}\" && used_by_id=\"\"", req.code);
|
Some(guard) => guard,
|
||||||
let lookup_url = format!(
|
None => {
|
||||||
"{pb_url}/api/collections/invites/records?filter={}&perPage=1",
|
return (
|
||||||
urlencoding::encode(&filter)
|
StatusCode::CONFLICT,
|
||||||
);
|
"Invite redemption is already in progress",
|
||||||
|
)
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
let res = match state
|
let invite = match lookup_unused_invite(&state, pb_url, &token, &req.code).await {
|
||||||
.http_client
|
Ok(Some(invite)) => invite,
|
||||||
.get(&lookup_url)
|
Ok(None) => {
|
||||||
.header("Authorization", format!("Bearer {token}"))
|
return (StatusCode::NOT_FOUND, "Invalid or already used invite code").into_response()
|
||||||
.send()
|
}
|
||||||
.await
|
Err(response) => return response,
|
||||||
{
|
};
|
||||||
Ok(r) => r,
|
|
||||||
Err(err) => {
|
let invite_id = match invite["id"].as_str().filter(|id| !id.is_empty()) {
|
||||||
warn!("Failed to look up invite: {err}");
|
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();
|
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" {
|
if invite_type == "admin" {
|
||||||
// Grant license directly
|
if let Err(response) = grant_license_for_invite(&state, pb_url, &token, &user.id).await {
|
||||||
let update_url = format!("{pb_url}/api/collections/users/records/{}", user.id);
|
return response;
|
||||||
let res = state
|
|
||||||
.http_client
|
|
||||||
.patch(&update_url)
|
|
||||||
.header("Authorization", format!("Bearer {token}"))
|
|
||||||
.json(&serde_json::json!({ "subscription": "licensed" }))
|
|
||||||
.send()
|
|
||||||
.await;
|
|
||||||
|
|
||||||
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 {
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let price_pence = super::pricing::price_for_count(count);
|
|
||||||
|
|
||||||
let secret_key = &state.stripe_secret_key;
|
if let Err(response) = mark_invite_used(&state, pb_url, &token, invite_id, &user.id).await {
|
||||||
let public_url = &state.public_url;
|
return response;
|
||||||
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(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");
|
|
||||||
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()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let checkout_url = match create_referral_checkout(&state, &user).await {
|
||||||
|
Ok(url) => url,
|
||||||
|
Err(response) => return response,
|
||||||
|
};
|
||||||
|
|
||||||
|
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, "Referral invite redeemed; checkout created");
|
||||||
|
Json(RedeemResponse {
|
||||||
|
result: "checkout".to_string(),
|
||||||
|
checkout_url: Some(checkout_url),
|
||||||
|
})
|
||||||
|
.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(
|
pub async fn get_invites(
|
||||||
State(shared): State<Arc<SharedState>>,
|
State(shared): State<Arc<SharedState>>,
|
||||||
Extension(user): Extension<OptionalUser>,
|
Extension(user): Extension<OptionalUser>,
|
||||||
|
|
@ -487,16 +645,9 @@ pub async fn get_invites(
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let filter = if user.is_admin {
|
let filter = format!("created_by=\"{}\"", user.id);
|
||||||
String::new()
|
|
||||||
} else {
|
|
||||||
format!("created_by=\"{}\"", user.id)
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut url = format!("{pb_url}/api/collections/invites/records?sort=-created&perPage=200");
|
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)));
|
||||||
url.push_str(&format!("&filter={}", urlencoding::encode(&filter)));
|
|
||||||
}
|
|
||||||
|
|
||||||
let res = match state
|
let res = match state
|
||||||
.http_client
|
.http_client
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue