More
Some checks failed
CI / Check (push) Failing after 2m14s
Build and publish Docker image / build-and-push (push) Failing after 2m38s

This commit is contained in:
Andras Schmelczer 2026-05-04 17:21:26 +01:00
parent cd34ee693f
commit 05a1f316e1
58 changed files with 3113 additions and 1277 deletions

View file

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

430
CLAUDE.md
View file

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

View file

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

173
README.md
View file

@ -1,43 +1,174 @@
# Property Map
## Area
uv run python scripts/remove_bg.py house-og.png 200 house.png
Interactive UK property intelligence map. The app combines transaction, EPC,
postcode, neighbourhood, transport, POI, and travel-time data into local parquet
files, serves fast geospatial aggregations from Rust, and renders the result as
a React/deck.gl map.
The public product is branded as Perfect Postcodes, while this repository is
still named `property-map`.
interesting links
- https://propertydata.co.uk/videos/quick-overview
- https://osdatahub.os.uk/data/downloads/open
## What Is In Here
https://xploria.co.uk/data-sources/
- `frontend/` - React 18, TypeScript, Tailwind, MapLibre, and deck.gl. The app
has a landing page, map dashboard, saved searches/properties, account pages,
pricing, invites, and shareable URLs.
- `server-rs/` - Rust Axum API. It loads the generated parquet data into memory,
builds spatial indexes, serves H3/postcode aggregations, proxies PocketBase,
serves PMTiles, handles AI filter parsing, screenshots, exports, checkout, and
telemetry.
- `pipeline/` - Python/Polars download and transform pipeline. `Makefile.data`
orchestrates the data DAG.
- `r5-java/` - Batch travel-time generator using Conveyal R5. It writes sparse
per-destination parquet files for car, bicycle, walking, and transit.
- `screenshot/` - Playwright/Express service used by the Rust API for map
screenshots and Open Graph images.
- `property-data/` and `manual-data/` - Local generated/downloaded data. These
are runtime inputs, not source code.
## Runtime Data
The Rust server expects these files or directories to exist:
- Why hexagons?
- Why the price tag?
- contact support
-
```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/
```
Most data can be downloaded or generated through `Makefile.data`. Some inputs
are deliberately manual:
- `manual-data/certificates.csv` from the EPC register
- `manual-data/crime/` CSV exports from police.uk
- postcode boundaries, generated from OA boundaries, INSPIRE polygons, and UPRN
lookup data
Build the main property datasets with:
```bash
uv sync
make -f Makefile.data prepare
make -f Makefile.data tiles
make -f Makefile.data download-places
make -f Makefile.data generate-postcode-boundaries
```
load tests with grafana
`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.
house reposession
Travel times are built separately because they are expensive:
## execution
```bash
make -f Makefile.data download-transit-network
./r5-java/run.sh --threads 8 --heap 40g
```
enum colour coding
For a quick R5 smoke test:
Better school searchs
```bash
./r5-java/run.sh --demo
```
fix links to markets,
## Local Development
404,
With the required files in `property-data/`, the full stack can be started with
Docker Compose:
Make prop density smaller
```bash
docker compose up --build
```
Test on safari
Services:
Test on android
- frontend: http://localhost:3001
- API: http://localhost:8001
- PocketBase: http://localhost:8090
- screenshot service: http://localhost:8002
check rendered index html,
The frontend dev server proxies `/api` and `/s` to the Rust API and `/pb` to
PocketBase.
To run pieces directly:
```bash
cd frontend
npm install
npm run dev
```
Export the server's service configuration first:
```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=...
```
```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
```
## Checks
Run the combined local check script:
```bash
./check.sh
```
It runs Python lint/tests, frontend lint/format/typecheck/tests, screenshot
service tests, and Rust clippy/format/tests.
Useful focused commands:
```bash
uv run ruff check .
uv run pytest
cd frontend
npm run lint
npm run typecheck
npm run test
npm run build
cd ../server-rs
cargo clippy --all-targets -- -D warnings
cargo fmt --all --check
cargo test
```
## 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.
```bash
docker build -t property-map .
```
The container entrypoint runs `property-map-server` with the expected data paths
under `/app/data` and serves `frontend/dist` when `--dist` is present.

View file

@ -1,134 +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/download/test_naptan.py
- uv run pytest pipeline/transform/postcode_boundaries/test_postcode_boundaries.py
test:python:fuzzy-join:
desc: Run fuzzy join test (requires data files in data/)
cmds:
- uv run -m pipeline.utils.test_fuzzy_join
test:server:
desc: Run Rust backend tests
dir: server-rs
cmds:
- cargo test
dev:
desc: Start all services (server, frontend, pocketbase) via Docker Compose
cmds:
- docker compose up --build
build:server:
desc: Build server for production
dir: server-rs
cmds:
- cargo build --release
build:frontend:
desc: Build frontend for production
dir: frontend
cmds:
- npm run typecheck
- npm run build
lint:
desc: Lint all code (Python, TypeScript, and Rust)
cmds:
- task: lint:python
- task: lint:frontend
- task: lint:rust
lint:python:
desc: Lint Python code with ruff and check for unused dependencies
cmds:
- uv run ruff check .
- uv run deptry .
lint:frontend:
desc: Lint frontend TypeScript code
dir: frontend
cmds:
- npm run lint
- npm run format:check
lint:rust:
desc: Lint Rust code with clippy, check formatting, and detect unused dependencies
dir: server-rs
cmds:
- cargo clippy -- -D warnings
- cargo fmt --check
- cargo machete
format:
desc: Format all code (Python, TypeScript, and Rust)
cmds:
- task: format:python
- task: format:frontend
- task: format:rust
format:python:
desc: Format Python code with ruff
cmds:
- uv run ruff check --fix .
- uv run ruff format .
format:frontend:
desc: Format frontend TypeScript code
dir: frontend
cmds:
- npm run lint:fix
- npm run format
format:rust:
desc: Format Rust code with cargo fmt
dir: server-rs
cmds:
- cargo fmt --all
ci:
desc: Run CI checks locally (lint + typecheck + test, no builds)
cmds:
- task: lint
- task: typecheck
- task: test
typecheck:
desc: TypeScript typecheck only
dir: frontend
cmds:
- npm run typecheck
check:
desc: Run all checks (lint, typecheck, build)
cmds:
- task: lint
- task: build:server
- task: build:frontend
- task: test

40
check.sh Executable file
View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -14,7 +14,12 @@ import type {
Bounds,
} from '../../types';
import { zoomToResolution, getBoundsFromViewState, getMapStyle } from '../../lib/map-utils';
import {
zoomToResolution,
getBoundsFromViewState,
getMapStyle,
getPoiIconUrl,
} from '../../lib/map-utils';
import {
INITIAL_VIEW_STATE,
MAP_MIN_ZOOM,
@ -395,7 +400,14 @@ export default memo(function Map({
) : (
<div className="px-3 py-2">
<div className="flex items-center gap-2">
<span className="text-lg leading-none">{popupInfo.emoji}</span>
<img
src={getPoiIconUrl(popupInfo.category, popupInfo.emoji)}
alt=""
aria-hidden="true"
loading="lazy"
referrerPolicy="no-referrer"
className="h-5 w-5 shrink-0 rounded-[4px] bg-white object-contain p-0.5"
/>
<div>
<div className="font-semibold dark:text-warm-100">{popupInfo.name}</div>
<div className="flex items-center gap-1.5 text-xs text-warm-500 dark:text-warm-400">

View file

@ -17,6 +17,7 @@ import POIPane from './POIPane';
import { PropertiesPane } from './PropertiesPane';
import AreaPane from './AreaPane';
import MobileDrawer from './MobileDrawer';
import MobileBottomSheet from './MobileBottomSheet';
import MapLegend from './MapLegend';
import { MapPageSelectionPane } from './MapPageSelectionPane';
import { useMapData } from '../../hooks/useMapData';
@ -41,6 +42,7 @@ import { useFilterCounts } from '../../hooks/useFilterCounts';
import { ts } from '../../i18n/server';
import { trackEvent } from '../../lib/analytics';
import { INITIAL_VIEW_STATE } from '../../lib/consts';
import { getSchoolBackendFeatureName } from '../../lib/school-filter';
import { useLicense } from '../../hooks/useLicense';
import UpgradeModal from '../ui/UpgradeModal';
import { SpinnerIcon } from '../ui/icons/SpinnerIcon';
@ -116,12 +118,6 @@ export default function MapPage({
const [leftPaneWidth, leftPaneHandlers] = usePaneResize(384, 200, 0.45, 'left');
const [rightPaneWidth, rightPaneHandlers] = usePaneResize(384, 200, 0.45, 'right');
const [, mobileResizeHandlers, mobileMapRef] = usePaneResize(
Math.round(window.innerHeight * 0.4),
120,
0.8,
'top'
);
const [mobileDrawerOpen, setMobileDrawerOpen] = useState(false);
const [poiPaneOpen, setPoiPaneOpen] = useState(false);
@ -510,9 +506,15 @@ export default function MapPage({
const densityLabel = t('mapLegend.historicalMatches');
const hasActiveFilters = Object.keys(filters).length > 0 || activeEntries.length > 0;
const mobileLegendMeta = useMemo(
() => (viewFeature ? features.find((f) => f.name === viewFeature) || null : null),
[viewFeature, features]
const mobileLegendMeta = useMemo(() => {
const featureName = viewFeature
? (getSchoolBackendFeatureName(viewFeature) ?? viewFeature)
: null;
return featureName ? features.find((f) => f.name === featureName) || null : null;
}, [viewFeature, features]);
const mapViewFeature = useMemo(
() => (viewFeature ? (getSchoolBackendFeatureName(viewFeature) ?? viewFeature) : null),
[viewFeature]
);
const mobileDensityRange = useMemo((): [number, number] => {
const items = mapData.usePostcodeView ? mapData.postcodeData : mapData.data;
@ -595,7 +597,7 @@ export default function MapPage({
usePostcodeView={mapData.usePostcodeView}
pois={[]}
onViewChange={mapData.handleViewChange}
viewFeature={viewFeature}
viewFeature={mapViewFeature}
colorRange={mapData.colorRange}
filterRange={filterRange}
viewSource={viewSource}
@ -663,7 +665,7 @@ export default function MapPage({
/>
);
const renderFilters = () => (
const renderFilters = (options?: { destinationDropdownPortal?: boolean }) => (
<Filters
features={features}
filters={filters}
@ -702,12 +704,71 @@ export default function MapPage({
onClearAll={handleClearAll}
onSaveSearch={onSaveSearch}
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) {
return (
<div className="flex-1 flex flex-col overflow-hidden relative touch-pan-y">
<div className="flex-1 overflow-hidden relative">
{initialLoading && (
<div className="absolute inset-0 z-50 flex items-center justify-center bg-warm-50/80 dark:bg-navy-950/80 backdrop-blur-sm">
<div className="flex flex-col items-center gap-4">
@ -719,14 +780,14 @@ export default function MapPage({
</div>
)}
<div ref={mobileMapRef} className="relative overflow-hidden">
<div className="absolute inset-0">
<Map
data={mapData.data}
postcodeData={mapData.postcodeData}
usePostcodeView={mapData.usePostcodeView}
pois={pois}
onViewChange={mapData.handleViewChange}
viewFeature={viewFeature}
viewFeature={mapViewFeature}
colorRange={mapData.colorRange}
filterRange={filterRange}
viewSource={viewSource}
@ -748,90 +809,39 @@ export default function MapPage({
hideLegend
travelTimeEntries={entries}
/>
{mapData.loading && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<div className="flex items-center gap-2 bg-white/90 dark:bg-warm-800/90 px-4 py-2.5 rounded-lg shadow-lg pointer-events-auto">
<SpinnerIcon className="w-5 h-5 text-teal-600 dark:text-teal-400 animate-spin" />
<span className="text-sm font-medium text-warm-700 dark:text-warm-200">
Loading...
</span>
</div>
</div>
)}
<button
onClick={() => setPoiPaneOpen((p) => !p)}
className={`absolute bottom-2 right-2 z-10 p-2 rounded-lg shadow-lg bg-white dark:bg-warm-800 ${poiPaneOpen ? 'text-teal-600 dark:text-teal-400' : 'text-warm-500 dark:text-warm-400 hover:text-teal-600 dark:hover:text-teal-400'}`}
>
<MapPinIcon className="w-5 h-5" />
</button>
{poiPaneOpen && (
<div className="absolute bottom-12 right-2 z-10 w-[calc(100%-1rem)] max-h-[60%] overflow-hidden flex flex-col rounded-lg shadow-xl border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-900">
{renderPOIPane()}
</div>
)}
</div>
<div
className="relative z-10 py-2 -my-2 cursor-row-resize touch-none group"
{...mobileResizeHandlers}
>
<div className="h-3 flex items-center justify-center bg-warm-100 dark:bg-navy-800 group-hover:bg-warm-200 dark:group-hover:bg-navy-700 border-y border-warm-200 dark:border-navy-700">
<div className="flex flex-row gap-1.5">
<div className="w-1 h-1 rounded-full bg-warm-300 dark:bg-navy-600 group-hover:bg-warm-400 dark:group-hover:bg-navy-500" />
<div className="w-1 h-1 rounded-full bg-warm-300 dark:bg-navy-600 group-hover:bg-warm-400 dark:group-hover:bg-navy-500" />
<div className="w-1 h-1 rounded-full bg-warm-300 dark:bg-navy-600 group-hover:bg-warm-400 dark:group-hover:bg-navy-500" />
{mapData.loading && (
<div className="absolute inset-0 z-10 flex items-center justify-center pointer-events-none">
<div className="flex items-center gap-2 bg-white/90 dark:bg-warm-800/90 px-4 py-2.5 rounded-lg shadow-lg pointer-events-auto">
<SpinnerIcon className="w-5 h-5 text-teal-600 dark:text-teal-400 animate-spin" />
<span className="text-sm font-medium text-warm-700 dark:text-warm-200">
Loading...
</span>
</div>
</div>
</div>
)}
<div className="flex-1 min-h-0 bg-white dark:bg-warm-900 overflow-hidden flex flex-col">
{viewFeature && mapData.colorRange ? (
viewFeature.startsWith('tt_') ? (
<MapLegend
featureLabel={t('travel.travelTime', {
mode: modes.label(
viewFeature.split('_')[1] as 'car' | 'bicycle' | 'walking' | 'transit'
),
})}
range={mapData.colorRange}
showCancel={viewSource === 'eye'}
onCancel={handleCancelPin}
mode="feature"
theme={theme}
inline
suffix=" min"
/>
) : mobileLegendMeta ? (
<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}
/>
) : 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>
<button
onClick={() => setPoiPaneOpen((p) => !p)}
className={`absolute top-3 right-3 z-20 p-2 rounded-lg shadow-lg bg-white dark:bg-warm-800 ${poiPaneOpen ? 'text-teal-600 dark:text-teal-400' : 'text-warm-500 dark:text-warm-400 hover:text-teal-600 dark:hover:text-teal-400'}`}
aria-label={t('poiPane.pointsOfInterest')}
>
<MapPinIcon className="w-5 h-5" />
</button>
{poiPaneOpen && (
<div className="absolute top-14 right-3 left-3 z-20 max-h-[45dvh] overflow-hidden flex flex-col rounded-lg shadow-xl border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-900">
{renderPOIPane()}
</div>
)}
<MobileBottomSheet
activeCount={Object.keys(filters).length + entries.length}
legend={renderMobileLegend()}
>
{renderFilters({ destinationDropdownPortal: false })}
</MobileBottomSheet>
{mobileDrawerOpen && selectedHexagon && (
<MobileDrawer
@ -914,7 +924,7 @@ export default function MapPage({
usePostcodeView={mapData.usePostcodeView}
pois={pois}
onViewChange={mapData.handleViewChange}
viewFeature={viewFeature}
viewFeature={mapViewFeature}
colorRange={mapData.colorRange}
filterRange={filterRange}
viewSource={viewSource}

View file

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

View file

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

View file

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

View file

@ -31,7 +31,12 @@ function shortenLabel(name: string): string {
export default function StackedBarChart({ segments, total, colorMap }: StackedBarChartProps) {
const sortedSegments = useMemo(() => [...segments].sort((a, b) => b.value - a.value), [segments]);
const roundedPcts = useMemo(
() => roundedPercentages(sortedSegments.map((s) => s.value), total, 1),
() =>
roundedPercentages(
sortedSegments.map((s) => s.value),
total,
1
),
[sortedSegments, total]
);

View file

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

View file

@ -31,6 +31,7 @@ interface TravelTimeCardProps {
onToggleBest: () => void;
onRemove: () => void;
filterImpact?: number;
destinationDropdownPortal?: boolean;
}
export function TravelTimeCard({
@ -51,6 +52,7 @@ export function TravelTimeCard({
onToggleBest,
onRemove,
filterImpact,
destinationDropdownPortal = true,
}: TravelTimeCardProps) {
const { t } = useTranslation();
const modes = useTranslatedModes();
@ -110,6 +112,7 @@ export function TravelTimeCard({
value={label || undefined}
onClear={() => onSetDestination('', '', 0, 0)}
placeholder={t('travel.selectDestination')}
portal={destinationDropdownPortal}
/>
{/* Best-case toggle — transit only, shown when destination is set */}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -14,6 +14,16 @@ const supermarket: POI = {
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',
@ -45,6 +55,18 @@ describe('usePoiLayers', () => {
]);
});
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 }),

View file

@ -12,7 +12,7 @@ import {
POI_CLUSTER_RADIUS,
POI_CLUSTER_MAX_ZOOM,
} from '../lib/consts';
import { emojiToTwemojiUrl } from '../lib/map-utils';
import { getPoiIconUrl } from '../lib/map-utils';
export interface PopupInfo {
x: number;
@ -176,7 +176,7 @@ export function usePoiLayers({ pois, zoom, isDark }: UsePoiLayersProps) {
data: visiblePois,
getPosition: (d) => [d.lng, d.lat],
getIcon: (d) => ({
url: emojiToTwemojiUrl(d.emoji),
url: getPoiIconUrl(d.category, d.emoji),
width: 72,
height: 72,
}),

View file

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

View file

@ -120,17 +120,17 @@ export const details: Record<string, Record<string, string>> = {
'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.",
'% Labour':
"Pourcentage des votes valides exprimés pour le Parti travailliste dans la circonscription couvrant ce code postal, lors des élections générales britanniques de juillet 2024. Comprend les votes de tous les candidats travaillistes.",
'Pourcentage des votes valides exprimés pour le Parti travailliste dans la circonscription couvrant ce code postal, lors des élections générales britanniques de juillet 2024. Comprend les votes de tous les candidats travaillistes.',
'% Conservative':
"Pourcentage des votes valides exprimés pour le Parti conservateur dans la circonscription couvrant ce code postal, lors des élections générales britanniques de juillet 2024.",
'Pourcentage des votes valides exprimés pour le Parti conservateur dans la circonscription couvrant ce code postal, lors des élections générales britanniques de juillet 2024.',
'% Liberal Democrat':
"Pourcentage des votes valides exprimés pour les Libéraux-démocrates dans la circonscription couvrant ce code postal, lors des élections générales britanniques de juillet 2024.",
'Pourcentage des votes valides exprimés pour les Libéraux-démocrates dans la circonscription couvrant ce code postal, lors des élections générales britanniques de juillet 2024.',
'% Reform UK':
"Pourcentage des votes valides exprimés pour Reform UK dans la circonscription couvrant ce code postal, lors des élections générales britanniques de juillet 2024.",
'Pourcentage des votes valides exprimés pour Reform UK dans la circonscription couvrant ce code postal, lors des élections générales britanniques de juillet 2024.',
'% Green':
"Pourcentage des votes valides exprimés pour le Parti vert dans la circonscription couvrant ce code postal, lors des élections générales britanniques de juillet 2024.",
'Pourcentage des votes valides exprimés pour le Parti vert dans la circonscription couvrant ce code postal, lors des élections générales britanniques de juillet 2024.',
'% Other parties':
"Pourcentage des votes valides exprimés pour des partis autres que Travailliste, Conservateur, Libéral-démocrate, Reform UK et Vert dans la circonscription couvrant ce code postal. Comprend les indépendants, le Président de la Chambre et les partis mineurs.",
'Pourcentage des votes valides exprimés pour des partis autres que Travailliste, Conservateur, Libéral-démocrate, Reform UK et Vert dans la circonscription couvrant ce code postal. Comprend les indépendants, le Président de la Chambre et les partis mineurs.',
'Distance to nearest park (km)':
"Distance à vol d'oiseau en kilomètres depuis le code postal jusqu'à l'entrée du parc la plus proche. Couvre les parcs publics, jardins, terrains de jeux et espaces de loisirs. Utilise les emplacements des points d'accès issus du jeu de données OS Open Greenspace, de sorte que les propriétés bordant un grand parc affichent correctement une courte distance.",
'Number of parks within 1km':
@ -399,14 +399,10 @@ export const details: Record<string, Record<string, string>> = {
'2024年7月英国大选中投出有效选票的登记选民比例。计算方式为有效票数除以选民总数。较高的投票率通常与较富裕地区和竞争更激烈的选举相关。',
'% Labour':
'2024年7月英国大选中该邮编所属选区投给工党的有效选票百分比。包括所有工党候选人的选票。',
'% Conservative':
'2024年7月英国大选中该邮编所属选区投给保守党的有效选票百分比。',
'% Liberal Democrat':
'2024年7月英国大选中该邮编所属选区投给自由民主党的有效选票百分比。',
'% Reform UK':
'2024年7月英国大选中该邮编所属选区投给英国改革党的有效选票百分比。',
'% Green':
'2024年7月英国大选中该邮编所属选区投给绿党的有效选票百分比。',
'% Conservative': '2024年7月英国大选中该邮编所属选区投给保守党的有效选票百分比。',
'% Liberal Democrat': '2024年7月英国大选中该邮编所属选区投给自由民主党的有效选票百分比。',
'% Reform UK': '2024年7月英国大选中该邮编所属选区投给英国改革党的有效选票百分比。',
'% Green': '2024年7月英国大选中该邮编所属选区投给绿党的有效选票百分比。',
'% Other parties':
'该选区中投给工党、保守党、自由民主党、英国改革党和绿党以外政党的有效选票百分比。包括独立候选人、议长和小型政党。',
'Distance to nearest park (km)':

View file

@ -293,6 +293,8 @@ const de: Translations = {
// ── Street View ────────────────────────────────────
streetView: {
title: 'Street View',
openLarge: 'Street View größer öffnen',
expandedTitle: 'Vergrößerte Street View',
},
// ── POI Pane ───────────────────────────────────────
@ -330,47 +332,105 @@ const de: Translations = {
// ── Home Page ──────────────────────────────────────
home: {
heroTitle1: 'Maximaler',
heroTitle2: 'Wert',
heroTitle3: 'Minimale Kompromisse.',
heroEyebrow: 'Für Käufer, die fragen: „Wo soll ich überhaupt suchen?“',
heroTitle1: 'Finden Sie die Postleitzahlen',
heroTitle2: 'die zu Ihrem Leben passen',
heroTitle3: 'Nicht nur die Gegenden, die Sie schon kennen.',
heroSubtitle:
'Auf Immobiliensuche? Mach aus deiner größten Investition deine klügste Entscheidung.',
'Von Londoner Stadtteilen über Pendlerorte bis zu regionalen Städten: England hat zu viele Orte, um sie einzeln zu recherchieren.',
heroDescription:
'So viele Möglichkeiten — die richtige Wahl kann überwältigend sein. Unsere interaktive Karte macht es einfach: Wähle deine Muss-Kriterien und sieh sofort die passenden Gebiete.',
exploreTheMap: 'Karte entdecken',
seeTheDifference: 'Den Unterschied sehen',
statProperties: 'Immobilien',
statFilters: 'Filter',
'Legen Sie Budget, Pendelzeit, Schulen, Sicherheit, Lärm, Breitband und Lebensstil fest. Perfect Postcode scannt Englands Postleitzahlen und zeigt Orte, die wirklich passen, auch Gegenden, die Sie nie in ein Immobilienportal eingegeben hätten.',
exploreTheMap: 'Passende Postleitzahlen finden',
seeTheDifference: 'So funktioniert es',
showcaseHeader: 'Produktvorschau',
showcaseContext: 'Käufersuche in ganz England',
showcaseStep1Tab: 'Beschreiben',
showcaseStep1Title: 'Beschreiben Sie das Leben, das Sie möchten',
showcaseStep1Body:
'Nutzen Sie natürliche Sprache oder Filter, um komplexe Kaufkriterien in eine Suche zu verwandeln.',
showcaseStep1Prompt:
'2 Schlafzimmer unter £525k, 45 Min. zur Arbeit, ruhige Straßen, gute Schulen',
showcaseStep1Chip1: '<= £525k',
showcaseStep1Chip2: '2+ Schlafzimmer',
showcaseStep1Chip3: '45 Min. Pendeln',
showcaseStep1Chip4: 'Wenig Straßenlärm',
showcaseStep2Tab: 'Entdecken',
showcaseStep2Title: 'Zeigen Sie Orte, die Sie nicht erwogen hatten',
showcaseStep2Body:
'Die Karte markiert passende Postleitzahlen, auch außerhalb Ihrer bisherigen Shortlist.',
showcaseStep2Metric: '47 passende Postleitzahlen',
showcaseStep2Note: 'jenseits der offensichtlichen Shortlist',
showcaseKnownAreas: 'Bekannte Gegenden',
showcaseNewMatches: 'Neue Treffer',
showcaseKnownAreaStatus: 'wenige Treffer',
showcaseStep3Tab: 'Prüfen',
showcaseStep3Title: 'Verstehen Sie, warum jede Postleitzahl passt',
showcaseStep3Body:
'Öffnen Sie einen Treffer und prüfen Sie die Belege, bevor Sie ein Wochenende für Besichtigungen opfern.',
showcaseStep3Postcode: 'Postleitzahl-Beispiel',
showcaseStep3Area: 'Penge',
showcaseStep3Code: 'SE20',
showcaseStep3Score: 'Starker Fit',
showcaseEvidence1: '42 Min. Pendelzeit',
showcaseEvidence2: 'Weniger Straßenlärm',
showcaseEvidence3: 'Gute Grundschuloptionen',
showcaseEvidence4: 'Verkaufspreise im Budget',
showcaseStep4Tab: 'Vergleichen',
showcaseStep4Title: 'Kompromisse vor Besichtigungen vergleichen',
showcaseStep4Body:
'Erstellen Sie eine Shortlist danach, was Sie gewinnen und aufgeben, nicht nur nach Ruf.',
showcaseCompare1: 'Penge: Londoner Bahnanschluss, mehr Platz',
showcaseCompare2: 'Totterdown: fußläufige Straßen in Bristol',
showcaseCompare3: 'Walkley: größere Häuser, guter Gegenwert',
showcaseMapLabel: 'Passende Postleitzahlen',
showcaseSaveLabel: 'Shortlist bereit',
showcaseMatchPenge: 'London im Budget',
showcaseMatchAbbeyWood: 'Elizabeth line + Grünflächen',
showcaseMatchTotterdown: 'Bristol gut zu Fuß',
showcaseMatchWalkley: 'Sheffield: Platz + Schulen',
statProperties: 'historische Verkäufe',
statFilters: 'kombinierbare Filter',
statEvery: 'Jede',
statPostcodeInEngland: 'Postleitzahl in England',
ourPhilosophy: 'Unsere Philosophie',
ourPhilosophy: 'Beginnen Sie mit Ihrem Leben, nicht mit einer Postleitzahl',
philosophyP1:
'Auf Rightmove wählt man zuerst ein Gebiet und hofft, dass es gut ist. Am Ende vergleicht man Kriminalitätsstatistiken, Schulberichte und Breitband-Checker in einem Dutzend Tabs, eine Postleitzahl nach der anderen.',
'Die meisten Immobilienseiten fragen, wo Sie wohnen möchten. In London ist das besonders schwierig, aber das gleiche Problem gibt es in ganz England: Käufer starten mit wenigen bekannten Orten und prüfen dann Pendelzeit, Schulen, Kriminalität, Street View, Breitband und Verkaufspreise in getrennten Tabs.',
philosophyP2:
'Wir drehen das um. Sag uns, was du brauchst (Budget, Pendelweg, Schulen, Sicherheit), und wir zeigen dir jedes Gebiet in England, das passt. Kein Raten. Keine verschwendeten Besichtigungen.',
'Perfect Postcode dreht die Suche um. Sagen Sie der Karte, was zählt, und sie zeigt passende Postleitzahlen mit nachvollziehbaren Gründen. Erst Daten, dann vor Ort das Gefühl prüfen.',
streetTitle: 'Orte ändern sich von Straße zu Straße',
streetIntro:
'Große Gebietsnamen verdecken die Details, die zählen: Bahnhofsseite, Straßenlärm, Schulmix, genaue Pendelzeit und echte Verkaufspreise.',
streetCard1Title: 'Finden Sie Gegenden, die Sie übersehen hätten',
streetCard1Body:
'Entdecken Sie Postleitzahlen, die Ihren Anforderungen entsprechen, statt sich nur auf bekannte Namen, Empfehlungen oder Hype zu verlassen.',
streetCard2Title: 'Sehen Sie Kompromisse vor Besichtigungen',
streetCard2Body:
'Vergleichen Sie Preis, Platz, Pendelzeit, Sicherheit, Schulen, Breitband, Lärm und Energieeffizienz, bevor Sie Wochenenden mit Besichtigungen verbringen.',
howToUseIt: 'So funktioniert es',
howStep1Title: 'Lege deine Muss-Kriterien fest',
howStep1Desc: 'Budget, Pendelweg, Schulen — die Karte zeigt nur, was passt.',
howStep2Title: 'Entdecke Gebiete und versteckte Perlen',
howStep2Desc: 'Zoom rein, schau dir Details und Kann-Kriterien an.',
howStep3Title: 'Einzelne Postleitzahlen erkunden',
howStep3Desc: 'Sieh einzelne Immobilien, Verkaufspreise, Wohnflächen und vergleiche.',
howStep4Title: 'Engere Auswahl mit Zuversicht',
howStep1Title: 'Beschreiben Sie das Leben, das Sie brauchen',
howStep1Desc: 'Budget, Pendelzeit, Immobilientyp, Schulen, Sicherheit, Platz und Alltag.',
howStep2Title: 'Passende Postleitzahlen anzeigen',
howStep2Desc: 'Die Karte markiert Orte, die Ihre Filter erfüllen, auch unbekanntere Gegenden.',
howStep3Title: 'Die Belege prüfen',
howStep3Desc:
'Prüfen Sie Verkaufspreise, Wohnfläche, EPC, Straßenlärm, Breitband, Kriminalität und Schulen.',
howStep4Title: 'Shortlist vor der Listingsuche',
howStep4Desc:
'Jedes Gebiet auf deiner Liste erfüllt deine tatsächlichen Kriterien — nicht nur, was diese Woche inseriert war.',
'Gehen Sie mit besseren Suchgebieten zu Rightmove, Zoopla, Maklern und Besichtigungen.',
othersVs: 'Andere vs',
checkMyPostcode: '„Meine Postleitzahl prüfen“',
areaGuides: 'Gebietsratgeber',
compSearchWithout: 'Suchen, ohne zuerst ein Gebiet auszuwählen',
compSearchWithoutSub: '(starte mit Bedürfnissen, nicht mit einem Ort)',
compAreaData: 'Gebietsdaten',
compAreaDataSub: '(Kriminalität, Schulen, Lärm, Breitband)',
compPropertyData: 'Immobilienspezifische Daten',
compPropertyDataSub: '(Preis, EPC, Wohnfläche)',
compFilters: '56 kombinierbare Filter an einem Ort',
compFiltersSub: '(alle Einblicke, eine interaktive Karte)',
ctaTitle: 'Mach aus deiner größten Investition deine klügste Entscheidung.',
ctaDescription: 'Das verdient die richtigen Werkzeuge — überlass es nicht dem Zufall.',
checkMyPostcode: 'Immobilienportale',
areaGuides: 'Postleitzahl-Berichte',
compSearchWithout: 'Gegenden entdecken, bevor Sie die Namen kennen',
compSearchWithoutSub: '(erst Anforderungen, dann Ort)',
compAreaData: 'Nachbarschaftsdaten auf Postleitzahl-Ebene',
compAreaDataSub: '(Kriminalität, Schulen, Lärm, Breitband, Ausstattung)',
compPropertyData: 'Historie auf Immobilienebene',
compPropertyDataSub: '(Verkaufspreise, EPC, Wohnfläche, Schätzwert)',
compFilters: '56 Filter, die zusammenarbeiten',
compFiltersSub: '(nicht eine Postleitzahl oder ein Listing nach dem anderen)',
ctaTitle: 'Hören Sie auf zu raten, wo Sie kaufen sollen.',
ctaDescription:
'Erstellen Sie eine Shortlist von Postleitzahlen, die zu Ihrem echten Leben passen, und prüfen Sie sie dann vor Ort.',
},
// ── Pricing Page ───────────────────────────────────
@ -456,6 +516,10 @@ const de: Translations = {
dsOsmOrigin: 'OpenStreetMap contributors / Geofabrik',
dsOsmUse:
'Sehenswürdigkeiten und Einrichtungen wie Geschäfte, Restaurants, Gesundheitseinrichtungen, Freizeit, Tourismus und mehr in ganz Großbritannien.',
dsGeolytixRetailName: 'GEOLYTIX Grocery Retail Points',
dsGeolytixRetailOrigin: 'GEOLYTIX',
dsGeolytixRetailUse:
'Supermarkt- und Convenience-Store-Standorte im Vereinigten Königreich, darunter Ketten wie Waitrose, Tesco, Sainsburys, Asda, Morrisons, Aldi, Lidl, Co-op, M&S, Iceland und Spar.',
dsGreenspaceName: 'OS Open Greenspace',
dsGreenspaceOrigin: 'Ordnance Survey',
dsGreenspaceUse:

View file

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

View file

@ -89,7 +89,7 @@ const fr: Translations = {
// ── Upgrade Modal ──────────────────────────────────
upgrade: {
title: "Découvrez toute lAngleterre",
title: 'Découvrez toute lAngleterre',
description:
'Vous explorez actuellement la zone de démonstration. Obtenez un accès à vie à chaque code postal, chaque filtre, chaque quartier. Un seul paiement, pour toujours.',
free: 'Gratuit',
@ -97,9 +97,9 @@ const fr: Translations = {
freeForEarly: 'Gratuit pour les premiers utilisateurs. Aucune carte bancaire requise.',
oneTimePayment: 'Paiement unique. Accès à vie.',
redirecting: 'Redirection...',
claimFreeAccess: "Réclamer laccès gratuit",
claimFreeAccess: 'Réclamer laccès gratuit',
upgradeFor: 'Passer à la version complète pour {{price}}',
registerAndUpgrade: "Sinscrire et passer à la version complète",
registerAndUpgrade: 'Sinscrire et passer à la version complète',
alreadyHaveAccount: 'Vous avez déjà un compte ? Connectez-vous',
continueWithDemo: 'Continuer avec la démo',
checkoutFailed: 'Échec du paiement',
@ -118,10 +118,10 @@ const fr: Translations = {
// ── License Success ────────────────────────────────
licenseSuccess: {
title: "Cest fait.",
title: 'Cest fait.',
subtitle: 'Votre accès à vie est maintenant actif.',
description:
"Accès complet à chaque fonctionnalité, chaque code postal, dans toute lAngleterre.",
'Accès complet à chaque fonctionnalité, chaque code postal, dans toute lAngleterre.',
startExploring: 'Commencer à explorer',
},
@ -133,7 +133,7 @@ const fr: Translations = {
addFiltersHint:
'Ajoutez des filtres ci-dessous pour restreindre la carte aux zones correspondant à vos critères',
upgradePrompt:
"Voir la criminalité, les écoles, le bruit, le débit internet et plus de 50 filtres dans toute lAngleterre.",
'Voir la criminalité, les écoles, le bruit, le débit internet et plus de 50 filtres dans toute lAngleterre.',
oneTimeLifetime: 'Paiement unique, accès à vie.',
upgradeToFullMap: 'Passer à la carte complète',
chooseFilters:
@ -168,7 +168,7 @@ const fr: Translations = {
step5Desc: '(restaurants, parcs, débit internet)',
step6Title: 'Énergie',
step6Desc: '(classements DPE, isolation, coûts de chauffage)',
tip: "Astuce : si rien ne correspond, assouplissez un critère à la fois pour voir quel compromis ouvre le plus doptions.",
tip: 'Astuce : si rien ne correspond, assouplissez un critère à la fois pour voir quel compromis ouvre le plus doptions.',
},
// ── Travel Time ────────────────────────────────────
@ -179,9 +179,9 @@ const fr: Translations = {
bestCase: 'Meilleur cas',
bestCaseTitle: 'Meilleur temps de trajet',
bestCaseDesc:
"Utilise le temps de trajet réaliste le plus rapide (si vous partez au bon moment et avez de bonnes correspondances). Par défaut, la <strong>médiane</strong> est utilisée, représentant un trajet typique quelle que soit lheure de départ.",
'Utilise le temps de trajet réaliste le plus rapide (si vous partez au bon moment et avez de bonnes correspondances). Par défaut, la <strong>médiane</strong> est utilisée, représentant un trajet typique quelle que soit lheure de départ.',
previewOnMap: 'Aperçu sur la carte',
stopPreviewing: "Arrêter laperçu",
stopPreviewing: 'Arrêter laperçu',
removeTravelTime: 'Supprimer le temps de trajet',
addTravelTime: 'Ajouter le temps de trajet en {{mode}}',
clearDestination: 'Effacer la destination',
@ -297,14 +297,16 @@ const fr: Translations = {
// ── Street View ────────────────────────────────────
streetView: {
title: 'Street View',
openLarge: 'Ouvrir Street View en grand',
expandedTitle: 'Street View agrandi',
},
// ── POI Pane ───────────────────────────────────────
poiPane: {
pois: 'POI',
pointsOfInterest: "Points dintérêt",
pointsOfInterest: 'Points dintérêt',
poiDescription:
"Données issues dOpenStreetMap. Couvre les arrêts de transport, commerces, restaurants, établissements de santé, loisirs et plus encore. Mise à jour régulière avec une couverture complète des catégories.",
'Données issues dOpenStreetMap. Couvre les arrêts de transport, commerces, restaurants, établissements de santé, loisirs et plus encore. Mise à jour régulière avec une couverture complète des catégories.',
searchCategories: 'Rechercher des catégories...',
dataSourceInfo: 'Informations sur la source',
},
@ -323,7 +325,7 @@ const fr: Translations = {
lookupFailed: 'Échec de la recherche',
searchLabel: 'Rechercher des lieux ou codes postaux',
locateMe: 'Aller à ma position',
geolocationUnsupported: "La géolocalisation nest pas prise en charge par votre navigateur",
geolocationUnsupported: 'La géolocalisation nest pas prise en charge par votre navigateur',
geolocationFailed: 'Impossible de déterminer votre position',
},
@ -334,48 +336,107 @@ const fr: Translations = {
// ── Home Page ──────────────────────────────────────
home: {
heroTitle1: 'Valeur',
heroTitle2: 'Maximale',
heroTitle3: 'Compromis Minimum.',
heroEyebrow: 'Pour les acheteurs qui se demandent « où chercher ? »',
heroTitle1: 'Trouvez les codes postaux',
heroTitle2: 'qui correspondent à votre vie',
heroTitle3: 'Pas seulement les quartiers que vous connaissez déjà.',
heroSubtitle:
'Vous cherchez un bien ? Faites de votre plus gros investissement votre meilleure décision.',
'Des quartiers londoniens aux villes de banlieue et aux villes régionales, lAngleterre compte trop de lieux pour les rechercher un par un.',
heroDescription:
"Tant doptions — choisir la bonne peut sembler décourageant. Notre carte interactive simplifie tout : sélectionnez vos critères et voyez instantanément les zones qui correspondent.",
exploreTheMap: 'Explorer la carte',
seeTheDifference: 'Voir la différence',
statProperties: 'propriétés',
statFilters: 'filtres',
'Définissez votre budget, trajet, écoles, sécurité, bruit, débit internet et style de vie. Perfect Postcode analyse les codes postaux dAngleterre et révèle les lieux qui correspondent vraiment, y compris ceux que vous nauriez jamais cherchés sur un portail immobilier.',
exploreTheMap: 'Trouver mes codes postaux',
seeTheDifference: 'Voir comment ça marche',
showcaseHeader: 'Aperçu du produit',
showcaseContext: 'Recherche dacheteur en Angleterre',
showcaseStep1Tab: 'Décrire',
showcaseStep1Title: 'Décrivez la vie que vous voulez',
showcaseStep1Body:
'Utilisez le langage naturel ou les filtres pour transformer des critères complexes en une seule recherche.',
showcaseStep1Prompt:
'2 chambres sous £525k, 45 min jusquau travail, rues calmes, bonnes écoles',
showcaseStep1Chip1: '<= £525k',
showcaseStep1Chip2: '2+ chambres',
showcaseStep1Chip3: '45 min de trajet',
showcaseStep1Chip4: 'Faible bruit routier',
showcaseStep2Tab: 'Découvrir',
showcaseStep2Title: 'Révélez des lieux que vous naviez pas envisagés',
showcaseStep2Body:
'La carte met en évidence les codes postaux compatibles, y compris hors de votre sélection habituelle.',
showcaseStep2Metric: '47 codes postaux compatibles',
showcaseStep2Note: 'au-delà de la sélection évidente',
showcaseKnownAreas: 'Zones connues',
showcaseNewMatches: 'Nouvelles correspondances',
showcaseKnownAreaStatus: 'peu de résultats',
showcaseStep3Tab: 'Vérifier',
showcaseStep3Title: 'Comprenez pourquoi chaque code postal correspond',
showcaseStep3Body:
'Ouvrez un résultat et vérifiez les preuves avant de réserver votre week-end pour des visites.',
showcaseStep3Postcode: 'Exemple de code postal',
showcaseStep3Area: 'Penge',
showcaseStep3Code: 'SE20',
showcaseStep3Score: 'Très bon ajustement',
showcaseEvidence1: '42 min de trajet',
showcaseEvidence2: 'Bruit routier plus faible',
showcaseEvidence3: 'Bonnes écoles primaires',
showcaseEvidence4: 'Prix vendus dans le budget',
showcaseStep4Tab: 'Comparer',
showcaseStep4Title: 'Comparez les compromis avant les visites',
showcaseStep4Body:
'Sélectionnez les zones selon ce que vous gagnez et perdez, pas seulement selon leur réputation.',
showcaseCompare1: 'Penge : liaisons londoniennes, plus despace',
showcaseCompare2: 'Totterdown : rues accessibles à pied à Bristol',
showcaseCompare3: 'Walkley : logements plus grands, bon rapport qualité-prix',
showcaseMapLabel: 'Codes postaux compatibles',
showcaseSaveLabel: 'Sélection prête',
showcaseMatchPenge: 'budget compatible à Londres',
showcaseMatchAbbeyWood: 'Elizabeth line + espaces verts',
showcaseMatchTotterdown: 'Bristol accessible à pied',
showcaseMatchWalkley: 'espace + écoles à Sheffield',
statProperties: 'ventes historiques',
statFilters: 'filtres combinables',
statEvery: 'Chaque',
statPostcodeInEngland: "code postal dAngleterre",
ourPhilosophy: 'Notre philosophie',
statPostcodeInEngland: 'code postal dAngleterre',
ourPhilosophy: 'Commencez par votre vie, pas par un code postal',
philosophyP1:
"Sur Rightmove, vous choisissez dabord une zone, puis vous espérez quelle convient. Vous finissez par croiser statistiques de criminalité, rapports scolaires et tests de débit sur une dizaine donglets, un code postal à la fois.",
'La plupart des sites immobiliers demandent où vous voulez vivre. À Londres, cest particulièrement difficile, mais le même problème existe partout en Angleterre : les acheteurs partent des quelques lieux quils connaissent, puis vérifient séparément trajets, écoles, criminalité, Street View, débit internet et prix vendus.',
philosophyP2:
"Nous inversons la logique. Dites-nous ce quil vous faut (budget, trajet, écoles, sécurité) et nous vous montrons chaque zone dAngleterre qui correspond. Plus de devinettes. Plus de visites inutiles.",
'Perfect Postcode inverse la recherche. Dites à la carte ce qui compte et elle affiche les codes postaux qui correspondent, avec les raisons pour lesquelles ils méritent dêtre étudiés. Les données dabord, puis allez tester lambiance.',
streetTitle: 'Tout change rue par rue',
streetIntro:
'Les grands noms de quartiers cachent les détails importants : le côté de la gare, le bruit de la route, les écoles, le trajet exact et les vrais prix de vente.',
streetCard1Title: 'Trouvez les zones que vous auriez manquées',
streetCard1Body:
'Faites ressortir les codes postaux qui correspondent à vos critères, au lieu de dépendre seulement des noms connus ou des recommandations.',
streetCard2Title: 'Voyez les compromis avant les visites',
streetCard2Body:
'Comparez prix, surface, trajet, sécurité, écoles, débit internet, bruit et énergie avant de passer vos week-ends à courir les visites.',
howToUseIt: 'Comment lutiliser',
howStep1Title: 'Définissez vos indispensables',
howStep1Desc: 'Budget, trajet, écoles — la carte naffiche que ce qui correspond.',
howStep2Title: 'Explorez les zones et découvrez des pépites cachées',
howStep2Desc: 'Zoomez, examinez les détails et les critères secondaires.',
howStep3Title: 'Plongez dans les codes postaux',
howStep1Title: 'Décrivez la vie dont vous avez besoin',
howStep1Desc:
'Budget, trajet, type de bien, écoles, sécurité, surface et essentiels du quotidien.',
howStep2Title: 'Révélez les codes postaux compatibles',
howStep2Desc:
'La carte met en évidence les lieux qui passent vos filtres, y compris les zones moins connues.',
howStep3Title: 'Vérifiez les preuves',
howStep3Desc:
'Consultez les propriétés individuelles, les prix de vente, la surface et comparez.',
howStep4Title: 'Constituez votre sélection en toute confiance',
'Consultez prix vendus, surface, DPE, bruit routier, débit internet, criminalité et écoles.',
howStep4Title: 'Faites votre sélection avant les annonces',
howStep4Desc:
'Chaque zone de votre liste répond à vos vrais critères — pas seulement à ce qui était en vente cette semaine-là.',
'Arrivez sur Rightmove, Zoopla, chez les agents et aux visites avec de meilleures zones de recherche.',
othersVs: 'Les autres vs',
checkMyPostcode: '« Vérifier mon code postal »',
areaGuides: 'Guides de quartier',
compSearchWithout: "Rechercher sans dabord choisir une zone",
compSearchWithoutSub: "(partir de ses besoins, pas dun lieu)",
compAreaData: 'Données de la zone',
compAreaDataSub: '(criminalité, écoles, bruit, débit internet)',
compPropertyData: 'Données par propriété',
compPropertyDataSub: '(prix, DPE, surface)',
compFilters: '56 filtres combinables en un seul endroit',
compFiltersSub: '(toutes les informations, une seule carte interactive)',
ctaTitle: 'Faites de votre plus gros investissement votre meilleure décision.',
ctaDescription: 'Un tel enjeu mérite de vrais outils, ne laissez pas la chance décider.',
checkMyPostcode: 'Portails dannonces',
areaGuides: 'Rapports de code postal',
compSearchWithout: 'Découvrir des zones avant den connaître le nom',
compSearchWithoutSub: '(besoins dabord, lieu ensuite)',
compAreaData: 'Preuves au niveau du code postal',
compAreaDataSub: '(criminalité, écoles, bruit, débit internet, services)',
compPropertyData: 'Historique par propriété',
compPropertyDataSub: '(prix vendus, DPE, surface, valeur estimée)',
compFilters: '56 filtres qui fonctionnent ensemble',
compFiltersSub: '(pas un code postal ou une annonce à la fois)',
ctaTitle: 'Arrêtez de deviner où acheter.',
ctaDescription:
'Construisez une sélection de codes postaux adaptés à votre vraie vie, puis allez les tester sur place.',
},
// ── Pricing Page ───────────────────────────────────
@ -383,8 +444,8 @@ const fr: Translations = {
title: 'Tarifs early access',
subtitle: 'Payez une fois, accédez pour toujours. Plus vous rejoignez tôt, moins vous payez.',
costContext:
"Lachat dun bien coûte plus de £10 000 en droits de mutation, £1 500 en frais de notaire, £500 pour une expertise. Choisissez le mauvais quartier et vous vous retrouvez avec un long trajet, de mauvaises écoles ou une route dont vous ignoriez lexistence.",
lessThanSurvey: "Moins cher quune expertise immobilière. Bien plus utile.",
'Lachat dun bien coûte plus de £10 000 en droits de mutation, £1 500 en frais de notaire, £500 pour une expertise. Choisissez le mauvais quartier et vous vous retrouvez avec un long trajet, de mauvaises écoles ou une route dont vous ignoriez lexistence.',
lessThanSurvey: 'Moins cher quune expertise immobilière. Bien plus utile.',
currentTier: 'Palier actuel',
firstNUsers: '{{count}} premiers utilisateurs',
everyoneAfter: 'Tous les suivants',
@ -401,7 +462,7 @@ const fr: Translations = {
soldOut: 'Épuisé',
upcoming: 'À venir',
failedToLoad: 'Échec du chargement des tarifs. Veuillez réessayer plus tard.',
feat1: "56 couches de données à travers lAngleterre",
feat1: '56 couches de données à travers lAngleterre',
feat2: 'Chaque code postal noté et filtrable',
feat3: 'Exploration de la carte et exportations illimitées',
feat4: 'Plusieurs décennies de données historiques de prix',
@ -460,6 +521,10 @@ const fr: Translations = {
dsOsmOrigin: 'OpenStreetMap contributors / Geofabrik',
dsOsmUse:
'Points dintérêt couvrant commerces, restaurants, santé, loisirs, tourisme et plus à travers la Grande-Bretagne.',
dsGeolytixRetailName: 'GEOLYTIX Grocery Retail Points',
dsGeolytixRetailOrigin: 'GEOLYTIX',
dsGeolytixRetailUse:
'Emplacements de supermarchés et magasins de proximité au Royaume-Uni, incluant des chaînes comme Waitrose, Tesco, Sainsburys, Asda, Morrisons, Aldi, Lidl, Co-op, M&S, Iceland et Spar.',
dsGreenspaceName: 'OS Open Greenspace',
dsGreenspaceOrigin: 'Ordnance Survey',
dsGreenspaceUse:
@ -623,16 +688,16 @@ const fr: Translations = {
// ── Invites Page ───────────────────────────────────
invitesPage: {
inviteLinksLicensed: "Les liens dinvitation sont disponibles pour les utilisateurs licenciés.",
inviteLinksLicensed: 'Les liens dinvitation sont disponibles pour les utilisateurs licenciés.',
inviteAdminLabel: 'Inviter des amis (100% de réduction)',
inviteReferralLabel: 'Inviter des amis (30% de réduction)',
generateFreeInvite: "Générer un lien dinvitation gratuit",
generateFreeInvite: 'Générer un lien dinvitation gratuit',
generateReferralLink: 'Générer un lien de parrainage',
copyInviteLink: "Copier le lien dinvitation",
copyInviteLink: 'Copier le lien dinvitation',
adminInvitesTitle: 'Invitations admin (100% de réduction)',
referralInvitesTitle: 'Invitations de parrainage (30% de réduction)',
yourInviteLinks: "Vos liens dinvitation",
noInvitesYet: "Aucune invitation générée pour linstant",
yourInviteLinks: 'Vos liens dinvitation',
noInvitesYet: 'Aucune invitation générée pour linstant',
link: 'Lien',
status: 'Statut',
created: 'Créé',
@ -645,26 +710,26 @@ const fr: Translations = {
youreInvited: 'Vous êtes invité !',
specialOffer: 'Offre spéciale !',
invitedByFree: '{{name}} vous invite à obtenir un accès à vie gratuit.',
invitedByDiscount: "{{name}} vous fait bénéficier dune réduction de 30% sur laccès à vie.",
invitedByDiscount: '{{name}} vous fait bénéficier dune réduction de 30% sur laccès à vie.',
genericFreeInvite: 'Vous avez été invité à obtenir un accès à vie gratuit.',
genericDiscount: "Un ami vous fait bénéficier dune réduction de 30% sur laccès à vie.",
exploreEvery: "Explorez chaque quartier dAngleterre",
genericDiscount: 'Un ami vous fait bénéficier dune réduction de 30% sur laccès à vie.',
exploreEvery: 'Explorez chaque quartier dAngleterre',
propertyInfo:
'Prix immobiliers, classements énergétiques, statistiques de criminalité, notes des écoles et plus encore',
invalidInvite: 'Invitation invalide',
inviteAlreadyUsed: 'Invitation déjà utilisée',
inviteAlreadyUsedDesc: "Ce lien dinvitation a déjà été utilisé.",
invalidInviteLink: "Lien dinvitation invalide",
invalidInviteLinkDesc: "Ce lien dinvitation est invalide ou a expiré.",
inviteAlreadyUsedDesc: 'Ce lien dinvitation a déjà été utilisé.',
invalidInviteLink: 'Lien dinvitation invalide',
invalidInviteLinkDesc: 'Ce lien dinvitation est invalide ou a expiré.',
licenseActivated: 'Licence activée !',
fullAccessGranted: 'Vous avez désormais un accès complet à Perfect Postcode.',
activating: 'Activation...',
activateLicense: 'Activer la licence',
claimDiscount: 'Réclamer la réduction',
registerToClaim: "Sinscrire pour réclamer",
registerToClaim: 'Sinscrire pour réclamer',
youAlreadyHaveLicense: 'Vous avez déjà une licence',
accountHasFullAccess: 'Votre compte dispose déjà dun accès complet.',
failedToValidate: "Échec de la validation du lien dinvitation",
failedToValidate: 'Échec de la validation du lien dinvitation',
},
// ── Map Page ───────────────────────────────────────

View file

@ -264,8 +264,7 @@ const hu: Translations = {
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.',
noUnfilteredAreaProperties: 'A kiválasztott területen szűrők nélkül sem található ingatlan.',
relaxFiltersHint: 'Lazítson vagy törölje a szűrőket, hogy lássa a terület ingatlanjait.',
viewProperties: '{{count}} ingatlan megtekintése',
priceHistory: 'Ártörténet',
@ -291,6 +290,8 @@ const hu: Translations = {
// ── Street View ────────────────────────────────────
streetView: {
title: 'Utcakép',
openLarge: 'Utcakép megnyitása nagyobb méretben',
expandedTitle: 'Nagyított utcakép',
},
// ── POI Pane ───────────────────────────────────────
@ -328,47 +329,105 @@ const hu: Translations = {
// ── Home Page ──────────────────────────────────────
home: {
heroTitle1: 'Maximális',
heroTitle2: 'Érték',
heroTitle3: 'Minimális kompromisszum.',
heroSubtitle: 'Ingatlant keresel? Legyen a legnagyobb befektetésed a legokosabb döntésed.',
heroEyebrow: 'Vevőknek, akik azt kérdezik: „hol is kezdjem?”',
heroTitle1: 'Találd meg az irányítószámokat',
heroTitle2: 'amelyek illenek az életedhez',
heroTitle3: 'Nem csak azokat a környékeket, amelyeket már ismersz.',
heroSubtitle:
'A londoni városrészeken, ingázó településeken és regionális városokon át Angliában túl sok hely van ahhoz, hogy egyenként kutasd át őket.',
heroDescription:
'Annyi lehetőség a megfelelő kiválasztása nehéz lehet. Interaktív térképünk egyszerűvé teszi: válaszd ki a feltételeidet, és azonnal lásd a megfelelő területeket.',
exploreTheMap: 'Térkép felfedezése',
seeTheDifference: 'Nézd meg a különbséget',
statProperties: 'ingatlan',
statFilters: 'szűrő',
'Állítsd be a költségvetést, ingázást, iskolákat, biztonságot, zajt, internetet és életstílust. A Perfect Postcode átnézi Anglia irányítószámait, és megmutatja azokat a helyeket is, amelyeket sosem írtál volna be egy ingatlanportálra.',
exploreTheMap: 'Megfelelő irányítószámok keresése',
seeTheDifference: 'Így működik',
showcaseHeader: 'Termékbemutató',
showcaseContext: 'Vevői keresés egész Angliában',
showcaseStep1Tab: 'Leírás',
showcaseStep1Title: 'Írd le, milyen életet szeretnél',
showcaseStep1Body:
'Természetes nyelvvel vagy szűrőkkel alakítsd a bonyolult vevői igényeket egy kereséssé.',
showcaseStep1Prompt: '2 háló £525k alatt, 45 perc munkáig, csendes utcák, jó iskolák',
showcaseStep1Chip1: '<= £525k',
showcaseStep1Chip2: '2+ háló',
showcaseStep1Chip3: '45 perc ingázás',
showcaseStep1Chip4: 'Alacsony útzaj',
showcaseStep2Tab: 'Felfedezés',
showcaseStep2Title: 'Mutasd meg azokat a helyeket, amelyekre nem gondoltál',
showcaseStep2Body:
'A térkép kiemeli a megfelelő irányítószámokat, a megszokott listádon kívül is.',
showcaseStep2Metric: '47 megfelelő irányítószám',
showcaseStep2Note: 'a kézenfekvő listán túl',
showcaseKnownAreas: 'Ismert területek',
showcaseNewMatches: 'Új találatok',
showcaseKnownAreaStatus: 'kevés találat',
showcaseStep3Tab: 'Ellenőrzés',
showcaseStep3Title: 'Értsd meg, miért illik egy irányítószám',
showcaseStep3Body:
'Nyiss meg egy találatot és ellenőrizd az adatokat, mielőtt egy hétvégét nézelődésre szánsz.',
showcaseStep3Postcode: 'Irányítószám példa',
showcaseStep3Area: 'Penge',
showcaseStep3Code: 'SE20',
showcaseStep3Score: 'Erős egyezés',
showcaseEvidence1: '42 perc ingázás',
showcaseEvidence2: 'Alacsonyabb útzaj',
showcaseEvidence3: 'Jó általános iskolák',
showcaseEvidence4: 'Eladási árak a kereten belül',
showcaseStep4Tab: 'Összevetés',
showcaseStep4Title: 'Hasonlítsd össze a kompromisszumokat megtekintés előtt',
showcaseStep4Body:
'A nyereségek és veszteségek alapján szűkíts, ne csak a környék híre alapján.',
showcaseCompare1: 'Penge: londoni vasút, több tér',
showcaseCompare2: 'Totterdown: gyalogos Bristol-utcák',
showcaseCompare3: 'Walkley: nagyobb otthonok, jó érték',
showcaseMapLabel: 'Megfelelő irányítószámok',
showcaseSaveLabel: 'Lista kész',
showcaseMatchPenge: 'London a kereten belül',
showcaseMatchAbbeyWood: 'Elizabeth line + zöldterület',
showcaseMatchTotterdown: 'Bristol gyalogosan élhető',
showcaseMatchWalkley: 'Sheffield: tér + iskolák',
statProperties: 'korábbi eladás',
statFilters: 'kombinálható szűrő',
statEvery: 'Minden',
statPostcodeInEngland: 'irányítószám Angliában',
ourPhilosophy: 'Filozófiánk',
ourPhilosophy: 'Az életedből indulj ki, ne egy irányítószámból',
philosophyP1:
'A Rightmove-on először területet választasz, és reméled, hogy jó. Végül bűnözési statisztikákat, iskolai jelentéseket és szélessáv-ellenőrzőket böngészel tucat füleken, egyszerre egy irányítószámmal.',
'A legtöbb ingatlanoldal először azt kérdezi, hol szeretnél élni. Londonban ez különösen nehéz, de ugyanez a probléma egész Angliában megjelenik: a vevők néhány ismert helyből indulnak ki, majd külön füleken ellenőrzik az ingázást, iskolákat, bűnözést, Street View-t, internetet és eladási árakat.',
philosophyP2:
'Mi megfordítjuk. Mondd el, mire van szükséged (költségvetés, ingazás, iskolák, biztonság), és megmutatjuk Anglia összes megfelelő területét. Nincs találgatás. Nincs felesleges megtekintés.',
'A Perfect Postcode megfordítja a keresést. Mondd meg a térképnek, mi számít, és megmutatja a megfelelő irányítószámokat, indoklással együtt. Előbb az adatok, aztán a helyszíni benyomás.',
streetTitle: 'A helyek utcáról utcára változnak',
streetIntro:
'A nagy környéknevek elrejtik a fontos részleteket: az állomás melyik oldalát, az útzajt, az iskolákat, a pontos ingázást és a valódi eladási árakat.',
streetCard1Title: 'Találd meg a kihagyott környékeket',
streetCard1Body:
'Hozd felszínre azokat az irányítószámokat, amelyek megfelelnek a feltételeidnek, ne csak ismert nevekre vagy ajánlásokra hagyatkozz.',
streetCard2Title: 'Lásd a kompromisszumokat megtekintés előtt',
streetCard2Body:
'Hasonlítsd össze az árat, méretet, ingázást, biztonságot, iskolákat, internetet, zajt és energiahatékonyságot, mielőtt hétvégéket töltesz megtekintésekkel.',
howToUseIt: 'Hogyan használd',
howStep1Title: 'Állítsd be a feltételeidet',
howStep1Desc: 'Költségvetés, ingazás, iskolák — a térkép csak a megfelelőket mutatja.',
howStep2Title: 'Fedezz fel területeket és rejtett kincseket',
howStep2Desc: 'Nagyíts rá, mélyedj el a részletekben és a pluszokban.',
howStep3Title: 'Vizsgáld meg az irányítószámokat',
howStep1Title: 'Írd le, milyen életre van szükséged',
howStep1Desc:
'Költségvetés, ingázás, ingatlantípus, iskolák, biztonság, tér és napi szükségletek.',
howStep2Title: 'Fedd fel a megfelelő irányítószámokat',
howStep2Desc: 'A térkép kiemeli azokat a helyeket, amelyek átmennek a szűrőiden.',
howStep3Title: 'Ellenőrizd a bizonyítékokat',
howStep3Desc:
'Nézd meg az egyes ingatlanokat, eladási árakat, alapterületet, és hasonlítsd össze.',
howStep4Title: 'Válassz magabiztosan',
'Nézd meg az eladási árakat, alapterületet, EPC-t, zajt, internetet, bűnözést és iskolákat.',
howStep4Title: 'Szűkíts listát hirdetések előtt',
howStep4Desc:
'A listádon minden terület megfelel a valós feltételeidnek — nem csak annak, amit azon a héten hirdettek.',
'Menj Rightmove-ra, Zooplára, ügynökökhöz és megtekintésekre jobb keresési területekkel.',
othersVs: 'Mások vs.',
checkMyPostcode: '“Irányítószám ellenőrzése”',
areaGuides: 'Területi útmutatók',
compSearchWithout: 'Keresés terület előzetes kiválasztása nélkül',
compSearchWithoutSub: '(igényekből indulj, nem helyszínből)',
compAreaData: 'Területi adatok',
compAreaDataSub: '(bűnözés, iskolák, zaj, szélessáv)',
compPropertyData: 'Ingatlanspecifikus adatok',
compPropertyDataSub: '(ár, EPC, alapterület)',
compFilters: '56 kombinálható szűrő egy helyen',
compFiltersSub: '(minden információ, egy interaktív térkép)',
ctaTitle: 'Legyen a legnagyobb befektetésed a legokosabb döntésed.',
ctaDescription: 'Ez megfelelő eszközöket érdemel, ne bízd a szerencsére.',
checkMyPostcode: 'Ingatlanportálok',
areaGuides: 'Irányítószám-riportok',
compSearchWithout: 'Területek felfedezése a nevük ismerete előtt',
compSearchWithoutSub: '(előbb igények, aztán helyszín)',
compAreaData: 'Irányítószám-szintű környékadatok',
compAreaDataSub: '(bűnözés, iskolák, zaj, internet, szolgáltatások)',
compPropertyData: 'Ingatlanszintű előzmények',
compPropertyDataSub: '(eladási árak, EPC, alapterület, becsült érték)',
compFilters: '56 együtt működő szűrő',
compFiltersSub: '(nem egy irányítószám vagy hirdetés egyszerre)',
ctaTitle: 'Ne találgasd, hol vegyél.',
ctaDescription:
'Készíts listát olyan irányítószámokból, amelyek illenek a valós életedhez, majd nézd meg őket személyesen.',
},
// ── Pricing Page ───────────────────────────────────
@ -454,6 +513,10 @@ const hu: Translations = {
dsOsmOrigin: 'OpenStreetMap contributors / Geofabrik',
dsOsmUse:
'Érdekes pontok, beleértve üzleteket, éttermeket, egészségügyet, szabadidőt, turizmust és még sok mást Nagy-Britanniában.',
dsGeolytixRetailName: 'GEOLYTIX Grocery Retail Points',
dsGeolytixRetailOrigin: 'GEOLYTIX',
dsGeolytixRetailUse:
'Szupermarketek és kisboltok helyei az Egyesült Királyságban, többek között Waitrose, Tesco, Sainsburys, Asda, Morrisons, Aldi, Lidl, Co-op, M&S, Iceland és Spar láncokkal.',
dsGreenspaceName: 'OS Open Greenspace',
dsGreenspaceOrigin: 'Ordnance Survey',
dsGreenspaceUse:

View file

@ -286,6 +286,8 @@ const zh: Translations = {
// ── Street View ────────────────────────────────────
streetView: {
title: '街景视图',
openLarge: '放大打开街景视图',
expandedTitle: '放大的街景视图',
},
// ── POI Pane ───────────────────────────────────────
@ -323,45 +325,96 @@ const zh: Translations = {
// ── Home Page ──────────────────────────────────────
home: {
heroTitle1: '最大',
heroTitle2: '价值',
heroTitle3: '最小妥协。',
heroSubtitle: '正在找房?让您最大的投资成为最明智的决定。',
heroEyebrow: '适合正在问“我到底该看哪里?”的买家',
heroTitle1: '找到真正',
heroTitle2: '适合您生活的邮编',
heroTitle3: '不只局限于您已经知道的区域。',
heroSubtitle: '从伦敦街区到通勤城镇和英格兰各地城市,可研究的地方太多,无法一个个筛查。',
heroDescription:
'选择太多,找到合适的可能让人不知所措。我们的交互式地图让一切变得简单:选择您的必要条件,立即看到符合的区域。',
exploreTheMap: '探索地图',
seeTheDifference: '看看有何不同',
statProperties: '处房产',
statFilters: '项筛选条件',
'设定预算、通勤、学校、安全、噪音、宽带和生活方式需求。Perfect Postcode 会扫描英格兰的邮编,显示真正匹配的地方,包括您从未想过要在房源网站上搜索的区域。',
exploreTheMap: '找到匹配的邮编',
seeTheDifference: '查看使用方式',
showcaseHeader: '产品展示',
showcaseContext: '英格兰买家搜索示例',
showcaseStep1Tab: '描述',
showcaseStep1Title: '描述您想要的生活',
showcaseStep1Body: '用自然语言或筛选条件,把复杂的买房需求变成一次搜索。',
showcaseStep1Prompt: '2房£525k以内45分钟到工作地点安静街道好学校',
showcaseStep1Chip1: '<= £525k',
showcaseStep1Chip2: '2+卧室',
showcaseStep1Chip3: '45分钟通勤',
showcaseStep1Chip4: '低道路噪音',
showcaseStep2Tab: '发现',
showcaseStep2Title: '发现您没有考虑过的地方',
showcaseStep2Body: '地图会点亮匹配的邮编,包括您原本候选范围之外的区域。',
showcaseStep2Metric: '47个匹配邮编',
showcaseStep2Note: '超出显而易见的候选范围',
showcaseKnownAreas: '熟悉区域',
showcaseNewMatches: '新匹配',
showcaseKnownAreaStatus: '匹配较少',
showcaseStep3Tab: '检查',
showcaseStep3Title: '了解每个邮编为什么匹配',
showcaseStep3Body: '打开结果,在周末看房前先检查证据。',
showcaseStep3Postcode: '邮编示例',
showcaseStep3Area: 'Penge',
showcaseStep3Code: 'SE20',
showcaseStep3Score: '高度匹配',
showcaseEvidence1: '42分钟通勤',
showcaseEvidence2: '较低道路噪音',
showcaseEvidence3: '不错的小学选择',
showcaseEvidence4: '成交价符合预算',
showcaseStep4Tab: '比较',
showcaseStep4Title: '看房前比较取舍',
showcaseStep4Body: '根据得到什么和放弃什么来筛选,而不是只看区域名声。',
showcaseCompare1: 'Penge伦敦铁路连接空间更大',
showcaseCompare2: 'Totterdown布里斯托可步行街区',
showcaseCompare3: 'Walkley更大住房更高性价比',
showcaseMapLabel: '匹配邮编',
showcaseSaveLabel: '候选名单已准备好',
showcaseMatchPenge: '伦敦预算匹配',
showcaseMatchAbbeyWood: 'Elizabeth line + 绿地',
showcaseMatchTotterdown: '布里斯托步行便利',
showcaseMatchWalkley: '谢菲尔德空间 + 学校',
statProperties: '历史成交记录',
statFilters: '可组合筛选条件',
statEvery: '覆盖',
statPostcodeInEngland: '英格兰每个邮编',
ourPhilosophy: '我们的理念',
ourPhilosophy: '从生活需求出发,而不是从邮编出发',
philosophyP1:
'在 Rightmove 上,您需要先选一个区域,然后期望它足够好。最终您不得不在十几个标签页中交叉对比犯罪数据、学校报告和宽带速度,一个邮编一个邮编地查。',
'大多数房产网站先问您想住哪里。在伦敦这个问题尤其困难,但英格兰各地都有同样的问题:买家通常只能从几个熟悉的地方开始,然后分别查询通勤、学校、犯罪率、街景、宽带和成交价。',
philosophyP2:
'我们反其道而行。告诉我们您的需求(预算、通勤、学校、安全),我们为您展示英格兰所有符合条件的区域。不用猜测,不浪费看房时间。',
'Perfect Postcode 反过来做搜索。告诉地图什么重要,它会显示符合条件的邮编,并解释为什么值得查看。先看数据,再去现场感受。',
streetTitle: '每条街都可能不同',
streetIntro:
'大的区域名称会掩盖关键细节:车站哪一侧、道路噪音、学校组合、真实通勤时间,以及类似房产的实际成交价。',
streetCard1Title: '发现您可能错过的区域',
streetCard1Body:
'根据您的条件找出匹配的邮编,而不是只依赖熟悉的地名、朋友推荐或“潜力区域”的宣传。',
streetCard2Title: '看房前先看清取舍',
streetCard2Body:
'在把周末花在看房之前,先比较价格、空间、通勤、安全、学校、宽带、噪音和能源评级。',
howToUseIt: '使用方法',
howStep1Title: '设定必要条件',
howStep1Desc: '预算、通勤、学校——地图只显示符合条件的区域。',
howStep2Title: '探索区域,发现隐藏的好地方',
howStep2Desc: '放大查看,深入了解细节和加分项。',
howStep3Title: '深入邮编级别',
howStep3Desc: '查看单个房产、成交价、建筑面积,并进行比较。',
howStep4Title: '自信地列出候选名单',
howStep4Desc: '您名单上的每个区域都满足您的实际需求——而不只是当周恰好有房源。',
howStep1Title: '描述您需要的生活',
howStep1Desc: '预算、通勤、房产类型、学校、安全、空间和日常生活设施。',
howStep2Title: '显示匹配的邮编',
howStep2Desc: '地图会高亮通过筛选的地方,包括不熟悉的区域。',
howStep3Title: '查看证据',
howStep3Desc: '查看成交价、建筑面积、EPC、道路噪音、宽带、犯罪率和学校。',
howStep4Title: '先筛区域,再看房源',
howStep4Desc: '带着更好的搜索区域去 Rightmove、Zoopla、中介和看房。',
othersVs: '其他平台 vs',
checkMyPostcode: '"查查我的邮编"类网站',
areaGuides: '区域指南',
compSearchWithout: '无需先选区域即可搜索',
compSearchWithoutSub: '(从需求出发,而非地点)',
compAreaData: '区域数据',
compAreaDataSub: '(犯罪率、学校、噪音、宽带)',
compPropertyData: '房产专属数据',
compPropertyDataSub: '(价格、能源性能证书、建筑面积)',
compFilters: '56 项可组合筛选条件,尽在一处',
compFiltersSub: '(所有信息,一张交互式地图)',
ctaTitle: '让您最大的投资成为最明智的 决定。',
ctaDescription: '这值得用专业的工具来做,别全靠运气。',
checkMyPostcode: '房源门户',
areaGuides: '邮编报告',
compSearchWithout: '在知道名称前先发现区域',
compSearchWithoutSub: '先需求,后地点)',
compAreaData: '邮编级社区证据',
compAreaDataSub: '(犯罪率、学校、噪音、宽带、设施',
compPropertyData: '房产级历史记录',
compPropertyDataSub: '成交价、EPC、面积、估值',
compFilters: '56 项联动筛选',
compFiltersSub: '不是一次查一个邮编或一个房源',
ctaTitle: '别再猜哪里值得买。',
ctaDescription: '先建立符合真实生活需求的邮编候选名单,再去实地感受。',
},
// ── Pricing Page ───────────────────────────────────
@ -441,6 +494,10 @@ const zh: Translations = {
dsOsmName: 'OpenStreetMap POIs',
dsOsmOrigin: 'OpenStreetMap contributors / Geofabrik',
dsOsmUse: '涵盖大不列颠地区的商店、餐厅、医疗、休闲、旅游等兴趣点。',
dsGeolytixRetailName: 'GEOLYTIX Grocery Retail Points',
dsGeolytixRetailOrigin: 'GEOLYTIX',
dsGeolytixRetailUse:
'英国超市和便利店位置数据,包括 Waitrose、Tesco、Sainsburys、Asda、Morrisons、Aldi、Lidl、Co-op、M&S、Iceland 和 Spar 等连锁品牌。',
dsGreenspaceName: 'OS Open Greenspace',
dsGreenspaceOrigin: 'Ordnance Survey',
dsGreenspaceUse:

View file

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

View file

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

View file

@ -2,6 +2,7 @@ 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', () => {
@ -64,4 +65,20 @@ describe('api utilities', () => {
)
).toBe('Property type:Flat');
});
it('deduplicates repeated synthetic school filters before backend routes', () => {
const features: FeatureMeta[] = [
{ name: 'Good+ primary schools within 2km', type: 'numeric', min: 0, max: 10 },
];
expect(
buildFilterString(
{
[createSchoolFilterKey('primary', 'good', 2, 1)]: [1, 10],
[createSchoolFilterKey('primary', 'good', 2, 2)]: [2, 8],
},
features
)
).toBe('Good+ primary schools within 2km:2:8');
});
});

View file

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

View file

@ -126,6 +126,47 @@ export const POI_GROUP_COLORS: Record<string, [number, number, number]> = {
/** Default color for unknown POI groups */
export const POI_DEFAULT_COLOR: [number, number, number] = [107, 114, 128];
/** POI category → icon/logo URL for branded and transport categories */
export const POI_CATEGORY_LOGOS: Record<string, string> = {
Airport: '/assets/twemoji/2708.png',
Aldi: 'https://geolytix.github.io/MapIcons/brands/aldi_24px.svg',
Amazon: 'https://geolytix.github.io/MapIcons/brands/amazon_fresh_alt_24px.svg',
Asda: 'https://geolytix.github.io/MapIcons/asda/asda_primary.svg',
Bakery: '/assets/twemoji/1f950.png',
Booths: 'https://geolytix.github.io/MapIcons/brands/booths_24px.svg',
Budgens: 'https://geolytix.github.io/MapIcons/brands/budgens_24px.svg',
'Bus station': '/assets/twemoji/1f68c.png',
'Bus stop': '/assets/twemoji/1f68f.png',
'Butcher & Fishmonger': '/assets/twemoji/1f969.png',
Centra: 'https://geolytix.github.io/MapIcons/brands/centra_24px.svg',
'Co-op': 'https://geolytix.github.io/MapIcons/brands/coop_24px.svg',
COOK: 'https://geolytix.github.io/MapIcons/brands/cook.svg',
'Convenience Store': '/assets/twemoji/1f3ea.png',
Costco: 'https://geolytix.github.io/MapIcons/brands/costco_24px.svg',
'Deli & Specialty': '/assets/twemoji/1f9c6.png',
'Dunnes Stores': 'https://geolytix.github.io/MapIcons/brands/dunnes_stores_24px.svg',
Farmfoods: 'https://geolytix.github.io/MapIcons/brands/farmfoods_updated_24px.svg',
Ferry: '/assets/twemoji/26f4.png',
Greengrocer: '/assets/twemoji/1f96c.png',
'Heron Foods': 'https://geolytix.github.io/MapIcons/brands/heron_24px.svg',
Iceland: 'https://geolytix.github.io/MapIcons/brands/iceland_24px.svg',
Lidl: 'https://geolytix.github.io/MapIcons/brands/lidl_24px.svg',
Makro: 'https://geolytix.github.io/MapIcons/brands/makro_24px.svg',
'M&S': 'https://geolytix.github.io/MapIcons/brands/mns_24px.svg',
Morrisons: 'https://geolytix.github.io/MapIcons/brands/morrisons_24px.svg',
'Off-Licence': '/assets/twemoji/1f377.png',
'Planet Organic': 'https://geolytix.github.io/MapIcons/logos/planet_organic_24px.svg',
'Rail station': '/assets/twemoji/1f686.png',
"Sainsbury's": 'https://geolytix.github.io/MapIcons/brands/sainsburys_24px.svg',
Spar: 'https://geolytix.github.io/MapIcons/brands/spar_24px.svg',
Supermarket: '/assets/twemoji/1f6d2.png',
Tesco: 'https://geolytix.github.io/MapIcons/brands/tesco_24px.svg',
'Taxi rank': '/assets/twemoji/1f695.png',
'Tube station': 'https://geolytix.github.io/MapIcons/public_transport/london_tube.svg',
Waitrose: 'https://geolytix.github.io/MapIcons/brands/waitrose_24px.svg',
'Whole Foods Market': 'https://geolytix.github.io/MapIcons/brands/wholefoods_24px.svg',
};
/** Categories only shown when zoomed in past MINOR_POI_ZOOM_THRESHOLD */
export const MINOR_POI_CATEGORIES = new Set(['Bus stop', 'Taxi rank', 'EV Charging', 'Playground']);

View file

@ -103,8 +103,7 @@ export function buildPropertySearchUrls({
const radiusMiles = isPostcode ? 0 : (H3_RADIUS_MILES[resolution] ?? 1);
const priceFilter =
filters['Estimated current price'] ?? filters['Last known price'];
const priceFilter = filters['Estimated current price'] ?? filters['Last known price'];
const minPrice =
Array.isArray(priceFilter) && typeof priceFilter[0] === 'number' ? priceFilter[0] : undefined;
const maxPrice =

View file

@ -6,6 +6,7 @@ import {
enumIndexToColor,
getBoundsFromViewState,
getFeatureFillColor,
getPoiIconUrl,
zoomToResolution,
} from './map-utils';
@ -36,6 +37,13 @@ describe('map utilities', () => {
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(

View file

@ -9,6 +9,7 @@ import {
TWEMOJI_BASE,
BUFFER_MULTIPLIER,
ENUM_PALETTE,
POI_CATEGORY_LOGOS,
type GradientStop,
} from './consts';
const ROAD_OPACITY = 0.4;
@ -196,6 +197,10 @@ export function emojiToTwemojiUrl(emoji: string): string {
return `${TWEMOJI_BASE}${hex}.png`;
}
export function getPoiIconUrl(category: string, emoji: string): string {
return POI_CATEGORY_LOGOS[category] ?? emojiToTwemojiUrl(emoji);
}
/** Look up a discrete color from the enum palette by index (wraps if > palette size). */
export function enumIndexToColor(
index: number,

View file

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

View file

@ -2,6 +2,7 @@ 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(() => {
@ -79,6 +80,36 @@ describe('url-state', () => {
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');

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,4 +1,4 @@
import express, { type Request } from 'express';
import express, { type Request, type Response } from 'express';
import { ScreenshotCache } from './cache.js';
import { takeScreenshot, checkWebGL, closeBrowser, initialize } from './screenshot.js';
import { buildScreenshotRequest, ValidationError } from './validation.js';
@ -27,25 +27,63 @@ 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 acquireScreenshotSlot(): (() => void) | null {
if (activeScreenshots >= SCREENSHOT_CONCURRENCY) {
return null;
}
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';
@ -117,9 +155,8 @@ app.get('/screenshot', async (req, res) => {
return;
}
releaseSlot = acquireScreenshotSlot();
releaseSlot = await acquireScreenshotSlot(res);
if (!releaseSlot) {
res.status(503).json({ error: 'Screenshot service busy' });
return;
}

View file

@ -1138,12 +1138,12 @@ pub fn bounds_for(name: &str) -> Option<&'static Bounds> {
/// The server will panic at startup if the data contains groups not in this list or vice versa.
pub const POI_GROUP_ORDER: &[&str] = &[
"Public Transport",
"Groceries",
"Leisure",
"Education",
"Health",
"Emergency Services",
"Other",
"Groceries",
"Local Businesses",
"Culture",
"Services",

View file

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

View file

@ -85,79 +85,80 @@ pub async fn get_filter_counts(
let has_travel = !travel_entries.is_empty();
let (pc_interner, pc_keys) = state.data.postcode_parts();
let rows = state.grid.query(south, west, north, east);
let row_count = rows.len();
let row_count = state.grid.count_in_bounds(south, west, north, east);
let mut total_passing: u32 = 0;
let mut impacts = vec![0u32; num_total_filters];
for row_idx in rows {
let row = row_idx as usize;
let base = row * num_features;
let mut fail_count: u32 = 0;
let mut fail_index: usize = 0;
state
.grid
.for_each_in_bounds(south, west, north, east, |row_idx| {
let row = row_idx as usize;
let base = row * num_features;
let mut fail_count: u32 = 0;
let mut fail_index: usize = 0;
// Test numeric filters
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() {
// Test numeric filters
for (i, f) in parsed_filters.iter().enumerate() {
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_index = parsed_filters.len() + i;
fail_index = i;
if fail_count > 1 {
break;
}
}
}
}
// Test travel time filters
if fail_count <= 1 && has_travel {
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;
// Test enum filters
if fail_count <= 1 {
for (i, f) in parsed_enum_filters.iter().enumerate() {
let raw = feature_data[base + f.feat_idx];
if raw == NAN_U16 || !f.allowed.contains(&raw) {
fail_count += 1;
fail_index = parsed_filters.len() + i;
if fail_count > 1 {
break;
}
}
}
}
}
match fail_count {
0 => total_passing += 1,
1 => impacts[fail_index] += 1,
_ => {}
}
}
// Test travel time filters
if fail_count <= 1 && has_travel {
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
let mut impact_map: FxHashMap<String, u32> = FxHashMap::default();

60
uv.lock generated
View file

@ -140,15 +140,6 @@ css = [
{ name = "tinycss2", marker = "python_full_version < '3.14' and sys_platform == 'linux'" },
]
[[package]]
name = "blinker"
version = "1.9.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/21/28/9b3f50ce0e048515135495f198351908d99540d69bfdc8c1d15b73dc55ce/blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf", size = 22460, upload-time = "2024-11-08T17:25:47.436Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458, upload-time = "2024-11-08T17:25:46.184Z" },
]
[[package]]
name = "branca"
version = "0.8.2"
@ -388,15 +379,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/c1/ea/53f2148663b321f21b5a606bd5f191517cf40b7072c0497d3c92c4a13b1e/executing-2.2.1-py2.py3-none-any.whl", hash = "sha256:760643d3452b4d777d295bb167ccc74c64a81df23fb5e08eff250c425a4b2017", size = 28317, upload-time = "2025-09-01T09:48:08.5Z" },
]
[[package]]
name = "fake-useragent"
version = "2.2.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/41/43/948d10bf42735709edb5ae51e23297d034086f17fc7279fef385a7acb473/fake_useragent-2.2.0.tar.gz", hash = "sha256:4e6ab6571e40cc086d788523cf9e018f618d07f9050f822ff409a4dfe17c16b2", size = 158898, upload-time = "2025-04-14T15:32:19.238Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/51/37/b3ea9cd5558ff4cb51957caca2193981c6b0ff30bd0d2630ac62505d99d0/fake_useragent-2.2.0-py3-none-any.whl", hash = "sha256:67f35ca4d847b0d298187443aaf020413746e56acd985a611908c73dba2daa24", size = 161695, upload-time = "2025-04-14T15:32:17.732Z" },
]
[[package]]
name = "fastexcel"
version = "0.19.0"
@ -418,23 +400,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/a8/20d0723294217e47de6d9e2e40fd4a9d2f7c4b6ef974babd482a59743694/fastjsonschema-2.21.2-py3-none-any.whl", hash = "sha256:1c797122d0a86c5cace2e54bf4e819c36223b552017172f32c5c024a6b77e463", size = 24024, upload-time = "2025-08-14T18:49:34.776Z" },
]
[[package]]
name = "flask"
version = "3.1.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "blinker", marker = "python_full_version < '3.14' and sys_platform == 'linux'" },
{ name = "click", marker = "python_full_version < '3.14' and sys_platform == 'linux'" },
{ name = "itsdangerous", marker = "python_full_version < '3.14' and sys_platform == 'linux'" },
{ name = "jinja2", marker = "python_full_version < '3.14' and sys_platform == 'linux'" },
{ name = "markupsafe", marker = "python_full_version < '3.14' and sys_platform == 'linux'" },
{ name = "werkzeug", marker = "python_full_version < '3.14' and sys_platform == 'linux'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/dc/6d/cfe3c0fcc5e477df242b98bfe186a4c34357b4847e87ecaef04507332dab/flask-3.1.2.tar.gz", hash = "sha256:bf656c15c80190ed628ad08cdfd3aaa35beb087855e2f494910aa3774cc4fd87", size = 720160, upload-time = "2025-08-19T21:03:21.205Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ec/f9/7f9263c5695f4bd0023734af91bedb2ff8209e8de6ead162f35d8dc762fd/flask-3.1.2-py3-none-any.whl", hash = "sha256:ca1d8112ec8a6158cc29ea4858963350011b5c846a414cdb7a954aa9e967d03c", size = 103308, upload-time = "2025-08-19T21:03:19.499Z" },
]
[[package]]
name = "folium"
version = "0.20.0"
@ -628,15 +593,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/7b/55/e5326141505c5d5e34c5e0935d2908a74e4561eca44108fbfb9c13d2911a/isoduration-20.11.0-py3-none-any.whl", hash = "sha256:b2904c2a4228c3d44f409c8ae8e2370eb21a26f7ac2ec5446df141dde3452042", size = 11321, upload-time = "2020-11-01T10:59:58.02Z" },
]
[[package]]
name = "itsdangerous"
version = "2.2.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410, upload-time = "2024-04-16T21:28:15.614Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" },
]
[[package]]
name = "jedi"
version = "0.19.2"
@ -1411,9 +1367,7 @@ name = "property-map"
version = "0.1.0"
source = { virtual = "." }
dependencies = [
{ name = "fake-useragent", marker = "python_full_version < '3.14' and sys_platform == 'linux'" },
{ name = "fastexcel", marker = "python_full_version < '3.14' and sys_platform == 'linux'" },
{ name = "flask", marker = "python_full_version < '3.14' and sys_platform == 'linux'" },
{ name = "folium", marker = "python_full_version < '3.14' and sys_platform == 'linux'" },
{ name = "httpx", extra = ["socks"], marker = "python_full_version < '3.14' and sys_platform == 'linux'" },
{ name = "ipywidgets", marker = "python_full_version < '3.14' and sys_platform == 'linux'" },
@ -1443,9 +1397,7 @@ dev = [
[package.metadata]
requires-dist = [
{ name = "fake-useragent", specifier = ">=2.2.0" },
{ name = "fastexcel", specifier = ">=0.19.0" },
{ name = "flask" },
{ name = "folium", specifier = ">=0.20.0" },
{ name = "httpx" },
{ name = "httpx", extras = ["socks"], specifier = ">=0.28.1" },
@ -2177,18 +2129,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/34/db/b10e48aa8fff7407e67470363eac595018441cf32d5e1001567a7aeba5d2/websocket_client-1.9.0-py3-none-any.whl", hash = "sha256:af248a825037ef591efbf6ed20cc5faa03d3b47b9e5a2230a529eeee1c1fc3ef", size = 82616, upload-time = "2025-10-07T21:16:34.951Z" },
]
[[package]]
name = "werkzeug"
version = "3.1.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markupsafe", marker = "python_full_version < '3.14' and sys_platform == 'linux'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5a/70/1469ef1d3542ae7c2c7b72bd5e3a4e6ee69d7978fa8a3af05a38eca5becf/werkzeug-3.1.5.tar.gz", hash = "sha256:6a548b0e88955dd07ccb25539d7d0cc97417ee9e179677d22c7041c8f078ce67", size = 864754, upload-time = "2026-01-08T17:49:23.247Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ad/e4/8d97cca767bcc1be76d16fb76951608305561c6e056811587f36cb1316a8/werkzeug-3.1.5-py3-none-any.whl", hash = "sha256:5111e36e91086ece91f93268bb39b4a35c1e6f1feac762c9c822ded0a4e322dc", size = 225025, upload-time = "2026-01-08T17:49:21.859Z" },
]
[[package]]
name = "widgetsnbextension"
version = "4.0.15"