More
This commit is contained in:
parent
cd34ee693f
commit
05a1f316e1
58 changed files with 3113 additions and 1277 deletions
|
|
@ -11,8 +11,8 @@ concurrency:
|
||||||
cancel-in-progress: true
|
cancel-in-progress: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
python:
|
check:
|
||||||
name: Python (lint + test)
|
name: Check
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
@ -20,70 +20,27 @@ jobs:
|
||||||
- name: Install uv
|
- name: Install uv
|
||||||
run: curl -LsSf https://astral.sh/uv/install.sh | sh
|
run: curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install Python dependencies
|
||||||
run: uv sync
|
run: uv sync
|
||||||
|
|
||||||
- name: Ruff check
|
|
||||||
run: uv run ruff check .
|
|
||||||
|
|
||||||
- name: Deptry (unused dependencies)
|
|
||||||
run: uv run deptry .
|
|
||||||
|
|
||||||
- name: Tests
|
|
||||||
run: |
|
|
||||||
uv run pytest pipeline/utils/test_haversine.py
|
|
||||||
uv run pytest pipeline/utils/test_poi_counts.py
|
|
||||||
uv run pytest pipeline/transform/postcode_boundaries/test_postcode_boundaries.py
|
|
||||||
|
|
||||||
frontend:
|
|
||||||
name: Frontend (lint + typecheck)
|
|
||||||
runs-on: docker
|
|
||||||
defaults:
|
|
||||||
run:
|
|
||||||
working-directory: frontend
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install frontend dependencies
|
||||||
|
working-directory: frontend
|
||||||
run: npm ci
|
run: npm ci
|
||||||
|
|
||||||
- name: ESLint
|
- name: Install screenshot service dependencies
|
||||||
run: npm run lint
|
working-directory: screenshot
|
||||||
|
run: npm ci
|
||||||
- name: Prettier check
|
|
||||||
run: npm run format:check
|
|
||||||
|
|
||||||
- name: TypeScript typecheck
|
|
||||||
run: npm run typecheck
|
|
||||||
|
|
||||||
rust:
|
|
||||||
name: Rust (lint + test)
|
|
||||||
runs-on: docker
|
|
||||||
defaults:
|
|
||||||
run:
|
|
||||||
working-directory: server-rs
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- uses: https://github.com/dtolnay/rust-toolchain@stable
|
- uses: https://github.com/dtolnay/rust-toolchain@stable
|
||||||
with:
|
with:
|
||||||
components: clippy, rustfmt
|
components: clippy, rustfmt
|
||||||
|
|
||||||
- name: Clippy
|
|
||||||
run: cargo clippy -- -D warnings
|
|
||||||
|
|
||||||
- name: Format check
|
|
||||||
run: cargo fmt --check
|
|
||||||
|
|
||||||
- name: Install cargo-machete
|
- name: Install cargo-machete
|
||||||
run: cargo install cargo-machete
|
run: cargo install cargo-machete
|
||||||
|
|
||||||
- name: Unused dependencies check
|
- name: Run checks
|
||||||
run: cargo machete
|
run: ./check.sh
|
||||||
|
|
||||||
- name: Tests
|
|
||||||
run: cargo test
|
|
||||||
|
|
|
||||||
430
CLAUDE.md
430
CLAUDE.md
|
|
@ -1,430 +0,0 @@
|
||||||
# CLAUDE.md
|
|
||||||
|
|
||||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
|
||||||
|
|
||||||
NEVER EVER RUN GIT COMMANDS!!
|
|
||||||
|
|
||||||
## Project Overview
|
|
||||||
|
|
||||||
Property Map is a full-stack geospatial application for visualizing UK property data on an interactive map. It combines Land Registry price-paid data, EPC energy certificates, postcode geolocation, TFL journey times, Index of Deprivation scores, crime statistics, ethnicity data, broadband speeds, school ratings, road noise, and OpenStreetMap POIs into a single wide parquet file, then serves aggregated H3 hexagon statistics and POI data via a Rust backend.
|
|
||||||
|
|
||||||
## Commands
|
|
||||||
|
|
||||||
All commands use [Task](https://taskfile.dev) runner. Python uses `uv run`. Frontend uses `npm run` from `frontend/`.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Development servers
|
|
||||||
task dev:server # Rust backend on :8001 (cargo run --release)
|
|
||||||
task dev:frontend # Webpack dev server on :3001 (proxies /api to :8001)
|
|
||||||
|
|
||||||
# Data pipeline (uses Make, not Task — see Makefile.data)
|
|
||||||
make -f Makefile.data prepare # Build properties.parquet (merge + price estimation)
|
|
||||||
make -f Makefile.data merge # Just the merge step (no price estimation)
|
|
||||||
|
|
||||||
# Assets
|
|
||||||
task download:map-assets # Download font glyphs + twemoji PNGs into frontend/public/assets/
|
|
||||||
|
|
||||||
# Quality
|
|
||||||
task lint # Lint all: Python (ruff) + TypeScript (ESLint+Prettier) + Rust (clippy+fmt)
|
|
||||||
task format # Auto-fix formatting for all languages
|
|
||||||
task test # Python tests (fuzzy join, haversine, POI counts)
|
|
||||||
task check # Full validation: lint + build + test
|
|
||||||
|
|
||||||
# Building
|
|
||||||
task build:frontend # TypeScript typecheck + webpack production build
|
|
||||||
task build:server # cargo build --release (NOTE: dir is wrong in Taskfile, run from server-rs/)
|
|
||||||
|
|
||||||
# Granular lint/format
|
|
||||||
task lint:python # uv run ruff check .
|
|
||||||
task lint:frontend # eslint + prettier --check
|
|
||||||
task lint:rust # cargo clippy -- -D warnings && cargo fmt --check
|
|
||||||
task format:python # ruff check --fix && ruff format
|
|
||||||
task format:frontend # eslint --fix + prettier --write
|
|
||||||
task format:rust # cargo fmt --all
|
|
||||||
```
|
|
||||||
|
|
||||||
Running individual tests:
|
|
||||||
```bash
|
|
||||||
uv run pytest pipeline/utils/test_haversine.py # Single test file
|
|
||||||
uv run pytest pipeline/utils/test_haversine.py -k "test_name" # Single test
|
|
||||||
```
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
### Data Flow
|
|
||||||
|
|
||||||
```
|
|
||||||
Raw sources → [Download scripts] → data/*.parquet
|
|
||||||
→ [Fuzzy join EPC ↔ Price-Paid] → epc_pp.parquet
|
|
||||||
→ [Merge all datasets] → properties.parquet
|
|
||||||
→ [Price estimation] → properties.parquet (augmented with estimated prices)
|
|
||||||
→ [Rust server loads into memory + precomputes H3 + spatial grid]
|
|
||||||
→ [Frontend renders deck.gl H3HexagonLayer over MapLibre GL]
|
|
||||||
```
|
|
||||||
|
|
||||||
### Data Pipeline (`pipeline/`)
|
|
||||||
|
|
||||||
Python + Polars. Orchestrated by `Makefile.data` (Make DAG with sentinel files like `.merge_done`, `.prices_done`). Two phases:
|
|
||||||
|
|
||||||
1. **Download** (`pipeline/download/`) — Each script fetches one raw dataset into `data/`
|
|
||||||
2. **Transform** (`pipeline/transform/`) — Joins and derives features:
|
|
||||||
- `join_epc_pp.py` — Fuzzy-joins EPC ↔ price-paid by address within postcode buckets
|
|
||||||
- `merge.py` — **Main pipeline**: joins all datasets → `properties.parquet` with human-readable column names
|
|
||||||
- `price_estimation/` — Post-merge step: adds "Estimated current price" and "Est. price per sqm" columns to `properties.parquet`. Uses repeat-sales price index + kNN spatial blending. Requires `price_index.parquet` (built by `price_estimation/index.py`). Run via `make -f Makefile.data prepare` (the `merge` target alone skips this).
|
|
||||||
- `transform_poi.py` — Filters POIs, maps to friendly names + emoji (exhaustive category validation)
|
|
||||||
- `poi_proximity.py` — Counts POIs within 2km per postcode using 0.05° spatial grid
|
|
||||||
- `crime.py` — Aggregates crime CSVs into yearly averages by LSOA
|
|
||||||
|
|
||||||
**Critical: column renaming in `merge.py`** — The pipeline renames columns from snake_case to human-readable names before writing `properties.parquet`. The Rust server and frontend use **only** these human-readable names — there are no fallbacks to snake_case. Key renames:
|
|
||||||
- `pp_address` → `Address per Property Register`
|
|
||||||
- `postcode` → `Postcode`
|
|
||||||
- `latest_price` → `Last known price`
|
|
||||||
- `duration` → `Leasehold/Freehold`
|
|
||||||
- `total_floor_area` → `Total floor area (sqm)`
|
|
||||||
- `current_energy_rating` → `Current energy rating`
|
|
||||||
|
|
||||||
The server requires these exact column names at startup (will error if missing). See the full rename map in `merge.py`.
|
|
||||||
|
|
||||||
### Backend (`server-rs/`)
|
|
||||||
|
|
||||||
Rust + Axum. Loads parquet into memory at startup.
|
|
||||||
|
|
||||||
**Structure** (uses Rust 2018 module style — `foo.rs` + `foo/` directory, not `foo/mod.rs`):
|
|
||||||
- `data.rs` + `data/` — Property and POI data loading
|
|
||||||
- `parsing.rs` + `parsing/` — Filter parsing and bounds parsing
|
|
||||||
- `routes.rs` + `routes/` — One file per endpoint. `properties.rs` exports shared `build_property()` used by both hexagon and postcode property endpoints
|
|
||||||
- `utils.rs` + `utils/` — GridIndex, hashing, interned columns
|
|
||||||
- `consts.rs` — Key constants (histogram bins, H3 range, max enum cardinality, excluded columns)
|
|
||||||
|
|
||||||
**API endpoints:**
|
|
||||||
- `GET /api/features` — Feature metadata with histograms and 2nd/98th percentiles
|
|
||||||
- `GET /api/hexagons?resolution=&bounds=&filters=&fields=&enum_dist=` — H3 aggregates (min/max per feature per hex), AABB-filtered to bounds. Optional `enum_dist=FeatureName` adds `dist_FeatureName: [count_per_value...]` arrays for pie chart visualization.
|
|
||||||
- `GET /api/postcodes?bounds=&filters=&fields=&enum_dist=` — Postcode polygon aggregates, AABB-filtered to bounds. Same `enum_dist` support as hexagons.
|
|
||||||
- `GET /api/postcode/:postcode` — Single postcode lookup (centroid + polygon)
|
|
||||||
- `GET /api/hexagon-properties?h3=&resolution=&filters=&limit=&offset=` — Paginated properties within a hexagon
|
|
||||||
- `GET /api/postcode-properties?postcode=&filters=&limit=&offset=` — Paginated properties within a postcode
|
|
||||||
- `GET /api/pois?bounds=&categories=` — POIs by bounds (max 5000)
|
|
||||||
- `GET /api/poi-categories` — Available POI category names
|
|
||||||
|
|
||||||
Serves `frontend/dist/` as static fallback in production **only** when `--dist` is explicitly provided. When `--dist` is set, the server panics at startup if `index.html` is unreadable. When omitted (dev mode), static serving and OG injection are disabled.
|
|
||||||
|
|
||||||
**Data representation (unified model):**
|
|
||||||
- All features (numeric and enum): row-major flat `Vec<f32>`, NaN = null
|
|
||||||
- Enum features: stored as f32 indices (0.0, 1.0, 2.0...) with `enum_values: FxHashMap<usize, Vec<String>>` mapping feature index → string values. Raw u16 indices are used directly for distribution counting (no dequantization needed for enums).
|
|
||||||
- Enum distribution: `Aggregator` optionally tracks per-value counts via `EnumDist` struct (configured by `EnumDistConfig`). Emitted as `dist_FeatureName: [count_val0, count_val1, ...]` in hex/postcode responses when `enum_dist` param is set.
|
|
||||||
- String fields (address, postcode): interned/packed for memory efficiency
|
|
||||||
- All CLI args are required (no hidden defaults). Optional services use `Option<String>`: `r5_url` (travel time disabled when None), `pocketbase_admin_email`/`password` (collection auto-creation skipped when None). Required config like `gemini_model` and `public_url` must be explicitly provided via env or CLI.
|
|
||||||
|
|
||||||
### Frontend (`frontend/`)
|
|
||||||
|
|
||||||
React 18 + TypeScript. deck.gl `H3HexagonLayer` over MapLibre GL. TailwindCSS. No state management library — pure React hooks.
|
|
||||||
|
|
||||||
**Architecture:**
|
|
||||||
- `App.tsx` — Minimal router: loads features/POI categories, handles page navigation. Page type is `'home' | 'dashboard' | 'learn' | 'pricing' | 'account' | 'saved' | 'invites' | 'invite'`. Auth-required pages (`account`, `saved`, `invites`) redirect to home with login modal when unauthenticated. `pageToPath()` / `pathToPage()` map between Page values and URL paths.
|
|
||||||
- `AccountPage.tsx` — Exports three separate page components: `SavedPage` (`/saved` — saved searches + saved properties with sub-tabs), `InvitesPage` (`/invites` — invite link generation + history), and `AccountPage` (default export, `/account` — email, subscription, newsletter, support). Note: `'invite'` (singular, `/invite/:code`) is the invite *redemption* flow — distinct from `'invites'` (plural, `/invites`) which is the invite *management* page.
|
|
||||||
- `MapPage.tsx` — Dashboard layout: composes map + left/right panes, uses custom hooks for all logic
|
|
||||||
- Custom hooks in `hooks/` encapsulate stateful logic:
|
|
||||||
- `useMapData` — Hexagon/postcode fetching, bounds, loading state, color range calculation
|
|
||||||
- `useFilters` — Filter state and handlers (add/remove/change/drag/pin)
|
|
||||||
- `useHexagonSelection` — Selection state, area stats, properties fetching (supports both hexagons and postcodes)
|
|
||||||
- `usePOIData` — POI fetching with debounce
|
|
||||||
- `usePaneResize` — Reusable pane resize handlers
|
|
||||||
- `useTheme` — Theme state with localStorage persistence
|
|
||||||
- `useUrlSync` — URL state synchronization
|
|
||||||
|
|
||||||
**Key patterns:**
|
|
||||||
- URL encodes view/filters/POI categories/active tab as query params for shareable links. Only the current format is supported — no legacy parameter parsing (old `v=`, `f=`, or tab abbreviations are not handled). `tmode` is always serialized when travel time is active (no implicit default); parsing throws if `tmode` is missing when `dest` is present.
|
|
||||||
- AbortControllers cancel in-flight requests on new queries (150ms debounce)
|
|
||||||
- Zoom → H3 resolution defined in `consts.ts` `ZOOM_TO_RESOLUTION_THRESHOLDS`: `<7.5→5, <9.5→6, <10.5→8, <12→9, ≥12→10`
|
|
||||||
- `POSTCODE_ZOOM_THRESHOLD = 15`: below 15 shows H3 hexagons, at/above 15 shows postcode polygons
|
|
||||||
- Viewport bounds computed via `getBoundsFromViewState()` in `map-utils.ts` — uses Web Mercator math with **TILE_SIZE=512** (MapLibre/deck.gl convention, NOT 256)
|
|
||||||
- Properties pane uses feature names from API response (human-readable), not hardcoded field names
|
|
||||||
- Proxy: dev server on :3001 proxies `/api` to :8001; also handles VS Code `/proxy/PORT` patterns
|
|
||||||
- **Nav links must be `<a>` tags, not `<button>`**: All page navigation items in `Header.tsx` and `MobileMenu.tsx` use `<a href={PAGE_PATHS[page]}>` with an `onClick` that calls `e.preventDefault()` + client-side navigation for normal clicks, but lets CMD/Ctrl+click fall through to open in a new tab. `PAGE_PATHS` is exported from `Header.tsx`. Pattern: `if (e.metaKey || e.ctrlKey || e.shiftKey || e.button !== 0) return;` before `preventDefault()`.
|
|
||||||
- **Portal outside-click handlers must check both refs**: When a dropdown uses `createPortal(content, document.body)`, the portal DOM is outside the trigger's container. An outside-click handler using `container.contains(e.target)` will treat clicks on portal content as "outside" and close the dropdown. On mobile this breaks selection entirely because the native `mousedown` listener on `document` preempts React's synthetic event on the portal content. Fix: add a separate ref to the portal content and check both in the handler (`!containerRef.current.contains(target) && !dropdownRef.current?.contains(target)`). See `DestinationDropdown.tsx` for the pattern.
|
|
||||||
|
|
||||||
**Shared UI Components (`frontend/src/components/ui/`):**
|
|
||||||
- `icons/` — One file per icon (CloseIcon, InfoIcon, EyeIcon, PlusIcon, ChevronIcon, FilterIcon, LightbulbIcon, DownloadIcon, MapPinIcon, CheckIcon, ClipboardIcon, SunIcon, MoonIcon, SpinnerIcon). All accept `className` prop. **Never inline SVGs** — always extract to this folder.
|
|
||||||
- `IconButton.tsx` — Reusable icon button wrapper with consistent hover states. Accepts `active` prop for teal highlight.
|
|
||||||
- `SearchInput.tsx` — Styled search input with dark mode support. Used in Filters, POIPane, PropertiesPane.
|
|
||||||
- `PaneHeader.tsx` — Reusable pane header with title, optional subtitle, info button, and close button.
|
|
||||||
- `SelectionButtons.tsx` — "All" / "None" selection buttons for checkbox lists.
|
|
||||||
- `TabButton.tsx` — Tab button with active state styling. Used in right pane tabs.
|
|
||||||
- `EmptyState.tsx` — Empty state display with icon, title, description. Also exports `PaneEmptyState` for centered pane messages.
|
|
||||||
- `CheckboxList.tsx` — Checkbox list with toggle logic. Variants for array and Set-based selection.
|
|
||||||
|
|
||||||
**Shared Components (`frontend/src/components/`):**
|
|
||||||
- `FeatureInfoPopup.tsx` — Popup showing feature name, description, detail, and "View data source" link.
|
|
||||||
- `FeatureIcons.tsx` — `FeatureActions` component combining eye/info/add/remove icons for feature rows.
|
|
||||||
|
|
||||||
**Shared Utilities (`frontend/src/lib/`):**
|
|
||||||
- `api.ts` — `apiUrl(endpoint, params?)` builds API URLs. `logNonAbortError(label, err)` and `isAbortError(err)` for error handling.
|
|
||||||
- `features.ts` — `groupFeaturesByCategory(features)` groups FeatureMeta[] by their `group` field.
|
|
||||||
- `format.ts` — `formatNumber(value, decimals)` for number formatting. `calculateHistogramMean(histogram)` for weighted mean calculation.
|
|
||||||
- `property-fields.ts` — `getNum(property, key)` for getting a single numeric property value. Takes exactly one key — no fallback names.
|
|
||||||
- `PieHexExtension.ts` — deck.gl `LayerExtension` that turns polygon fills into hexagonal pie charts. Injects GLSL that computes angle from fragment position to centroid, picks slice color from ENUM_PALETTE. See "deck.gl LayerExtension patterns" below.
|
|
||||||
|
|
||||||
When adding new UI, prefer using these shared components over inline implementations to maintain consistency.
|
|
||||||
|
|
||||||
**When to extract vs inline:**
|
|
||||||
- Extract to `hooks/`: Stateful logic with useState/useEffect/useCallback that can be named as a cohesive unit (e.g., `useFilters`, `useMapData`). If a component has 5+ related state variables and handlers, extract them to a hook.
|
|
||||||
- Extract to page component: Layout + hook composition for a major view (e.g., `MapPage` composes `useMapData` + `useFilters` + child components). Keep App.tsx focused on routing.
|
|
||||||
- Extract to `ui/` component: Repeated 3+ times with same styling (buttons, inputs, icons)
|
|
||||||
- Extract to `lib/`: Pure functions used across components (formatting, calculations, lookups)
|
|
||||||
- Keep inline: One-off UI specific to a single component
|
|
||||||
|
|
||||||
**deck.gl LayerExtension patterns (CRITICAL — hard-won knowledge):**
|
|
||||||
|
|
||||||
Creating custom `LayerExtension`s that add per-instance attributes to CompositeLayer sublayers (H3HexagonLayer, PolygonLayer, GeoJsonLayer) requires following the exact canonical pattern. Getting any part wrong silently fails (attributes read as zero).
|
|
||||||
|
|
||||||
1. **`static defaultProps` with `type: 'accessor'`** — This is what tells `LayerExtension.getSubLayerProps()` to wrap accessors via `getSubLayerAccessor()`, which unwraps `__source.object` to reach the original data item through CompositeLayer sublayer chains. Without this, accessors receive `undefined` or binary data objects instead of the original data.
|
|
||||||
2. **`stepMode: 'dynamic'`** instead of `addInstanced()` — Use `am.add({...})` with `stepMode: 'dynamic'`, not `am.addInstanced({...})`. Dynamic step mode handles per-instance counting automatically for variable-geometry layers like SolidPolygonLayer.
|
|
||||||
3. **`isEnabled(layer)` must guard all hooks** — Check in `getShaders()` and `initializeState()`. For polygon fills, use `layer.id.endsWith('-fill')` to skip PathLayer (stroke) sublayers.
|
|
||||||
4. **Change layer ID when extensions change** — deck.gl recycles layers with the same ID. If you conditionally add/remove extensions, use a different layer ID (e.g., `'h3-hexagons-pie'` vs `'h3-hexagons'`) to force full teardown/rebuild. Otherwise `initializeState` never re-runs and attributes are never populated.
|
|
||||||
5. **Include `data` in updateTriggers for extension accessors** — When API data changes (e.g., new response with `dist_` fields), `colorTrigger` may not change. Include the `data` array reference in the extension accessor updateTriggers so the attribute manager re-runs the accessors on fresh data.
|
|
||||||
6. **FragmentGeometry only has `uv`** — In deck.gl v9's fragment shader, `geometry.position` does NOT exist. The `VertexGeometry` struct has `position`, `worldPosition`, `normal`, etc., but `FragmentGeometry` only has `uv`. To get fragment position in the FS, capture `geometry.position.xy` in the VS into a custom varying.
|
|
||||||
7. **Binary attribute overrides go in `data.attributes`** — In deck.gl v9, `props.instanceFoo` is rejected with "has been removed". Use `data.attributes.instanceFoo` instead. But for extensions using the accessor pattern above, this isn't needed.
|
|
||||||
8. **`getSubLayerProps` only forwards whitelisted props** — Custom props (binary buffers, accessors) set on a CompositeLayer are NOT automatically forwarded to sublayers. The `defaultProps` + `getSubLayerProps()` mechanism in step 1 is the ONLY reliable way to get extension data through the chain.
|
|
||||||
|
|
||||||
See `PieHexExtension.ts` for a working example and `DataFilterExtension` / `FillStyleExtension` in `@deck.gl/extensions` for reference implementations.
|
|
||||||
|
|
||||||
**Component size guideline:** If a component exceeds ~300 lines, look for extraction opportunities. Large components are usually doing too much — split into hooks (for logic) and child components (for UI sections).
|
|
||||||
|
|
||||||
**Naming conventions:**
|
|
||||||
- UI components: PascalCase, noun-based (`TabButton`, `EmptyState`)
|
|
||||||
- Utilities: camelCase verb-based (`formatNumber`, `calculateHistogramMean`)
|
|
||||||
|
|
||||||
## Frontend Design Guide (STRICT — must be followed for all UI changes)
|
|
||||||
|
|
||||||
The frontend uses Tailwind's `darkMode: 'class'` strategy. The `dark` class is toggled on `<html>`. Every visible element must have both light and dark styles. **Never add a light-only color class without its `dark:` counterpart.** Run `task build:frontend` after any UI change to verify.
|
|
||||||
|
|
||||||
### Theme System
|
|
||||||
|
|
||||||
- **State**: `App.tsx` owns a `theme` state (`'light' | 'dark' | 'system'`), persisted in `localStorage` under the key `theme`, default `'system'`.
|
|
||||||
- **Effective theme**: When `'system'`, resolved via `window.matchMedia('(prefers-color-scheme: dark)')`. A `change` listener re-renders on OS preference flip.
|
|
||||||
- **Toggle cycle**: light → dark → system → light. Three-way, not binary.
|
|
||||||
- **Flash prevention**: `index.html` contains an inline `<script>` that applies the `dark` class before first paint. If the localStorage/matchMedia logic in that script changes, update it to match `App.tsx`.
|
|
||||||
- **Prop plumbing**: `effectiveTheme` (`'light' | 'dark'`) is passed as a prop to `<Map>` and `<HomePage>`. Components that need the resolved theme must receive it as a prop — do not read localStorage or matchMedia inside child components.
|
|
||||||
|
|
||||||
### Color Token Reference
|
|
||||||
|
|
||||||
Every UI element must use the correct token from this table. Do not invent new pairings.
|
|
||||||
|
|
||||||
| Role | Light class | Dark class | Hex (dark) |
|
|
||||||
|------|------------|------------|------------|
|
|
||||||
| **Page / pane background** | `bg-warm-50` or `bg-white` | `dark:bg-warm-900` | #1c1917 |
|
|
||||||
| **Card / elevated surface** | `bg-white` | `dark:bg-warm-800` | #292524 |
|
|
||||||
| **Inset / recessed surface** | `bg-warm-100` or `bg-warm-50` | `dark:bg-warm-800` | #292524 |
|
|
||||||
| **Input / select background** | `bg-white` | `dark:bg-warm-800` or `dark:bg-warm-900` | |
|
|
||||||
| **Primary border** | `border-warm-200` | `dark:border-warm-700` | #44403c |
|
|
||||||
| **Subtle border (dividers)** | `border-warm-100` | `dark:border-warm-800` | #292524 |
|
|
||||||
| **Primary text (headings)** | `text-navy-950` or implicit dark | `dark:text-warm-100` | #f5f5f4 |
|
|
||||||
| **Body text** | `text-warm-700` | `dark:text-warm-300` | #d6d3d1 |
|
|
||||||
| **Secondary text (labels, hints)** | `text-warm-500` or `text-warm-600` | `dark:text-warm-400` | #a8a29e |
|
|
||||||
| **Disabled / placeholder text** | `text-warm-400` / `placeholder-warm-400` | `dark:text-warm-500` / `dark:placeholder-warm-500` | #78716c |
|
|
||||||
| **Accent text (links, actions)** | `text-teal-600` | `dark:text-teal-400` | #1de4c3 |
|
|
||||||
| **Accent hover text** | `hover:text-teal-800` | `dark:hover:text-teal-300` | #51f7d9 |
|
|
||||||
| **Accent background (highlights)** | `bg-teal-50` | `dark:bg-teal-900/30` | |
|
|
||||||
| **Active ring / focus ring** | `ring-teal-400` | same — works in both | |
|
|
||||||
| **Price / key metric text** | `text-teal-700` | `dark:text-teal-400` | |
|
|
||||||
| **Remove / close button** | `text-warm-400 hover:text-warm-700` | `dark:hover:text-warm-300` | |
|
|
||||||
| **Checkbox accent** | `accent-teal-600` | same — works in both | |
|
|
||||||
| **Header (unchanged both modes)** | `bg-navy-900 text-white` | same | |
|
|
||||||
|
|
||||||
### Mapping Rules for Specific Contexts
|
|
||||||
|
|
||||||
**Sidebars (Filters, POIPane, PropertiesPane, right-pane tabs):**
|
|
||||||
- Container: `bg-white dark:bg-warm-900`
|
|
||||||
- Inner cards / dropdown menus: `bg-white dark:bg-warm-800`
|
|
||||||
- Borders: `border-warm-200 dark:border-warm-700`
|
|
||||||
- Tab text (active): add `dark:text-warm-100`
|
|
||||||
- Tab text (inactive): `text-warm-600 dark:text-warm-400`
|
|
||||||
|
|
||||||
**Map overlays (PostcodeSearch, MapLegend, POI popup, loading indicator):**
|
|
||||||
- Background: `bg-white dark:bg-warm-800`
|
|
||||||
- Text: `dark:text-warm-200`
|
|
||||||
- Semi-transparent variants: use `/90` opacity suffix (e.g. `dark:bg-warm-800/90`)
|
|
||||||
- Deck.gl tooltip (inline styles, not Tailwind): use `#292524` bg / `#e7e5e4` text / `rgba(0,0,0,0.5)` shadow in dark.
|
|
||||||
- Deck.gl postcode labels (RGB arrays): `[220,220,220,220]` text / `[30,30,30,200]` outline in dark; inverse in light.
|
|
||||||
|
|
||||||
**Map basemaps:**
|
|
||||||
- Self-hosted Protomaps tiles served from PMTiles via `/api/tiles/{z}/{x}/{y}`
|
|
||||||
- Style built by `@protomaps/basemaps` library with `namedFlavor(theme)` for light/dark
|
|
||||||
- Font glyphs and twemoji PNGs served locally from `frontend/public/assets/` (no external CDN deps at runtime)
|
|
||||||
- `CopyWebpackPlugin` copies `frontend/public/` → `dist/` on build; Rust `ServeDir` fallback serves them in prod
|
|
||||||
- Download assets with `task download:map-assets` (script: `pipeline/download/map_assets.py`)
|
|
||||||
|
|
||||||
**HomePage (landing page):**
|
|
||||||
- Page bg: `bg-warm-50 dark:bg-warm-900`
|
|
||||||
- Cards: `bg-white dark:bg-warm-800` with `border-warm-200 dark:border-warm-700`
|
|
||||||
- Backdrop-blur panels: use `/60` or `/40` opacity on both `bg-warm-50` and `dark:bg-warm-900`
|
|
||||||
- HexCanvas: reads `isDark` ref; uses dimmer fill (`#058172`) and stroke (`#0a665b`) at 60% opacity multiplier.
|
|
||||||
- All headings: `dark:text-warm-100`. All body: `dark:text-warm-300` or `dark:text-warm-400`.
|
|
||||||
|
|
||||||
**DataSourcesPage:**
|
|
||||||
- Same card pattern as above. Footer is already dark (`bg-navy-900`) — no changes needed.
|
|
||||||
- License badges: `bg-warm-100 dark:bg-warm-700 text-warm-600 dark:text-warm-300`
|
|
||||||
- Links: `text-teal-600 dark:text-teal-400`
|
|
||||||
|
|
||||||
**DataSources floating button (on map):**
|
|
||||||
- `bg-white/90 dark:bg-warm-800/90` with `text-teal-600 dark:text-teal-400`
|
|
||||||
|
|
||||||
### Rules for New Components
|
|
||||||
|
|
||||||
1. **Every `bg-white` needs `dark:bg-warm-800` or `dark:bg-warm-900`.** Pane-level = warm-900, card-level = warm-800.
|
|
||||||
2. **Every `border-warm-200` needs `dark:border-warm-700`.**
|
|
||||||
3. **Every `text-warm-*` needs a `dark:text-warm-*` counterpart.** Follow the token table — don't guess.
|
|
||||||
4. **Every `text-teal-600` needs `dark:text-teal-400`.** Every `hover:text-teal-800` needs `dark:hover:text-teal-300`.
|
|
||||||
5. **Every `bg-teal-50` needs `dark:bg-teal-900/30`.**
|
|
||||||
6. **Every `hover:bg-warm-50` needs `dark:hover:bg-warm-700` or `dark:hover:bg-warm-800`.**
|
|
||||||
7. **Inputs and selects**: always add `dark:bg-warm-800 dark:text-warm-200 dark:border-warm-700`. Placeholders get `dark:placeholder-warm-500`.
|
|
||||||
8. **Checkboxes**: always include `accent-teal-600 rounded`.
|
|
||||||
9. **Do not use Tailwind `dark:` classes inside deck.gl layers or canvas code.** Use the `theme` prop / ref and conditional JS values.
|
|
||||||
10. **Do not add `transition-*` classes for theme switching.** The global CSS rule in `index.css` handles transitions for `background-color`, `border-color`, and `color` on all standard HTML elements. Adding per-element transition classes will conflict.
|
|
||||||
11. **Never hardcode hex colors in JSX `style=` props for themed elements** (except deck.gl tooltip and canvas, which can't use Tailwind). Use the Tailwind classes from the token table instead.
|
|
||||||
12. **The header (`bg-navy-900`) is identical in both themes.** Do not add dark variants to it.
|
|
||||||
|
|
||||||
### Verification Checklist (for any UI PR)
|
|
||||||
|
|
||||||
- [ ] `task build:frontend` passes with no errors
|
|
||||||
- [ ] Every new `bg-*`, `text-*`, `border-*` class has a `dark:` counterpart (search your diff)
|
|
||||||
- [ ] Toggle through all three modes (light → dark → system) with no flash
|
|
||||||
- [ ] Map basemap switches when theme changes
|
|
||||||
- [ ] Sidebars, dropdowns, and popups are readable in both modes
|
|
||||||
- [ ] HomePage and DataSourcesPage adapt correctly
|
|
||||||
|
|
||||||
## Internationalization (i18n) — MANDATORY
|
|
||||||
|
|
||||||
All user-visible text in the frontend MUST be translated. The build will fail if any language file is missing keys. Supported languages: English, French, German, Hungarian, Chinese.
|
|
||||||
|
|
||||||
### Architecture
|
|
||||||
|
|
||||||
```
|
|
||||||
frontend/src/i18n/
|
|
||||||
index.ts # i18next init, language detection, SUPPORTED_LANGUAGES
|
|
||||||
i18next.d.ts # Module augmentation — makes t() type-safe
|
|
||||||
server.ts # ts() for server-derived values, re-exports tsDesc()
|
|
||||||
descriptions.ts # Feature description translations (separate from locale files)
|
|
||||||
locales/
|
|
||||||
en.ts # English (source of truth, as const)
|
|
||||||
fr.ts / de.ts / hu.ts / zh.ts # Each typed as Translations = DeepStringify<typeof en>
|
|
||||||
```
|
|
||||||
|
|
||||||
**Three translation mechanisms:**
|
|
||||||
1. **`t('key')`** — UI strings (buttons, labels, headings). Type-safe: `t('typo')` is a compile error.
|
|
||||||
2. **`ts(value)`** — Server-derived values (feature names, group names, enum values, POI categories). Looks up `server.${value}` in the locale file, falls back to English.
|
|
||||||
3. **`tsDesc(featureName, englishFallback)`** — Feature descriptions. English comes from the server (single source of truth); other languages from `descriptions.ts`. Keyed by feature name, not description text.
|
|
||||||
|
|
||||||
### Adding a new UI string
|
|
||||||
|
|
||||||
1. Add the key to `en.ts` in the appropriate section
|
|
||||||
2. The build will immediately fail for all other locale files — add translations to each
|
|
||||||
3. Use `t('section.keyName')` in the component
|
|
||||||
|
|
||||||
### Adding a new language
|
|
||||||
|
|
||||||
1. Create `locales/xx.ts` importing `Translations` from `./en` — TypeScript enforces every key exists
|
|
||||||
2. Add a `xx` section to `descriptions.ts` for feature descriptions
|
|
||||||
3. Register in `index.ts`: import, add to `SUPPORTED_LANGUAGES` (with flag emoji) and `resources`
|
|
||||||
|
|
||||||
### Translating server-derived values (feature names, POI categories, etc.)
|
|
||||||
|
|
||||||
When a new feature is added in `features.rs`:
|
|
||||||
- Its name should be added to the `server` section of **all** locale files (keyed by the English name)
|
|
||||||
- Its description should be added to `descriptions.ts` for each non-English language
|
|
||||||
- English descriptions come from the server — do NOT duplicate them in `en.ts`
|
|
||||||
|
|
||||||
### Rules
|
|
||||||
|
|
||||||
- **Every `bg-*`, `text-*` class still needs `dark:` counterpart** (i18n doesn't change the design system)
|
|
||||||
- **URLs always contain English feature names** — `ts()` only wraps display, never data keys or URL params
|
|
||||||
- **Never use dynamic key construction with `t()`** — it breaks TypeScript checking. Use `ts()` for runtime lookups or `tDynamic()` from `index.ts`
|
|
||||||
- **`useTranslatedModes()`** hook provides translated travel mode labels — don't use `MODE_LABELS` for display
|
|
||||||
- **Format utilities** (`formatRelativeTime`, `formatDuration`, `summarizeParams`) are already i18n-aware — they import `i18n` directly since they're not React components
|
|
||||||
|
|
||||||
## Coding Preferences
|
|
||||||
|
|
||||||
- **No backwards compatibility, no silent fallbacks**: Never add fallback codepaths for old data formats, legacy URL parameters, or alternate field names. Never silently swallow errors — always error loudly (return an error, panic, or at minimum log). If something is wrong, the code should fail visibly. One canonical name per field, one format per API, one way to do things. Specific patterns:
|
|
||||||
- Use `Option<String>` for truly optional config, never `default_value = ""` with `.is_empty()` checks
|
|
||||||
- Use `expect()` not `unwrap_or(0.0)` when a value is logically guaranteed to be present
|
|
||||||
- Return error responses on upstream failures, never silently drop results
|
|
||||||
- Don't add `#[serde(default)]` on `Option<T>` fields — serde already defaults them to `None`
|
|
||||||
- Required query params should be non-Option types so serde rejects missing params with 400 automatically
|
|
||||||
- **Unified data models over special-casing**: Prefer storing different data types uniformly (e.g., enums as f32 indices alongside numeric features) rather than maintaining separate code paths
|
|
||||||
- **Terse tests**: Test what matters in as few tests as possible — don't overcomplicate with excessive setup or edge cases that don't add value
|
|
||||||
- **Extract and organize**: Group related utilities into proper modules (e.g., `utils/`, `parsing/`) rather than leaving helpers scattered
|
|
||||||
- **Inline module tests**: Place `#[cfg(test)] mod tests { }` at the bottom of each module file rather than in separate test files
|
|
||||||
- **Decompose large React components**: Extract stateful logic into custom hooks (`useXxx`), extract page layouts into page components. App.tsx should only handle routing and initial data loading. Each hook should encapsulate one cohesive concern (e.g., `useFilters` owns filter state + all filter handlers).
|
|
||||||
|
|
||||||
## Rust Code Style (server-rs)
|
|
||||||
|
|
||||||
Follow these conventions in all Rust code:
|
|
||||||
|
|
||||||
1. **Module style**: Use Rust 2018 module naming — `foo.rs` + `foo/` directory, NOT `foo/mod.rs`
|
|
||||||
2. **Imports over inline paths**: Import items at the top of the file, don't use `crate::` inline in code
|
|
||||||
```rust
|
|
||||||
// Good
|
|
||||||
use crate::utils::generate_priorities;
|
|
||||||
let p = generate_priorities(n);
|
|
||||||
|
|
||||||
// Bad
|
|
||||||
let p = crate::utils::generate_priorities(n);
|
|
||||||
```
|
|
||||||
3. **Tracing macros**: Import and use short form, not fully qualified
|
|
||||||
```rust
|
|
||||||
// Good
|
|
||||||
use tracing::{info, warn};
|
|
||||||
info!("message");
|
|
||||||
|
|
||||||
// Bad
|
|
||||||
tracing::info!("message");
|
|
||||||
```
|
|
||||||
4. **JSON serialization**: Use `serde_json` with `#[derive(Serialize)]` structs, not manual string building
|
|
||||||
5. **Precompute at startup**: For static/rarely-changing responses, compute once at startup and store in `AppState`
|
|
||||||
6. **Unique placeholders**: When injecting content into HTML, use distinctive markers like `__PERFECT_POSTCODES_OG_TAGS__` that won't accidentally match other content
|
|
||||||
|
|
||||||
## Key Implementation Details
|
|
||||||
|
|
||||||
- **Spatial sort**: Rows sorted by 0.01° grid cell at load time for cache-friendly sequential access
|
|
||||||
- **Row-major layout**: `feature_data[row * num_features + feat_idx]` — all features (numeric and enum) for one property are contiguous
|
|
||||||
- **H3 precomputation**: Resolutions 4–12 computed in parallel (rayon) at startup
|
|
||||||
- **Histogram percentiles without sorting**: O(n) two-pass algorithm — build histogram, interpolate percentiles
|
|
||||||
- **Startup precomputation**: Static responses (like `/api/features`) are computed once at startup and cached in `AppState`
|
|
||||||
- **POI transform validation**: Fails if any OSM category is unmapped — guarantees exhaustive coverage
|
|
||||||
- **Fuzzy join**: Groups by postcode, uses `thefuzz.token_sort_ratio` with numeric token compatibility, greedy assignment from highest score
|
|
||||||
- **Filter parsing is strict**: `parse_filters()` returns `Result` — malformed entries, unknown feature names, and unparseable numbers all return 400 Bad Request. No silent skipping of invalid filters.
|
|
||||||
- **Data loading is strict**: `extract_string_col` and `lookup_enum_value` take a single column name (no fallback names). H3 precomputation panics on invalid coordinates. All configured features (defined in `features.rs`) must exist in the data — the server panics at startup if any are missing (no NaN placeholders). This means all pipeline steps must be complete before starting the server.
|
|
||||||
- **Travel time is strict**: `mode` param is required (400) when `destination` is set — no silent default to "car". R5 failures return 502 Bad Gateway, not silent omission. `r5_url` is `Option<String>` — returns 503 if travel time requested without R5 configured.
|
|
||||||
- **Filter bounds format**: `south,west,north,east` (not standard bbox order)
|
|
||||||
- **Server-side AABB filtering**: Both `/api/hexagons` and `/api/postcodes` filter results by bounding-box intersection with query bounds. Hexagons use `h3_cell_bounds()` (h3o returns degrees, not radians). Postcodes compute polygon AABB from vertices. See `bounds_intersect()` in `parsing/bounds.rs`.
|
|
||||||
- **Postcode row matching**: Both `postcode-stats` and `postcode-properties` use the same pattern: look up centroid from `postcode_data`, search `GridIndex` within `POSTCODE_SEARCH_OFFSET` (0.02°) of centroid, then exact string match on `state.data.postcode(row)`. Simpler than hexagon matching (no H3 cell computation needed).
|
|
||||||
- **GridIndex returns slightly more than requested**: The 0.01° grid cells mean properties up to ~1km outside the viewport may be returned. The AABB filter in the route handlers catches these extras.
|
|
||||||
- **POI proximity**: Uses 0.05° grid (~5km cells) to reduce candidates before haversine distance check
|
|
||||||
- **OG tag injection**: Uses `<meta name="x-og-placeholder" content="__PERFECT_POSTCODES_OG_TAGS__"/>` placeholder in HTML, replaced at runtime by middleware
|
|
||||||
- **Enum distribution (pie charts)**: When `enum_dist=FeatureName` is set on `/api/hexagons` or `/api/postcodes`, each cell includes `dist_FeatureName: [count_for_val0, count_for_val1, ...]`. The `Aggregator` struct has optional `EnumDist` that counts raw u16 enum indices per cell. `parse_enum_dist()` in `parsing/fields.rs` validates the feature name and confirms it's an enum. On the frontend, `PieHexExtension` (LayerExtension) injects GLSL into SolidPolygonLayer's fragment shader: computes angle from fragment position to hex centroid (passed as `instancePieCenter` varying), picks slice color from ENUM_PALETTE. `useMapData` adds the `enum_dist` query param when `viewFeatureIsEnum` is true.
|
|
||||||
- **Dev invite code**: The code `devdevdevdev` is recognized as a valid admin invite in dev mode only (`state.index_html.is_none()`, i.e., `--dist` not passed). Both `get_invite` and `post_redeem_invite` short-circuit for this code, returning a fake valid admin invite / no-op "licensed" response without hitting PocketBase. Preview at `http://localhost:3001/invite/devdevdevdev`.
|
|
||||||
|
|
||||||
## Rust Performance Patterns (server-rs)
|
|
||||||
|
|
||||||
**Lookup optimization:**
|
|
||||||
- `AppState.feature_name_to_index: FxHashMap<String, usize>` for O(1) feature lookups (used in filter parsing, field selection)
|
|
||||||
- Never use `.position()` on feature_names in hot paths — always use the prebuilt HashMap
|
|
||||||
- Enum filters use `FxHashSet<u32>` (f32 bits) for O(1) contains checks instead of `Vec::contains`
|
|
||||||
|
|
||||||
**Hot loop patterns:**
|
|
||||||
- Hoist conditional branches outside loops when possible (e.g., `if has_selective` check moved outside aggregation loop in hexagons.rs)
|
|
||||||
- Use `into_par_iter()` for file I/O (postcode GeoJSON loading) and CPU-bound startup work (H3 precomputation)
|
|
||||||
|
|
||||||
**Cardinality counting:**
|
|
||||||
- Use `FxHashSet` with `f32::to_bits()` for O(n) unique value counting instead of collect→sort→dedup O(n log n)
|
|
||||||
- For enum ordering, convert order slice to `FxHashSet` before filtering to get O(1) contains
|
|
||||||
|
|
||||||
**Data structure choices:**
|
|
||||||
- CSR (Compressed Sparse Row) for GridIndex — single flat `values` array + `offsets` array eliminates per-cell Vec overhead
|
|
||||||
- `Box<[f32]>` for fixed-size aggregation arrays — avoids Vec capacity field (8 bytes saved per cell)
|
|
||||||
- Bit-packed booleans for flags like `is_approx_build_date` — 8x memory savings vs `Vec<bool>`
|
|
||||||
|
|
||||||
**What NOT to optimize:**
|
|
||||||
- String cloning in JSON responses (~10-20 small strings) — negligible vs serialization overhead
|
|
||||||
- GridIndex 3-pass build (min/max → count → fill) — necessary for CSR without O(n) extra memory
|
|
||||||
- Arc<str> for enum values — complexity not worth modest benefit
|
|
||||||
|
|
@ -19,6 +19,7 @@ ARCGIS := $(DATA_DIR)/arcgis_data.parquet
|
||||||
PRICE_PAID := $(DATA_DIR)/price-paid-complete.parquet
|
PRICE_PAID := $(DATA_DIR)/price-paid-complete.parquet
|
||||||
IOD := $(DATA_DIR)/IoD2025_Scores.parquet
|
IOD := $(DATA_DIR)/IoD2025_Scores.parquet
|
||||||
POIS_RAW := $(DATA_DIR)/uk_pois.parquet
|
POIS_RAW := $(DATA_DIR)/uk_pois.parquet
|
||||||
|
GROCERY_RETAIL_POINTS := $(DATA_DIR)/geolytix_retail_points.parquet
|
||||||
POIS_FILTERED := $(DATA_DIR)/filtered_uk_pois.parquet
|
POIS_FILTERED := $(DATA_DIR)/filtered_uk_pois.parquet
|
||||||
POI_PROXIMITY := $(DATA_DIR)/poi_proximity.parquet
|
POI_PROXIMITY := $(DATA_DIR)/poi_proximity.parquet
|
||||||
EPC_PP := $(DATA_DIR)/epc_pp.parquet
|
EPC_PP := $(DATA_DIR)/epc_pp.parquet
|
||||||
|
|
@ -63,7 +64,7 @@ PMTILES_VERSION := 1.22.3
|
||||||
|
|
||||||
.PHONY: prepare merge tiles \
|
.PHONY: prepare merge tiles \
|
||||||
download-arcgis download-price-paid download-deprivation download-ethnicity \
|
download-arcgis download-price-paid download-deprivation download-ethnicity \
|
||||||
download-naptan download-pois download-ofsted download-broadband download-rental-prices \
|
download-naptan download-pois download-grocery-retail-points download-ofsted download-broadband download-rental-prices \
|
||||||
download-postcodes download-noise download-inspire \
|
download-postcodes download-noise download-inspire \
|
||||||
download-oa-boundaries download-uprn-lookup download-transit-network download-greenspace download-os-greenspace download-pbf download-places download-lsoa-population download-median-age download-england-boundary download-rightmove-outcodes \
|
download-oa-boundaries download-uprn-lookup download-transit-network download-greenspace download-os-greenspace download-pbf download-places download-lsoa-population download-median-age download-england-boundary download-rightmove-outcodes \
|
||||||
transform-pois transform-epc-pp transform-crime transform-poi-proximity \
|
transform-pois transform-epc-pp transform-crime transform-poi-proximity \
|
||||||
|
|
@ -79,6 +80,7 @@ download-deprivation: $(IOD)
|
||||||
download-ethnicity: $(ETHNICITY)
|
download-ethnicity: $(ETHNICITY)
|
||||||
download-naptan: $(NAPTAN)
|
download-naptan: $(NAPTAN)
|
||||||
download-pois: $(POIS_RAW)
|
download-pois: $(POIS_RAW)
|
||||||
|
download-grocery-retail-points: $(GROCERY_RETAIL_POINTS)
|
||||||
download-ofsted: $(OFSTED)
|
download-ofsted: $(OFSTED)
|
||||||
download-broadband: $(BROADBAND)
|
download-broadband: $(BROADBAND)
|
||||||
download-postcodes: $(POSTCODES_RAW)
|
download-postcodes: $(POSTCODES_RAW)
|
||||||
|
|
@ -149,6 +151,9 @@ $(PBF):
|
||||||
$(POIS_RAW): $(PBF) $(ENGLAND_BOUNDARY)
|
$(POIS_RAW): $(PBF) $(ENGLAND_BOUNDARY)
|
||||||
uv run python -m pipeline.download.pois --output $@ --pbf $(PBF) --boundary $(ENGLAND_BOUNDARY)
|
uv run python -m pipeline.download.pois --output $@ --pbf $(PBF) --boundary $(ENGLAND_BOUNDARY)
|
||||||
|
|
||||||
|
$(GROCERY_RETAIL_POINTS):
|
||||||
|
uv run python -m pipeline.download.geolytix_retail_points --output $@
|
||||||
|
|
||||||
$(OFSTED):
|
$(OFSTED):
|
||||||
uv run python -m pipeline.download.ofsted --output $@
|
uv run python -m pipeline.download.ofsted --output $@
|
||||||
|
|
||||||
|
|
@ -205,8 +210,8 @@ $(RM_OUTCODES): $(MERGE_STAMP)
|
||||||
|
|
||||||
# ── Transforms ────────────────────────────────────────────────────────────────
|
# ── Transforms ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
$(POIS_FILTERED): $(POIS_RAW) $(NAPTAN) $(ENGLAND_BOUNDARY)
|
$(POIS_FILTERED): $(POIS_RAW) $(NAPTAN) $(GROCERY_RETAIL_POINTS) $(ENGLAND_BOUNDARY)
|
||||||
uv run python -m pipeline.transform.transform_poi --input $(POIS_RAW) --naptan $(NAPTAN) --boundary $(ENGLAND_BOUNDARY) --output $@
|
uv run python -m pipeline.transform.transform_poi --input $(POIS_RAW) --naptan $(NAPTAN) --boundary $(ENGLAND_BOUNDARY) --grocery-retail-points $(GROCERY_RETAIL_POINTS) --output $@
|
||||||
|
|
||||||
$(EPC_PP): $(PRICE_PAID) $(EPC)
|
$(EPC_PP): $(PRICE_PAID) $(EPC)
|
||||||
uv run python -m pipeline.transform.join_epc_pp --epc $(EPC) --price-paid $(PRICE_PAID) --output $@
|
uv run python -m pipeline.transform.join_epc_pp --epc $(EPC) --price-paid $(PRICE_PAID) --output $@
|
||||||
|
|
|
||||||
173
README.md
173
README.md
|
|
@ -1,43 +1,174 @@
|
||||||
# Property Map
|
# Property Map
|
||||||
|
|
||||||
## Area
|
Interactive UK property intelligence map. The app combines transaction, EPC,
|
||||||
uv run python scripts/remove_bg.py house-og.png 200 house.png
|
postcode, neighbourhood, transport, POI, and travel-time data into local parquet
|
||||||
|
files, serves fast geospatial aggregations from Rust, and renders the result as
|
||||||
|
a React/deck.gl map.
|
||||||
|
|
||||||
|
The public product is branded as Perfect Postcodes, while this repository is
|
||||||
|
still named `property-map`.
|
||||||
|
|
||||||
interesting links
|
## What Is In Here
|
||||||
- https://propertydata.co.uk/videos/quick-overview
|
|
||||||
- https://osdatahub.os.uk/data/downloads/open
|
|
||||||
|
|
||||||
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?
|
```text
|
||||||
- Why the price tag?
|
property-data/properties.parquet
|
||||||
- contact support
|
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 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.
|
||||||
|
|
|
||||||
134
Taskfile.yml
134
Taskfile.yml
|
|
@ -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
40
check.sh
Executable file
|
|
@ -0,0 +1,40 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
cd "$ROOT_DIR"
|
||||||
|
|
||||||
|
step() {
|
||||||
|
printf '\n==> %s\n' "$1"
|
||||||
|
shift
|
||||||
|
"$@"
|
||||||
|
}
|
||||||
|
|
||||||
|
step "Python lint: ruff" uv run ruff check .
|
||||||
|
step "Python dependency lint: deptry" uv run deptry .
|
||||||
|
step "Python unit tests" uv run pytest \
|
||||||
|
pipeline/utils/test_haversine.py \
|
||||||
|
pipeline/utils/test_poi_counts.py \
|
||||||
|
pipeline/download/test_naptan.py \
|
||||||
|
pipeline/transform/postcode_boundaries/test_postcode_boundaries.py
|
||||||
|
|
||||||
|
(
|
||||||
|
cd "$ROOT_DIR/frontend"
|
||||||
|
step "Frontend lint: ESLint" npm run lint
|
||||||
|
step "Frontend format check: Prettier" npm run format:check
|
||||||
|
step "Frontend typecheck: TypeScript" npm run typecheck
|
||||||
|
step "Frontend unit tests: Vitest" npm run test
|
||||||
|
)
|
||||||
|
|
||||||
|
(
|
||||||
|
cd "$ROOT_DIR/screenshot"
|
||||||
|
step "Screenshot service unit tests" npm run test
|
||||||
|
)
|
||||||
|
|
||||||
|
(
|
||||||
|
cd "$ROOT_DIR/server-rs"
|
||||||
|
step "Rust lint: clippy" cargo clippy --all-targets -- -D warnings
|
||||||
|
step "Rust format check" cargo fmt --all --check
|
||||||
|
step "Rust dependency lint: cargo machete" cargo machete
|
||||||
|
step "Rust unit tests" cargo test
|
||||||
|
)
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useState, useEffect, useRef } from 'react';
|
import { useState, useEffect, useRef, type ReactNode } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useFadeInRef } from '../../hooks/useFadeIn';
|
import { useFadeInRef } from '../../hooks/useFadeIn';
|
||||||
import HexCanvas from './HexCanvas';
|
import HexCanvas from './HexCanvas';
|
||||||
|
|
@ -7,6 +7,386 @@ import { TickerValue } from '../ui/TickerValue';
|
||||||
import { LogoIcon } from '../ui/icons/LogoIcon';
|
import { LogoIcon } from '../ui/icons/LogoIcon';
|
||||||
import { trackEvent } from '../../lib/analytics';
|
import { trackEvent } from '../../lib/analytics';
|
||||||
|
|
||||||
|
const SHOWCASE_STEP_COUNT = 4;
|
||||||
|
const SHOWCASE_INTERVAL_MS = 4200;
|
||||||
|
|
||||||
|
function ProductMapFrame({ children }: { children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="relative h-full min-h-0 rounded-lg overflow-hidden bg-warm-900 border border-white/10">
|
||||||
|
<div className="absolute inset-0 opacity-70">
|
||||||
|
<div className="absolute left-[12%] top-[18%] w-[76%] h-[68%] rounded-[45%] border border-warm-500/25" />
|
||||||
|
<div className="absolute left-[22%] top-[24%] w-[52%] h-[55%] rounded-[45%] border border-warm-500/20" />
|
||||||
|
<div className="absolute left-[36%] top-[8%] h-[84%] w-px bg-warm-500/20 rotate-12" />
|
||||||
|
<div className="absolute left-[18%] top-[49%] h-px w-[68%] bg-warm-500/20 -rotate-6" />
|
||||||
|
<div className="absolute left-[48%] top-[16%] h-[72%] w-px bg-warm-500/20 -rotate-24" />
|
||||||
|
<div className="absolute left-[4%] top-[34%] h-px w-[92%] bg-teal-400/10 rotate-12" />
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DemoMapPin({
|
||||||
|
name,
|
||||||
|
detail,
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
active = false,
|
||||||
|
}: {
|
||||||
|
name: string;
|
||||||
|
detail: string;
|
||||||
|
x: string;
|
||||||
|
y: string;
|
||||||
|
active?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`absolute -translate-x-1/2 -translate-y-1/2 ${active ? 'z-20' : 'z-10'}`}
|
||||||
|
style={{ left: x, top: y }}
|
||||||
|
>
|
||||||
|
<div className="relative mx-auto w-4 h-4">
|
||||||
|
<div
|
||||||
|
className={`absolute inset-0 rounded-full ${
|
||||||
|
active ? 'bg-coral-400 ring-8 ring-coral-400/20' : 'bg-teal-400 ring-4 ring-teal-400/15'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={`mt-2 w-32 rounded-md border px-2 py-1 shadow-lg ${
|
||||||
|
active
|
||||||
|
? 'border-coral-400/40 bg-navy-950/95 text-white'
|
||||||
|
: 'border-white/10 bg-navy-950/85 text-warm-100'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="text-xs font-semibold leading-tight">{name}</div>
|
||||||
|
<div className="text-[10px] leading-tight text-warm-400">{detail}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function HeroProductShowcase() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [activeStep, setActiveStep] = useState(0);
|
||||||
|
const [isPaused, setIsPaused] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const prefersReducedMotion =
|
||||||
|
typeof window !== 'undefined' &&
|
||||||
|
window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||||
|
if (isPaused || prefersReducedMotion) return;
|
||||||
|
|
||||||
|
const timer = window.setInterval(() => {
|
||||||
|
setActiveStep((step) => (step + 1) % SHOWCASE_STEP_COUNT);
|
||||||
|
}, SHOWCASE_INTERVAL_MS);
|
||||||
|
|
||||||
|
return () => window.clearInterval(timer);
|
||||||
|
}, [isPaused]);
|
||||||
|
|
||||||
|
const steps = [
|
||||||
|
{
|
||||||
|
tab: t('home.showcaseStep1Tab'),
|
||||||
|
title: t('home.showcaseStep1Title'),
|
||||||
|
body: t('home.showcaseStep1Body'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
tab: t('home.showcaseStep2Tab'),
|
||||||
|
title: t('home.showcaseStep2Title'),
|
||||||
|
body: t('home.showcaseStep2Body'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
tab: t('home.showcaseStep3Tab'),
|
||||||
|
title: t('home.showcaseStep3Title'),
|
||||||
|
body: t('home.showcaseStep3Body'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
tab: t('home.showcaseStep4Tab'),
|
||||||
|
title: t('home.showcaseStep4Title'),
|
||||||
|
body: t('home.showcaseStep4Body'),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const criteriaChips = [
|
||||||
|
t('home.showcaseStep1Chip1'),
|
||||||
|
t('home.showcaseStep1Chip2'),
|
||||||
|
t('home.showcaseStep1Chip3'),
|
||||||
|
t('home.showcaseStep1Chip4'),
|
||||||
|
];
|
||||||
|
|
||||||
|
const knownAreas = ['Clapham', 'St Albans', 'Brighton'];
|
||||||
|
const mapPins = [
|
||||||
|
{ name: 'Penge', detail: t('home.showcaseMatchPenge'), x: '63%', y: '54%' },
|
||||||
|
{ name: 'Abbey Wood', detail: t('home.showcaseMatchAbbeyWood'), x: '73%', y: '63%' },
|
||||||
|
{ name: 'Totterdown', detail: t('home.showcaseMatchTotterdown'), x: '42%', y: '71%' },
|
||||||
|
{ name: 'Walkley', detail: t('home.showcaseMatchWalkley'), x: '50%', y: '35%' },
|
||||||
|
];
|
||||||
|
const evidenceRows = [
|
||||||
|
t('home.showcaseEvidence1'),
|
||||||
|
t('home.showcaseEvidence2'),
|
||||||
|
t('home.showcaseEvidence3'),
|
||||||
|
t('home.showcaseEvidence4'),
|
||||||
|
];
|
||||||
|
const compareRows = [
|
||||||
|
t('home.showcaseCompare1'),
|
||||||
|
t('home.showcaseCompare2'),
|
||||||
|
t('home.showcaseCompare3'),
|
||||||
|
];
|
||||||
|
const active = steps[activeStep];
|
||||||
|
|
||||||
|
const renderSidePanel = () => {
|
||||||
|
if (activeStep === 0) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="rounded-lg border border-white/10 bg-white/[0.04] p-3">
|
||||||
|
<div className="text-[11px] font-semibold uppercase text-warm-400">
|
||||||
|
{t('aiFilter.describeIdealArea')}
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 text-sm leading-snug text-white">
|
||||||
|
{t('home.showcaseStep1Prompt')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{criteriaChips.map((chip) => (
|
||||||
|
<span
|
||||||
|
key={chip}
|
||||||
|
className="rounded-full border border-teal-400/30 bg-teal-400/10 px-2 py-1 text-[11px] font-medium text-teal-200"
|
||||||
|
>
|
||||||
|
{chip}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeStep === 1) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="rounded-lg border border-coral-400/30 bg-coral-400/10 p-3">
|
||||||
|
<div className="text-2xl font-bold text-white">{t('home.showcaseStep2Metric')}</div>
|
||||||
|
<div className="text-xs text-coral-100">{t('home.showcaseStep2Note')}</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||||
|
<div className="rounded-lg border border-white/10 bg-white/[0.04] p-2">
|
||||||
|
<div className="font-semibold text-warm-300">{t('home.showcaseKnownAreas')}</div>
|
||||||
|
{knownAreas.map((area) => (
|
||||||
|
<div key={area} className="mt-1 flex justify-between gap-2 text-warm-500">
|
||||||
|
<span>{area}</span>
|
||||||
|
<span>{t('home.showcaseKnownAreaStatus')}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg border border-teal-400/30 bg-teal-400/10 p-2">
|
||||||
|
<div className="font-semibold text-teal-100">{t('home.showcaseNewMatches')}</div>
|
||||||
|
{mapPins.slice(0, 3).map((pin) => (
|
||||||
|
<div key={pin.name} className="mt-1 text-teal-200">
|
||||||
|
{pin.name}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeStep === 2) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-white/10 bg-white/[0.04] p-3">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<div className="text-xs font-semibold text-warm-400">
|
||||||
|
{t('home.showcaseStep3Postcode')}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-lg font-bold text-white">{t('home.showcaseStep3Area')}</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-full bg-teal-400/15 px-2.5 py-1 text-xs font-semibold text-teal-200">
|
||||||
|
{t('home.showcaseStep3Score')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 space-y-1.5">
|
||||||
|
{evidenceRows.map((row) => (
|
||||||
|
<div key={row} className="flex items-center gap-2 text-xs text-warm-300">
|
||||||
|
<span className="w-4 h-4 rounded-full bg-teal-400/15 text-teal-200 flex items-center justify-center text-[10px]">
|
||||||
|
✓
|
||||||
|
</span>
|
||||||
|
<span>{row}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{compareRows.map((row, index) => (
|
||||||
|
<div key={row} className="rounded-lg border border-white/10 bg-white/[0.04] p-2.5">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<span className="mt-0.5 w-5 h-5 rounded-full bg-coral-400/15 text-coral-100 flex items-center justify-center text-xs font-bold">
|
||||||
|
{index + 1}
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0 flex-1 text-xs leading-snug text-warm-200">{row}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div className="text-xs font-semibold text-teal-200">{t('home.showcaseSaveLabel')}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderVisual = () => {
|
||||||
|
if (activeStep === 0) {
|
||||||
|
return (
|
||||||
|
<ProductMapFrame>
|
||||||
|
<div className="absolute inset-x-4 top-4 rounded-lg border border-white/10 bg-navy-950/90 p-3 shadow-xl">
|
||||||
|
<div className="text-[10px] font-semibold uppercase text-warm-400">
|
||||||
|
{t('home.showcaseStep1Tab')}
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 rounded-md bg-white text-warm-800 px-3 py-2 text-xs leading-snug">
|
||||||
|
{t('home.showcaseStep1Prompt')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="absolute inset-x-4 bottom-4 flex flex-wrap gap-2">
|
||||||
|
{criteriaChips.map((chip) => (
|
||||||
|
<span
|
||||||
|
key={chip}
|
||||||
|
className="rounded-full border border-teal-400/30 bg-navy-950/85 px-2.5 py-1 text-[11px] font-semibold text-teal-100"
|
||||||
|
>
|
||||||
|
{chip}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</ProductMapFrame>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeStep === 1) {
|
||||||
|
return (
|
||||||
|
<ProductMapFrame>
|
||||||
|
<div className="absolute left-3 top-3 rounded-md bg-navy-950/85 px-2.5 py-1.5 text-xs text-warm-300 border border-white/10">
|
||||||
|
{t('home.showcaseMapLabel')}
|
||||||
|
</div>
|
||||||
|
{mapPins.map((pin, index) => (
|
||||||
|
<DemoMapPin key={pin.name} {...pin} active={index === 0} />
|
||||||
|
))}
|
||||||
|
</ProductMapFrame>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeStep === 2) {
|
||||||
|
return (
|
||||||
|
<ProductMapFrame>
|
||||||
|
<DemoMapPin {...mapPins[0]} active />
|
||||||
|
<div className="absolute right-3 top-3 w-40 rounded-lg border border-white/10 bg-navy-950/95 p-3 shadow-xl">
|
||||||
|
<div className="text-xs font-semibold text-warm-400">
|
||||||
|
{t('home.showcaseStep3Postcode')}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-xl font-bold text-white">{t('home.showcaseStep3Code')}</div>
|
||||||
|
<div className="mt-2 rounded-full bg-teal-400/15 px-2 py-1 text-center text-xs font-semibold text-teal-200">
|
||||||
|
{t('home.showcaseStep3Score')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="absolute inset-x-3 bottom-3 rounded-lg border border-white/10 bg-navy-950/90 p-3">
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
{evidenceRows.map((row) => (
|
||||||
|
<div key={row} className="text-[11px] text-warm-300">
|
||||||
|
<span className="text-teal-300">✓</span> {row}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ProductMapFrame>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ProductMapFrame>
|
||||||
|
<div className="absolute inset-3 grid grid-rows-3 gap-2">
|
||||||
|
{compareRows.map((row, index) => (
|
||||||
|
<div
|
||||||
|
key={row}
|
||||||
|
className="rounded-lg border border-white/10 bg-navy-950/90 p-3 shadow-lg"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div className="min-w-0 flex-1 text-xs font-semibold leading-snug text-white">
|
||||||
|
{row}
|
||||||
|
</div>
|
||||||
|
<div className="h-2 w-16 shrink-0 rounded-full bg-white/10 overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full bg-coral-400"
|
||||||
|
style={{ width: `${88 - index * 14}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</ProductMapFrame>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="relative w-full max-w-xl mt-10 lg:mt-0"
|
||||||
|
onMouseEnter={() => setIsPaused(true)}
|
||||||
|
onMouseLeave={() => setIsPaused(false)}
|
||||||
|
onFocus={() => setIsPaused(true)}
|
||||||
|
onBlur={() => setIsPaused(false)}
|
||||||
|
aria-label={t('home.showcaseHeader')}
|
||||||
|
>
|
||||||
|
<div className="h-[44rem] sm:h-[40rem] md:h-[34rem] lg:h-[35rem] xl:h-[33rem] rounded-xl border border-white/10 bg-navy-950/85 shadow-2xl overflow-hidden flex flex-col">
|
||||||
|
<div className="shrink-0 px-4 py-3 border-b border-white/10 flex items-center gap-2">
|
||||||
|
<LogoIcon className="w-5 h-5 text-teal-400" />
|
||||||
|
<div className="text-sm font-semibold text-white">{t('home.showcaseHeader')}</div>
|
||||||
|
<div className="ml-auto text-xs text-warm-400">{t('home.showcaseContext')}</div>
|
||||||
|
</div>
|
||||||
|
<div className="shrink-0 grid grid-cols-2 sm:grid-cols-4 gap-1 p-2 bg-white/[0.03] border-b border-white/10">
|
||||||
|
{steps.map((step, index) => (
|
||||||
|
<button
|
||||||
|
key={step.tab}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActiveStep(index)}
|
||||||
|
aria-pressed={activeStep === index}
|
||||||
|
className={`rounded-md px-2 py-2 text-[11px] sm:text-xs font-semibold leading-tight text-left transition-colors ${
|
||||||
|
activeStep === index
|
||||||
|
? 'bg-white/10 text-white'
|
||||||
|
: 'text-warm-400 hover:bg-white/[0.06] hover:text-warm-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span>{step.tab}</span>
|
||||||
|
<span className="mt-2 block h-0.5 overflow-hidden rounded-full bg-white/10">
|
||||||
|
{activeStep === index && (
|
||||||
|
<span
|
||||||
|
key={activeStep}
|
||||||
|
className="showcase-progress block h-full origin-left bg-teal-400"
|
||||||
|
style={{
|
||||||
|
animationDuration: `${SHOWCASE_INTERVAL_MS}ms`,
|
||||||
|
animationPlayState: isPaused ? 'paused' : 'running',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-h-0 p-4 md:p-5">
|
||||||
|
<div className="grid h-full min-h-0 grid-rows-[minmax(0,0.92fr)_minmax(0,1.18fr)] md:grid-rows-1 md:grid-cols-[0.88fr_1.12fr] gap-4">
|
||||||
|
<div className="min-h-0 overflow-hidden flex flex-col justify-between gap-4 rounded-lg border border-white/10 bg-white/[0.035] p-4">
|
||||||
|
<div className="shrink-0" aria-live={isPaused ? 'polite' : 'off'}>
|
||||||
|
<div className="text-xs font-semibold text-teal-300">{steps[activeStep].tab}</div>
|
||||||
|
<h2 className="mt-2 text-xl font-bold leading-tight text-white">{active.title}</h2>
|
||||||
|
<p className="mt-3 text-sm leading-relaxed text-warm-400">{active.body}</p>
|
||||||
|
</div>
|
||||||
|
<div className="min-h-0 overflow-hidden">{renderSidePanel()}</div>
|
||||||
|
</div>
|
||||||
|
{renderVisual()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function HomePage({
|
export default function HomePage({
|
||||||
onOpenDashboard,
|
onOpenDashboard,
|
||||||
onOpenPricing: _onOpenPricing,
|
onOpenPricing: _onOpenPricing,
|
||||||
|
|
@ -63,82 +443,84 @@ export default function HomePage({
|
||||||
{/* Hero */}
|
{/* Hero */}
|
||||||
<div className="relative overflow-hidden bg-gradient-to-b from-navy-950 via-navy-900 to-navy-900 dark:from-navy-950 dark:via-navy-900 dark:to-navy-950 min-h-[calc(100dvh-3rem)] flex flex-col">
|
<div className="relative overflow-hidden bg-gradient-to-b from-navy-950 via-navy-900 to-navy-900 dark:from-navy-950 dark:via-navy-900 dark:to-navy-950 min-h-[calc(100dvh-3rem)] flex flex-col">
|
||||||
<HexCanvas isDark={theme === 'dark'} />
|
<HexCanvas isDark={theme === 'dark'} />
|
||||||
<div className="absolute top-1/3 left-1/4 w-[500px] h-[500px] bg-teal-500/[0.04] rounded-full blur-[120px] pointer-events-none" />
|
|
||||||
<div className="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="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">
|
<div className="grid lg:grid-cols-[1fr_0.9fr] gap-8 lg:gap-12 items-center">
|
||||||
<h1 className="text-3xl md:text-5xl font-extrabold text-white mb-4 leading-[1.1] tracking-tight">
|
<div className="max-w-4xl">
|
||||||
{t('home.heroTitle1')} <span className="text-teal-400">{t('home.heroTitle2')}</span>
|
<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]">
|
||||||
<br />
|
{t('home.heroTitle1')}{' '}
|
||||||
{t('home.heroTitle3')}
|
<span className="text-teal-400">{t('home.heroTitle2')}</span>.
|
||||||
</h1>
|
<br />
|
||||||
<p className="text-base md:text-lg text-warm-300 mb-6 leading-relaxed max-w-xl">
|
{t('home.heroTitle3')}
|
||||||
{t('home.heroSubtitle')}
|
</h1>
|
||||||
</p>
|
<p className="text-base md:text-lg text-warm-300 mb-6 leading-relaxed max-w-xl">
|
||||||
<p className="text-base md:text-lg text-warm-400 mb-8 max-w-xl">
|
{t('home.heroSubtitle')}
|
||||||
{t('home.heroDescription')}
|
</p>
|
||||||
</p>
|
<p className="text-base md:text-lg text-warm-400 mb-8 max-w-xl">
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center gap-3 sm:gap-4 mb-10">
|
{t('home.heroDescription')}
|
||||||
<button
|
</p>
|
||||||
onClick={() => {
|
<div className="flex flex-col sm:flex-row sm:items-center gap-3 sm:gap-4 mb-10">
|
||||||
trackEvent('CTA Click', { location: 'hero', label: 'explore_map' });
|
<button
|
||||||
onOpenDashboard();
|
onClick={() => {
|
||||||
}}
|
trackEvent('CTA Click', { location: 'hero', label: 'explore_map' });
|
||||||
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"
|
onOpenDashboard();
|
||||||
>
|
}}
|
||||||
{t('home.exploreTheMap')}
|
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"
|
||||||
</button>
|
>
|
||||||
<button
|
{t('home.exploreTheMap')}
|
||||||
onClick={() => {
|
</button>
|
||||||
trackEvent('CTA Click', { location: 'hero', label: 'see_difference' });
|
<button
|
||||||
const target = document.getElementById('comparison');
|
onClick={() => {
|
||||||
if (!target) return;
|
trackEvent('CTA Click', { location: 'hero', label: 'see_difference' });
|
||||||
const scroller = target.closest('.overflow-y-auto') as HTMLElement | null;
|
const target = document.getElementById('how-it-works');
|
||||||
if (!scroller) return;
|
if (!target) return;
|
||||||
const start = scroller.scrollTop;
|
const scroller = target.closest('.overflow-y-auto') as HTMLElement | null;
|
||||||
const end =
|
if (!scroller) return;
|
||||||
start +
|
const start = scroller.scrollTop;
|
||||||
target.getBoundingClientRect().top -
|
const end =
|
||||||
scroller.getBoundingClientRect().top -
|
start +
|
||||||
48;
|
target.getBoundingClientRect().top -
|
||||||
const distance = end - start;
|
scroller.getBoundingClientRect().top -
|
||||||
const duration = 1200;
|
48;
|
||||||
let startTime: number;
|
const distance = end - start;
|
||||||
const step = (time: number) => {
|
const duration = 1200;
|
||||||
if (!startTime) startTime = time;
|
let startTime: number;
|
||||||
const p = Math.min((time - startTime) / duration, 1);
|
const step = (time: number) => {
|
||||||
const ease = p < 0.5 ? 4 * p * p * p : 1 - Math.pow(-2 * p + 2, 3) / 2;
|
if (!startTime) startTime = time;
|
||||||
scroller.scrollTop = start + distance * ease;
|
const p = Math.min((time - startTime) / duration, 1);
|
||||||
if (p < 1) requestAnimationFrame(step);
|
const ease = p < 0.5 ? 4 * p * p * p : 1 - Math.pow(-2 * p + 2, 3) / 2;
|
||||||
};
|
scroller.scrollTop = start + distance * ease;
|
||||||
requestAnimationFrame(step);
|
if (p < 1) 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"
|
requestAnimationFrame(step);
|
||||||
>
|
}}
|
||||||
{t('home.seeTheDifference')}
|
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"
|
||||||
</button>
|
>
|
||||||
</div>
|
{t('home.seeTheDifference')}
|
||||||
<div className="flex flex-wrap gap-x-8 sm:gap-x-12 gap-y-4 pt-3 border-t border-white/10">
|
</button>
|
||||||
<div>
|
|
||||||
<div className="text-2xl md:text-3xl font-bold text-white">
|
|
||||||
<TickerValue text="13M" active={statsActive} />
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-warm-400">{t('home.statProperties')}</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="flex flex-wrap gap-x-8 sm:gap-x-12 gap-y-4 pt-3 border-t border-white/10">
|
||||||
<div className="text-2xl md:text-3xl font-bold text-white">
|
<div>
|
||||||
<TickerValue text="56" active={statsActive} />
|
<div className="text-2xl md:text-3xl font-bold text-white">
|
||||||
|
<TickerValue text="13M" active={statsActive} />
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-warm-400">{t('home.statProperties')}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-warm-400">{t('home.statFilters')}</div>
|
<div>
|
||||||
</div>
|
<div className="text-2xl md:text-3xl font-bold text-white">
|
||||||
<div>
|
<TickerValue text="56" active={statsActive} />
|
||||||
<div className="text-2xl md:text-3xl font-bold text-white">
|
</div>
|
||||||
{t('home.statEvery')}
|
<div className="text-sm text-warm-400">{t('home.statFilters')}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-2xl md:text-3xl font-bold text-white">
|
||||||
|
{t('home.statEvery')}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-warm-400">{t('home.statPostcodeInEngland')}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-warm-400">{t('home.statPostcodeInEngland')}</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<HeroProductShowcase />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -155,6 +537,32 @@ export default function HomePage({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Street by street */}
|
||||||
|
<div className="max-w-7xl mx-auto px-6 md:px-10 pt-10 pb-2">
|
||||||
|
<div className="grid md:grid-cols-3 gap-4">
|
||||||
|
<div className="md:col-span-1">
|
||||||
|
<h2 className="text-2xl md:text-3xl font-bold text-navy-950 dark:text-warm-100 mb-3">
|
||||||
|
{t('home.streetTitle')}
|
||||||
|
</h2>
|
||||||
|
<p className="text-warm-600 dark:text-warm-400 leading-relaxed">
|
||||||
|
{t('home.streetIntro')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{[
|
||||||
|
{ label: t('home.streetCard1Title'), body: t('home.streetCard1Body') },
|
||||||
|
{ label: t('home.streetCard2Title'), body: t('home.streetCard2Body') },
|
||||||
|
].map((item) => (
|
||||||
|
<div
|
||||||
|
key={item.label}
|
||||||
|
className="rounded-lg border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 p-5"
|
||||||
|
>
|
||||||
|
<h3 className="font-bold text-navy-950 dark:text-warm-100 mb-2">{item.label}</h3>
|
||||||
|
<p className="text-warm-600 dark:text-warm-400 leading-relaxed">{item.body}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* How to use it + Comparison table (two columns) */}
|
{/* How to use it + Comparison table (two columns) */}
|
||||||
<div id="how-it-works" className="max-w-7xl mx-auto px-6 md:px-10 pt-10 pb-2">
|
<div id="how-it-works" className="max-w-7xl mx-auto px-6 md:px-10 pt-10 pb-2">
|
||||||
<div ref={whyRef} className="fade-in-section">
|
<div ref={whyRef} className="fade-in-section">
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,11 @@ const DATA_SOURCE_DEFS: DataSourceDef[] = [
|
||||||
url: 'https://download.geofabrik.de/europe/great-britain-latest.osm.pbf',
|
url: 'https://download.geofabrik.de/europe/great-britain-latest.osm.pbf',
|
||||||
license: 'Open Data Commons Open Database License (ODbL)',
|
license: 'Open Data Commons Open Database License (ODbL)',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'geolytix-retail-points',
|
||||||
|
url: 'https://geolytix.com/blog/supermarket-retail-points/',
|
||||||
|
license: 'GEOLYTIX Open Data License',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'os-open-greenspace',
|
id: 'os-open-greenspace',
|
||||||
url: 'https://osdatahub.os.uk/downloads/open/OpenGreenspace',
|
url: 'https://osdatahub.os.uk/downloads/open/OpenGreenspace',
|
||||||
|
|
@ -106,6 +111,11 @@ const DS_KEYS: Record<string, [string, string, string]> = {
|
||||||
],
|
],
|
||||||
crime: ['learnPage.dsCrimeName', 'learnPage.dsCrimeOrigin', 'learnPage.dsCrimeUse'],
|
crime: ['learnPage.dsCrimeName', 'learnPage.dsCrimeOrigin', 'learnPage.dsCrimeUse'],
|
||||||
'osm-pois': ['learnPage.dsOsmName', 'learnPage.dsOsmOrigin', 'learnPage.dsOsmUse'],
|
'osm-pois': ['learnPage.dsOsmName', 'learnPage.dsOsmOrigin', 'learnPage.dsOsmUse'],
|
||||||
|
'geolytix-retail-points': [
|
||||||
|
'learnPage.dsGeolytixRetailName',
|
||||||
|
'learnPage.dsGeolytixRetailOrigin',
|
||||||
|
'learnPage.dsGeolytixRetailUse',
|
||||||
|
],
|
||||||
'os-open-greenspace': [
|
'os-open-greenspace': [
|
||||||
'learnPage.dsGreenspaceName',
|
'learnPage.dsGreenspaceName',
|
||||||
'learnPage.dsGreenspaceOrigin',
|
'learnPage.dsGreenspaceOrigin',
|
||||||
|
|
|
||||||
|
|
@ -13,9 +13,7 @@ export default function EnumBarChart({
|
||||||
const localTotal = entries.reduce((sum, [, c]) => sum + c, 0);
|
const localTotal = entries.reduce((sum, [, c]) => sum + c, 0);
|
||||||
|
|
||||||
// When global counts are available, normalize both to percentages for comparison
|
// When global counts are available, normalize both to percentages for comparison
|
||||||
const globalTotal = globalCounts
|
const globalTotal = globalCounts ? Object.values(globalCounts).reduce((sum, c) => sum + c, 0) : 0;
|
||||||
? Object.values(globalCounts).reduce((sum, c) => sum + c, 0)
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
const hasGlobal = globalCounts && globalTotal > 0;
|
const hasGlobal = globalCounts && globalTotal > 0;
|
||||||
|
|
||||||
|
|
@ -61,9 +59,14 @@ export default function EnumBarChart({
|
||||||
)}
|
)}
|
||||||
<div
|
<div
|
||||||
className={
|
className={
|
||||||
barStyle ? 'h-full rounded relative' : 'h-full bg-teal-500 dark:bg-teal-400 rounded relative'
|
barStyle
|
||||||
|
? 'h-full rounded relative'
|
||||||
|
: 'h-full bg-teal-500 dark:bg-teal-400 rounded relative'
|
||||||
}
|
}
|
||||||
style={{ width: `${localWidth}%`, ...(barStyle ? { backgroundColor: barStyle } : {}) }}
|
style={{
|
||||||
|
width: `${localWidth}%`,
|
||||||
|
...(barStyle ? { backgroundColor: barStyle } : {}),
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<span className="w-8 text-warm-500 dark:text-warm-400 text-right shrink-0">
|
<span className="w-8 text-warm-500 dark:text-warm-400 text-right shrink-0">
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,19 @@ import {
|
||||||
type TravelTimeEntry,
|
type TravelTimeEntry,
|
||||||
travelFieldKey,
|
travelFieldKey,
|
||||||
} from '../../hooks/useTravelTime';
|
} from '../../hooks/useTravelTime';
|
||||||
|
import {
|
||||||
|
SCHOOL_FILTER_NAME,
|
||||||
|
clampSchoolRange,
|
||||||
|
getDefaultSchoolFeatureName,
|
||||||
|
getSchoolBackendFeatureName,
|
||||||
|
getSchoolFilterConfig,
|
||||||
|
getSchoolFilterMeta,
|
||||||
|
isSchoolFilterName,
|
||||||
|
replaceSchoolFilterKeySelection,
|
||||||
|
type SchoolDistance,
|
||||||
|
type SchoolPhase,
|
||||||
|
type SchoolRating,
|
||||||
|
} from '../../lib/school-filter';
|
||||||
|
|
||||||
function EditableLabel({
|
function EditableLabel({
|
||||||
value,
|
value,
|
||||||
|
|
@ -169,6 +182,223 @@ function SliderLabels({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function SchoolFilterCard({
|
||||||
|
features,
|
||||||
|
schoolFeature,
|
||||||
|
filters,
|
||||||
|
activeFeature,
|
||||||
|
dragValue,
|
||||||
|
pinnedFeature,
|
||||||
|
filterImpact,
|
||||||
|
onFilterChange,
|
||||||
|
onDragStart,
|
||||||
|
onDragChange,
|
||||||
|
onDragEnd,
|
||||||
|
onTogglePin,
|
||||||
|
onShowInfo,
|
||||||
|
onRemove,
|
||||||
|
}: {
|
||||||
|
features: FeatureMeta[];
|
||||||
|
schoolFeature: FeatureMeta;
|
||||||
|
filters: FeatureFilters;
|
||||||
|
activeFeature: string | null;
|
||||||
|
dragValue: [number, number] | null;
|
||||||
|
pinnedFeature: string | null;
|
||||||
|
filterImpact?: number;
|
||||||
|
onFilterChange: (name: string, value: [number, number] | string[]) => void;
|
||||||
|
onDragStart: (name: string) => void;
|
||||||
|
onDragChange: (value: [number, number]) => void;
|
||||||
|
onDragEnd: () => void;
|
||||||
|
onTogglePin: (name: string) => void;
|
||||||
|
onShowInfo: (feature: FeatureMeta) => void;
|
||||||
|
onRemove: () => void;
|
||||||
|
}) {
|
||||||
|
const config = getSchoolFilterConfig(schoolFeature.name);
|
||||||
|
const schoolMeta = getSchoolFilterMeta(features);
|
||||||
|
const backendFeature = config
|
||||||
|
? features.find((feature) => feature.name === config.featureName)
|
||||||
|
: undefined;
|
||||||
|
const isActive = activeFeature === schoolFeature.name;
|
||||||
|
const isPinned = pinnedFeature === schoolFeature.name;
|
||||||
|
const hist = backendFeature?.histogram;
|
||||||
|
const dataMin = hist?.min ?? backendFeature?.min ?? 0;
|
||||||
|
const dataMax = hist?.max ?? backendFeature?.max ?? 10;
|
||||||
|
const displayValue =
|
||||||
|
isActive && dragValue
|
||||||
|
? dragValue
|
||||||
|
: (filters[schoolFeature.name] as [number, number]) || [dataMin, dataMax];
|
||||||
|
const sliderValue: [number, number] = [
|
||||||
|
displayValue[0] <= dataMin ? (backendFeature?.min ?? dataMin) : displayValue[0],
|
||||||
|
displayValue[1] >= dataMax ? (backendFeature?.max ?? dataMax) : displayValue[1],
|
||||||
|
];
|
||||||
|
|
||||||
|
if (!config) return null;
|
||||||
|
|
||||||
|
const replaceSchoolFeature = (
|
||||||
|
next: Partial<{
|
||||||
|
phase: SchoolPhase;
|
||||||
|
rating: SchoolRating;
|
||||||
|
distance: SchoolDistance;
|
||||||
|
}>
|
||||||
|
) => {
|
||||||
|
const nextName = replaceSchoolFilterKeySelection(schoolFeature.name, next);
|
||||||
|
if (nextName === schoolFeature.name) return;
|
||||||
|
|
||||||
|
const nextBackendName = getSchoolBackendFeatureName(nextName);
|
||||||
|
const nextFeature = nextBackendName
|
||||||
|
? features.find((feature) => feature.name === nextBackendName)
|
||||||
|
: undefined;
|
||||||
|
const nextDataMin = nextFeature?.histogram?.min ?? nextFeature?.min ?? 0;
|
||||||
|
const nextDataMax = nextFeature?.histogram?.max ?? nextFeature?.max ?? Math.max(1, dataMax);
|
||||||
|
const nextRange = clampSchoolRange(
|
||||||
|
[
|
||||||
|
displayValue[0] <= dataMin ? nextDataMin : displayValue[0],
|
||||||
|
displayValue[1] >= dataMax ? nextDataMax : displayValue[1],
|
||||||
|
],
|
||||||
|
nextFeature
|
||||||
|
);
|
||||||
|
onFilterChange(nextName, nextRange);
|
||||||
|
if (isPinned) onTogglePin(nextName);
|
||||||
|
};
|
||||||
|
|
||||||
|
const segmentedClass =
|
||||||
|
'grid grid-cols-2 overflow-hidden rounded-md border border-warm-200 dark:border-warm-700 bg-warm-50 dark:bg-warm-800';
|
||||||
|
const optionClass = (active: boolean) =>
|
||||||
|
`px-2 py-1 text-xs font-medium border-r last:border-r-0 border-warm-200 dark:border-warm-700 transition-colors ${
|
||||||
|
active
|
||||||
|
? 'bg-teal-600 text-white dark:bg-teal-500'
|
||||||
|
: 'text-warm-600 hover:bg-warm-100 dark:text-warm-300 dark:hover:bg-warm-700'
|
||||||
|
}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-filter-name={SCHOOL_FILTER_NAME}
|
||||||
|
className={`space-y-1.5 px-2 py-1.5 rounded ${isActive ? 'ring-2 ring-teal-400 bg-teal-50 dark:bg-teal-900/30' : isPinned ? 'ring-2 ring-teal-400 bg-teal-50/50 dark:bg-teal-900/20' : ''}`}
|
||||||
|
>
|
||||||
|
<div className="relative z-10 flex items-center justify-between gap-1">
|
||||||
|
<FeatureLabel feature={schoolMeta} size="sm" className="min-w-0 shrink" hideIconOnMobile />
|
||||||
|
<FeatureActions
|
||||||
|
feature={schoolMeta}
|
||||||
|
isPinned={isPinned}
|
||||||
|
isPreviewing={isActive}
|
||||||
|
onTogglePin={() => onTogglePin(schoolFeature.name)}
|
||||||
|
onShowInfo={() => onShowInfo(schoolMeta)}
|
||||||
|
onRemove={onRemove}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<div>
|
||||||
|
<div className="mb-0.5 text-[10px] font-medium uppercase text-warm-400 dark:text-warm-500">
|
||||||
|
School type
|
||||||
|
</div>
|
||||||
|
<div className={segmentedClass} role="radiogroup" aria-label="School type">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="radio"
|
||||||
|
aria-checked={config.phase === 'primary'}
|
||||||
|
onClick={() => replaceSchoolFeature({ phase: 'primary' })}
|
||||||
|
className={optionClass(config.phase === 'primary')}
|
||||||
|
>
|
||||||
|
Primary
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="radio"
|
||||||
|
aria-checked={config.phase === 'secondary'}
|
||||||
|
onClick={() => replaceSchoolFeature({ phase: 'secondary' })}
|
||||||
|
className={optionClass(config.phase === 'secondary')}
|
||||||
|
>
|
||||||
|
Secondary
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="mb-0.5 text-[10px] font-medium uppercase text-warm-400 dark:text-warm-500">
|
||||||
|
Rating
|
||||||
|
</div>
|
||||||
|
<div className={segmentedClass} role="radiogroup" aria-label="School rating">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="radio"
|
||||||
|
aria-checked={config.rating === 'good'}
|
||||||
|
onClick={() => replaceSchoolFeature({ rating: 'good' })}
|
||||||
|
className={optionClass(config.rating === 'good')}
|
||||||
|
>
|
||||||
|
Good+
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="radio"
|
||||||
|
aria-checked={config.rating === 'outstanding'}
|
||||||
|
onClick={() => replaceSchoolFeature({ rating: 'outstanding' })}
|
||||||
|
className={optionClass(config.rating === 'outstanding')}
|
||||||
|
>
|
||||||
|
Outstanding
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="mb-0.5 text-[10px] font-medium uppercase text-warm-400 dark:text-warm-500">
|
||||||
|
Distance
|
||||||
|
</div>
|
||||||
|
<div className={segmentedClass} role="radiogroup" aria-label="School distance">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="radio"
|
||||||
|
aria-checked={config.distance === 2}
|
||||||
|
onClick={() => replaceSchoolFeature({ distance: 2 })}
|
||||||
|
className={optionClass(config.distance === 2)}
|
||||||
|
>
|
||||||
|
2 km
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="radio"
|
||||||
|
aria-checked={config.distance === 5}
|
||||||
|
onClick={() => replaceSchoolFeature({ distance: 5 })}
|
||||||
|
className={optionClass(config.distance === 5)}
|
||||||
|
>
|
||||||
|
5 km
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Slider
|
||||||
|
min={backendFeature?.min ?? dataMin}
|
||||||
|
max={backendFeature?.max ?? dataMax}
|
||||||
|
step={backendFeature?.step ?? 1}
|
||||||
|
value={sliderValue}
|
||||||
|
onValueChange={([min, max]) =>
|
||||||
|
onDragChange([
|
||||||
|
min <= (backendFeature?.min ?? dataMin) ? dataMin : min,
|
||||||
|
max >= (backendFeature?.max ?? dataMax) ? dataMax : max,
|
||||||
|
])
|
||||||
|
}
|
||||||
|
onPointerDown={() => onDragStart(schoolFeature.name)}
|
||||||
|
onPointerUp={() => onDragEnd()}
|
||||||
|
/>
|
||||||
|
<SliderLabels
|
||||||
|
min={backendFeature?.min ?? dataMin}
|
||||||
|
max={backendFeature?.max ?? dataMax}
|
||||||
|
value={sliderValue}
|
||||||
|
displayValues={displayValue}
|
||||||
|
isAtMin={displayValue[0] === dataMin}
|
||||||
|
isAtMax={displayValue[1] === dataMax}
|
||||||
|
raw={backendFeature?.raw}
|
||||||
|
feature={backendFeature}
|
||||||
|
onValueChange={(v) => onFilterChange(schoolFeature.name, v)}
|
||||||
|
/>
|
||||||
|
{filterImpact != null && filterImpact > 0 && (
|
||||||
|
<p className="text-[10px] text-warm-400 dark:text-warm-500 -mt-1 ml-2.5">
|
||||||
|
+{formatNumber(filterImpact)} without this filter
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
interface FiltersProps {
|
interface FiltersProps {
|
||||||
features: FeatureMeta[];
|
features: FeatureMeta[];
|
||||||
filters: FeatureFilters;
|
filters: FeatureFilters;
|
||||||
|
|
@ -214,6 +444,7 @@ interface FiltersProps {
|
||||||
onClearAll: () => void;
|
onClearAll: () => void;
|
||||||
onSaveSearch?: (name: string) => Promise<void>;
|
onSaveSearch?: (name: string) => Promise<void>;
|
||||||
savingSearch?: boolean;
|
savingSearch?: boolean;
|
||||||
|
destinationDropdownPortal?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default memo(function Filters({
|
export default memo(function Filters({
|
||||||
|
|
@ -255,17 +486,57 @@ export default memo(function Filters({
|
||||||
onClearAll,
|
onClearAll,
|
||||||
onSaveSearch,
|
onSaveSearch,
|
||||||
savingSearch,
|
savingSearch,
|
||||||
|
destinationDropdownPortal = true,
|
||||||
}: FiltersProps) {
|
}: FiltersProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const availableFeatures = useMemo(
|
const defaultSchoolFeatureName = useMemo(() => getDefaultSchoolFeatureName(features), [features]);
|
||||||
() => features.filter((f) => !enabledFeatures.has(f.name)),
|
const schoolMeta = useMemo(() => getSchoolFilterMeta(features), [features]);
|
||||||
[features, enabledFeatures]
|
const schoolFilterItems = useMemo(() => {
|
||||||
);
|
return Object.keys(filters)
|
||||||
const enabledFeatureList = useMemo(
|
.filter(isSchoolFilterName)
|
||||||
() => features.filter((f) => enabledFeatures.has(f.name)),
|
.map((name) => {
|
||||||
[features, enabledFeatures]
|
const backendName = getSchoolBackendFeatureName(name);
|
||||||
);
|
const backendFeature = backendName
|
||||||
|
? features.find((feature) => feature.name === backendName)
|
||||||
|
: undefined;
|
||||||
|
return { ...(backendFeature ?? schoolMeta), name, group: 'Education' };
|
||||||
|
});
|
||||||
|
}, [filters, features, schoolMeta]);
|
||||||
|
const availableFeatures = useMemo(() => {
|
||||||
|
const result: FeatureMeta[] = [];
|
||||||
|
let insertedSchoolFilter = false;
|
||||||
|
|
||||||
|
for (const feature of features) {
|
||||||
|
if (isSchoolFilterName(feature.name)) {
|
||||||
|
if (defaultSchoolFeatureName && !insertedSchoolFilter) {
|
||||||
|
result.push(schoolMeta);
|
||||||
|
insertedSchoolFilter = true;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!enabledFeatures.has(feature.name)) result.push(feature);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}, [features, enabledFeatures, defaultSchoolFeatureName, schoolMeta]);
|
||||||
|
const enabledFeatureList = useMemo(() => {
|
||||||
|
const result: FeatureMeta[] = [];
|
||||||
|
let insertedSchoolFilter = false;
|
||||||
|
|
||||||
|
for (const feature of features) {
|
||||||
|
if (isSchoolFilterName(feature.name)) {
|
||||||
|
if (!insertedSchoolFilter) {
|
||||||
|
result.push(...schoolFilterItems);
|
||||||
|
insertedSchoolFilter = true;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (enabledFeatures.has(feature.name)) result.push(feature);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}, [features, enabledFeatures, schoolFilterItems]);
|
||||||
|
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
@ -279,10 +550,24 @@ export default memo(function Filters({
|
||||||
|
|
||||||
const handleAddAndScroll = useCallback(
|
const handleAddAndScroll = useCallback(
|
||||||
(name: string) => {
|
(name: string) => {
|
||||||
|
if (name === SCHOOL_FILTER_NAME) {
|
||||||
|
if (!defaultSchoolFeatureName) return;
|
||||||
|
pendingScrollRef.current = SCHOOL_FILTER_NAME;
|
||||||
|
onAddFilter(SCHOOL_FILTER_NAME);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
pendingScrollRef.current = name;
|
pendingScrollRef.current = name;
|
||||||
onAddFilter(name);
|
onAddFilter(name);
|
||||||
},
|
},
|
||||||
[onAddFilter]
|
[defaultSchoolFeatureName, onAddFilter]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleRemoveSchoolFilter = useCallback(
|
||||||
|
(name: string) => {
|
||||||
|
onRemoveFilter(name);
|
||||||
|
},
|
||||||
|
[onRemoveFilter]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleAddTravelTimeAndScroll = useCallback(
|
const handleAddTravelTimeAndScroll = useCallback(
|
||||||
|
|
@ -455,6 +740,59 @@ export default memo(function Filters({
|
||||||
|
|
||||||
<div className="px-2 py-1 space-y-1">
|
<div className="px-2 py-1 space-y-1">
|
||||||
{enabledFeatureList.map((feature, featureIdx) => {
|
{enabledFeatureList.map((feature, featureIdx) => {
|
||||||
|
if (isSchoolFilterName(feature.name)) {
|
||||||
|
const schoolBackendName = getSchoolBackendFeatureName(feature.name);
|
||||||
|
return (
|
||||||
|
<Fragment key={feature.name}>
|
||||||
|
{featureIdx === travelInsertIdx &&
|
||||||
|
travelTimeEntries.map((entry, index) => (
|
||||||
|
<div key={`tt_${index}`} data-filter-name={`tt_${index}`}>
|
||||||
|
<TravelTimeCard
|
||||||
|
mode={entry.mode}
|
||||||
|
slug={entry.slug}
|
||||||
|
label={entry.label}
|
||||||
|
timeRange={entry.timeRange}
|
||||||
|
useBest={entry.useBest}
|
||||||
|
isPinned={pinnedFeature === travelFieldKey(entry)}
|
||||||
|
isActive={activeFeature === travelFieldKey(entry)}
|
||||||
|
dragValue={activeFeature === travelFieldKey(entry) ? dragValue : null}
|
||||||
|
onTogglePin={() => onTogglePin(travelFieldKey(entry))}
|
||||||
|
onSetDestination={(slug, label, lat, lon) =>
|
||||||
|
onTravelTimeSetDestination(index, slug, label, lat, lon)
|
||||||
|
}
|
||||||
|
onTimeRangeChange={(range) => onTravelTimeRangeChange(index, range)}
|
||||||
|
onDragStart={() => onDragStart(travelFieldKey(entry))}
|
||||||
|
onDragChange={onDragChange}
|
||||||
|
onDragEnd={() => onTravelTimeDragEnd(index)}
|
||||||
|
onToggleBest={() => onTravelTimeToggleBest(index)}
|
||||||
|
onRemove={() => onTravelTimeRemoveEntry(index)}
|
||||||
|
filterImpact={filterImpacts?.[travelFieldKey(entry)]}
|
||||||
|
destinationDropdownPortal={destinationDropdownPortal}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<SchoolFilterCard
|
||||||
|
features={features}
|
||||||
|
schoolFeature={feature}
|
||||||
|
filters={filters}
|
||||||
|
activeFeature={activeFeature}
|
||||||
|
dragValue={dragValue}
|
||||||
|
pinnedFeature={pinnedFeature}
|
||||||
|
filterImpact={
|
||||||
|
schoolBackendName ? filterImpacts?.[schoolBackendName] : undefined
|
||||||
|
}
|
||||||
|
onFilterChange={onFilterChange}
|
||||||
|
onDragStart={onDragStart}
|
||||||
|
onDragChange={onDragChange}
|
||||||
|
onDragEnd={onDragEnd}
|
||||||
|
onTogglePin={onTogglePin}
|
||||||
|
onShowInfo={setActiveInfoFeature}
|
||||||
|
onRemove={() => handleRemoveSchoolFilter(feature.name)}
|
||||||
|
/>
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (feature.type === 'enum') {
|
if (feature.type === 'enum') {
|
||||||
const selectedValues = (filters[feature.name] as string[]) || [];
|
const selectedValues = (filters[feature.name] as string[]) || [];
|
||||||
const allValues = feature.values || [];
|
const allValues = feature.values || [];
|
||||||
|
|
@ -483,6 +821,7 @@ export default memo(function Filters({
|
||||||
onToggleBest={() => onTravelTimeToggleBest(index)}
|
onToggleBest={() => onTravelTimeToggleBest(index)}
|
||||||
onRemove={() => onTravelTimeRemoveEntry(index)}
|
onRemove={() => onTravelTimeRemoveEntry(index)}
|
||||||
filterImpact={filterImpacts?.[travelFieldKey(entry)]}
|
filterImpact={filterImpacts?.[travelFieldKey(entry)]}
|
||||||
|
destinationDropdownPortal={destinationDropdownPortal}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
@ -587,6 +926,7 @@ export default memo(function Filters({
|
||||||
onToggleBest={() => onTravelTimeToggleBest(index)}
|
onToggleBest={() => onTravelTimeToggleBest(index)}
|
||||||
onRemove={() => onTravelTimeRemoveEntry(index)}
|
onRemove={() => onTravelTimeRemoveEntry(index)}
|
||||||
filterImpact={filterImpacts?.[travelFieldKey(entry)]}
|
filterImpact={filterImpacts?.[travelFieldKey(entry)]}
|
||||||
|
destinationDropdownPortal={destinationDropdownPortal}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
@ -689,6 +1029,7 @@ export default memo(function Filters({
|
||||||
onToggleBest={() => onTravelTimeToggleBest(index)}
|
onToggleBest={() => onTravelTimeToggleBest(index)}
|
||||||
onRemove={() => onTravelTimeRemoveEntry(index)}
|
onRemove={() => onTravelTimeRemoveEntry(index)}
|
||||||
filterImpact={filterImpacts?.[travelFieldKey(entry)]}
|
filterImpact={filterImpacts?.[travelFieldKey(entry)]}
|
||||||
|
destinationDropdownPortal={destinationDropdownPortal}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
@ -722,10 +1063,20 @@ export default memo(function Filters({
|
||||||
<div className="md:flex-1 md:min-h-0 md:overflow-y-auto">
|
<div className="md:flex-1 md:min-h-0 md:overflow-y-auto">
|
||||||
<FeatureBrowser
|
<FeatureBrowser
|
||||||
availableFeatures={availableFeatures}
|
availableFeatures={availableFeatures}
|
||||||
allFeatures={features}
|
allFeatures={[...features, schoolMeta]}
|
||||||
pinnedFeature={pinnedFeature}
|
pinnedFeature={
|
||||||
|
pinnedFeature && isSchoolFilterName(pinnedFeature)
|
||||||
|
? SCHOOL_FILTER_NAME
|
||||||
|
: pinnedFeature
|
||||||
|
}
|
||||||
onAddFilter={handleAddAndScroll}
|
onAddFilter={handleAddAndScroll}
|
||||||
onTogglePin={onTogglePin}
|
onTogglePin={(name) => {
|
||||||
|
if (name === SCHOOL_FILTER_NAME) {
|
||||||
|
if (defaultSchoolFeatureName) onTogglePin(defaultSchoolFeatureName);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onTogglePin(name);
|
||||||
|
}}
|
||||||
onNavigateToSource={onNavigateToSource}
|
onNavigateToSource={onNavigateToSource}
|
||||||
openInfoFeature={openInfoFeature}
|
openInfoFeature={openInfoFeature}
|
||||||
onClearOpenInfoFeature={onClearOpenInfoFeature}
|
onClearOpenInfoFeature={onClearOpenInfoFeature}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next';
|
||||||
import type { FeatureFilters, FeatureMeta } from '../../types';
|
import type { FeatureFilters, FeatureMeta } from '../../types';
|
||||||
import { formatValue } from '../../lib/format';
|
import { formatValue } from '../../lib/format';
|
||||||
import { ts } from '../../i18n/server';
|
import { ts } from '../../i18n/server';
|
||||||
|
import { SCHOOL_FILTER_NAME, getSchoolBackendFeatureName } from '../../lib/school-filter';
|
||||||
|
|
||||||
interface HoverCardData {
|
interface HoverCardData {
|
||||||
count: number;
|
count: number;
|
||||||
|
|
@ -41,14 +42,18 @@ export default memo(function HoverCard({
|
||||||
|
|
||||||
// Show stats for active filters (up to 4)
|
// Show stats for active filters (up to 4)
|
||||||
for (const name of activeFilterNames.slice(0, 4)) {
|
for (const name of activeFilterNames.slice(0, 4)) {
|
||||||
const val = data[`avg_${name}`] ?? data[`min_${name}`];
|
const backendName = getSchoolBackendFeatureName(name) ?? name;
|
||||||
|
const val = data[`avg_${backendName}`] ?? data[`min_${backendName}`];
|
||||||
if (val == null || typeof val !== 'number') continue;
|
if (val == null || typeof val !== 'number') continue;
|
||||||
const meta = featureMap.get(name);
|
const meta = featureMap.get(backendName);
|
||||||
if (meta?.type === 'enum' && meta.values) {
|
if (meta?.type === 'enum' && meta.values) {
|
||||||
const label = meta.values[Math.round(val)];
|
const label = meta.values[Math.round(val)];
|
||||||
if (label) results.push({ name, value: ts(label) });
|
if (label) results.push({ name: backendName, value: ts(label) });
|
||||||
} else {
|
} else {
|
||||||
results.push({ name, value: formatValue(val, meta) });
|
results.push({
|
||||||
|
name: backendName === name ? name : SCHOOL_FILTER_NAME,
|
||||||
|
value: formatValue(val, meta),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,12 @@ import type {
|
||||||
Bounds,
|
Bounds,
|
||||||
} from '../../types';
|
} from '../../types';
|
||||||
|
|
||||||
import { zoomToResolution, getBoundsFromViewState, getMapStyle } from '../../lib/map-utils';
|
import {
|
||||||
|
zoomToResolution,
|
||||||
|
getBoundsFromViewState,
|
||||||
|
getMapStyle,
|
||||||
|
getPoiIconUrl,
|
||||||
|
} from '../../lib/map-utils';
|
||||||
import {
|
import {
|
||||||
INITIAL_VIEW_STATE,
|
INITIAL_VIEW_STATE,
|
||||||
MAP_MIN_ZOOM,
|
MAP_MIN_ZOOM,
|
||||||
|
|
@ -395,7 +400,14 @@ export default memo(function Map({
|
||||||
) : (
|
) : (
|
||||||
<div className="px-3 py-2">
|
<div className="px-3 py-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-lg leading-none">{popupInfo.emoji}</span>
|
<img
|
||||||
|
src={getPoiIconUrl(popupInfo.category, popupInfo.emoji)}
|
||||||
|
alt=""
|
||||||
|
aria-hidden="true"
|
||||||
|
loading="lazy"
|
||||||
|
referrerPolicy="no-referrer"
|
||||||
|
className="h-5 w-5 shrink-0 rounded-[4px] bg-white object-contain p-0.5"
|
||||||
|
/>
|
||||||
<div>
|
<div>
|
||||||
<div className="font-semibold dark:text-warm-100">{popupInfo.name}</div>
|
<div className="font-semibold dark:text-warm-100">{popupInfo.name}</div>
|
||||||
<div className="flex items-center gap-1.5 text-xs text-warm-500 dark:text-warm-400">
|
<div className="flex items-center gap-1.5 text-xs text-warm-500 dark:text-warm-400">
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ import POIPane from './POIPane';
|
||||||
import { PropertiesPane } from './PropertiesPane';
|
import { PropertiesPane } from './PropertiesPane';
|
||||||
import AreaPane from './AreaPane';
|
import AreaPane from './AreaPane';
|
||||||
import MobileDrawer from './MobileDrawer';
|
import MobileDrawer from './MobileDrawer';
|
||||||
|
import MobileBottomSheet from './MobileBottomSheet';
|
||||||
import MapLegend from './MapLegend';
|
import MapLegend from './MapLegend';
|
||||||
import { MapPageSelectionPane } from './MapPageSelectionPane';
|
import { MapPageSelectionPane } from './MapPageSelectionPane';
|
||||||
import { useMapData } from '../../hooks/useMapData';
|
import { useMapData } from '../../hooks/useMapData';
|
||||||
|
|
@ -41,6 +42,7 @@ import { useFilterCounts } from '../../hooks/useFilterCounts';
|
||||||
import { ts } from '../../i18n/server';
|
import { ts } from '../../i18n/server';
|
||||||
import { trackEvent } from '../../lib/analytics';
|
import { trackEvent } from '../../lib/analytics';
|
||||||
import { INITIAL_VIEW_STATE } from '../../lib/consts';
|
import { INITIAL_VIEW_STATE } from '../../lib/consts';
|
||||||
|
import { getSchoolBackendFeatureName } from '../../lib/school-filter';
|
||||||
import { useLicense } from '../../hooks/useLicense';
|
import { useLicense } from '../../hooks/useLicense';
|
||||||
import UpgradeModal from '../ui/UpgradeModal';
|
import UpgradeModal from '../ui/UpgradeModal';
|
||||||
import { SpinnerIcon } from '../ui/icons/SpinnerIcon';
|
import { SpinnerIcon } from '../ui/icons/SpinnerIcon';
|
||||||
|
|
@ -116,12 +118,6 @@ export default function MapPage({
|
||||||
|
|
||||||
const [leftPaneWidth, leftPaneHandlers] = usePaneResize(384, 200, 0.45, 'left');
|
const [leftPaneWidth, leftPaneHandlers] = usePaneResize(384, 200, 0.45, 'left');
|
||||||
const [rightPaneWidth, rightPaneHandlers] = usePaneResize(384, 200, 0.45, 'right');
|
const [rightPaneWidth, rightPaneHandlers] = usePaneResize(384, 200, 0.45, 'right');
|
||||||
const [, mobileResizeHandlers, mobileMapRef] = usePaneResize(
|
|
||||||
Math.round(window.innerHeight * 0.4),
|
|
||||||
120,
|
|
||||||
0.8,
|
|
||||||
'top'
|
|
||||||
);
|
|
||||||
|
|
||||||
const [mobileDrawerOpen, setMobileDrawerOpen] = useState(false);
|
const [mobileDrawerOpen, setMobileDrawerOpen] = useState(false);
|
||||||
const [poiPaneOpen, setPoiPaneOpen] = useState(false);
|
const [poiPaneOpen, setPoiPaneOpen] = useState(false);
|
||||||
|
|
@ -510,9 +506,15 @@ export default function MapPage({
|
||||||
const densityLabel = t('mapLegend.historicalMatches');
|
const densityLabel = t('mapLegend.historicalMatches');
|
||||||
const hasActiveFilters = Object.keys(filters).length > 0 || activeEntries.length > 0;
|
const hasActiveFilters = Object.keys(filters).length > 0 || activeEntries.length > 0;
|
||||||
|
|
||||||
const mobileLegendMeta = useMemo(
|
const mobileLegendMeta = useMemo(() => {
|
||||||
() => (viewFeature ? features.find((f) => f.name === viewFeature) || null : null),
|
const featureName = viewFeature
|
||||||
[viewFeature, features]
|
? (getSchoolBackendFeatureName(viewFeature) ?? viewFeature)
|
||||||
|
: null;
|
||||||
|
return featureName ? features.find((f) => f.name === featureName) || null : null;
|
||||||
|
}, [viewFeature, features]);
|
||||||
|
const mapViewFeature = useMemo(
|
||||||
|
() => (viewFeature ? (getSchoolBackendFeatureName(viewFeature) ?? viewFeature) : null),
|
||||||
|
[viewFeature]
|
||||||
);
|
);
|
||||||
const mobileDensityRange = useMemo((): [number, number] => {
|
const mobileDensityRange = useMemo((): [number, number] => {
|
||||||
const items = mapData.usePostcodeView ? mapData.postcodeData : mapData.data;
|
const items = mapData.usePostcodeView ? mapData.postcodeData : mapData.data;
|
||||||
|
|
@ -595,7 +597,7 @@ export default function MapPage({
|
||||||
usePostcodeView={mapData.usePostcodeView}
|
usePostcodeView={mapData.usePostcodeView}
|
||||||
pois={[]}
|
pois={[]}
|
||||||
onViewChange={mapData.handleViewChange}
|
onViewChange={mapData.handleViewChange}
|
||||||
viewFeature={viewFeature}
|
viewFeature={mapViewFeature}
|
||||||
colorRange={mapData.colorRange}
|
colorRange={mapData.colorRange}
|
||||||
filterRange={filterRange}
|
filterRange={filterRange}
|
||||||
viewSource={viewSource}
|
viewSource={viewSource}
|
||||||
|
|
@ -663,7 +665,7 @@ export default function MapPage({
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
const renderFilters = () => (
|
const renderFilters = (options?: { destinationDropdownPortal?: boolean }) => (
|
||||||
<Filters
|
<Filters
|
||||||
features={features}
|
features={features}
|
||||||
filters={filters}
|
filters={filters}
|
||||||
|
|
@ -702,12 +704,71 @@ export default function MapPage({
|
||||||
onClearAll={handleClearAll}
|
onClearAll={handleClearAll}
|
||||||
onSaveSearch={onSaveSearch}
|
onSaveSearch={onSaveSearch}
|
||||||
savingSearch={savingSearch}
|
savingSearch={savingSearch}
|
||||||
|
destinationDropdownPortal={options?.destinationDropdownPortal}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const renderMobileLegend = () => {
|
||||||
|
if (mapViewFeature && mapData.colorRange) {
|
||||||
|
if (mapViewFeature.startsWith('tt_')) {
|
||||||
|
return (
|
||||||
|
<MapLegend
|
||||||
|
featureLabel={t('travel.travelTime', {
|
||||||
|
mode: modes.label(
|
||||||
|
mapViewFeature.split('_')[1] as 'car' | 'bicycle' | 'walking' | 'transit'
|
||||||
|
),
|
||||||
|
})}
|
||||||
|
range={mapData.colorRange}
|
||||||
|
showCancel={viewSource === 'eye'}
|
||||||
|
onCancel={handleCancelPin}
|
||||||
|
mode="feature"
|
||||||
|
theme={theme}
|
||||||
|
inline
|
||||||
|
suffix=" min"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mobileLegendMeta) {
|
||||||
|
return (
|
||||||
|
<MapLegend
|
||||||
|
featureLabel={
|
||||||
|
viewSource === 'eye'
|
||||||
|
? t('mapLegend.previewing', { name: ts(mobileLegendMeta.name) })
|
||||||
|
: ts(mobileLegendMeta.name)
|
||||||
|
}
|
||||||
|
range={mapData.colorRange}
|
||||||
|
showCancel={viewSource === 'eye'}
|
||||||
|
onCancel={handleCancelPin}
|
||||||
|
mode="feature"
|
||||||
|
enumValues={mobileLegendMeta.type === 'enum' ? mobileLegendMeta.values : undefined}
|
||||||
|
featureName={mobileLegendMeta.name}
|
||||||
|
theme={theme}
|
||||||
|
inline
|
||||||
|
raw={mobileLegendMeta.raw}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MapLegend
|
||||||
|
featureLabel={densityLabel}
|
||||||
|
range={mobileDensityRange}
|
||||||
|
showCancel={false}
|
||||||
|
onCancel={handleCancelPin}
|
||||||
|
mode="density"
|
||||||
|
theme={theme}
|
||||||
|
inline
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
if (isMobile) {
|
if (isMobile) {
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 flex flex-col overflow-hidden relative touch-pan-y">
|
<div className="flex-1 overflow-hidden relative">
|
||||||
{initialLoading && (
|
{initialLoading && (
|
||||||
<div className="absolute inset-0 z-50 flex items-center justify-center bg-warm-50/80 dark:bg-navy-950/80 backdrop-blur-sm">
|
<div className="absolute inset-0 z-50 flex items-center justify-center bg-warm-50/80 dark:bg-navy-950/80 backdrop-blur-sm">
|
||||||
<div className="flex flex-col items-center gap-4">
|
<div className="flex flex-col items-center gap-4">
|
||||||
|
|
@ -719,14 +780,14 @@ export default function MapPage({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div ref={mobileMapRef} className="relative overflow-hidden">
|
<div className="absolute inset-0">
|
||||||
<Map
|
<Map
|
||||||
data={mapData.data}
|
data={mapData.data}
|
||||||
postcodeData={mapData.postcodeData}
|
postcodeData={mapData.postcodeData}
|
||||||
usePostcodeView={mapData.usePostcodeView}
|
usePostcodeView={mapData.usePostcodeView}
|
||||||
pois={pois}
|
pois={pois}
|
||||||
onViewChange={mapData.handleViewChange}
|
onViewChange={mapData.handleViewChange}
|
||||||
viewFeature={viewFeature}
|
viewFeature={mapViewFeature}
|
||||||
colorRange={mapData.colorRange}
|
colorRange={mapData.colorRange}
|
||||||
filterRange={filterRange}
|
filterRange={filterRange}
|
||||||
viewSource={viewSource}
|
viewSource={viewSource}
|
||||||
|
|
@ -748,90 +809,39 @@ export default function MapPage({
|
||||||
hideLegend
|
hideLegend
|
||||||
travelTimeEntries={entries}
|
travelTimeEntries={entries}
|
||||||
/>
|
/>
|
||||||
{mapData.loading && (
|
|
||||||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
|
||||||
<div className="flex items-center gap-2 bg-white/90 dark:bg-warm-800/90 px-4 py-2.5 rounded-lg shadow-lg pointer-events-auto">
|
|
||||||
<SpinnerIcon className="w-5 h-5 text-teal-600 dark:text-teal-400 animate-spin" />
|
|
||||||
<span className="text-sm font-medium text-warm-700 dark:text-warm-200">
|
|
||||||
Loading...
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
onClick={() => setPoiPaneOpen((p) => !p)}
|
|
||||||
className={`absolute bottom-2 right-2 z-10 p-2 rounded-lg shadow-lg bg-white dark:bg-warm-800 ${poiPaneOpen ? 'text-teal-600 dark:text-teal-400' : 'text-warm-500 dark:text-warm-400 hover:text-teal-600 dark:hover:text-teal-400'}`}
|
|
||||||
>
|
|
||||||
<MapPinIcon className="w-5 h-5" />
|
|
||||||
</button>
|
|
||||||
{poiPaneOpen && (
|
|
||||||
<div className="absolute bottom-12 right-2 z-10 w-[calc(100%-1rem)] max-h-[60%] overflow-hidden flex flex-col rounded-lg shadow-xl border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-900">
|
|
||||||
{renderPOIPane()}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
{mapData.loading && (
|
||||||
className="relative z-10 py-2 -my-2 cursor-row-resize touch-none group"
|
<div className="absolute inset-0 z-10 flex items-center justify-center pointer-events-none">
|
||||||
{...mobileResizeHandlers}
|
<div className="flex items-center gap-2 bg-white/90 dark:bg-warm-800/90 px-4 py-2.5 rounded-lg shadow-lg pointer-events-auto">
|
||||||
>
|
<SpinnerIcon className="w-5 h-5 text-teal-600 dark:text-teal-400 animate-spin" />
|
||||||
<div className="h-3 flex items-center justify-center bg-warm-100 dark:bg-navy-800 group-hover:bg-warm-200 dark:group-hover:bg-navy-700 border-y border-warm-200 dark:border-navy-700">
|
<span className="text-sm font-medium text-warm-700 dark:text-warm-200">
|
||||||
<div className="flex flex-row gap-1.5">
|
Loading...
|
||||||
<div className="w-1 h-1 rounded-full bg-warm-300 dark:bg-navy-600 group-hover:bg-warm-400 dark:group-hover:bg-navy-500" />
|
</span>
|
||||||
<div className="w-1 h-1 rounded-full bg-warm-300 dark:bg-navy-600 group-hover:bg-warm-400 dark:group-hover:bg-navy-500" />
|
|
||||||
<div className="w-1 h-1 rounded-full bg-warm-300 dark:bg-navy-600 group-hover:bg-warm-400 dark:group-hover:bg-navy-500" />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
<div className="flex-1 min-h-0 bg-white dark:bg-warm-900 overflow-hidden flex flex-col">
|
<button
|
||||||
{viewFeature && mapData.colorRange ? (
|
onClick={() => setPoiPaneOpen((p) => !p)}
|
||||||
viewFeature.startsWith('tt_') ? (
|
className={`absolute top-3 right-3 z-20 p-2 rounded-lg shadow-lg bg-white dark:bg-warm-800 ${poiPaneOpen ? 'text-teal-600 dark:text-teal-400' : 'text-warm-500 dark:text-warm-400 hover:text-teal-600 dark:hover:text-teal-400'}`}
|
||||||
<MapLegend
|
aria-label={t('poiPane.pointsOfInterest')}
|
||||||
featureLabel={t('travel.travelTime', {
|
>
|
||||||
mode: modes.label(
|
<MapPinIcon className="w-5 h-5" />
|
||||||
viewFeature.split('_')[1] as 'car' | 'bicycle' | 'walking' | 'transit'
|
</button>
|
||||||
),
|
|
||||||
})}
|
{poiPaneOpen && (
|
||||||
range={mapData.colorRange}
|
<div className="absolute top-14 right-3 left-3 z-20 max-h-[45dvh] overflow-hidden flex flex-col rounded-lg shadow-xl border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-900">
|
||||||
showCancel={viewSource === 'eye'}
|
{renderPOIPane()}
|
||||||
onCancel={handleCancelPin}
|
</div>
|
||||||
mode="feature"
|
)}
|
||||||
theme={theme}
|
|
||||||
inline
|
<MobileBottomSheet
|
||||||
suffix=" min"
|
activeCount={Object.keys(filters).length + entries.length}
|
||||||
/>
|
legend={renderMobileLegend()}
|
||||||
) : mobileLegendMeta ? (
|
>
|
||||||
<MapLegend
|
{renderFilters({ destinationDropdownPortal: false })}
|
||||||
featureLabel={
|
</MobileBottomSheet>
|
||||||
viewSource === 'eye'
|
|
||||||
? t('mapLegend.previewing', { name: ts(mobileLegendMeta.name) })
|
|
||||||
: ts(mobileLegendMeta.name)
|
|
||||||
}
|
|
||||||
range={mapData.colorRange}
|
|
||||||
showCancel={viewSource === 'eye'}
|
|
||||||
onCancel={handleCancelPin}
|
|
||||||
mode="feature"
|
|
||||||
enumValues={mobileLegendMeta.type === 'enum' ? mobileLegendMeta.values : undefined}
|
|
||||||
featureName={mobileLegendMeta.name}
|
|
||||||
theme={theme}
|
|
||||||
inline
|
|
||||||
raw={mobileLegendMeta.raw}
|
|
||||||
/>
|
|
||||||
) : null
|
|
||||||
) : (
|
|
||||||
<MapLegend
|
|
||||||
featureLabel={densityLabel}
|
|
||||||
range={mobileDensityRange}
|
|
||||||
showCancel={false}
|
|
||||||
onCancel={handleCancelPin}
|
|
||||||
mode="density"
|
|
||||||
theme={theme}
|
|
||||||
inline
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<div className="flex-1 min-h-0">{renderFilters()}</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{mobileDrawerOpen && selectedHexagon && (
|
{mobileDrawerOpen && selectedHexagon && (
|
||||||
<MobileDrawer
|
<MobileDrawer
|
||||||
|
|
@ -914,7 +924,7 @@ export default function MapPage({
|
||||||
usePostcodeView={mapData.usePostcodeView}
|
usePostcodeView={mapData.usePostcodeView}
|
||||||
pois={pois}
|
pois={pois}
|
||||||
onViewChange={mapData.handleViewChange}
|
onViewChange={mapData.handleViewChange}
|
||||||
viewFeature={viewFeature}
|
viewFeature={mapViewFeature}
|
||||||
colorRange={mapData.colorRange}
|
colorRange={mapData.colorRange}
|
||||||
filterRange={filterRange}
|
filterRange={filterRange}
|
||||||
viewSource={viewSource}
|
viewSource={viewSource}
|
||||||
|
|
|
||||||
189
frontend/src/components/map/MobileBottomSheet.tsx
Normal file
189
frontend/src/components/map/MobileBottomSheet.tsx
Normal file
|
|
@ -0,0 +1,189 @@
|
||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
interface VisualViewportState {
|
||||||
|
height: number;
|
||||||
|
bottomInset: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MobileBottomSheetProps {
|
||||||
|
activeCount: number;
|
||||||
|
children: ReactNode;
|
||||||
|
legend?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getVisualViewportState(): VisualViewportState {
|
||||||
|
const vv = window.visualViewport;
|
||||||
|
if (!vv) {
|
||||||
|
return {
|
||||||
|
height: window.innerHeight,
|
||||||
|
bottomInset: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const bottomInset = Math.max(0, window.innerHeight - vv.height - vv.offsetTop);
|
||||||
|
return {
|
||||||
|
height: vv.height,
|
||||||
|
bottomInset,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function useVisualViewportState(): VisualViewportState {
|
||||||
|
const [state, setState] = useState(getVisualViewportState);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const vv = window.visualViewport;
|
||||||
|
const update = () => setState(getVisualViewportState());
|
||||||
|
|
||||||
|
window.addEventListener('resize', update);
|
||||||
|
window.addEventListener('orientationchange', update);
|
||||||
|
vv?.addEventListener('resize', update);
|
||||||
|
vv?.addEventListener('scroll', update);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('resize', update);
|
||||||
|
window.removeEventListener('orientationchange', update);
|
||||||
|
vv?.removeEventListener('resize', update);
|
||||||
|
vv?.removeEventListener('scroll', update);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clamp(value: number, min: number, max: number): number {
|
||||||
|
return Math.min(max, Math.max(min, value));
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MobileBottomSheet({
|
||||||
|
activeCount,
|
||||||
|
children,
|
||||||
|
legend,
|
||||||
|
}: MobileBottomSheetProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const viewport = useVisualViewportState();
|
||||||
|
const sheetRef = useRef<HTMLDivElement>(null);
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
const dragStartYRef = useRef(0);
|
||||||
|
const dragStartHeightRef = useRef(0);
|
||||||
|
const [height, setHeight] = useState<number | null>(null);
|
||||||
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
|
||||||
|
const heightBounds = useMemo(() => {
|
||||||
|
const available = viewport.height;
|
||||||
|
return {
|
||||||
|
min: Math.min(132, Math.max(104, available * 0.22)),
|
||||||
|
initial: Math.min(available * 0.56, Math.max(330, available * 0.44)),
|
||||||
|
max: Math.max(300, available - 12),
|
||||||
|
};
|
||||||
|
}, [viewport.height]);
|
||||||
|
|
||||||
|
const currentHeight = clamp(height ?? heightBounds.initial, heightBounds.min, heightBounds.max);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setHeight((value) =>
|
||||||
|
value == null ? value : clamp(value, heightBounds.min, heightBounds.max)
|
||||||
|
);
|
||||||
|
}, [heightBounds]);
|
||||||
|
|
||||||
|
const handlePointerDown = useCallback(
|
||||||
|
(e: React.PointerEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
||||||
|
dragStartYRef.current = e.clientY;
|
||||||
|
dragStartHeightRef.current = currentHeight;
|
||||||
|
setIsDragging(true);
|
||||||
|
},
|
||||||
|
[currentHeight]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handlePointerMove = useCallback(
|
||||||
|
(e: React.PointerEvent) => {
|
||||||
|
if (dragStartHeightRef.current === 0) return;
|
||||||
|
const nextHeight = dragStartHeightRef.current + dragStartYRef.current - e.clientY;
|
||||||
|
setHeight(clamp(nextHeight, heightBounds.min, heightBounds.max));
|
||||||
|
},
|
||||||
|
[heightBounds]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handlePointerUp = useCallback(() => {
|
||||||
|
if (dragStartHeightRef.current === 0) return;
|
||||||
|
dragStartHeightRef.current = 0;
|
||||||
|
setIsDragging(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const sheet = sheetRef.current;
|
||||||
|
if (!sheet) return;
|
||||||
|
|
||||||
|
const handleFocusIn = (event: FocusEvent) => {
|
||||||
|
const target = event.target;
|
||||||
|
if (!(target instanceof HTMLElement)) return;
|
||||||
|
if (!target.matches('input, textarea, select, [contenteditable="true"]')) return;
|
||||||
|
|
||||||
|
const keyboardMinHeight = Math.min(heightBounds.max, Math.max(300, viewport.height * 0.55));
|
||||||
|
setHeight((value) => Math.max(value ?? heightBounds.initial, keyboardMinHeight));
|
||||||
|
window.setTimeout(() => {
|
||||||
|
target.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
||||||
|
}, 120);
|
||||||
|
};
|
||||||
|
|
||||||
|
sheet.addEventListener('focusin', handleFocusIn);
|
||||||
|
return () => sheet.removeEventListener('focusin', handleFocusIn);
|
||||||
|
}, [heightBounds.initial, heightBounds.max, viewport.height]);
|
||||||
|
|
||||||
|
const sheetTitle = activeCount === 0 ? t('filters.chooseFilters') : t('filters.activeFilters');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
ref={sheetRef}
|
||||||
|
className="fixed inset-x-0 z-30 flex flex-col rounded-t-2xl bg-white dark:bg-navy-950 shadow-2xl border-t border-warm-200 dark:border-navy-700 overflow-hidden"
|
||||||
|
style={{
|
||||||
|
bottom: viewport.bottomInset,
|
||||||
|
height: currentHeight,
|
||||||
|
paddingBottom: 'env(safe-area-inset-bottom)',
|
||||||
|
transition:
|
||||||
|
isDragging || viewport.bottomInset > 0
|
||||||
|
? undefined
|
||||||
|
: 'height 140ms ease, bottom 180ms ease',
|
||||||
|
}}
|
||||||
|
aria-label={sheetTitle}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="shrink-0 touch-none px-4 pt-2 pb-1"
|
||||||
|
onPointerDown={handlePointerDown}
|
||||||
|
onPointerMove={handlePointerMove}
|
||||||
|
onPointerUp={handlePointerUp}
|
||||||
|
onPointerCancel={handlePointerUp}
|
||||||
|
>
|
||||||
|
<div className="w-full flex flex-col items-center gap-2" role="presentation">
|
||||||
|
<span className="h-1.5 w-12 rounded-full bg-warm-300 dark:bg-navy-600" />
|
||||||
|
<span className="w-full flex items-center justify-between">
|
||||||
|
<span className="flex items-center gap-2 min-w-0">
|
||||||
|
<span className="text-sm font-semibold text-navy-950 dark:text-warm-100 truncate">
|
||||||
|
{sheetTitle}
|
||||||
|
</span>
|
||||||
|
{activeCount > 0 && (
|
||||||
|
<span className="text-xs font-medium px-1.5 py-0.5 rounded-full bg-teal-50 dark:bg-teal-900/30 text-teal-600 dark:text-teal-400">
|
||||||
|
{activeCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{legend && (
|
||||||
|
<div className="shrink-0 border-y border-warm-200 dark:border-navy-700">{legend}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div
|
||||||
|
ref={scrollRef}
|
||||||
|
className="flex-1 min-h-0 overflow-y-auto overscroll-contain touch-pan-y"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next';
|
||||||
import { ts } from '../../i18n/server';
|
import { ts } from '../../i18n/server';
|
||||||
import { useCollapsibleGroups } from '../../hooks/useCollapsibleGroups';
|
import { useCollapsibleGroups } from '../../hooks/useCollapsibleGroups';
|
||||||
import { trackEvent } from '../../lib/analytics';
|
import { trackEvent } from '../../lib/analytics';
|
||||||
|
import { POI_CATEGORY_LOGOS } from '../../lib/consts';
|
||||||
import type { POICategoryGroup } from '../../types';
|
import type { POICategoryGroup } from '../../types';
|
||||||
import InfoPopup from '../ui/InfoPopup';
|
import InfoPopup from '../ui/InfoPopup';
|
||||||
import { SearchInput } from '../ui/SearchInput';
|
import { SearchInput } from '../ui/SearchInput';
|
||||||
|
|
@ -186,15 +187,30 @@ export default function POIPane({
|
||||||
{!isCollapsed && (
|
{!isCollapsed && (
|
||||||
<div className="px-3 py-2">
|
<div className="px-3 py-2">
|
||||||
<PillGroup>
|
<PillGroup>
|
||||||
{group.categories.map((category) => (
|
{group.categories.map((category) => {
|
||||||
<PillToggle
|
const logo = POI_CATEGORY_LOGOS[category];
|
||||||
key={category}
|
return (
|
||||||
label={ts(category)}
|
<PillToggle
|
||||||
active={selectedCategories.has(category)}
|
key={category}
|
||||||
onClick={() => toggleCategory(category)}
|
label={ts(category)}
|
||||||
size="xs"
|
icon={
|
||||||
/>
|
logo ? (
|
||||||
))}
|
<img
|
||||||
|
src={logo}
|
||||||
|
alt=""
|
||||||
|
aria-hidden="true"
|
||||||
|
loading="lazy"
|
||||||
|
referrerPolicy="no-referrer"
|
||||||
|
className="h-4 w-4 shrink-0 rounded-[3px] bg-white object-contain p-0.5"
|
||||||
|
/>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
active={selectedCategories.has(category)}
|
||||||
|
onClick={() => toggleCategory(category)}
|
||||||
|
size="xs"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</PillGroup>
|
</PillGroup>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -316,7 +316,6 @@ function PropertyCard({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,12 @@ function shortenLabel(name: string): string {
|
||||||
export default function StackedBarChart({ segments, total, colorMap }: StackedBarChartProps) {
|
export default function StackedBarChart({ segments, total, colorMap }: StackedBarChartProps) {
|
||||||
const sortedSegments = useMemo(() => [...segments].sort((a, b) => b.value - a.value), [segments]);
|
const sortedSegments = useMemo(() => [...segments].sort((a, b) => b.value - a.value), [segments]);
|
||||||
const roundedPcts = useMemo(
|
const roundedPcts = useMemo(
|
||||||
() => roundedPercentages(sortedSegments.map((s) => s.value), total, 1),
|
() =>
|
||||||
|
roundedPercentages(
|
||||||
|
sortedSegments.map((s) => s.value),
|
||||||
|
total,
|
||||||
|
1
|
||||||
|
),
|
||||||
[sortedSegments, total]
|
[sortedSegments, total]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import type { HexagonLocation } from '../../lib/external-search';
|
import type { HexagonLocation } from '../../lib/external-search';
|
||||||
import { apiUrl, logNonAbortError } from '../../lib/api';
|
import { apiUrl, logNonAbortError } from '../../lib/api';
|
||||||
|
import { CloseIcon, ExpandIcon } from '../ui/icons';
|
||||||
|
|
||||||
interface StreetViewEmbedProps {
|
interface StreetViewEmbedProps {
|
||||||
location: HexagonLocation;
|
location: HexagonLocation;
|
||||||
|
|
@ -13,6 +15,7 @@ export default function StreetViewEmbed({ location }: StreetViewEmbedProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [status, setStatus] = useState<Status>('loading');
|
const [status, setStatus] = useState<Status>('loading');
|
||||||
const [panoId, setPanoId] = useState<string | null>(null);
|
const [panoId, setPanoId] = useState<string | null>(null);
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setStatus('loading');
|
setStatus('loading');
|
||||||
|
|
@ -47,31 +50,107 @@ export default function StreetViewEmbed({ location }: StreetViewEmbedProps) {
|
||||||
return () => controller.abort();
|
return () => controller.abort();
|
||||||
}, [location.lat, location.lon]);
|
}, [location.lat, location.lon]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!expanded) return;
|
||||||
|
|
||||||
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
setExpanded(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const originalOverflow = document.body.style.overflow;
|
||||||
|
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.body.style.overflow = originalOverflow;
|
||||||
|
window.removeEventListener('keydown', handleKeyDown);
|
||||||
|
};
|
||||||
|
}, [expanded]);
|
||||||
|
|
||||||
if (status === 'none' || status === 'error') return null;
|
if (status === 'none' || status === 'error') return null;
|
||||||
|
|
||||||
|
const panoUrl = panoId
|
||||||
|
? `https://maps.google.com/maps?layer=c&panoid=${panoId}&cbp=11,0,0,0,0&output=svembed`
|
||||||
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="px-3 py-1.5 text-xs font-bold text-warm-500 bg-warm-50 dark:bg-warm-900 dark:text-warm-400 sticky top-0">
|
<div className="px-3 py-1.5 text-xs font-bold text-warm-500 bg-warm-50 dark:bg-warm-900 dark:text-warm-400 sticky top-0 flex items-center justify-between">
|
||||||
{t('streetView.title')}
|
<span>{t('streetView.title')}</span>
|
||||||
|
{status === 'ok' && panoUrl && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setExpanded(true)}
|
||||||
|
title={t('streetView.openLarge')}
|
||||||
|
aria-label={t('streetView.openLarge')}
|
||||||
|
className="rounded p-1 text-warm-400 hover:bg-warm-100 hover:text-warm-700 dark:text-warm-500 dark:hover:bg-warm-800 dark:hover:text-warm-200"
|
||||||
|
>
|
||||||
|
<ExpandIcon className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="px-3 py-2">
|
<div className="px-3 py-2">
|
||||||
<div className="rounded overflow-hidden border border-warm-200 dark:border-warm-700">
|
<div className="rounded overflow-hidden border border-warm-200 dark:border-warm-700">
|
||||||
{status === 'loading' ? (
|
{status === 'loading' || !panoUrl ? (
|
||||||
<div
|
<div
|
||||||
className="w-full animate-pulse bg-warm-200 dark:bg-warm-700"
|
className="w-full animate-pulse bg-warm-200 dark:bg-warm-700"
|
||||||
style={{ height: 240 }}
|
style={{ height: 240 }}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<iframe
|
<iframe
|
||||||
|
title={t('streetView.title')}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
style={{ height: 240, border: 0 }}
|
style={{ height: 240, border: 0 }}
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
referrerPolicy="no-referrer-when-downgrade"
|
referrerPolicy="no-referrer-when-downgrade"
|
||||||
src={`https://maps.google.com/maps?layer=c&panoid=${panoId}&cbp=11,0,0,0,0&output=svembed`}
|
src={panoUrl}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{expanded &&
|
||||||
|
panoUrl &&
|
||||||
|
createPortal(
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center p-3 sm:p-6"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label={t('streetView.title')}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 bg-black/60 dark:bg-black/75"
|
||||||
|
aria-hidden="true"
|
||||||
|
onMouseDown={() => setExpanded(false)}
|
||||||
|
/>
|
||||||
|
<div className="relative flex h-[86vh] w-full max-w-7xl flex-col overflow-hidden rounded-lg border border-warm-200 bg-white shadow-2xl dark:border-warm-700 dark:bg-warm-900">
|
||||||
|
<div className="flex items-center justify-between border-b border-warm-200 px-4 py-3 dark:border-warm-700">
|
||||||
|
<h2 className="text-sm font-semibold text-navy-950 dark:text-warm-100">
|
||||||
|
{t('streetView.title')}
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setExpanded(false)}
|
||||||
|
title={t('common.close')}
|
||||||
|
aria-label={t('common.close')}
|
||||||
|
className="rounded p-1 text-warm-400 hover:bg-warm-100 hover:text-warm-700 dark:text-warm-500 dark:hover:bg-warm-800 dark:hover:text-warm-200"
|
||||||
|
>
|
||||||
|
<CloseIcon className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<iframe
|
||||||
|
title={t('streetView.expandedTitle')}
|
||||||
|
className="min-h-0 flex-1"
|
||||||
|
style={{ border: 0 }}
|
||||||
|
loading="lazy"
|
||||||
|
referrerPolicy="no-referrer-when-downgrade"
|
||||||
|
src={panoUrl}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@ interface TravelTimeCardProps {
|
||||||
onToggleBest: () => void;
|
onToggleBest: () => void;
|
||||||
onRemove: () => void;
|
onRemove: () => void;
|
||||||
filterImpact?: number;
|
filterImpact?: number;
|
||||||
|
destinationDropdownPortal?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TravelTimeCard({
|
export function TravelTimeCard({
|
||||||
|
|
@ -51,6 +52,7 @@ export function TravelTimeCard({
|
||||||
onToggleBest,
|
onToggleBest,
|
||||||
onRemove,
|
onRemove,
|
||||||
filterImpact,
|
filterImpact,
|
||||||
|
destinationDropdownPortal = true,
|
||||||
}: TravelTimeCardProps) {
|
}: TravelTimeCardProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const modes = useTranslatedModes();
|
const modes = useTranslatedModes();
|
||||||
|
|
@ -110,6 +112,7 @@ export function TravelTimeCard({
|
||||||
value={label || undefined}
|
value={label || undefined}
|
||||||
onClear={() => onSetDestination('', '', 0, 0)}
|
onClear={() => onSetDestination('', '', 0, 0)}
|
||||||
placeholder={t('travel.selectDestination')}
|
placeholder={t('travel.selectDestination')}
|
||||||
|
portal={destinationDropdownPortal}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Best-case toggle — transit only, shown when destination is set */}
|
{/* Best-case toggle — transit only, shown when destination is set */}
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ interface DestinationDropdownProps {
|
||||||
onClear?: () => void;
|
onClear?: () => void;
|
||||||
value?: string;
|
value?: string;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
|
portal?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DestinationDropdown({
|
export function DestinationDropdown({
|
||||||
|
|
@ -23,6 +24,7 @@ export function DestinationDropdown({
|
||||||
onClear,
|
onClear,
|
||||||
value,
|
value,
|
||||||
placeholder,
|
placeholder,
|
||||||
|
portal = true,
|
||||||
}: DestinationDropdownProps) {
|
}: DestinationDropdownProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
|
@ -32,7 +34,7 @@ export function DestinationDropdown({
|
||||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
const listRef = useRef<HTMLDivElement>(null);
|
const listRef = useRef<HTMLDivElement>(null);
|
||||||
const pos = useDropdownPosition(containerRef, open);
|
const pos = useDropdownPosition(containerRef, open && portal);
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
if (!filter) return destinations;
|
if (!filter) return destinations;
|
||||||
|
|
@ -212,7 +214,12 @@ export function DestinationDropdown({
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{open && createPortal(dropdown, document.body)}
|
{open &&
|
||||||
|
(portal ? (
|
||||||
|
createPortal(dropdown, document.body)
|
||||||
|
) : (
|
||||||
|
<div className="absolute top-full left-0 right-0 mt-1 z-30">{dropdown}</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -72,9 +72,9 @@ export default function MobileMenu({
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Backdrop */}
|
{/* Backdrop */}
|
||||||
<div className="fixed inset-0 bg-black/50 z-40" onClick={onClose} />
|
<div className="fixed inset-0 bg-black/50 z-[70]" onClick={onClose} />
|
||||||
{/* Menu panel */}
|
{/* Menu panel */}
|
||||||
<div className="fixed top-0 right-0 bottom-0 w-64 bg-navy-900 z-50 flex flex-col shadow-xl">
|
<div className="fixed top-0 right-0 bottom-0 w-64 bg-navy-900 z-[80] flex flex-col shadow-xl">
|
||||||
<div className="flex items-center justify-between px-4 h-12 border-b border-navy-700">
|
<div className="flex items-center justify-between px-4 h-12 border-b border-navy-700">
|
||||||
<span className="font-semibold">{t('mobileMenu.menu')}</span>
|
<span className="font-semibold">{t('mobileMenu.menu')}</span>
|
||||||
<button
|
<button
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,10 @@
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
interface PillToggleProps {
|
interface PillToggleProps {
|
||||||
label: string;
|
label: string;
|
||||||
active: boolean;
|
active: boolean;
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
|
icon?: ReactNode;
|
||||||
/** Visual hint for partial selection (e.g. some children selected) */
|
/** Visual hint for partial selection (e.g. some children selected) */
|
||||||
indeterminate?: boolean;
|
indeterminate?: boolean;
|
||||||
size?: 'sm' | 'xs';
|
size?: 'sm' | 'xs';
|
||||||
|
|
@ -11,6 +14,7 @@ export function PillToggle({
|
||||||
label,
|
label,
|
||||||
active,
|
active,
|
||||||
onClick,
|
onClick,
|
||||||
|
icon,
|
||||||
indeterminate,
|
indeterminate,
|
||||||
size = 'sm',
|
size = 'sm',
|
||||||
}: PillToggleProps) {
|
}: PillToggleProps) {
|
||||||
|
|
@ -26,8 +30,9 @@ export function PillToggle({
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
className={`${sizeClasses} ${colorClasses} rounded-full font-medium whitespace-nowrap cursor-pointer`}
|
className={`${sizeClasses} ${colorClasses} inline-flex items-center gap-1.5 rounded-full font-medium whitespace-nowrap cursor-pointer`}
|
||||||
>
|
>
|
||||||
|
{icon}
|
||||||
{label}
|
{label}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
20
frontend/src/components/ui/icons/ExpandIcon.tsx
Normal file
20
frontend/src/components/ui/icons/ExpandIcon.tsx
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
interface IconProps {
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ExpandIcon({ className = 'w-4 h-4' }: IconProps) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
className={className}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M15 3h6v6" />
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M21 3l-7 7" />
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M9 21H3v-6" />
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M3 21l7-7" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -7,6 +7,7 @@ export { ChevronIcon } from './ChevronIcon';
|
||||||
export { ClipboardIcon } from './ClipboardIcon';
|
export { ClipboardIcon } from './ClipboardIcon';
|
||||||
export { CloseIcon } from './CloseIcon';
|
export { CloseIcon } from './CloseIcon';
|
||||||
export { DownloadIcon } from './DownloadIcon';
|
export { DownloadIcon } from './DownloadIcon';
|
||||||
|
export { ExpandIcon } from './ExpandIcon';
|
||||||
export { EyeIcon } from './EyeIcon';
|
export { EyeIcon } from './EyeIcon';
|
||||||
export { FilterIcon } from './FilterIcon';
|
export { FilterIcon } from './FilterIcon';
|
||||||
export { GoogleIcon } from './GoogleIcon';
|
export { GoogleIcon } from './GoogleIcon';
|
||||||
|
|
|
||||||
|
|
@ -1,25 +1,65 @@
|
||||||
import { useCallback, useLayoutEffect, useState } from 'react';
|
import { useCallback, useLayoutEffect, useRef, useState } from 'react';
|
||||||
import type React from 'react';
|
import type React from 'react';
|
||||||
|
|
||||||
export function useDropdownPosition(anchorRef: React.RefObject<HTMLElement | null>, open: boolean) {
|
export function useDropdownPosition(anchorRef: React.RefObject<HTMLElement | null>, open: boolean) {
|
||||||
const [pos, setPos] = useState<{ top: number; left: number; width: number } | null>(null);
|
const [pos, setPos] = useState<{ top: number; left: number; width: number } | null>(null);
|
||||||
|
const posRef = useRef(pos);
|
||||||
|
posRef.current = pos;
|
||||||
|
|
||||||
const update = useCallback(() => {
|
const update = useCallback(() => {
|
||||||
if (!anchorRef.current) return;
|
if (!anchorRef.current) return;
|
||||||
const rect = anchorRef.current.getBoundingClientRect();
|
const rect = anchorRef.current.getBoundingClientRect();
|
||||||
setPos({ top: rect.bottom + 4, left: rect.left, width: rect.width });
|
const next = { top: rect.bottom + 4, left: rect.left, width: rect.width };
|
||||||
|
const prev = posRef.current;
|
||||||
|
if (
|
||||||
|
prev &&
|
||||||
|
Math.abs(prev.top - next.top) < 0.5 &&
|
||||||
|
Math.abs(prev.left - next.left) < 0.5 &&
|
||||||
|
Math.abs(prev.width - next.width) < 0.5
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPos(next);
|
||||||
}, [anchorRef]);
|
}, [anchorRef]);
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
update();
|
const vv = window.visualViewport;
|
||||||
window.addEventListener('scroll', update, true);
|
let raf = 0;
|
||||||
window.addEventListener('resize', update);
|
let frame = 0;
|
||||||
return () => {
|
const anchor = anchorRef.current;
|
||||||
window.removeEventListener('scroll', update, true);
|
|
||||||
window.removeEventListener('resize', update);
|
const updateNextFrame = () => {
|
||||||
|
if (raf) cancelAnimationFrame(raf);
|
||||||
|
raf = requestAnimationFrame(update);
|
||||||
};
|
};
|
||||||
}, [open, update]);
|
|
||||||
|
const trackAnchorMovement = () => {
|
||||||
|
update();
|
||||||
|
frame = requestAnimationFrame(trackAnchorMovement);
|
||||||
|
};
|
||||||
|
|
||||||
|
update();
|
||||||
|
frame = requestAnimationFrame(trackAnchorMovement);
|
||||||
|
window.addEventListener('scroll', update, true);
|
||||||
|
window.addEventListener('resize', updateNextFrame);
|
||||||
|
vv?.addEventListener('resize', updateNextFrame);
|
||||||
|
vv?.addEventListener('scroll', updateNextFrame);
|
||||||
|
|
||||||
|
const observer =
|
||||||
|
anchor && typeof ResizeObserver !== 'undefined' ? new ResizeObserver(updateNextFrame) : null;
|
||||||
|
if (anchor && observer) observer.observe(anchor);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (raf) cancelAnimationFrame(raf);
|
||||||
|
if (frame) cancelAnimationFrame(frame);
|
||||||
|
window.removeEventListener('scroll', update, true);
|
||||||
|
window.removeEventListener('resize', updateNextFrame);
|
||||||
|
vv?.removeEventListener('resize', updateNextFrame);
|
||||||
|
vv?.removeEventListener('scroll', updateNextFrame);
|
||||||
|
observer?.disconnect();
|
||||||
|
};
|
||||||
|
}, [anchorRef, open, update]);
|
||||||
|
|
||||||
return pos;
|
return pos;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,13 @@
|
||||||
import { useState, useCallback, useMemo, useRef, useEffect } from 'react';
|
import { useState, useCallback, useMemo, useRef, useEffect } from 'react';
|
||||||
import type { FeatureMeta, FeatureFilters } from '../types';
|
import type { FeatureMeta, FeatureFilters } from '../types';
|
||||||
import { trackEvent } from '../lib/analytics';
|
import { trackEvent } from '../lib/analytics';
|
||||||
|
import {
|
||||||
|
SCHOOL_FILTER_NAME,
|
||||||
|
createSchoolFilterKey,
|
||||||
|
getDefaultSchoolFeatureName,
|
||||||
|
getSchoolFilterKeyId,
|
||||||
|
normalizeSchoolFilters,
|
||||||
|
} from '../lib/school-filter';
|
||||||
|
|
||||||
interface UseFiltersOptions {
|
interface UseFiltersOptions {
|
||||||
initialFilters: FeatureFilters;
|
initialFilters: FeatureFilters;
|
||||||
|
|
@ -8,7 +15,9 @@ interface UseFiltersOptions {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useFilters({ initialFilters, features }: UseFiltersOptions) {
|
export function useFilters({ initialFilters, features }: UseFiltersOptions) {
|
||||||
const [filters, setFilters] = useState<FeatureFilters>(initialFilters);
|
const [filters, setFilters] = useState<FeatureFilters>(() =>
|
||||||
|
normalizeSchoolFilters(initialFilters)
|
||||||
|
);
|
||||||
const [activeFeature, setActiveFeature] = useState<string | null>(null);
|
const [activeFeature, setActiveFeature] = useState<string | null>(null);
|
||||||
const [dragValue, setDragValue] = useState<[number, number] | null>(null);
|
const [dragValue, setDragValue] = useState<[number, number] | null>(null);
|
||||||
const [pinnedFeature, setPinnedFeature] = useState<string | null>(null);
|
const [pinnedFeature, setPinnedFeature] = useState<string | null>(null);
|
||||||
|
|
@ -16,6 +25,7 @@ export function useFilters({ initialFilters, features }: UseFiltersOptions) {
|
||||||
const dragActiveRef = useRef<string | null>(null);
|
const dragActiveRef = useRef<string | null>(null);
|
||||||
const dragValueRef = useRef<[number, number] | null>(null);
|
const dragValueRef = useRef<[number, number] | null>(null);
|
||||||
const undoStackRef = useRef<FeatureFilters[]>([]);
|
const undoStackRef = useRef<FeatureFilters[]>([]);
|
||||||
|
const schoolFilterIdRef = useRef(1);
|
||||||
|
|
||||||
const enabledFeatures = useMemo(() => new Set(Object.keys(filters)), [filters]);
|
const enabledFeatures = useMemo(() => new Set(Object.keys(filters)), [filters]);
|
||||||
|
|
||||||
|
|
@ -33,11 +43,31 @@ export function useFilters({ initialFilters, features }: UseFiltersOptions) {
|
||||||
const handleAddFilter = useCallback(
|
const handleAddFilter = useCallback(
|
||||||
(name: string) => {
|
(name: string) => {
|
||||||
const meta = features.find((f) => f.name === name);
|
const meta = features.find((f) => f.name === name);
|
||||||
if (!meta) return;
|
if (name !== SCHOOL_FILTER_NAME && !meta) return;
|
||||||
trackEvent('Filter Add', { feature: name });
|
trackEvent('Filter Add', { feature: name });
|
||||||
setFilters((prev) => {
|
setFilters((prev) => {
|
||||||
undoStackRef.current.push(prev);
|
undoStackRef.current.push(prev);
|
||||||
if (undoStackRef.current.length > 50) undoStackRef.current.shift();
|
if (undoStackRef.current.length > 50) undoStackRef.current.shift();
|
||||||
|
if (name === SCHOOL_FILTER_NAME) {
|
||||||
|
const schoolKey = createSchoolFilterKey(
|
||||||
|
'primary',
|
||||||
|
'good',
|
||||||
|
2,
|
||||||
|
schoolFilterIdRef.current++
|
||||||
|
);
|
||||||
|
const defaultSchoolFeatureName = getDefaultSchoolFeatureName(features);
|
||||||
|
const defaultSchoolFeature = defaultSchoolFeatureName
|
||||||
|
? features.find((feature) => feature.name === defaultSchoolFeatureName)
|
||||||
|
: undefined;
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
[schoolKey]: [
|
||||||
|
defaultSchoolFeature?.histogram?.min ?? defaultSchoolFeature?.min ?? 0,
|
||||||
|
defaultSchoolFeature?.histogram?.max ?? defaultSchoolFeature?.max ?? 10,
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (!meta) return prev;
|
||||||
if (meta.type === 'enum' && meta.values) {
|
if (meta.type === 'enum' && meta.values) {
|
||||||
return { ...prev, [name]: [...meta.values!] };
|
return { ...prev, [name]: [...meta.values!] };
|
||||||
} else if (meta.type === 'numeric' && meta.histogram) {
|
} else if (meta.type === 'numeric' && meta.histogram) {
|
||||||
|
|
@ -75,9 +105,27 @@ export function useFilters({ initialFilters, features }: UseFiltersOptions) {
|
||||||
if (Array.isArray(value) && value.length === 0) {
|
if (Array.isArray(value) && value.length === 0) {
|
||||||
const next = { ...prev };
|
const next = { ...prev };
|
||||||
delete next[name];
|
delete next[name];
|
||||||
return next;
|
return normalizeSchoolFilters(next);
|
||||||
}
|
}
|
||||||
return { ...prev, [name]: value };
|
|
||||||
|
const schoolKeyId = getSchoolFilterKeyId(name);
|
||||||
|
if (schoolKeyId != null) {
|
||||||
|
let replaced = false;
|
||||||
|
const next: FeatureFilters = {};
|
||||||
|
for (const [existingName, existingValue] of Object.entries(prev)) {
|
||||||
|
if (getSchoolFilterKeyId(existingName) === schoolKeyId) {
|
||||||
|
if (!replaced) {
|
||||||
|
next[name] = value;
|
||||||
|
replaced = true;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
next[existingName] = existingValue;
|
||||||
|
}
|
||||||
|
if (replaced) return normalizeSchoolFilters(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalizeSchoolFilters({ ...prev, [name]: value });
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|
@ -145,7 +193,7 @@ export function useFilters({ initialFilters, features }: UseFiltersOptions) {
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleSetFilters = useCallback((newFilters: FeatureFilters) => {
|
const handleSetFilters = useCallback((newFilters: FeatureFilters) => {
|
||||||
setFilters(newFilters);
|
setFilters(normalizeSchoolFilters(newFilters));
|
||||||
setActiveFeature(null);
|
setActiveFeature(null);
|
||||||
setDragValue(null);
|
setDragValue(null);
|
||||||
setPinnedFeature(null);
|
setPinnedFeature(null);
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import {
|
||||||
authHeaders,
|
authHeaders,
|
||||||
isAbortError,
|
isAbortError,
|
||||||
} from '../lib/api';
|
} from '../lib/api';
|
||||||
|
import { getSchoolBackendFeatureName } from '../lib/school-filter';
|
||||||
import { POSTCODE_ZOOM_THRESHOLD } from '../lib/consts';
|
import { POSTCODE_ZOOM_THRESHOLD } from '../lib/consts';
|
||||||
import { COLOR_RANGE_LOW_PERCENTILE, COLOR_RANGE_HIGH_PERCENTILE } from '../lib/consts';
|
import { COLOR_RANGE_LOW_PERCENTILE, COLOR_RANGE_HIGH_PERCENTILE } from '../lib/consts';
|
||||||
import { type TravelTimeEntry } from './useTravelTime';
|
import { type TravelTimeEntry } from './useTravelTime';
|
||||||
|
|
@ -74,11 +75,16 @@ export function useMapData({
|
||||||
const prevBoundsRef = useRef<string>('');
|
const prevBoundsRef = useRef<string>('');
|
||||||
|
|
||||||
const usePostcodeView = zoom >= POSTCODE_ZOOM_THRESHOLD;
|
const usePostcodeView = zoom >= POSTCODE_ZOOM_THRESHOLD;
|
||||||
|
const dataViewFeature = useMemo(
|
||||||
|
() => (viewFeature ? (getSchoolBackendFeatureName(viewFeature) ?? viewFeature) : null),
|
||||||
|
[viewFeature]
|
||||||
|
);
|
||||||
|
|
||||||
// Determine if the current viewFeature is an enum (for enum_dist param)
|
// Determine if the current viewFeature is an enum (for enum_dist param)
|
||||||
const viewFeatureIsEnum = useMemo(
|
const viewFeatureIsEnum = useMemo(
|
||||||
() => (viewFeature ? features.find((f) => f.name === viewFeature)?.type === 'enum' : false),
|
() =>
|
||||||
[viewFeature, features]
|
dataViewFeature ? features.find((f) => f.name === dataViewFeature)?.type === 'enum' : false,
|
||||||
|
[dataViewFeature, features]
|
||||||
);
|
);
|
||||||
|
|
||||||
const buildFilterParam = useCallback(
|
const buildFilterParam = useCallback(
|
||||||
|
|
@ -130,17 +136,18 @@ export function useMapData({
|
||||||
const filtersStr = buildFilterString(filters, features, activeFeature);
|
const filtersStr = buildFilterString(filters, features, activeFeature);
|
||||||
const boundsStr = `${bounds.south},${bounds.west},${bounds.north},${bounds.east}`;
|
const boundsStr = `${bounds.south},${bounds.west},${bounds.north},${bounds.east}`;
|
||||||
const isTravelTimeDrag = activeFeature.startsWith('tt_');
|
const isTravelTimeDrag = activeFeature.startsWith('tt_');
|
||||||
|
const dataActiveFeature = getSchoolBackendFeatureName(activeFeature) ?? activeFeature;
|
||||||
const dragTravelParam = isTravelTimeDrag ? buildTravelParam(activeFeature) : travelParam;
|
const dragTravelParam = isTravelTimeDrag ? buildTravelParam(activeFeature) : travelParam;
|
||||||
// Travel time fields are computed from the travel param, not regular feature columns.
|
// Travel time fields are computed from the travel param, not regular feature columns.
|
||||||
// Sending a tt_* name as fields would cause a 400 (unknown field). Use empty string instead.
|
// Sending a tt_* name as fields would cause a 400 (unknown field). Use empty string instead.
|
||||||
const fieldsParam = isTravelTimeDrag ? '' : activeFeature;
|
const fieldsParam = isTravelTimeDrag ? '' : dataActiveFeature;
|
||||||
|
|
||||||
if (usePostcodeView) {
|
if (usePostcodeView) {
|
||||||
const params = new URLSearchParams({ bounds: boundsStr });
|
const params = new URLSearchParams({ bounds: boundsStr });
|
||||||
if (filtersStr) params.set('filters', filtersStr);
|
if (filtersStr) params.set('filters', filtersStr);
|
||||||
params.set('fields', fieldsParam);
|
params.set('fields', fieldsParam);
|
||||||
if (dragTravelParam) params.set('travel', dragTravelParam);
|
if (dragTravelParam) params.set('travel', dragTravelParam);
|
||||||
if (viewFeatureIsEnum && viewFeature) params.set('enum_dist', viewFeature);
|
if (viewFeatureIsEnum && dataViewFeature) params.set('enum_dist', dataViewFeature);
|
||||||
|
|
||||||
fetch(apiUrl('postcodes', params), authHeaders({ signal: dragAbortRef.current.signal }))
|
fetch(apiUrl('postcodes', params), authHeaders({ signal: dragAbortRef.current.signal }))
|
||||||
.then((res) => res.json())
|
.then((res) => res.json())
|
||||||
|
|
@ -158,7 +165,7 @@ export function useMapData({
|
||||||
if (filtersStr) params.set('filters', filtersStr);
|
if (filtersStr) params.set('filters', filtersStr);
|
||||||
params.set('fields', fieldsParam);
|
params.set('fields', fieldsParam);
|
||||||
if (dragTravelParam) params.set('travel', dragTravelParam);
|
if (dragTravelParam) params.set('travel', dragTravelParam);
|
||||||
if (viewFeatureIsEnum && viewFeature) params.set('enum_dist', viewFeature);
|
if (viewFeatureIsEnum && dataViewFeature) params.set('enum_dist', dataViewFeature);
|
||||||
|
|
||||||
fetch(apiUrl('hexagons', params), authHeaders({ signal: dragAbortRef.current.signal }))
|
fetch(apiUrl('hexagons', params), authHeaders({ signal: dragAbortRef.current.signal }))
|
||||||
.then((res) => res.json())
|
.then((res) => res.json())
|
||||||
|
|
@ -185,7 +192,7 @@ export function useMapData({
|
||||||
usePostcodeView,
|
usePostcodeView,
|
||||||
travelParam,
|
travelParam,
|
||||||
buildTravelParam,
|
buildTravelParam,
|
||||||
viewFeature,
|
dataViewFeature,
|
||||||
viewFeatureIsEnum,
|
viewFeatureIsEnum,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|
@ -211,11 +218,14 @@ export function useMapData({
|
||||||
if (usePostcodeView) {
|
if (usePostcodeView) {
|
||||||
const params = new URLSearchParams({ bounds: boundsStr });
|
const params = new URLSearchParams({ bounds: boundsStr });
|
||||||
if (filtersStr) params.set('filters', filtersStr);
|
if (filtersStr) params.set('filters', filtersStr);
|
||||||
params.set('fields', viewFeature && !viewFeature.startsWith('tt_') ? viewFeature : '');
|
params.set(
|
||||||
|
'fields',
|
||||||
|
dataViewFeature && !dataViewFeature.startsWith('tt_') ? dataViewFeature : ''
|
||||||
|
);
|
||||||
if (travelParam) {
|
if (travelParam) {
|
||||||
params.set('travel', travelParam);
|
params.set('travel', travelParam);
|
||||||
}
|
}
|
||||||
if (viewFeatureIsEnum && viewFeature) params.set('enum_dist', viewFeature);
|
if (viewFeatureIsEnum && dataViewFeature) params.set('enum_dist', dataViewFeature);
|
||||||
const res = await fetch(
|
const res = await fetch(
|
||||||
apiUrl('postcodes', params),
|
apiUrl('postcodes', params),
|
||||||
authHeaders({
|
authHeaders({
|
||||||
|
|
@ -242,11 +252,14 @@ export function useMapData({
|
||||||
bounds: boundsStr,
|
bounds: boundsStr,
|
||||||
});
|
});
|
||||||
if (filtersStr) params.set('filters', filtersStr);
|
if (filtersStr) params.set('filters', filtersStr);
|
||||||
params.set('fields', viewFeature && !viewFeature.startsWith('tt_') ? viewFeature : '');
|
params.set(
|
||||||
|
'fields',
|
||||||
|
dataViewFeature && !dataViewFeature.startsWith('tt_') ? dataViewFeature : ''
|
||||||
|
);
|
||||||
if (travelParam) {
|
if (travelParam) {
|
||||||
params.set('travel', travelParam);
|
params.set('travel', travelParam);
|
||||||
}
|
}
|
||||||
if (viewFeatureIsEnum && viewFeature) params.set('enum_dist', viewFeature);
|
if (viewFeatureIsEnum && dataViewFeature) params.set('enum_dist', dataViewFeature);
|
||||||
const res = await fetch(
|
const res = await fetch(
|
||||||
apiUrl('hexagons', params),
|
apiUrl('hexagons', params),
|
||||||
authHeaders({
|
authHeaders({
|
||||||
|
|
@ -294,7 +307,7 @@ export function useMapData({
|
||||||
bounds,
|
bounds,
|
||||||
filters,
|
filters,
|
||||||
buildFilterParam,
|
buildFilterParam,
|
||||||
viewFeature,
|
dataViewFeature,
|
||||||
viewFeatureIsEnum,
|
viewFeatureIsEnum,
|
||||||
usePostcodeView,
|
usePostcodeView,
|
||||||
travelParam,
|
travelParam,
|
||||||
|
|
@ -311,12 +324,12 @@ export function useMapData({
|
||||||
// Always uses rawData/postcodeData (not drag preview data) so the color
|
// Always uses rawData/postcodeData (not drag preview data) so the color
|
||||||
// scale stays stable while dragging a filter slider.
|
// scale stays stable while dragging a filter slider.
|
||||||
const dataRange = useMemo((): [number, number] | null => {
|
const dataRange = useMemo((): [number, number] | null => {
|
||||||
if (!viewFeature) return null;
|
if (!dataViewFeature) return null;
|
||||||
|
|
||||||
const isTravelTime = viewFeature.startsWith('tt_');
|
const isTravelTime = dataViewFeature.startsWith('tt_');
|
||||||
|
|
||||||
if (!isTravelTime) {
|
if (!isTravelTime) {
|
||||||
const meta = features.find((f) => f.name === viewFeature);
|
const meta = features.find((f) => f.name === dataViewFeature);
|
||||||
if (!meta || meta.type === 'enum') return null;
|
if (!meta || meta.type === 'enum') return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -330,7 +343,7 @@ export function useMapData({
|
||||||
if (lat < bounds.south || lat > bounds.north || lng < bounds.west || lng > bounds.east)
|
if (lat < bounds.south || lat > bounds.north || lng < bounds.west || lng > bounds.east)
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const val = feat.properties[`avg_${viewFeature}`];
|
const val = feat.properties[`avg_${dataViewFeature}`];
|
||||||
if (typeof val === 'number' && !isNaN(val)) vals.push(val);
|
if (typeof val === 'number' && !isNaN(val)) vals.push(val);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -341,7 +354,7 @@ export function useMapData({
|
||||||
if (lat < bounds.south || lat > bounds.north || lon < bounds.west || lon > bounds.east)
|
if (lat < bounds.south || lat > bounds.north || lon < bounds.west || lon > bounds.east)
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const val = item[`avg_${viewFeature}`];
|
const val = item[`avg_${dataViewFeature}`];
|
||||||
if (typeof val === 'number' && !isNaN(val)) vals.push(val);
|
if (typeof val === 'number' && !isNaN(val)) vals.push(val);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -352,18 +365,18 @@ export function useMapData({
|
||||||
percentile(vals, COLOR_RANGE_LOW_PERCENTILE),
|
percentile(vals, COLOR_RANGE_LOW_PERCENTILE),
|
||||||
percentile(vals, COLOR_RANGE_HIGH_PERCENTILE),
|
percentile(vals, COLOR_RANGE_HIGH_PERCENTILE),
|
||||||
];
|
];
|
||||||
}, [viewFeature, rawData, postcodeData, usePostcodeView, features, bounds]);
|
}, [dataViewFeature, rawData, postcodeData, usePostcodeView, features, bounds]);
|
||||||
|
|
||||||
// Color range for the legend and hex coloring
|
// Color range for the legend and hex coloring
|
||||||
const colorRange = useMemo((): [number, number] | null => {
|
const colorRange = useMemo((): [number, number] | null => {
|
||||||
if (!viewFeature) return null;
|
if (!dataViewFeature) return null;
|
||||||
|
|
||||||
// Travel time keys: use dataRange directly (no FeatureMeta)
|
// Travel time keys: use dataRange directly (no FeatureMeta)
|
||||||
if (viewFeature.startsWith('tt_')) {
|
if (dataViewFeature.startsWith('tt_')) {
|
||||||
return dataRange;
|
return dataRange;
|
||||||
}
|
}
|
||||||
|
|
||||||
const meta = features.find((f) => f.name === viewFeature);
|
const meta = features.find((f) => f.name === dataViewFeature);
|
||||||
if (!meta) return null;
|
if (!meta) return null;
|
||||||
if (meta.type === 'enum' && meta.values && meta.values.length > 0) {
|
if (meta.type === 'enum' && meta.values && meta.values.length > 0) {
|
||||||
return [0, meta.values.length - 1];
|
return [0, meta.values.length - 1];
|
||||||
|
|
@ -371,7 +384,7 @@ export function useMapData({
|
||||||
if (dataRange) return dataRange;
|
if (dataRange) return dataRange;
|
||||||
if (meta.min != null && meta.max != null) return [meta.min, meta.max];
|
if (meta.min != null && meta.max != null) return [meta.min, meta.max];
|
||||||
return null;
|
return null;
|
||||||
}, [viewFeature, features, dataRange]);
|
}, [dataViewFeature, features, dataRange]);
|
||||||
|
|
||||||
const handleViewChange = useCallback(
|
const handleViewChange = useCallback(
|
||||||
({
|
({
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,16 @@ const supermarket: POI = {
|
||||||
emoji: '🛒',
|
emoji: '🛒',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const waitrose: POI = {
|
||||||
|
id: 'poi-3',
|
||||||
|
name: 'Waitrose Marylebone',
|
||||||
|
category: 'Waitrose',
|
||||||
|
group: 'Groceries',
|
||||||
|
lat: 51.52,
|
||||||
|
lng: -0.15,
|
||||||
|
emoji: '🛒',
|
||||||
|
};
|
||||||
|
|
||||||
const busStop: POI = {
|
const busStop: POI = {
|
||||||
id: 'poi-2',
|
id: 'poi-2',
|
||||||
name: 'High Street Stop',
|
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', () => {
|
it('hides minor POI categories until the configured zoom threshold', () => {
|
||||||
const { result, rerender } = renderHook(
|
const { result, rerender } = renderHook(
|
||||||
({ zoom }) => usePoiLayers({ pois: [busStop], zoom, isDark: false }),
|
({ zoom }) => usePoiLayers({ pois: [busStop], zoom, isDark: false }),
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ import {
|
||||||
POI_CLUSTER_RADIUS,
|
POI_CLUSTER_RADIUS,
|
||||||
POI_CLUSTER_MAX_ZOOM,
|
POI_CLUSTER_MAX_ZOOM,
|
||||||
} from '../lib/consts';
|
} from '../lib/consts';
|
||||||
import { emojiToTwemojiUrl } from '../lib/map-utils';
|
import { getPoiIconUrl } from '../lib/map-utils';
|
||||||
|
|
||||||
export interface PopupInfo {
|
export interface PopupInfo {
|
||||||
x: number;
|
x: number;
|
||||||
|
|
@ -176,7 +176,7 @@ export function usePoiLayers({ pois, zoom, isDark }: UsePoiLayersProps) {
|
||||||
data: visiblePois,
|
data: visiblePois,
|
||||||
getPosition: (d) => [d.lng, d.lat],
|
getPosition: (d) => [d.lng, d.lat],
|
||||||
getIcon: (d) => ({
|
getIcon: (d) => ({
|
||||||
url: emojiToTwemojiUrl(d.emoji),
|
url: getPoiIconUrl(d.category, d.emoji),
|
||||||
width: 72,
|
width: 72,
|
||||||
height: 72,
|
height: 72,
|
||||||
}),
|
}),
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ export const MODE_LABELS: Record<TransportMode, string> = {
|
||||||
car: 'Car',
|
car: 'Car',
|
||||||
bicycle: 'Bicycle',
|
bicycle: 'Bicycle',
|
||||||
walking: 'Walking',
|
walking: 'Walking',
|
||||||
transit: 'Transit',
|
transit: 'Public Transport',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const MODE_DESCRIPTIONS: Record<TransportMode, string> = {
|
export const MODE_DESCRIPTIONS: Record<TransportMode, string> = {
|
||||||
|
|
|
||||||
|
|
@ -120,17 +120,17 @@ export const details: Record<string, Record<string, string>> = {
|
||||||
'Voter turnout (%)':
|
'Voter turnout (%)':
|
||||||
"La proportion de l'électorat inscrit qui a voté de manière valide lors des élections générales britanniques de juillet 2024. Calculée comme le nombre de votes valides divisé par la taille de l'électorat. Une participation plus élevée est généralement corrélée avec des zones plus aisées et des scrutins plus serrés.",
|
"La proportion de l'électorat inscrit qui a voté de manière valide lors des élections générales britanniques de juillet 2024. Calculée comme le nombre de votes valides divisé par la taille de l'électorat. Une participation plus élevée est généralement corrélée avec des zones plus aisées et des scrutins plus serrés.",
|
||||||
'% Labour':
|
'% Labour':
|
||||||
"Pourcentage des votes valides exprimés pour le Parti travailliste dans la circonscription couvrant ce code postal, lors des élections générales britanniques de juillet 2024. Comprend les votes de tous les candidats travaillistes.",
|
'Pourcentage des votes valides exprimés pour le Parti travailliste dans la circonscription couvrant ce code postal, lors des élections générales britanniques de juillet 2024. Comprend les votes de tous les candidats travaillistes.',
|
||||||
'% Conservative':
|
'% Conservative':
|
||||||
"Pourcentage des votes valides exprimés pour le Parti conservateur dans la circonscription couvrant ce code postal, lors des élections générales britanniques de juillet 2024.",
|
'Pourcentage des votes valides exprimés pour le Parti conservateur dans la circonscription couvrant ce code postal, lors des élections générales britanniques de juillet 2024.',
|
||||||
'% Liberal Democrat':
|
'% Liberal Democrat':
|
||||||
"Pourcentage des votes valides exprimés pour les Libéraux-démocrates dans la circonscription couvrant ce code postal, lors des élections générales britanniques de juillet 2024.",
|
'Pourcentage des votes valides exprimés pour les Libéraux-démocrates dans la circonscription couvrant ce code postal, lors des élections générales britanniques de juillet 2024.',
|
||||||
'% Reform UK':
|
'% Reform UK':
|
||||||
"Pourcentage des votes valides exprimés pour Reform UK dans la circonscription couvrant ce code postal, lors des élections générales britanniques de juillet 2024.",
|
'Pourcentage des votes valides exprimés pour Reform UK dans la circonscription couvrant ce code postal, lors des élections générales britanniques de juillet 2024.',
|
||||||
'% Green':
|
'% Green':
|
||||||
"Pourcentage des votes valides exprimés pour le Parti vert dans la circonscription couvrant ce code postal, lors des élections générales britanniques de juillet 2024.",
|
'Pourcentage des votes valides exprimés pour le Parti vert dans la circonscription couvrant ce code postal, lors des élections générales britanniques de juillet 2024.',
|
||||||
'% Other parties':
|
'% Other parties':
|
||||||
"Pourcentage des votes valides exprimés pour des partis autres que Travailliste, Conservateur, Libéral-démocrate, Reform UK et Vert dans la circonscription couvrant ce code postal. Comprend les indépendants, le Président de la Chambre et les partis mineurs.",
|
'Pourcentage des votes valides exprimés pour des partis autres que Travailliste, Conservateur, Libéral-démocrate, Reform UK et Vert dans la circonscription couvrant ce code postal. Comprend les indépendants, le Président de la Chambre et les partis mineurs.',
|
||||||
'Distance to nearest park (km)':
|
'Distance to nearest park (km)':
|
||||||
"Distance à vol d'oiseau en kilomètres depuis le code postal jusqu'à l'entrée du parc la plus proche. Couvre les parcs publics, jardins, terrains de jeux et espaces de loisirs. Utilise les emplacements des points d'accès issus du jeu de données OS Open Greenspace, de sorte que les propriétés bordant un grand parc affichent correctement une courte distance.",
|
"Distance à vol d'oiseau en kilomètres depuis le code postal jusqu'à l'entrée du parc la plus proche. Couvre les parcs publics, jardins, terrains de jeux et espaces de loisirs. Utilise les emplacements des points d'accès issus du jeu de données OS Open Greenspace, de sorte que les propriétés bordant un grand parc affichent correctement une courte distance.",
|
||||||
'Number of parks within 1km':
|
'Number of parks within 1km':
|
||||||
|
|
@ -399,14 +399,10 @@ export const details: Record<string, Record<string, string>> = {
|
||||||
'2024年7月英国大选中投出有效选票的登记选民比例。计算方式为有效票数除以选民总数。较高的投票率通常与较富裕地区和竞争更激烈的选举相关。',
|
'2024年7月英国大选中投出有效选票的登记选民比例。计算方式为有效票数除以选民总数。较高的投票率通常与较富裕地区和竞争更激烈的选举相关。',
|
||||||
'% Labour':
|
'% Labour':
|
||||||
'2024年7月英国大选中,该邮编所属选区投给工党的有效选票百分比。包括所有工党候选人的选票。',
|
'2024年7月英国大选中,该邮编所属选区投给工党的有效选票百分比。包括所有工党候选人的选票。',
|
||||||
'% Conservative':
|
'% Conservative': '2024年7月英国大选中,该邮编所属选区投给保守党的有效选票百分比。',
|
||||||
'2024年7月英国大选中,该邮编所属选区投给保守党的有效选票百分比。',
|
'% Liberal Democrat': '2024年7月英国大选中,该邮编所属选区投给自由民主党的有效选票百分比。',
|
||||||
'% Liberal Democrat':
|
'% Reform UK': '2024年7月英国大选中,该邮编所属选区投给英国改革党的有效选票百分比。',
|
||||||
'2024年7月英国大选中,该邮编所属选区投给自由民主党的有效选票百分比。',
|
'% Green': '2024年7月英国大选中,该邮编所属选区投给绿党的有效选票百分比。',
|
||||||
'% Reform UK':
|
|
||||||
'2024年7月英国大选中,该邮编所属选区投给英国改革党的有效选票百分比。',
|
|
||||||
'% Green':
|
|
||||||
'2024年7月英国大选中,该邮编所属选区投给绿党的有效选票百分比。',
|
|
||||||
'% Other parties':
|
'% Other parties':
|
||||||
'该选区中投给工党、保守党、自由民主党、英国改革党和绿党以外政党的有效选票百分比。包括独立候选人、议长和小型政党。',
|
'该选区中投给工党、保守党、自由民主党、英国改革党和绿党以外政党的有效选票百分比。包括独立候选人、议长和小型政党。',
|
||||||
'Distance to nearest park (km)':
|
'Distance to nearest park (km)':
|
||||||
|
|
|
||||||
|
|
@ -293,6 +293,8 @@ const de: Translations = {
|
||||||
// ── Street View ────────────────────────────────────
|
// ── Street View ────────────────────────────────────
|
||||||
streetView: {
|
streetView: {
|
||||||
title: 'Street View',
|
title: 'Street View',
|
||||||
|
openLarge: 'Street View größer öffnen',
|
||||||
|
expandedTitle: 'Vergrößerte Street View',
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── POI Pane ───────────────────────────────────────
|
// ── POI Pane ───────────────────────────────────────
|
||||||
|
|
@ -330,47 +332,105 @@ const de: Translations = {
|
||||||
|
|
||||||
// ── Home Page ──────────────────────────────────────
|
// ── Home Page ──────────────────────────────────────
|
||||||
home: {
|
home: {
|
||||||
heroTitle1: 'Maximaler',
|
heroEyebrow: 'Für Käufer, die fragen: „Wo soll ich überhaupt suchen?“',
|
||||||
heroTitle2: 'Wert',
|
heroTitle1: 'Finden Sie die Postleitzahlen',
|
||||||
heroTitle3: 'Minimale Kompromisse.',
|
heroTitle2: 'die zu Ihrem Leben passen',
|
||||||
|
heroTitle3: 'Nicht nur die Gegenden, die Sie schon kennen.',
|
||||||
heroSubtitle:
|
heroSubtitle:
|
||||||
'Auf Immobiliensuche? Mach aus deiner größten Investition deine klügste Entscheidung.',
|
'Von Londoner Stadtteilen über Pendlerorte bis zu regionalen Städten: England hat zu viele Orte, um sie einzeln zu recherchieren.',
|
||||||
heroDescription:
|
heroDescription:
|
||||||
'So viele Möglichkeiten — die richtige Wahl kann überwältigend sein. Unsere interaktive Karte macht es einfach: Wähle deine Muss-Kriterien und sieh sofort die passenden Gebiete.',
|
'Legen Sie Budget, Pendelzeit, Schulen, Sicherheit, Lärm, Breitband und Lebensstil fest. Perfect Postcode scannt Englands Postleitzahlen und zeigt Orte, die wirklich passen, auch Gegenden, die Sie nie in ein Immobilienportal eingegeben hätten.',
|
||||||
exploreTheMap: 'Karte entdecken',
|
exploreTheMap: 'Passende Postleitzahlen finden',
|
||||||
seeTheDifference: 'Den Unterschied sehen',
|
seeTheDifference: 'So funktioniert es',
|
||||||
statProperties: 'Immobilien',
|
showcaseHeader: 'Produktvorschau',
|
||||||
statFilters: 'Filter',
|
showcaseContext: 'Käufersuche in ganz England',
|
||||||
|
showcaseStep1Tab: 'Beschreiben',
|
||||||
|
showcaseStep1Title: 'Beschreiben Sie das Leben, das Sie möchten',
|
||||||
|
showcaseStep1Body:
|
||||||
|
'Nutzen Sie natürliche Sprache oder Filter, um komplexe Kaufkriterien in eine Suche zu verwandeln.',
|
||||||
|
showcaseStep1Prompt:
|
||||||
|
'2 Schlafzimmer unter £525k, 45 Min. zur Arbeit, ruhige Straßen, gute Schulen',
|
||||||
|
showcaseStep1Chip1: '<= £525k',
|
||||||
|
showcaseStep1Chip2: '2+ Schlafzimmer',
|
||||||
|
showcaseStep1Chip3: '45 Min. Pendeln',
|
||||||
|
showcaseStep1Chip4: 'Wenig Straßenlärm',
|
||||||
|
showcaseStep2Tab: 'Entdecken',
|
||||||
|
showcaseStep2Title: 'Zeigen Sie Orte, die Sie nicht erwogen hatten',
|
||||||
|
showcaseStep2Body:
|
||||||
|
'Die Karte markiert passende Postleitzahlen, auch außerhalb Ihrer bisherigen Shortlist.',
|
||||||
|
showcaseStep2Metric: '47 passende Postleitzahlen',
|
||||||
|
showcaseStep2Note: 'jenseits der offensichtlichen Shortlist',
|
||||||
|
showcaseKnownAreas: 'Bekannte Gegenden',
|
||||||
|
showcaseNewMatches: 'Neue Treffer',
|
||||||
|
showcaseKnownAreaStatus: 'wenige Treffer',
|
||||||
|
showcaseStep3Tab: 'Prüfen',
|
||||||
|
showcaseStep3Title: 'Verstehen Sie, warum jede Postleitzahl passt',
|
||||||
|
showcaseStep3Body:
|
||||||
|
'Öffnen Sie einen Treffer und prüfen Sie die Belege, bevor Sie ein Wochenende für Besichtigungen opfern.',
|
||||||
|
showcaseStep3Postcode: 'Postleitzahl-Beispiel',
|
||||||
|
showcaseStep3Area: 'Penge',
|
||||||
|
showcaseStep3Code: 'SE20',
|
||||||
|
showcaseStep3Score: 'Starker Fit',
|
||||||
|
showcaseEvidence1: '42 Min. Pendelzeit',
|
||||||
|
showcaseEvidence2: 'Weniger Straßenlärm',
|
||||||
|
showcaseEvidence3: 'Gute Grundschuloptionen',
|
||||||
|
showcaseEvidence4: 'Verkaufspreise im Budget',
|
||||||
|
showcaseStep4Tab: 'Vergleichen',
|
||||||
|
showcaseStep4Title: 'Kompromisse vor Besichtigungen vergleichen',
|
||||||
|
showcaseStep4Body:
|
||||||
|
'Erstellen Sie eine Shortlist danach, was Sie gewinnen und aufgeben, nicht nur nach Ruf.',
|
||||||
|
showcaseCompare1: 'Penge: Londoner Bahnanschluss, mehr Platz',
|
||||||
|
showcaseCompare2: 'Totterdown: fußläufige Straßen in Bristol',
|
||||||
|
showcaseCompare3: 'Walkley: größere Häuser, guter Gegenwert',
|
||||||
|
showcaseMapLabel: 'Passende Postleitzahlen',
|
||||||
|
showcaseSaveLabel: 'Shortlist bereit',
|
||||||
|
showcaseMatchPenge: 'London im Budget',
|
||||||
|
showcaseMatchAbbeyWood: 'Elizabeth line + Grünflächen',
|
||||||
|
showcaseMatchTotterdown: 'Bristol gut zu Fuß',
|
||||||
|
showcaseMatchWalkley: 'Sheffield: Platz + Schulen',
|
||||||
|
statProperties: 'historische Verkäufe',
|
||||||
|
statFilters: 'kombinierbare Filter',
|
||||||
statEvery: 'Jede',
|
statEvery: 'Jede',
|
||||||
statPostcodeInEngland: 'Postleitzahl in England',
|
statPostcodeInEngland: 'Postleitzahl in England',
|
||||||
ourPhilosophy: 'Unsere Philosophie',
|
ourPhilosophy: 'Beginnen Sie mit Ihrem Leben, nicht mit einer Postleitzahl',
|
||||||
philosophyP1:
|
philosophyP1:
|
||||||
'Auf Rightmove wählt man zuerst ein Gebiet und hofft, dass es gut ist. Am Ende vergleicht man Kriminalitätsstatistiken, Schulberichte und Breitband-Checker in einem Dutzend Tabs, eine Postleitzahl nach der anderen.',
|
'Die meisten Immobilienseiten fragen, wo Sie wohnen möchten. In London ist das besonders schwierig, aber das gleiche Problem gibt es in ganz England: Käufer starten mit wenigen bekannten Orten und prüfen dann Pendelzeit, Schulen, Kriminalität, Street View, Breitband und Verkaufspreise in getrennten Tabs.',
|
||||||
philosophyP2:
|
philosophyP2:
|
||||||
'Wir drehen das um. Sag uns, was du brauchst (Budget, Pendelweg, Schulen, Sicherheit), und wir zeigen dir jedes Gebiet in England, das passt. Kein Raten. Keine verschwendeten Besichtigungen.',
|
'Perfect Postcode dreht die Suche um. Sagen Sie der Karte, was zählt, und sie zeigt passende Postleitzahlen mit nachvollziehbaren Gründen. Erst Daten, dann vor Ort das Gefühl prüfen.',
|
||||||
|
streetTitle: 'Orte ändern sich von Straße zu Straße',
|
||||||
|
streetIntro:
|
||||||
|
'Große Gebietsnamen verdecken die Details, die zählen: Bahnhofsseite, Straßenlärm, Schulmix, genaue Pendelzeit und echte Verkaufspreise.',
|
||||||
|
streetCard1Title: 'Finden Sie Gegenden, die Sie übersehen hätten',
|
||||||
|
streetCard1Body:
|
||||||
|
'Entdecken Sie Postleitzahlen, die Ihren Anforderungen entsprechen, statt sich nur auf bekannte Namen, Empfehlungen oder Hype zu verlassen.',
|
||||||
|
streetCard2Title: 'Sehen Sie Kompromisse vor Besichtigungen',
|
||||||
|
streetCard2Body:
|
||||||
|
'Vergleichen Sie Preis, Platz, Pendelzeit, Sicherheit, Schulen, Breitband, Lärm und Energieeffizienz, bevor Sie Wochenenden mit Besichtigungen verbringen.',
|
||||||
howToUseIt: 'So funktioniert es',
|
howToUseIt: 'So funktioniert es',
|
||||||
howStep1Title: 'Lege deine Muss-Kriterien fest',
|
howStep1Title: 'Beschreiben Sie das Leben, das Sie brauchen',
|
||||||
howStep1Desc: 'Budget, Pendelweg, Schulen — die Karte zeigt nur, was passt.',
|
howStep1Desc: 'Budget, Pendelzeit, Immobilientyp, Schulen, Sicherheit, Platz und Alltag.',
|
||||||
howStep2Title: 'Entdecke Gebiete und versteckte Perlen',
|
howStep2Title: 'Passende Postleitzahlen anzeigen',
|
||||||
howStep2Desc: 'Zoom rein, schau dir Details und Kann-Kriterien an.',
|
howStep2Desc: 'Die Karte markiert Orte, die Ihre Filter erfüllen, auch unbekanntere Gegenden.',
|
||||||
howStep3Title: 'Einzelne Postleitzahlen erkunden',
|
howStep3Title: 'Die Belege prüfen',
|
||||||
howStep3Desc: 'Sieh einzelne Immobilien, Verkaufspreise, Wohnflächen und vergleiche.',
|
howStep3Desc:
|
||||||
howStep4Title: 'Engere Auswahl mit Zuversicht',
|
'Prüfen Sie Verkaufspreise, Wohnfläche, EPC, Straßenlärm, Breitband, Kriminalität und Schulen.',
|
||||||
|
howStep4Title: 'Shortlist vor der Listingsuche',
|
||||||
howStep4Desc:
|
howStep4Desc:
|
||||||
'Jedes Gebiet auf deiner Liste erfüllt deine tatsächlichen Kriterien — nicht nur, was diese Woche inseriert war.',
|
'Gehen Sie mit besseren Suchgebieten zu Rightmove, Zoopla, Maklern und Besichtigungen.',
|
||||||
othersVs: 'Andere vs',
|
othersVs: 'Andere vs',
|
||||||
checkMyPostcode: '„Meine Postleitzahl prüfen“',
|
checkMyPostcode: 'Immobilienportale',
|
||||||
areaGuides: 'Gebietsratgeber',
|
areaGuides: 'Postleitzahl-Berichte',
|
||||||
compSearchWithout: 'Suchen, ohne zuerst ein Gebiet auszuwählen',
|
compSearchWithout: 'Gegenden entdecken, bevor Sie die Namen kennen',
|
||||||
compSearchWithoutSub: '(starte mit Bedürfnissen, nicht mit einem Ort)',
|
compSearchWithoutSub: '(erst Anforderungen, dann Ort)',
|
||||||
compAreaData: 'Gebietsdaten',
|
compAreaData: 'Nachbarschaftsdaten auf Postleitzahl-Ebene',
|
||||||
compAreaDataSub: '(Kriminalität, Schulen, Lärm, Breitband)',
|
compAreaDataSub: '(Kriminalität, Schulen, Lärm, Breitband, Ausstattung)',
|
||||||
compPropertyData: 'Immobilienspezifische Daten',
|
compPropertyData: 'Historie auf Immobilienebene',
|
||||||
compPropertyDataSub: '(Preis, EPC, Wohnfläche)',
|
compPropertyDataSub: '(Verkaufspreise, EPC, Wohnfläche, Schätzwert)',
|
||||||
compFilters: '56 kombinierbare Filter an einem Ort',
|
compFilters: '56 Filter, die zusammenarbeiten',
|
||||||
compFiltersSub: '(alle Einblicke, eine interaktive Karte)',
|
compFiltersSub: '(nicht eine Postleitzahl oder ein Listing nach dem anderen)',
|
||||||
ctaTitle: 'Mach aus deiner größten Investition deine klügste Entscheidung.',
|
ctaTitle: 'Hören Sie auf zu raten, wo Sie kaufen sollen.',
|
||||||
ctaDescription: 'Das verdient die richtigen Werkzeuge — überlass es nicht dem Zufall.',
|
ctaDescription:
|
||||||
|
'Erstellen Sie eine Shortlist von Postleitzahlen, die zu Ihrem echten Leben passen, und prüfen Sie sie dann vor Ort.',
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── Pricing Page ───────────────────────────────────
|
// ── Pricing Page ───────────────────────────────────
|
||||||
|
|
@ -456,6 +516,10 @@ const de: Translations = {
|
||||||
dsOsmOrigin: 'OpenStreetMap contributors / Geofabrik',
|
dsOsmOrigin: 'OpenStreetMap contributors / Geofabrik',
|
||||||
dsOsmUse:
|
dsOsmUse:
|
||||||
'Sehenswürdigkeiten und Einrichtungen wie Geschäfte, Restaurants, Gesundheitseinrichtungen, Freizeit, Tourismus und mehr in ganz Großbritannien.',
|
'Sehenswürdigkeiten und Einrichtungen wie Geschäfte, Restaurants, Gesundheitseinrichtungen, Freizeit, Tourismus und mehr in ganz Großbritannien.',
|
||||||
|
dsGeolytixRetailName: 'GEOLYTIX Grocery Retail Points',
|
||||||
|
dsGeolytixRetailOrigin: 'GEOLYTIX',
|
||||||
|
dsGeolytixRetailUse:
|
||||||
|
'Supermarkt- und Convenience-Store-Standorte im Vereinigten Königreich, darunter Ketten wie Waitrose, Tesco, Sainsbury’s, Asda, Morrisons, Aldi, Lidl, Co-op, M&S, Iceland und Spar.',
|
||||||
dsGreenspaceName: 'OS Open Greenspace',
|
dsGreenspaceName: 'OS Open Greenspace',
|
||||||
dsGreenspaceOrigin: 'Ordnance Survey',
|
dsGreenspaceOrigin: 'Ordnance Survey',
|
||||||
dsGreenspaceUse:
|
dsGreenspaceUse:
|
||||||
|
|
|
||||||
|
|
@ -70,7 +70,7 @@ const en = {
|
||||||
logIn: 'Log in',
|
logIn: 'Log in',
|
||||||
createAccount: 'Create account',
|
createAccount: 'Create account',
|
||||||
resetPassword: 'Reset password',
|
resetPassword: 'Reset password',
|
||||||
valueProp: 'Save searches, bookmark properties, and pick up where you left off.',
|
valueProp: 'Save searches, bookmark properties, and build a shortlist of areas that fit.',
|
||||||
continueWithGoogle: 'Continue with Google',
|
continueWithGoogle: 'Continue with Google',
|
||||||
email: 'Email',
|
email: 'Email',
|
||||||
emailPlaceholder: 'you@example.com',
|
emailPlaceholder: 'you@example.com',
|
||||||
|
|
@ -86,9 +86,9 @@ const en = {
|
||||||
|
|
||||||
// ── Upgrade Modal ──────────────────────────────────
|
// ── Upgrade Modal ──────────────────────────────────
|
||||||
upgrade: {
|
upgrade: {
|
||||||
title: 'See all of England',
|
title: 'Find every matching postcode',
|
||||||
description:
|
description:
|
||||||
"You’re currently exploring the demo area. Get lifetime access to every postcode, every filter, every neighbourhood. One payment, forever.",
|
'You’re currently exploring the demo area. Get lifetime access to every postcode, every filter, and every neighbourhood in England. One payment, forever.',
|
||||||
free: 'Free',
|
free: 'Free',
|
||||||
once: '/once',
|
once: '/once',
|
||||||
freeForEarly: 'Free for early adopters. No credit card required.',
|
freeForEarly: 'Free for early adopters. No credit card required.',
|
||||||
|
|
@ -115,7 +115,7 @@ const en = {
|
||||||
|
|
||||||
// ── License Success ────────────────────────────────
|
// ── License Success ────────────────────────────────
|
||||||
licenseSuccess: {
|
licenseSuccess: {
|
||||||
title: "You’re in.",
|
title: 'You’re in.',
|
||||||
subtitle: 'Your lifetime access is now active.',
|
subtitle: 'Your lifetime access is now active.',
|
||||||
description: 'Full access to every feature, every postcode, across all of England.',
|
description: 'Full access to every feature, every postcode, across all of England.',
|
||||||
startExploring: 'Start exploring',
|
startExploring: 'Start exploring',
|
||||||
|
|
@ -128,7 +128,7 @@ const en = {
|
||||||
findingPerfectPostcode: 'Finding the Perfect Postcode',
|
findingPerfectPostcode: 'Finding the Perfect Postcode',
|
||||||
addFiltersHint: 'Add filters below to narrow the map to areas that match your criteria',
|
addFiltersHint: 'Add filters below to narrow the map to areas that match your criteria',
|
||||||
upgradePrompt:
|
upgradePrompt:
|
||||||
'See crime, schools, noise, broadband, and 50+ more filters across all of England.',
|
'Find matching postcodes using crime, schools, noise, broadband, prices, and 50+ more filters across England.',
|
||||||
oneTimeLifetime: 'One-time payment, lifetime access.',
|
oneTimeLifetime: 'One-time payment, lifetime access.',
|
||||||
upgradeToFullMap: 'Upgrade to full map',
|
upgradeToFullMap: 'Upgrade to full map',
|
||||||
chooseFilters: 'Choose the filters that matter to you. The map updates as you go.',
|
chooseFilters: 'Choose the filters that matter to you. The map updates as you go.',
|
||||||
|
|
@ -184,7 +184,7 @@ const en = {
|
||||||
modeCar: 'Car',
|
modeCar: 'Car',
|
||||||
modeBicycle: 'Bicycle',
|
modeBicycle: 'Bicycle',
|
||||||
modeWalking: 'Walking',
|
modeWalking: 'Walking',
|
||||||
modeTransit: 'Transit',
|
modeTransit: 'Public Transport',
|
||||||
modeCarDesc: 'Drive time via the fastest road route',
|
modeCarDesc: 'Drive time via the fastest road route',
|
||||||
modeBicycleDesc: 'Cycling time using bike-friendly routes',
|
modeBicycleDesc: 'Cycling time using bike-friendly routes',
|
||||||
modeWalkingDesc: 'Walking time along pedestrian paths and pavements',
|
modeWalkingDesc: 'Walking time along pedestrian paths and pavements',
|
||||||
|
|
@ -204,19 +204,19 @@ const en = {
|
||||||
|
|
||||||
// ── AI Filter ──────────────────────────────────────
|
// ── AI Filter ──────────────────────────────────────
|
||||||
aiFilter: {
|
aiFilter: {
|
||||||
describeIdealArea: 'Describe your ideal area with AI',
|
describeIdealArea: 'Describe where you want to live',
|
||||||
aiSearch: 'AI Search',
|
aiSearch: 'AI Search',
|
||||||
describeHint: "describe what you’re looking for",
|
describeHint: 'describe what you’re looking for',
|
||||||
placeholder: 'e.g. quiet area, under £400k, near good schools...',
|
placeholder: 'e.g. 2-bed under £525k, 45 mins to work, quiet...',
|
||||||
example1: 'House 40 mins from Bank in a low crime area',
|
example1: '2-bed under £525k, 45 mins to work',
|
||||||
example2: 'Flats around good primary schools not too far from Manchester',
|
example2: 'Family areas near good schools under £650k',
|
||||||
example3: 'Best ex-council houses under 200k',
|
example3: 'More space with a sane commute',
|
||||||
analysing: 'Analysing your query...',
|
analysing: 'Analysing your query...',
|
||||||
searchingDestinations: 'Searching for destinations...',
|
searchingDestinations: 'Searching for destinations...',
|
||||||
generatingFilters: 'Generating filters...',
|
generatingFilters: 'Generating filters...',
|
||||||
refiningResults: 'Refining results...',
|
refiningResults: 'Refining results...',
|
||||||
weeklyLimitReached:
|
weeklyLimitReached:
|
||||||
"You’ve reached the weekly AI usage limit. It will reset automatically next week.",
|
'You’ve reached the weekly AI usage limit. It will reset automatically next week.',
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── Map Legend ─────────────────────────────────────
|
// ── Map Legend ─────────────────────────────────────
|
||||||
|
|
@ -289,6 +289,8 @@ const en = {
|
||||||
// ── Street View ────────────────────────────────────
|
// ── Street View ────────────────────────────────────
|
||||||
streetView: {
|
streetView: {
|
||||||
title: 'Street View',
|
title: 'Street View',
|
||||||
|
openLarge: 'Open Street View larger',
|
||||||
|
expandedTitle: 'Expanded Street View',
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── POI Pane ───────────────────────────────────────
|
// ── POI Pane ───────────────────────────────────────
|
||||||
|
|
@ -296,7 +298,7 @@ const en = {
|
||||||
pois: 'POIs',
|
pois: 'POIs',
|
||||||
pointsOfInterest: 'Points of Interest',
|
pointsOfInterest: 'Points of Interest',
|
||||||
poiDescription:
|
poiDescription:
|
||||||
'Sourced from OpenStreetMap. Covers public transport stops, shops, restaurants, healthcare, leisure, and more. Updated regularly with complete category coverage.',
|
'Sourced from OpenStreetMap, NaPTAN, and GEOLYTIX Grocery Retail Points. Covers transport stops, shops, chain supermarkets, restaurants, healthcare, leisure, and more.',
|
||||||
searchCategories: 'Search categories...',
|
searchCategories: 'Search categories...',
|
||||||
dataSourceInfo: 'Data source info',
|
dataSourceInfo: 'Data source info',
|
||||||
},
|
},
|
||||||
|
|
@ -326,55 +328,113 @@ const en = {
|
||||||
|
|
||||||
// ── Home Page ──────────────────────────────────────
|
// ── Home Page ──────────────────────────────────────
|
||||||
home: {
|
home: {
|
||||||
heroTitle1: 'Maximum',
|
heroEyebrow: 'For buyers asking “where should I even look?”',
|
||||||
heroTitle2: 'Value',
|
heroTitle1: 'Find the postcodes',
|
||||||
heroTitle3: 'Minimum Compromise.',
|
heroTitle2: 'that fit your life',
|
||||||
heroSubtitle: 'House hunting? Make your biggest investment your smartest move.',
|
heroTitle3: 'Not just the areas you already know.',
|
||||||
|
heroSubtitle:
|
||||||
|
'From London boroughs to commuter towns and regional cities, England has too many places to research one by one.',
|
||||||
heroDescription:
|
heroDescription:
|
||||||
'So many options - choosing the right one can feel overwhelming. Our interactive map makes it simple: select your must-haves and instantly see the areas that fit.',
|
'Set your budget, commute, schools, safety, noise, broadband, and lifestyle needs. Perfect Postcode scans England’s postcodes and reveals the places that actually fit, including areas you would never have typed into a listing portal.',
|
||||||
exploreTheMap: 'Explore the map',
|
exploreTheMap: 'Find my matching postcodes',
|
||||||
seeTheDifference: 'See the difference',
|
seeTheDifference: 'See how it works',
|
||||||
statProperties: 'properties',
|
showcaseHeader: 'Product showcase',
|
||||||
statFilters: 'filters',
|
showcaseContext: 'England-wide buyer search',
|
||||||
|
showcaseStep1Tab: 'Describe',
|
||||||
|
showcaseStep1Title: 'Describe the life you want',
|
||||||
|
showcaseStep1Body:
|
||||||
|
'Use natural language or filters to turn messy buyer criteria into one search.',
|
||||||
|
showcaseStep1Prompt: '2-bed under £525k, 45 mins to work, quiet streets, good schools',
|
||||||
|
showcaseStep1Chip1: '<= £525k',
|
||||||
|
showcaseStep1Chip2: '2+ beds',
|
||||||
|
showcaseStep1Chip3: '45 min commute',
|
||||||
|
showcaseStep1Chip4: 'Low road noise',
|
||||||
|
showcaseStep2Tab: 'Discover',
|
||||||
|
showcaseStep2Title: 'Reveal places you had not considered',
|
||||||
|
showcaseStep2Body:
|
||||||
|
'The map lights up matching postcodes, including areas outside your usual shortlist.',
|
||||||
|
showcaseStep2Metric: '47 matching postcodes',
|
||||||
|
showcaseStep2Note: 'beyond the obvious shortlist',
|
||||||
|
showcaseKnownAreas: 'Known areas',
|
||||||
|
showcaseNewMatches: 'New matches',
|
||||||
|
showcaseKnownAreaStatus: 'few matches',
|
||||||
|
showcaseStep3Tab: 'Check',
|
||||||
|
showcaseStep3Title: 'Understand why each postcode fits',
|
||||||
|
showcaseStep3Body:
|
||||||
|
'Open a result and check the evidence before you give up a weekend for viewings.',
|
||||||
|
showcaseStep3Postcode: 'Postcode example',
|
||||||
|
showcaseStep3Area: 'Penge',
|
||||||
|
showcaseStep3Code: 'SE20',
|
||||||
|
showcaseStep3Score: 'Strong fit',
|
||||||
|
showcaseEvidence1: '42 min commute',
|
||||||
|
showcaseEvidence2: 'Lower road noise',
|
||||||
|
showcaseEvidence3: 'Good primary options',
|
||||||
|
showcaseEvidence4: 'Sold prices in budget',
|
||||||
|
showcaseStep4Tab: 'Compare',
|
||||||
|
showcaseStep4Title: 'Compare trade-offs before viewings',
|
||||||
|
showcaseStep4Body: 'Shortlist areas by what you gain and give up, not by reputation alone.',
|
||||||
|
showcaseCompare1: 'Penge: London rail links, more space',
|
||||||
|
showcaseCompare2: 'Totterdown: walkable Bristol streets',
|
||||||
|
showcaseCompare3: 'Walkley: larger homes, strong value',
|
||||||
|
showcaseMapLabel: 'Matching postcodes',
|
||||||
|
showcaseSaveLabel: 'Shortlist ready',
|
||||||
|
showcaseMatchPenge: 'London budget fit',
|
||||||
|
showcaseMatchAbbeyWood: 'Elizabeth line + green space',
|
||||||
|
showcaseMatchTotterdown: 'Bristol walkability',
|
||||||
|
showcaseMatchWalkley: 'Sheffield space + schools',
|
||||||
|
statProperties: 'historical sales',
|
||||||
|
statFilters: 'combinable filters',
|
||||||
statEvery: 'Every',
|
statEvery: 'Every',
|
||||||
statPostcodeInEngland: 'postcode in England',
|
statPostcodeInEngland: 'postcode in England',
|
||||||
ourPhilosophy: 'Our philosophy',
|
ourPhilosophy: 'Start with your life, not a postcode',
|
||||||
philosophyP1:
|
philosophyP1:
|
||||||
"On Rightmove, you pick an area first, then hope it’s good. You end up cross-referencing crime stats, school reports, and broadband checkers across a dozen tabs, one postcode at a time.",
|
'Most property sites ask where you want to live. In London that is painfully hard, but the same problem shows up across England: buyers choose from the few places they know, then cross-check commute tools, Ofsted, police data, Street View, broadband checkers, and sold prices in separate tabs.',
|
||||||
philosophyP2:
|
philosophyP2:
|
||||||
'We flip that. Tell us what you need (budget, commute, schools, safety) and we show you every area in England that qualifies. No guesswork. No wasted viewings.',
|
'Perfect Postcode flips the search. Tell the map what matters and it shows the postcodes that qualify, with evidence for why they are worth inspecting. Data first, then go test the vibe.',
|
||||||
|
streetTitle: 'Places change street by street',
|
||||||
|
streetIntro:
|
||||||
|
'Broad area names hide the details that matter: the station side, the road noise, the school mix, the exact commute, and what similar homes actually sold for.',
|
||||||
|
streetCard1Title: 'Find areas you may have missed',
|
||||||
|
streetCard1Body:
|
||||||
|
'Surface postcodes that match your requirements instead of relying on familiar names, friend recommendations, or “up-and-coming” hype.',
|
||||||
|
streetCard2Title: 'See the trade-offs before viewings',
|
||||||
|
streetCard2Body:
|
||||||
|
'Compare price, space, commute, safety, schools, broadband, noise, and energy ratings before you spend weekends travelling between viewings.',
|
||||||
howToUseIt: 'How to use it',
|
howToUseIt: 'How to use it',
|
||||||
howStep1Title: 'Set your must-haves',
|
howStep1Title: 'Describe the life you need',
|
||||||
howStep1Desc: 'Budget, commute, schools — the map shows only what qualifies.',
|
howStep1Desc: 'Budget, commute, property type, schools, safety, space, and daily essentials.',
|
||||||
howStep2Title: 'Explore areas and discover hidden gems',
|
howStep2Title: 'Reveal matching postcodes',
|
||||||
howStep2Desc: 'Zoom in, dig into details and nice to haves.',
|
howStep2Desc:
|
||||||
howStep3Title: 'Drill into postcodes',
|
'The map highlights the places that pass your filters, including unfamiliar areas.',
|
||||||
howStep3Desc: 'See individual properties, sale prices, floor area, and compare.',
|
howStep3Title: 'Check the evidence',
|
||||||
howStep4Title: 'Shortlist with confidence',
|
howStep3Desc:
|
||||||
howStep4Desc:
|
'Inspect sold prices, floor area, EPC, road noise, broadband, crime, and schools.',
|
||||||
'Every area on your list meets your actual criteria — not just what was listed that week.',
|
howStep4Title: 'Shortlist before you browse listings',
|
||||||
|
howStep4Desc: 'Take a better search area to Rightmove, Zoopla, agents, and viewings.',
|
||||||
othersVs: 'Others vs',
|
othersVs: 'Others vs',
|
||||||
checkMyPostcode: '“Check my postcode”',
|
checkMyPostcode: 'Listing portals',
|
||||||
areaGuides: 'Area guides',
|
areaGuides: 'Postcode reports',
|
||||||
compSearchWithout: 'Search without choosing an area first',
|
compSearchWithout: 'Discover areas before you know their names',
|
||||||
compSearchWithoutSub: '(start with needs, not a location)',
|
compSearchWithoutSub: '(requirements first, location second)',
|
||||||
compAreaData: 'Area data',
|
compAreaData: 'Postcode-level neighbourhood evidence',
|
||||||
compAreaDataSub: '(crime, schools, noise, broadband)',
|
compAreaDataSub: '(crime, schools, noise, broadband, amenities)',
|
||||||
compPropertyData: 'Property-specific data',
|
compPropertyData: 'Property-level history',
|
||||||
compPropertyDataSub: '(price, EPC, floor area)',
|
compPropertyDataSub: '(sold prices, EPC, floor area, estimated value)',
|
||||||
compFilters: '56 combinable filters in one place',
|
compFilters: '56 filters working together',
|
||||||
compFiltersSub: '(all insights, one interactive map)',
|
compFiltersSub: '(not one postcode or one listing at a time)',
|
||||||
ctaTitle: 'Make your biggest investment your smartest move.',
|
ctaTitle: 'Stop guessing where to buy.',
|
||||||
ctaDescription: "This deserves proper tools behind it, don’t leave it to luck.",
|
ctaDescription:
|
||||||
|
'Build a shortlist of postcodes that fit your actual life, then test them in person.',
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── Pricing Page ───────────────────────────────────
|
// ── Pricing Page ───────────────────────────────────
|
||||||
pricingPage: {
|
pricingPage: {
|
||||||
title: 'Early access pricing',
|
title: 'Buy with a better search area',
|
||||||
subtitle: 'Pay once, access forever. The earlier you join, the less you pay.',
|
subtitle:
|
||||||
|
'Lifetime access to the map that helps you find where to look before you book viewings.',
|
||||||
costContext:
|
costContext:
|
||||||
"Buying a home costs £10k+ in stamp duty, £1,500 in solicitor fees, £500 for a survey. Get the wrong area and you’re stuck with a long commute, bad schools, or a road you didn’t know about.",
|
'Buyers often spend evenings stitching together listings, commute checks, school reports, crime maps, Street View, and sold prices. In London this is relentless, but the same research problem appears across England. Perfect Postcode puts the area research on one map before you commit your weekends, fees, and attention.',
|
||||||
lessThanSurvey: 'Less than a home survey. Far more useful.',
|
lessThanSurvey: 'Less than a survey. Useful before you even choose an area.',
|
||||||
currentTier: 'Current tier',
|
currentTier: 'Current tier',
|
||||||
firstNUsers: 'First {{count}} users',
|
firstNUsers: 'First {{count}} users',
|
||||||
everyoneAfter: 'Everyone after',
|
everyoneAfter: 'Everyone after',
|
||||||
|
|
@ -391,11 +451,11 @@ const en = {
|
||||||
soldOut: 'Sold out',
|
soldOut: 'Sold out',
|
||||||
upcoming: 'Upcoming',
|
upcoming: 'Upcoming',
|
||||||
failedToLoad: 'Failed to load pricing. Please try again later.',
|
failedToLoad: 'Failed to load pricing. Please try again later.',
|
||||||
feat1: '56 data layers across England',
|
feat1: '56 filters across England',
|
||||||
feat2: 'Every postcode scored and filterable',
|
feat2: 'Every postcode searchable from your needs',
|
||||||
feat3: 'Unlimited map exploration and exports',
|
feat3: 'Unlimited map exploration, saved searches and exports',
|
||||||
feat4: 'Multiple decades of historical price data',
|
feat4: '13M historical transactions and price context',
|
||||||
feat5: 'Crime, schools, transport, broadband and more',
|
feat5: 'Commute, schools, crime, noise, broadband and more',
|
||||||
feat6: 'All future data updates included',
|
feat6: 'All future data updates included',
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
@ -407,7 +467,7 @@ const en = {
|
||||||
dataSourcesIntro:
|
dataSourcesIntro:
|
||||||
'This application combines {{count}} open datasets covering property prices, energy performance, transport, demographics, crime, environment, and more.',
|
'This application combines {{count}} open datasets covering property prices, energy performance, transport, demographics, crime, environment, and more.',
|
||||||
faqIntro:
|
faqIntro:
|
||||||
"Whether you’re buying, renting, or just exploring, here’s how Perfect Postcode helps you find the right area.",
|
'Whether you’re buying in London, comparing commuter towns, or sanity-checking an unfamiliar postcode, here’s how Perfect Postcode helps you work out where to look.',
|
||||||
supportIntro: 'Have a question? Check our FAQ or reach out to us directly.',
|
supportIntro: 'Have a question? Check our FAQ or reach out to us directly.',
|
||||||
source: 'Source:',
|
source: 'Source:',
|
||||||
optOut: 'Opt out of public disclosure',
|
optOut: 'Opt out of public disclosure',
|
||||||
|
|
@ -449,6 +509,10 @@ const en = {
|
||||||
dsOsmOrigin: 'OpenStreetMap contributors / Geofabrik',
|
dsOsmOrigin: 'OpenStreetMap contributors / Geofabrik',
|
||||||
dsOsmUse:
|
dsOsmUse:
|
||||||
'Points of interest covering shops, restaurants, healthcare, leisure, tourism, and more across Great Britain.',
|
'Points of interest covering shops, restaurants, healthcare, leisure, tourism, and more across Great Britain.',
|
||||||
|
dsGeolytixRetailName: 'GEOLYTIX Grocery Retail Points',
|
||||||
|
dsGeolytixRetailOrigin: 'GEOLYTIX',
|
||||||
|
dsGeolytixRetailUse:
|
||||||
|
'Supermarket and convenience store locations across the UK, including chain retailers such as Waitrose, Tesco, Sainsbury’s, Asda, Morrisons, Aldi, Lidl, Co-op, M&S, Iceland, and Spar.',
|
||||||
dsGreenspaceName: 'OS Open Greenspace',
|
dsGreenspaceName: 'OS Open Greenspace',
|
||||||
dsGreenspaceOrigin: 'Ordnance Survey',
|
dsGreenspaceOrigin: 'Ordnance Survey',
|
||||||
dsGreenspaceUse:
|
dsGreenspaceUse:
|
||||||
|
|
@ -492,29 +556,29 @@ const en = {
|
||||||
faqPricingTitle: 'Pricing and Access',
|
faqPricingTitle: 'Pricing and Access',
|
||||||
faqTipsTitle: 'Tips and Tricks',
|
faqTipsTitle: 'Tips and Tricks',
|
||||||
// FAQ items — Finding Your Area
|
// FAQ items — Finding Your Area
|
||||||
faqFinding1Q: "I don’t even know which areas to look at. Can this help?",
|
faqFinding1Q: 'I don’t even know which areas to look at. Can this help?',
|
||||||
faqFinding1A:
|
faqFinding1A:
|
||||||
'That\'s exactly what it\'s for. Set your filters (budget, commute time, low crime, good schools) and the map lights up to show you every area that ticks every box. No more Googling "best areas to live near Manchester" at midnight.',
|
'That is exactly what it is for. Set your filters (budget, commute time, low crime, good schools, broadband, road noise) and the map lights up to show every postcode that fits. You can discover areas before you know their names.',
|
||||||
faqFinding2Q: "I’m moving somewhere I’ve never been. How do I even start?",
|
faqFinding2Q: 'I’m moving somewhere I’ve never been. How do I even start?',
|
||||||
faqFinding2A:
|
faqFinding2A:
|
||||||
'Set your filters for what matters and the map instantly highlights the areas that qualify. You go from "I don\'t know a single street" to a shortlist in minutes.',
|
'Set what matters and the map highlights the postcodes that qualify. You go from "I do not know a single street" to a shortlist you can inspect in minutes.',
|
||||||
faqFinding3Q: 'How do I find areas that tick all my boxes at once?',
|
faqFinding3Q: 'How do I find areas that tick all my boxes at once?',
|
||||||
faqFinding3A:
|
faqFinding3A:
|
||||||
'Stack multiple filters (crime below average, good schools, commute under 40 minutes) then colour the map by price to spot the best value areas. The map updates live as you drag sliders, so you can see results change in real time.',
|
'Stack multiple filters (crime below average, good schools, commute under 40 minutes) then colour the map by price to spot the best value areas. The map updates live as you drag sliders, so you can see results change in real time.',
|
||||||
// FAQ items — Commute and Travel
|
// FAQ items — Commute and Travel
|
||||||
faqCommute1Q: 'Can I see how long my commute would actually be from different areas?',
|
faqCommute1Q: 'Can I see how long my commute would actually be from different areas?',
|
||||||
faqCommute1A:
|
faqCommute1A:
|
||||||
"Set your workplace as a destination and we’ll colour every postcode by journey time, whether that’s by car, bike, or public transport. Filter to your max commute and the rest disappears.",
|
'Set your workplace as a destination and we’ll colour every postcode by journey time, whether that’s by car, bike, or public transport. Filter to your max commute and the rest disappears.',
|
||||||
faqCommute2Q: 'How is that better than checking Google Maps?',
|
faqCommute2Q: 'How is that better than checking Google Maps?',
|
||||||
faqCommute2A:
|
faqCommute2A:
|
||||||
'Google Maps shows you one journey at a time. We colour every postcode in England by commute time in one go, so you can compare hundreds of areas side by side instead of searching them one by one.',
|
'Google Maps shows you one journey at a time. We colour every postcode in England by commute time in one go, so you can compare hundreds of areas side by side instead of searching them one by one.',
|
||||||
// FAQ items — Budget and Value
|
// FAQ items — Budget and Value
|
||||||
faqBudget1Q: 'How do I find areas where I get the most space for my money?',
|
faqBudget1Q: 'How do I find areas where I get the most space for my money?',
|
||||||
faqBudget1A:
|
faqBudget1A:
|
||||||
"Filter by price per sqm and you’ll instantly see which postcodes give you the most space per pound. Pair it with the energy rating filter to avoid properties with high heating costs.",
|
'Filter by price per sqm and you’ll instantly see which postcodes give you the most space per pound. Pair it with the energy rating filter to avoid properties with high heating costs.',
|
||||||
faqBudget2Q: "How do I make sure a cheap area isn’t cheap for a reason?",
|
faqBudget2Q: 'How do I make sure a cheap area isn’t cheap for a reason?',
|
||||||
faqBudget2A:
|
faqBudget2A:
|
||||||
"Layer deprivation scores, crime stats, school ratings, and broadband speeds alongside price. If a postcode is affordable and scores well on everything that matters, you’ve found genuine value, not just a low price with trade-offs you haven’t spotted yet.",
|
'Layer deprivation scores, crime stats, school ratings, and broadband speeds alongside price. If a postcode is affordable and scores well on everything that matters, you’ve found genuine value, not just a low price with trade-offs you haven’t spotted yet.',
|
||||||
// FAQ items — Safety and Neighbourhood
|
// FAQ items — Safety and Neighbourhood
|
||||||
faqSafety1Q: 'How can I check if an area is safe before I move there?',
|
faqSafety1Q: 'How can I check if an area is safe before I move there?',
|
||||||
faqSafety1A:
|
faqSafety1A:
|
||||||
|
|
@ -522,7 +586,7 @@ const en = {
|
||||||
faqSafety2Q:
|
faqSafety2Q:
|
||||||
'I keep finding flats that look great online, then the area turns out to be rough.',
|
'I keep finding flats that look great online, then the area turns out to be rough.',
|
||||||
faqSafety2A:
|
faqSafety2A:
|
||||||
"That’s exactly why this exists. Stack crime rates, noise levels, deprivation scores, nearby pubs and parks, and broadband speeds all on one map so you know what a neighbourhood is actually like before you book a viewing.",
|
'That’s exactly why this exists. Stack crime rates, noise levels, deprivation scores, nearby pubs and parks, and broadband speeds all on one map so you know what a neighbourhood is actually like before you book a viewing.',
|
||||||
// FAQ items — Families and Schools
|
// FAQ items — Families and Schools
|
||||||
faqFamilies1Q: 'Can I find areas with good schools AND low crime in one search?',
|
faqFamilies1Q: 'Can I find areas with good schools AND low crime in one search?',
|
||||||
faqFamilies1A:
|
faqFamilies1A:
|
||||||
|
|
@ -531,7 +595,7 @@ const en = {
|
||||||
faqFamilies2A:
|
faqFamilies2A:
|
||||||
'Toggle on the parks and green spaces POI layer to see them right on the map. You can also filter by how many are within walking distance of each postcode.',
|
'Toggle on the parks and green spaces POI layer to see them right on the map. You can also filter by how many are within walking distance of each postcode.',
|
||||||
// FAQ items — Environment and Quality of Life
|
// FAQ items — Environment and Quality of Life
|
||||||
faqEnv1Q: "Can I find energy-efficient homes that aren’t on a noisy road?",
|
faqEnv1Q: 'Can I find energy-efficient homes that aren’t on a noisy road?',
|
||||||
faqEnv1A:
|
faqEnv1A:
|
||||||
'Filter by EPC rating (A to C), then layer on road noise data to rule out anything above your threshold. Colour-code by either feature to spot quiet, efficient streets at a glance.',
|
'Filter by EPC rating (A to C), then layer on road noise data to rule out anything above your threshold. Colour-code by either feature to spot quiet, efficient streets at a glance.',
|
||||||
faqEnv2Q: 'Does it show flood or subsidence risk?',
|
faqEnv2Q: 'Does it show flood or subsidence risk?',
|
||||||
|
|
@ -543,20 +607,20 @@ const en = {
|
||||||
// FAQ items — Why Perfect Postcode
|
// FAQ items — Why Perfect Postcode
|
||||||
faqWhy1Q: 'I already use Rightmove. What does this add?',
|
faqWhy1Q: 'I already use Rightmove. What does this add?',
|
||||||
faqWhy1A:
|
faqWhy1A:
|
||||||
'Rightmove shows you houses. We show you areas. Crime rates, school ratings, broadband speeds, noise levels, deprivation scores, and more, all filterable on one map. You can judge a neighbourhood before you even look at listings.',
|
'Rightmove shows you listings. Perfect Postcode shows you where to look. Crime rates, school ratings, broadband speeds, noise levels, sold prices, floor area, EPC data, and more are all filterable on one map before you open listings.',
|
||||||
faqWhy2Q: "Can’t I just research all this myself for free?",
|
faqWhy2Q: 'Can’t I just research all this myself for free?',
|
||||||
faqWhy2A:
|
faqWhy2A:
|
||||||
'You could cross-reference police data, Ofsted reports, EPC registers, Land Registry records, and ONS statistics one postcode at a time. Or you could have it all filterable and colour-coded on one map in seconds.',
|
'You could cross-reference police data, Ofsted reports, EPC registers, Land Registry records, ONS statistics, Street View and commute tools one postcode at a time. Or you could have the evidence filterable and colour-coded on one map.',
|
||||||
faqWhy3Q: 'Where does the data actually come from?',
|
faqWhy3Q: 'Where does the data actually come from?',
|
||||||
faqWhy3A:
|
faqWhy3A:
|
||||||
"Every dataset comes from official UK government sources: Land Registry, the EPC register, ONS, Ofsted, Ofcom, data.police.uk, and Defra. We don’t scrape estate agents or make anything up. You can verify any record against the original source.",
|
'Every dataset comes from official UK government sources: Land Registry, the EPC register, ONS, Ofsted, Ofcom, data.police.uk, and Defra. We don’t scrape estate agents or make anything up. You can verify any record against the original source.',
|
||||||
// FAQ items — Pricing and Access
|
// FAQ items — Pricing and Access
|
||||||
faqPricing1Q: 'Is it really worth paying for a property search tool?',
|
faqPricing1Q: 'Is it really worth paying for a property search tool?',
|
||||||
faqPricing1A:
|
faqPricing1A:
|
||||||
"Buying a home is likely the biggest purchase you’ll make. Spotting one red flag (a noisy road, poor broadband, rising crime) before committing could save you years of regret. This costs less than a tank of petrol.",
|
'Buying a home is likely the biggest purchase you’ll make. Spotting one red flag (a noisy road, weak broadband, awkward commute, poor school access, or bad value) before committing could save you years of regret.',
|
||||||
faqPricing2Q: 'Is this a subscription?',
|
faqPricing2Q: 'Is this a subscription?',
|
||||||
faqPricing2A:
|
faqPricing2A:
|
||||||
"No. One-time payment, yours forever. Use it intensively during your search, come back whenever you’re curious about a new area, and it’s still there if you ever move again.",
|
'No. One-time payment, yours forever. Use it intensively during your search, come back whenever you’re curious about a new area, and it’s still there if you ever move again.',
|
||||||
faqPricing3Q: 'What can I access on the free tier?',
|
faqPricing3Q: 'What can I access on the free tier?',
|
||||||
faqPricing3A:
|
faqPricing3A:
|
||||||
'Free users can explore all features within the demo area (inner London, roughly zones 1 to 2). To access data for the rest of England, you need lifetime access.',
|
'Free users can explore all features within the demo area (inner London, roughly zones 1 to 2). To access data for the rest of England, you need lifetime access.',
|
||||||
|
|
@ -564,11 +628,11 @@ const en = {
|
||||||
// FAQ items — Tips and Tricks
|
// FAQ items — Tips and Tricks
|
||||||
faqTips1Q: 'How do I use the AI filter instead of adding filters one by one?',
|
faqTips1Q: 'How do I use the AI filter instead of adding filters one by one?',
|
||||||
faqTips1A:
|
faqTips1A:
|
||||||
'Type what you want in plain English, something like "quiet area near good schools with fast broadband under £400k", and it\'ll set up all the relevant filters in one go. Tweak any of them manually afterwards.',
|
'Type what you want in plain English, something like "2-bed under £525k, 45 minutes to work, quiet, good broadband", and it will set up the relevant filters in one go. Tweak any of them manually afterwards.',
|
||||||
faqTips2Q: 'Can I save a search and come back to it later?',
|
faqTips2Q: 'Can I save a search and come back to it later?',
|
||||||
faqTips2A:
|
faqTips2A:
|
||||||
'Hit the save button and everything is captured: your filters, zoom level, and which data layer you’re colouring by. Pick up exactly where you left off or share the link with your partner.',
|
'Hit the save button and everything is captured: your filters, zoom level, and which data layer you’re colouring by. Pick up exactly where you left off or share the link with your partner.',
|
||||||
faqTips3Q: "Can I export the data I’m looking at?",
|
faqTips3Q: 'Can I export the data I’m looking at?',
|
||||||
faqTips3A:
|
faqTips3A:
|
||||||
'Use the export button to download the currently filtered properties as a spreadsheet. The export respects all your active filters, so you get exactly the data you want.',
|
'Use the export button to download the currently filtered properties as a spreadsheet. The export respects all your active filters, so you get exactly the data you want.',
|
||||||
},
|
},
|
||||||
|
|
@ -627,14 +691,14 @@ const en = {
|
||||||
|
|
||||||
// ── Invite Page ────────────────────────────────────
|
// ── Invite Page ────────────────────────────────────
|
||||||
invitePage: {
|
invitePage: {
|
||||||
youreInvited: "You’re invited!",
|
youreInvited: 'You’re invited!',
|
||||||
specialOffer: 'Special offer!',
|
specialOffer: 'Special offer!',
|
||||||
invitedByFree: '{{name}} has invited you to get free lifetime access.',
|
invitedByFree: '{{name}} has invited you to get free lifetime access.',
|
||||||
invitedByDiscount: '{{name}} has shared a 30% discount on lifetime access.',
|
invitedByDiscount: '{{name}} has shared a 30% discount on lifetime access.',
|
||||||
genericFreeInvite: 'You have been invited to get free lifetime access.',
|
genericFreeInvite: 'You have been invited to get free lifetime access.',
|
||||||
genericDiscount: 'A friend has shared a 30% discount on lifetime access.',
|
genericDiscount: 'A friend has shared a 30% discount on lifetime access.',
|
||||||
exploreEvery: 'Explore every neighbourhood in England',
|
exploreEvery: 'Find postcodes that fit your life',
|
||||||
propertyInfo: 'Property prices, energy ratings, crime stats, school ratings and more',
|
propertyInfo: 'Prices, commute, schools, crime, noise, broadband, EPC and more',
|
||||||
invalidInvite: 'Invalid invite',
|
invalidInvite: 'Invalid invite',
|
||||||
inviteAlreadyUsed: 'Invite already used',
|
inviteAlreadyUsed: 'Invite already used',
|
||||||
inviteAlreadyUsedDesc: 'This invite link has already been redeemed.',
|
inviteAlreadyUsedDesc: 'This invite link has already been redeemed.',
|
||||||
|
|
@ -681,13 +745,13 @@ const en = {
|
||||||
tutorial: {
|
tutorial: {
|
||||||
step1Title: 'Tell the map what matters',
|
step1Title: 'Tell the map what matters',
|
||||||
step1Content:
|
step1Content:
|
||||||
'Set your budget, commute limit, school quality, crime threshold. Whatever matters to you. Only areas that qualify stay lit. Use the eye icon to colour by any feature.',
|
'Set your budget, commute limit, school quality, crime threshold, noise tolerance, broadband needs, or whatever matters to you. Only matching areas stay lit. Use the eye icon to colour by any feature.',
|
||||||
step2Title: 'Or just describe it',
|
step2Title: 'Or just describe it',
|
||||||
step2Content:
|
step2Content:
|
||||||
'Type what you want in plain English, like "quiet area near good schools under £400k", and we’ll set up the filters for you.',
|
'Type what you want in plain English, like "quiet area near good schools under £400k", and we’ll set up the filters for you.',
|
||||||
step3Title: 'Explore what’s out there',
|
step3Title: 'Explore what’s out there',
|
||||||
step3Content:
|
step3Content:
|
||||||
'Pan and zoom across England. Click any coloured area to see crime, schools, prices, broadband, noise, and more about that neighbourhood.',
|
'Pan and zoom across England. Click any coloured area to see why it matches: crime, schools, prices, broadband, noise, and more.',
|
||||||
step4Title: 'Jump to a location',
|
step4Title: 'Jump to a location',
|
||||||
step4Content: 'Search for any place or postcode to fly straight there.',
|
step4Content: 'Search for any place or postcode to fly straight there.',
|
||||||
step5Title: 'Dig into the details',
|
step5Title: 'Dig into the details',
|
||||||
|
|
|
||||||
|
|
@ -89,7 +89,7 @@ const fr: Translations = {
|
||||||
|
|
||||||
// ── Upgrade Modal ──────────────────────────────────
|
// ── Upgrade Modal ──────────────────────────────────
|
||||||
upgrade: {
|
upgrade: {
|
||||||
title: "Découvrez toute l’Angleterre",
|
title: 'Découvrez toute l’Angleterre',
|
||||||
description:
|
description:
|
||||||
'Vous explorez actuellement la zone de démonstration. Obtenez un accès à vie à chaque code postal, chaque filtre, chaque quartier. Un seul paiement, pour toujours.',
|
'Vous explorez actuellement la zone de démonstration. Obtenez un accès à vie à chaque code postal, chaque filtre, chaque quartier. Un seul paiement, pour toujours.',
|
||||||
free: 'Gratuit',
|
free: 'Gratuit',
|
||||||
|
|
@ -97,9 +97,9 @@ const fr: Translations = {
|
||||||
freeForEarly: 'Gratuit pour les premiers utilisateurs. Aucune carte bancaire requise.',
|
freeForEarly: 'Gratuit pour les premiers utilisateurs. Aucune carte bancaire requise.',
|
||||||
oneTimePayment: 'Paiement unique. Accès à vie.',
|
oneTimePayment: 'Paiement unique. Accès à vie.',
|
||||||
redirecting: 'Redirection...',
|
redirecting: 'Redirection...',
|
||||||
claimFreeAccess: "Réclamer l’accès gratuit",
|
claimFreeAccess: 'Réclamer l’accès gratuit',
|
||||||
upgradeFor: 'Passer à la version complète pour {{price}}',
|
upgradeFor: 'Passer à la version complète pour {{price}}',
|
||||||
registerAndUpgrade: "S’inscrire et passer à la version complète",
|
registerAndUpgrade: 'S’inscrire et passer à la version complète',
|
||||||
alreadyHaveAccount: 'Vous avez déjà un compte ? Connectez-vous',
|
alreadyHaveAccount: 'Vous avez déjà un compte ? Connectez-vous',
|
||||||
continueWithDemo: 'Continuer avec la démo',
|
continueWithDemo: 'Continuer avec la démo',
|
||||||
checkoutFailed: 'Échec du paiement',
|
checkoutFailed: 'Échec du paiement',
|
||||||
|
|
@ -118,10 +118,10 @@ const fr: Translations = {
|
||||||
|
|
||||||
// ── License Success ────────────────────────────────
|
// ── License Success ────────────────────────────────
|
||||||
licenseSuccess: {
|
licenseSuccess: {
|
||||||
title: "C’est fait.",
|
title: 'C’est fait.',
|
||||||
subtitle: 'Votre accès à vie est maintenant actif.',
|
subtitle: 'Votre accès à vie est maintenant actif.',
|
||||||
description:
|
description:
|
||||||
"Accès complet à chaque fonctionnalité, chaque code postal, dans toute l’Angleterre.",
|
'Accès complet à chaque fonctionnalité, chaque code postal, dans toute l’Angleterre.',
|
||||||
startExploring: 'Commencer à explorer',
|
startExploring: 'Commencer à explorer',
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
@ -133,7 +133,7 @@ const fr: Translations = {
|
||||||
addFiltersHint:
|
addFiltersHint:
|
||||||
'Ajoutez des filtres ci-dessous pour restreindre la carte aux zones correspondant à vos critères',
|
'Ajoutez des filtres ci-dessous pour restreindre la carte aux zones correspondant à vos critères',
|
||||||
upgradePrompt:
|
upgradePrompt:
|
||||||
"Voir la criminalité, les écoles, le bruit, le débit internet et plus de 50 filtres dans toute l’Angleterre.",
|
'Voir la criminalité, les écoles, le bruit, le débit internet et plus de 50 filtres dans toute l’Angleterre.',
|
||||||
oneTimeLifetime: 'Paiement unique, accès à vie.',
|
oneTimeLifetime: 'Paiement unique, accès à vie.',
|
||||||
upgradeToFullMap: 'Passer à la carte complète',
|
upgradeToFullMap: 'Passer à la carte complète',
|
||||||
chooseFilters:
|
chooseFilters:
|
||||||
|
|
@ -168,7 +168,7 @@ const fr: Translations = {
|
||||||
step5Desc: '(restaurants, parcs, débit internet)',
|
step5Desc: '(restaurants, parcs, débit internet)',
|
||||||
step6Title: 'Énergie',
|
step6Title: 'Énergie',
|
||||||
step6Desc: '(classements DPE, isolation, coûts de chauffage)',
|
step6Desc: '(classements DPE, isolation, coûts de chauffage)',
|
||||||
tip: "Astuce : si rien ne correspond, assouplissez un critère à la fois pour voir quel compromis ouvre le plus d’options.",
|
tip: 'Astuce : si rien ne correspond, assouplissez un critère à la fois pour voir quel compromis ouvre le plus d’options.',
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── Travel Time ────────────────────────────────────
|
// ── Travel Time ────────────────────────────────────
|
||||||
|
|
@ -179,9 +179,9 @@ const fr: Translations = {
|
||||||
bestCase: 'Meilleur cas',
|
bestCase: 'Meilleur cas',
|
||||||
bestCaseTitle: 'Meilleur temps de trajet',
|
bestCaseTitle: 'Meilleur temps de trajet',
|
||||||
bestCaseDesc:
|
bestCaseDesc:
|
||||||
"Utilise le temps de trajet réaliste le plus rapide (si vous partez au bon moment et avez de bonnes correspondances). Par défaut, la <strong>médiane</strong> est utilisée, représentant un trajet typique quelle que soit l’heure de départ.",
|
'Utilise le temps de trajet réaliste le plus rapide (si vous partez au bon moment et avez de bonnes correspondances). Par défaut, la <strong>médiane</strong> est utilisée, représentant un trajet typique quelle que soit l’heure de départ.',
|
||||||
previewOnMap: 'Aperçu sur la carte',
|
previewOnMap: 'Aperçu sur la carte',
|
||||||
stopPreviewing: "Arrêter l’aperçu",
|
stopPreviewing: 'Arrêter l’aperçu',
|
||||||
removeTravelTime: 'Supprimer le temps de trajet',
|
removeTravelTime: 'Supprimer le temps de trajet',
|
||||||
addTravelTime: 'Ajouter le temps de trajet en {{mode}}',
|
addTravelTime: 'Ajouter le temps de trajet en {{mode}}',
|
||||||
clearDestination: 'Effacer la destination',
|
clearDestination: 'Effacer la destination',
|
||||||
|
|
@ -297,14 +297,16 @@ const fr: Translations = {
|
||||||
// ── Street View ────────────────────────────────────
|
// ── Street View ────────────────────────────────────
|
||||||
streetView: {
|
streetView: {
|
||||||
title: 'Street View',
|
title: 'Street View',
|
||||||
|
openLarge: 'Ouvrir Street View en grand',
|
||||||
|
expandedTitle: 'Street View agrandi',
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── POI Pane ───────────────────────────────────────
|
// ── POI Pane ───────────────────────────────────────
|
||||||
poiPane: {
|
poiPane: {
|
||||||
pois: 'POI',
|
pois: 'POI',
|
||||||
pointsOfInterest: "Points d’intérêt",
|
pointsOfInterest: 'Points d’intérêt',
|
||||||
poiDescription:
|
poiDescription:
|
||||||
"Données issues d’OpenStreetMap. Couvre les arrêts de transport, commerces, restaurants, établissements de santé, loisirs et plus encore. Mise à jour régulière avec une couverture complète des catégories.",
|
'Données issues d’OpenStreetMap. Couvre les arrêts de transport, commerces, restaurants, établissements de santé, loisirs et plus encore. Mise à jour régulière avec une couverture complète des catégories.',
|
||||||
searchCategories: 'Rechercher des catégories...',
|
searchCategories: 'Rechercher des catégories...',
|
||||||
dataSourceInfo: 'Informations sur la source',
|
dataSourceInfo: 'Informations sur la source',
|
||||||
},
|
},
|
||||||
|
|
@ -323,7 +325,7 @@ const fr: Translations = {
|
||||||
lookupFailed: 'Échec de la recherche',
|
lookupFailed: 'Échec de la recherche',
|
||||||
searchLabel: 'Rechercher des lieux ou codes postaux',
|
searchLabel: 'Rechercher des lieux ou codes postaux',
|
||||||
locateMe: 'Aller à ma position',
|
locateMe: 'Aller à ma position',
|
||||||
geolocationUnsupported: "La géolocalisation n’est pas prise en charge par votre navigateur",
|
geolocationUnsupported: 'La géolocalisation n’est pas prise en charge par votre navigateur',
|
||||||
geolocationFailed: 'Impossible de déterminer votre position',
|
geolocationFailed: 'Impossible de déterminer votre position',
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
@ -334,48 +336,107 @@ const fr: Translations = {
|
||||||
|
|
||||||
// ── Home Page ──────────────────────────────────────
|
// ── Home Page ──────────────────────────────────────
|
||||||
home: {
|
home: {
|
||||||
heroTitle1: 'Valeur',
|
heroEyebrow: 'Pour les acheteurs qui se demandent « où chercher ? »',
|
||||||
heroTitle2: 'Maximale',
|
heroTitle1: 'Trouvez les codes postaux',
|
||||||
heroTitle3: 'Compromis Minimum.',
|
heroTitle2: 'qui correspondent à votre vie',
|
||||||
|
heroTitle3: 'Pas seulement les quartiers que vous connaissez déjà.',
|
||||||
heroSubtitle:
|
heroSubtitle:
|
||||||
'Vous cherchez un bien ? Faites de votre plus gros investissement votre meilleure décision.',
|
'Des quartiers londoniens aux villes de banlieue et aux villes régionales, l’Angleterre compte trop de lieux pour les rechercher un par un.',
|
||||||
heroDescription:
|
heroDescription:
|
||||||
"Tant d’options — choisir la bonne peut sembler décourageant. Notre carte interactive simplifie tout : sélectionnez vos critères et voyez instantanément les zones qui correspondent.",
|
'Définissez votre budget, trajet, écoles, sécurité, bruit, débit internet et style de vie. Perfect Postcode analyse les codes postaux d’Angleterre et révèle les lieux qui correspondent vraiment, y compris ceux que vous n’auriez jamais cherchés sur un portail immobilier.',
|
||||||
exploreTheMap: 'Explorer la carte',
|
exploreTheMap: 'Trouver mes codes postaux',
|
||||||
seeTheDifference: 'Voir la différence',
|
seeTheDifference: 'Voir comment ça marche',
|
||||||
statProperties: 'propriétés',
|
showcaseHeader: 'Aperçu du produit',
|
||||||
statFilters: 'filtres',
|
showcaseContext: 'Recherche d’acheteur en Angleterre',
|
||||||
|
showcaseStep1Tab: 'Décrire',
|
||||||
|
showcaseStep1Title: 'Décrivez la vie que vous voulez',
|
||||||
|
showcaseStep1Body:
|
||||||
|
'Utilisez le langage naturel ou les filtres pour transformer des critères complexes en une seule recherche.',
|
||||||
|
showcaseStep1Prompt:
|
||||||
|
'2 chambres sous £525k, 45 min jusqu’au travail, rues calmes, bonnes écoles',
|
||||||
|
showcaseStep1Chip1: '<= £525k',
|
||||||
|
showcaseStep1Chip2: '2+ chambres',
|
||||||
|
showcaseStep1Chip3: '45 min de trajet',
|
||||||
|
showcaseStep1Chip4: 'Faible bruit routier',
|
||||||
|
showcaseStep2Tab: 'Découvrir',
|
||||||
|
showcaseStep2Title: 'Révélez des lieux que vous n’aviez pas envisagés',
|
||||||
|
showcaseStep2Body:
|
||||||
|
'La carte met en évidence les codes postaux compatibles, y compris hors de votre sélection habituelle.',
|
||||||
|
showcaseStep2Metric: '47 codes postaux compatibles',
|
||||||
|
showcaseStep2Note: 'au-delà de la sélection évidente',
|
||||||
|
showcaseKnownAreas: 'Zones connues',
|
||||||
|
showcaseNewMatches: 'Nouvelles correspondances',
|
||||||
|
showcaseKnownAreaStatus: 'peu de résultats',
|
||||||
|
showcaseStep3Tab: 'Vérifier',
|
||||||
|
showcaseStep3Title: 'Comprenez pourquoi chaque code postal correspond',
|
||||||
|
showcaseStep3Body:
|
||||||
|
'Ouvrez un résultat et vérifiez les preuves avant de réserver votre week-end pour des visites.',
|
||||||
|
showcaseStep3Postcode: 'Exemple de code postal',
|
||||||
|
showcaseStep3Area: 'Penge',
|
||||||
|
showcaseStep3Code: 'SE20',
|
||||||
|
showcaseStep3Score: 'Très bon ajustement',
|
||||||
|
showcaseEvidence1: '42 min de trajet',
|
||||||
|
showcaseEvidence2: 'Bruit routier plus faible',
|
||||||
|
showcaseEvidence3: 'Bonnes écoles primaires',
|
||||||
|
showcaseEvidence4: 'Prix vendus dans le budget',
|
||||||
|
showcaseStep4Tab: 'Comparer',
|
||||||
|
showcaseStep4Title: 'Comparez les compromis avant les visites',
|
||||||
|
showcaseStep4Body:
|
||||||
|
'Sélectionnez les zones selon ce que vous gagnez et perdez, pas seulement selon leur réputation.',
|
||||||
|
showcaseCompare1: 'Penge : liaisons londoniennes, plus d’espace',
|
||||||
|
showcaseCompare2: 'Totterdown : rues accessibles à pied à Bristol',
|
||||||
|
showcaseCompare3: 'Walkley : logements plus grands, bon rapport qualité-prix',
|
||||||
|
showcaseMapLabel: 'Codes postaux compatibles',
|
||||||
|
showcaseSaveLabel: 'Sélection prête',
|
||||||
|
showcaseMatchPenge: 'budget compatible à Londres',
|
||||||
|
showcaseMatchAbbeyWood: 'Elizabeth line + espaces verts',
|
||||||
|
showcaseMatchTotterdown: 'Bristol accessible à pied',
|
||||||
|
showcaseMatchWalkley: 'espace + écoles à Sheffield',
|
||||||
|
statProperties: 'ventes historiques',
|
||||||
|
statFilters: 'filtres combinables',
|
||||||
statEvery: 'Chaque',
|
statEvery: 'Chaque',
|
||||||
statPostcodeInEngland: "code postal d’Angleterre",
|
statPostcodeInEngland: 'code postal d’Angleterre',
|
||||||
ourPhilosophy: 'Notre philosophie',
|
ourPhilosophy: 'Commencez par votre vie, pas par un code postal',
|
||||||
philosophyP1:
|
philosophyP1:
|
||||||
"Sur Rightmove, vous choisissez d’abord une zone, puis vous espérez qu’elle convient. Vous finissez par croiser statistiques de criminalité, rapports scolaires et tests de débit sur une dizaine d’onglets, un code postal à la fois.",
|
'La plupart des sites immobiliers demandent où vous voulez vivre. À Londres, c’est particulièrement difficile, mais le même problème existe partout en Angleterre : les acheteurs partent des quelques lieux qu’ils connaissent, puis vérifient séparément trajets, écoles, criminalité, Street View, débit internet et prix vendus.',
|
||||||
philosophyP2:
|
philosophyP2:
|
||||||
"Nous inversons la logique. Dites-nous ce qu’il vous faut (budget, trajet, écoles, sécurité) et nous vous montrons chaque zone d’Angleterre qui correspond. Plus de devinettes. Plus de visites inutiles.",
|
'Perfect Postcode inverse la recherche. Dites à la carte ce qui compte et elle affiche les codes postaux qui correspondent, avec les raisons pour lesquelles ils méritent d’être étudiés. Les données d’abord, puis allez tester l’ambiance.',
|
||||||
|
streetTitle: 'Tout change rue par rue',
|
||||||
|
streetIntro:
|
||||||
|
'Les grands noms de quartiers cachent les détails importants : le côté de la gare, le bruit de la route, les écoles, le trajet exact et les vrais prix de vente.',
|
||||||
|
streetCard1Title: 'Trouvez les zones que vous auriez manquées',
|
||||||
|
streetCard1Body:
|
||||||
|
'Faites ressortir les codes postaux qui correspondent à vos critères, au lieu de dépendre seulement des noms connus ou des recommandations.',
|
||||||
|
streetCard2Title: 'Voyez les compromis avant les visites',
|
||||||
|
streetCard2Body:
|
||||||
|
'Comparez prix, surface, trajet, sécurité, écoles, débit internet, bruit et énergie avant de passer vos week-ends à courir les visites.',
|
||||||
howToUseIt: 'Comment l’utiliser',
|
howToUseIt: 'Comment l’utiliser',
|
||||||
howStep1Title: 'Définissez vos indispensables',
|
howStep1Title: 'Décrivez la vie dont vous avez besoin',
|
||||||
howStep1Desc: 'Budget, trajet, écoles — la carte n’affiche que ce qui correspond.',
|
howStep1Desc:
|
||||||
howStep2Title: 'Explorez les zones et découvrez des pépites cachées',
|
'Budget, trajet, type de bien, écoles, sécurité, surface et essentiels du quotidien.',
|
||||||
howStep2Desc: 'Zoomez, examinez les détails et les critères secondaires.',
|
howStep2Title: 'Révélez les codes postaux compatibles',
|
||||||
howStep3Title: 'Plongez dans les codes postaux',
|
howStep2Desc:
|
||||||
|
'La carte met en évidence les lieux qui passent vos filtres, y compris les zones moins connues.',
|
||||||
|
howStep3Title: 'Vérifiez les preuves',
|
||||||
howStep3Desc:
|
howStep3Desc:
|
||||||
'Consultez les propriétés individuelles, les prix de vente, la surface et comparez.',
|
'Consultez prix vendus, surface, DPE, bruit routier, débit internet, criminalité et écoles.',
|
||||||
howStep4Title: 'Constituez votre sélection en toute confiance',
|
howStep4Title: 'Faites votre sélection avant les annonces',
|
||||||
howStep4Desc:
|
howStep4Desc:
|
||||||
'Chaque zone de votre liste répond à vos vrais critères — pas seulement à ce qui était en vente cette semaine-là.',
|
'Arrivez sur Rightmove, Zoopla, chez les agents et aux visites avec de meilleures zones de recherche.',
|
||||||
othersVs: 'Les autres vs',
|
othersVs: 'Les autres vs',
|
||||||
checkMyPostcode: '« Vérifier mon code postal »',
|
checkMyPostcode: 'Portails d’annonces',
|
||||||
areaGuides: 'Guides de quartier',
|
areaGuides: 'Rapports de code postal',
|
||||||
compSearchWithout: "Rechercher sans d’abord choisir une zone",
|
compSearchWithout: 'Découvrir des zones avant d’en connaître le nom',
|
||||||
compSearchWithoutSub: "(partir de ses besoins, pas d’un lieu)",
|
compSearchWithoutSub: '(besoins d’abord, lieu ensuite)',
|
||||||
compAreaData: 'Données de la zone',
|
compAreaData: 'Preuves au niveau du code postal',
|
||||||
compAreaDataSub: '(criminalité, écoles, bruit, débit internet)',
|
compAreaDataSub: '(criminalité, écoles, bruit, débit internet, services)',
|
||||||
compPropertyData: 'Données par propriété',
|
compPropertyData: 'Historique par propriété',
|
||||||
compPropertyDataSub: '(prix, DPE, surface)',
|
compPropertyDataSub: '(prix vendus, DPE, surface, valeur estimée)',
|
||||||
compFilters: '56 filtres combinables en un seul endroit',
|
compFilters: '56 filtres qui fonctionnent ensemble',
|
||||||
compFiltersSub: '(toutes les informations, une seule carte interactive)',
|
compFiltersSub: '(pas un code postal ou une annonce à la fois)',
|
||||||
ctaTitle: 'Faites de votre plus gros investissement votre meilleure décision.',
|
ctaTitle: 'Arrêtez de deviner où acheter.',
|
||||||
ctaDescription: 'Un tel enjeu mérite de vrais outils, ne laissez pas la chance décider.',
|
ctaDescription:
|
||||||
|
'Construisez une sélection de codes postaux adaptés à votre vraie vie, puis allez les tester sur place.',
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── Pricing Page ───────────────────────────────────
|
// ── Pricing Page ───────────────────────────────────
|
||||||
|
|
@ -383,8 +444,8 @@ const fr: Translations = {
|
||||||
title: 'Tarifs early access',
|
title: 'Tarifs early access',
|
||||||
subtitle: 'Payez une fois, accédez pour toujours. Plus vous rejoignez tôt, moins vous payez.',
|
subtitle: 'Payez une fois, accédez pour toujours. Plus vous rejoignez tôt, moins vous payez.',
|
||||||
costContext:
|
costContext:
|
||||||
"L’achat d’un bien coûte plus de £10 000 en droits de mutation, £1 500 en frais de notaire, £500 pour une expertise. Choisissez le mauvais quartier et vous vous retrouvez avec un long trajet, de mauvaises écoles ou une route dont vous ignoriez l’existence.",
|
'L’achat d’un bien coûte plus de £10 000 en droits de mutation, £1 500 en frais de notaire, £500 pour une expertise. Choisissez le mauvais quartier et vous vous retrouvez avec un long trajet, de mauvaises écoles ou une route dont vous ignoriez l’existence.',
|
||||||
lessThanSurvey: "Moins cher qu’une expertise immobilière. Bien plus utile.",
|
lessThanSurvey: 'Moins cher qu’une expertise immobilière. Bien plus utile.',
|
||||||
currentTier: 'Palier actuel',
|
currentTier: 'Palier actuel',
|
||||||
firstNUsers: '{{count}} premiers utilisateurs',
|
firstNUsers: '{{count}} premiers utilisateurs',
|
||||||
everyoneAfter: 'Tous les suivants',
|
everyoneAfter: 'Tous les suivants',
|
||||||
|
|
@ -401,7 +462,7 @@ const fr: Translations = {
|
||||||
soldOut: 'Épuisé',
|
soldOut: 'Épuisé',
|
||||||
upcoming: 'À venir',
|
upcoming: 'À venir',
|
||||||
failedToLoad: 'Échec du chargement des tarifs. Veuillez réessayer plus tard.',
|
failedToLoad: 'Échec du chargement des tarifs. Veuillez réessayer plus tard.',
|
||||||
feat1: "56 couches de données à travers l’Angleterre",
|
feat1: '56 couches de données à travers l’Angleterre',
|
||||||
feat2: 'Chaque code postal noté et filtrable',
|
feat2: 'Chaque code postal noté et filtrable',
|
||||||
feat3: 'Exploration de la carte et exportations illimitées',
|
feat3: 'Exploration de la carte et exportations illimitées',
|
||||||
feat4: 'Plusieurs décennies de données historiques de prix',
|
feat4: 'Plusieurs décennies de données historiques de prix',
|
||||||
|
|
@ -460,6 +521,10 @@ const fr: Translations = {
|
||||||
dsOsmOrigin: 'OpenStreetMap contributors / Geofabrik',
|
dsOsmOrigin: 'OpenStreetMap contributors / Geofabrik',
|
||||||
dsOsmUse:
|
dsOsmUse:
|
||||||
'Points d’intérêt couvrant commerces, restaurants, santé, loisirs, tourisme et plus à travers la Grande-Bretagne.',
|
'Points d’intérêt couvrant commerces, restaurants, santé, loisirs, tourisme et plus à travers la Grande-Bretagne.',
|
||||||
|
dsGeolytixRetailName: 'GEOLYTIX Grocery Retail Points',
|
||||||
|
dsGeolytixRetailOrigin: 'GEOLYTIX',
|
||||||
|
dsGeolytixRetailUse:
|
||||||
|
'Emplacements de supermarchés et magasins de proximité au Royaume-Uni, incluant des chaînes comme Waitrose, Tesco, Sainsbury’s, Asda, Morrisons, Aldi, Lidl, Co-op, M&S, Iceland et Spar.',
|
||||||
dsGreenspaceName: 'OS Open Greenspace',
|
dsGreenspaceName: 'OS Open Greenspace',
|
||||||
dsGreenspaceOrigin: 'Ordnance Survey',
|
dsGreenspaceOrigin: 'Ordnance Survey',
|
||||||
dsGreenspaceUse:
|
dsGreenspaceUse:
|
||||||
|
|
@ -623,16 +688,16 @@ const fr: Translations = {
|
||||||
|
|
||||||
// ── Invites Page ───────────────────────────────────
|
// ── Invites Page ───────────────────────────────────
|
||||||
invitesPage: {
|
invitesPage: {
|
||||||
inviteLinksLicensed: "Les liens d’invitation sont disponibles pour les utilisateurs licenciés.",
|
inviteLinksLicensed: 'Les liens d’invitation sont disponibles pour les utilisateurs licenciés.',
|
||||||
inviteAdminLabel: 'Inviter des amis (100% de réduction)',
|
inviteAdminLabel: 'Inviter des amis (100% de réduction)',
|
||||||
inviteReferralLabel: 'Inviter des amis (30% de réduction)',
|
inviteReferralLabel: 'Inviter des amis (30% de réduction)',
|
||||||
generateFreeInvite: "Générer un lien d’invitation gratuit",
|
generateFreeInvite: 'Générer un lien d’invitation gratuit',
|
||||||
generateReferralLink: 'Générer un lien de parrainage',
|
generateReferralLink: 'Générer un lien de parrainage',
|
||||||
copyInviteLink: "Copier le lien d’invitation",
|
copyInviteLink: 'Copier le lien d’invitation',
|
||||||
adminInvitesTitle: 'Invitations admin (100% de réduction)',
|
adminInvitesTitle: 'Invitations admin (100% de réduction)',
|
||||||
referralInvitesTitle: 'Invitations de parrainage (30% de réduction)',
|
referralInvitesTitle: 'Invitations de parrainage (30% de réduction)',
|
||||||
yourInviteLinks: "Vos liens d’invitation",
|
yourInviteLinks: 'Vos liens d’invitation',
|
||||||
noInvitesYet: "Aucune invitation générée pour l’instant",
|
noInvitesYet: 'Aucune invitation générée pour l’instant',
|
||||||
link: 'Lien',
|
link: 'Lien',
|
||||||
status: 'Statut',
|
status: 'Statut',
|
||||||
created: 'Créé',
|
created: 'Créé',
|
||||||
|
|
@ -645,26 +710,26 @@ const fr: Translations = {
|
||||||
youreInvited: 'Vous êtes invité !',
|
youreInvited: 'Vous êtes invité !',
|
||||||
specialOffer: 'Offre spéciale !',
|
specialOffer: 'Offre spéciale !',
|
||||||
invitedByFree: '{{name}} vous invite à obtenir un accès à vie gratuit.',
|
invitedByFree: '{{name}} vous invite à obtenir un accès à vie gratuit.',
|
||||||
invitedByDiscount: "{{name}} vous fait bénéficier d’une réduction de 30% sur l’accès à vie.",
|
invitedByDiscount: '{{name}} vous fait bénéficier d’une réduction de 30% sur l’accès à vie.',
|
||||||
genericFreeInvite: 'Vous avez été invité à obtenir un accès à vie gratuit.',
|
genericFreeInvite: 'Vous avez été invité à obtenir un accès à vie gratuit.',
|
||||||
genericDiscount: "Un ami vous fait bénéficier d’une réduction de 30% sur l’accès à vie.",
|
genericDiscount: 'Un ami vous fait bénéficier d’une réduction de 30% sur l’accès à vie.',
|
||||||
exploreEvery: "Explorez chaque quartier d’Angleterre",
|
exploreEvery: 'Explorez chaque quartier d’Angleterre',
|
||||||
propertyInfo:
|
propertyInfo:
|
||||||
'Prix immobiliers, classements énergétiques, statistiques de criminalité, notes des écoles et plus encore',
|
'Prix immobiliers, classements énergétiques, statistiques de criminalité, notes des écoles et plus encore',
|
||||||
invalidInvite: 'Invitation invalide',
|
invalidInvite: 'Invitation invalide',
|
||||||
inviteAlreadyUsed: 'Invitation déjà utilisée',
|
inviteAlreadyUsed: 'Invitation déjà utilisée',
|
||||||
inviteAlreadyUsedDesc: "Ce lien d’invitation a déjà été utilisé.",
|
inviteAlreadyUsedDesc: 'Ce lien d’invitation a déjà été utilisé.',
|
||||||
invalidInviteLink: "Lien d’invitation invalide",
|
invalidInviteLink: 'Lien d’invitation invalide',
|
||||||
invalidInviteLinkDesc: "Ce lien d’invitation est invalide ou a expiré.",
|
invalidInviteLinkDesc: 'Ce lien d’invitation est invalide ou a expiré.',
|
||||||
licenseActivated: 'Licence activée !',
|
licenseActivated: 'Licence activée !',
|
||||||
fullAccessGranted: 'Vous avez désormais un accès complet à Perfect Postcode.',
|
fullAccessGranted: 'Vous avez désormais un accès complet à Perfect Postcode.',
|
||||||
activating: 'Activation...',
|
activating: 'Activation...',
|
||||||
activateLicense: 'Activer la licence',
|
activateLicense: 'Activer la licence',
|
||||||
claimDiscount: 'Réclamer la réduction',
|
claimDiscount: 'Réclamer la réduction',
|
||||||
registerToClaim: "S’inscrire pour réclamer",
|
registerToClaim: 'S’inscrire pour réclamer',
|
||||||
youAlreadyHaveLicense: 'Vous avez déjà une licence',
|
youAlreadyHaveLicense: 'Vous avez déjà une licence',
|
||||||
accountHasFullAccess: 'Votre compte dispose déjà d’un accès complet.',
|
accountHasFullAccess: 'Votre compte dispose déjà d’un accès complet.',
|
||||||
failedToValidate: "Échec de la validation du lien d’invitation",
|
failedToValidate: 'Échec de la validation du lien d’invitation',
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── Map Page ───────────────────────────────────────
|
// ── Map Page ───────────────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -264,8 +264,7 @@ const hu: Translations = {
|
||||||
noFilteredMatches: 'Ezen a területen egyetlen ingatlan sem felel meg a szűrőknek.',
|
noFilteredMatches: 'Ezen a területen egyetlen ingatlan sem felel meg a szűrőknek.',
|
||||||
unfilteredAreaCount:
|
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.',
|
'{{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:
|
noUnfilteredAreaProperties: 'A kiválasztott területen szűrők nélkül sem található ingatlan.',
|
||||||
'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.',
|
relaxFiltersHint: 'Lazítson vagy törölje a szűrőket, hogy lássa a terület ingatlanjait.',
|
||||||
viewProperties: '{{count}} ingatlan megtekintése',
|
viewProperties: '{{count}} ingatlan megtekintése',
|
||||||
priceHistory: 'Ártörténet',
|
priceHistory: 'Ártörténet',
|
||||||
|
|
@ -291,6 +290,8 @@ const hu: Translations = {
|
||||||
// ── Street View ────────────────────────────────────
|
// ── Street View ────────────────────────────────────
|
||||||
streetView: {
|
streetView: {
|
||||||
title: 'Utcakép',
|
title: 'Utcakép',
|
||||||
|
openLarge: 'Utcakép megnyitása nagyobb méretben',
|
||||||
|
expandedTitle: 'Nagyított utcakép',
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── POI Pane ───────────────────────────────────────
|
// ── POI Pane ───────────────────────────────────────
|
||||||
|
|
@ -328,47 +329,105 @@ const hu: Translations = {
|
||||||
|
|
||||||
// ── Home Page ──────────────────────────────────────
|
// ── Home Page ──────────────────────────────────────
|
||||||
home: {
|
home: {
|
||||||
heroTitle1: 'Maximális',
|
heroEyebrow: 'Vevőknek, akik azt kérdezik: „hol is kezdjem?”',
|
||||||
heroTitle2: 'Érték',
|
heroTitle1: 'Találd meg az irányítószámokat',
|
||||||
heroTitle3: 'Minimális kompromisszum.',
|
heroTitle2: 'amelyek illenek az életedhez',
|
||||||
heroSubtitle: 'Ingatlant keresel? Legyen a legnagyobb befektetésed a legokosabb döntésed.',
|
heroTitle3: 'Nem csak azokat a környékeket, amelyeket már ismersz.',
|
||||||
|
heroSubtitle:
|
||||||
|
'A londoni városrészeken, ingázó településeken és regionális városokon át Angliában túl sok hely van ahhoz, hogy egyenként kutasd át őket.',
|
||||||
heroDescription:
|
heroDescription:
|
||||||
'Annyi lehetőség – a megfelelő kiválasztása nehéz lehet. Interaktív térképünk egyszerűvé teszi: válaszd ki a feltételeidet, és azonnal lásd a megfelelő területeket.',
|
'Állítsd be a költségvetést, ingázást, iskolákat, biztonságot, zajt, internetet és életstílust. A Perfect Postcode átnézi Anglia irányítószámait, és megmutatja azokat a helyeket is, amelyeket sosem írtál volna be egy ingatlanportálra.',
|
||||||
exploreTheMap: 'Térkép felfedezése',
|
exploreTheMap: 'Megfelelő irányítószámok keresése',
|
||||||
seeTheDifference: 'Nézd meg a különbséget',
|
seeTheDifference: 'Így működik',
|
||||||
statProperties: 'ingatlan',
|
showcaseHeader: 'Termékbemutató',
|
||||||
statFilters: 'szűrő',
|
showcaseContext: 'Vevői keresés egész Angliában',
|
||||||
|
showcaseStep1Tab: 'Leírás',
|
||||||
|
showcaseStep1Title: 'Írd le, milyen életet szeretnél',
|
||||||
|
showcaseStep1Body:
|
||||||
|
'Természetes nyelvvel vagy szűrőkkel alakítsd a bonyolult vevői igényeket egy kereséssé.',
|
||||||
|
showcaseStep1Prompt: '2 háló £525k alatt, 45 perc munkáig, csendes utcák, jó iskolák',
|
||||||
|
showcaseStep1Chip1: '<= £525k',
|
||||||
|
showcaseStep1Chip2: '2+ háló',
|
||||||
|
showcaseStep1Chip3: '45 perc ingázás',
|
||||||
|
showcaseStep1Chip4: 'Alacsony útzaj',
|
||||||
|
showcaseStep2Tab: 'Felfedezés',
|
||||||
|
showcaseStep2Title: 'Mutasd meg azokat a helyeket, amelyekre nem gondoltál',
|
||||||
|
showcaseStep2Body:
|
||||||
|
'A térkép kiemeli a megfelelő irányítószámokat, a megszokott listádon kívül is.',
|
||||||
|
showcaseStep2Metric: '47 megfelelő irányítószám',
|
||||||
|
showcaseStep2Note: 'a kézenfekvő listán túl',
|
||||||
|
showcaseKnownAreas: 'Ismert területek',
|
||||||
|
showcaseNewMatches: 'Új találatok',
|
||||||
|
showcaseKnownAreaStatus: 'kevés találat',
|
||||||
|
showcaseStep3Tab: 'Ellenőrzés',
|
||||||
|
showcaseStep3Title: 'Értsd meg, miért illik egy irányítószám',
|
||||||
|
showcaseStep3Body:
|
||||||
|
'Nyiss meg egy találatot és ellenőrizd az adatokat, mielőtt egy hétvégét nézelődésre szánsz.',
|
||||||
|
showcaseStep3Postcode: 'Irányítószám példa',
|
||||||
|
showcaseStep3Area: 'Penge',
|
||||||
|
showcaseStep3Code: 'SE20',
|
||||||
|
showcaseStep3Score: 'Erős egyezés',
|
||||||
|
showcaseEvidence1: '42 perc ingázás',
|
||||||
|
showcaseEvidence2: 'Alacsonyabb útzaj',
|
||||||
|
showcaseEvidence3: 'Jó általános iskolák',
|
||||||
|
showcaseEvidence4: 'Eladási árak a kereten belül',
|
||||||
|
showcaseStep4Tab: 'Összevetés',
|
||||||
|
showcaseStep4Title: 'Hasonlítsd össze a kompromisszumokat megtekintés előtt',
|
||||||
|
showcaseStep4Body:
|
||||||
|
'A nyereségek és veszteségek alapján szűkíts, ne csak a környék híre alapján.',
|
||||||
|
showcaseCompare1: 'Penge: londoni vasút, több tér',
|
||||||
|
showcaseCompare2: 'Totterdown: gyalogos Bristol-utcák',
|
||||||
|
showcaseCompare3: 'Walkley: nagyobb otthonok, jó érték',
|
||||||
|
showcaseMapLabel: 'Megfelelő irányítószámok',
|
||||||
|
showcaseSaveLabel: 'Lista kész',
|
||||||
|
showcaseMatchPenge: 'London a kereten belül',
|
||||||
|
showcaseMatchAbbeyWood: 'Elizabeth line + zöldterület',
|
||||||
|
showcaseMatchTotterdown: 'Bristol gyalogosan élhető',
|
||||||
|
showcaseMatchWalkley: 'Sheffield: tér + iskolák',
|
||||||
|
statProperties: 'korábbi eladás',
|
||||||
|
statFilters: 'kombinálható szűrő',
|
||||||
statEvery: 'Minden',
|
statEvery: 'Minden',
|
||||||
statPostcodeInEngland: 'irányítószám Angliában',
|
statPostcodeInEngland: 'irányítószám Angliában',
|
||||||
ourPhilosophy: 'Filozófiánk',
|
ourPhilosophy: 'Az életedből indulj ki, ne egy irányítószámból',
|
||||||
philosophyP1:
|
philosophyP1:
|
||||||
'A Rightmove-on először területet választasz, és reméled, hogy jó. Végül bűnözési statisztikákat, iskolai jelentéseket és szélessáv-ellenőrzőket böngészel tucat füleken, egyszerre egy irányítószámmal.',
|
'A legtöbb ingatlanoldal először azt kérdezi, hol szeretnél élni. Londonban ez különösen nehéz, de ugyanez a probléma egész Angliában megjelenik: a vevők néhány ismert helyből indulnak ki, majd külön füleken ellenőrzik az ingázást, iskolákat, bűnözést, Street View-t, internetet és eladási árakat.',
|
||||||
philosophyP2:
|
philosophyP2:
|
||||||
'Mi megfordítjuk. Mondd el, mire van szükséged (költségvetés, ingazás, iskolák, biztonság), és megmutatjuk Anglia összes megfelelő területét. Nincs találgatás. Nincs felesleges megtekintés.',
|
'A Perfect Postcode megfordítja a keresést. Mondd meg a térképnek, mi számít, és megmutatja a megfelelő irányítószámokat, indoklással együtt. Előbb az adatok, aztán a helyszíni benyomás.',
|
||||||
|
streetTitle: 'A helyek utcáról utcára változnak',
|
||||||
|
streetIntro:
|
||||||
|
'A nagy környéknevek elrejtik a fontos részleteket: az állomás melyik oldalát, az útzajt, az iskolákat, a pontos ingázást és a valódi eladási árakat.',
|
||||||
|
streetCard1Title: 'Találd meg a kihagyott környékeket',
|
||||||
|
streetCard1Body:
|
||||||
|
'Hozd felszínre azokat az irányítószámokat, amelyek megfelelnek a feltételeidnek, ne csak ismert nevekre vagy ajánlásokra hagyatkozz.',
|
||||||
|
streetCard2Title: 'Lásd a kompromisszumokat megtekintés előtt',
|
||||||
|
streetCard2Body:
|
||||||
|
'Hasonlítsd össze az árat, méretet, ingázást, biztonságot, iskolákat, internetet, zajt és energiahatékonyságot, mielőtt hétvégéket töltesz megtekintésekkel.',
|
||||||
howToUseIt: 'Hogyan használd',
|
howToUseIt: 'Hogyan használd',
|
||||||
howStep1Title: 'Állítsd be a feltételeidet',
|
howStep1Title: 'Írd le, milyen életre van szükséged',
|
||||||
howStep1Desc: 'Költségvetés, ingazás, iskolák — a térkép csak a megfelelőket mutatja.',
|
howStep1Desc:
|
||||||
howStep2Title: 'Fedezz fel területeket és rejtett kincseket',
|
'Költségvetés, ingázás, ingatlantípus, iskolák, biztonság, tér és napi szükségletek.',
|
||||||
howStep2Desc: 'Nagyíts rá, mélyedj el a részletekben és a pluszokban.',
|
howStep2Title: 'Fedd fel a megfelelő irányítószámokat',
|
||||||
howStep3Title: 'Vizsgáld meg az irányítószámokat',
|
howStep2Desc: 'A térkép kiemeli azokat a helyeket, amelyek átmennek a szűrőiden.',
|
||||||
|
howStep3Title: 'Ellenőrizd a bizonyítékokat',
|
||||||
howStep3Desc:
|
howStep3Desc:
|
||||||
'Nézd meg az egyes ingatlanokat, eladási árakat, alapterületet, és hasonlítsd össze.',
|
'Nézd meg az eladási árakat, alapterületet, EPC-t, zajt, internetet, bűnözést és iskolákat.',
|
||||||
howStep4Title: 'Válassz magabiztosan',
|
howStep4Title: 'Szűkíts listát hirdetések előtt',
|
||||||
howStep4Desc:
|
howStep4Desc:
|
||||||
'A listádon minden terület megfelel a valós feltételeidnek — nem csak annak, amit azon a héten hirdettek.',
|
'Menj Rightmove-ra, Zooplára, ügynökökhöz és megtekintésekre jobb keresési területekkel.',
|
||||||
othersVs: 'Mások vs.',
|
othersVs: 'Mások vs.',
|
||||||
checkMyPostcode: '“Irányítószám ellenőrzése”',
|
checkMyPostcode: 'Ingatlanportálok',
|
||||||
areaGuides: 'Területi útmutatók',
|
areaGuides: 'Irányítószám-riportok',
|
||||||
compSearchWithout: 'Keresés terület előzetes kiválasztása nélkül',
|
compSearchWithout: 'Területek felfedezése a nevük ismerete előtt',
|
||||||
compSearchWithoutSub: '(igényekből indulj, nem helyszínből)',
|
compSearchWithoutSub: '(előbb igények, aztán helyszín)',
|
||||||
compAreaData: 'Területi adatok',
|
compAreaData: 'Irányítószám-szintű környékadatok',
|
||||||
compAreaDataSub: '(bűnözés, iskolák, zaj, szélessáv)',
|
compAreaDataSub: '(bűnözés, iskolák, zaj, internet, szolgáltatások)',
|
||||||
compPropertyData: 'Ingatlanspecifikus adatok',
|
compPropertyData: 'Ingatlanszintű előzmények',
|
||||||
compPropertyDataSub: '(ár, EPC, alapterület)',
|
compPropertyDataSub: '(eladási árak, EPC, alapterület, becsült érték)',
|
||||||
compFilters: '56 kombinálható szűrő egy helyen',
|
compFilters: '56 együtt működő szűrő',
|
||||||
compFiltersSub: '(minden információ, egy interaktív térkép)',
|
compFiltersSub: '(nem egy irányítószám vagy hirdetés egyszerre)',
|
||||||
ctaTitle: 'Legyen a legnagyobb befektetésed a legokosabb döntésed.',
|
ctaTitle: 'Ne találgasd, hol vegyél.',
|
||||||
ctaDescription: 'Ez megfelelő eszközöket érdemel, ne bízd a szerencsére.',
|
ctaDescription:
|
||||||
|
'Készíts listát olyan irányítószámokból, amelyek illenek a valós életedhez, majd nézd meg őket személyesen.',
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── Pricing Page ───────────────────────────────────
|
// ── Pricing Page ───────────────────────────────────
|
||||||
|
|
@ -454,6 +513,10 @@ const hu: Translations = {
|
||||||
dsOsmOrigin: 'OpenStreetMap contributors / Geofabrik',
|
dsOsmOrigin: 'OpenStreetMap contributors / Geofabrik',
|
||||||
dsOsmUse:
|
dsOsmUse:
|
||||||
'Érdekes pontok, beleértve üzleteket, éttermeket, egészségügyet, szabadidőt, turizmust és még sok mást Nagy-Britanniában.',
|
'Érdekes pontok, beleértve üzleteket, éttermeket, egészségügyet, szabadidőt, turizmust és még sok mást Nagy-Britanniában.',
|
||||||
|
dsGeolytixRetailName: 'GEOLYTIX Grocery Retail Points',
|
||||||
|
dsGeolytixRetailOrigin: 'GEOLYTIX',
|
||||||
|
dsGeolytixRetailUse:
|
||||||
|
'Szupermarketek és kisboltok helyei az Egyesült Királyságban, többek között Waitrose, Tesco, Sainsbury’s, Asda, Morrisons, Aldi, Lidl, Co-op, M&S, Iceland és Spar láncokkal.',
|
||||||
dsGreenspaceName: 'OS Open Greenspace',
|
dsGreenspaceName: 'OS Open Greenspace',
|
||||||
dsGreenspaceOrigin: 'Ordnance Survey',
|
dsGreenspaceOrigin: 'Ordnance Survey',
|
||||||
dsGreenspaceUse:
|
dsGreenspaceUse:
|
||||||
|
|
|
||||||
|
|
@ -286,6 +286,8 @@ const zh: Translations = {
|
||||||
// ── Street View ────────────────────────────────────
|
// ── Street View ────────────────────────────────────
|
||||||
streetView: {
|
streetView: {
|
||||||
title: '街景视图',
|
title: '街景视图',
|
||||||
|
openLarge: '放大打开街景视图',
|
||||||
|
expandedTitle: '放大的街景视图',
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── POI Pane ───────────────────────────────────────
|
// ── POI Pane ───────────────────────────────────────
|
||||||
|
|
@ -323,45 +325,96 @@ const zh: Translations = {
|
||||||
|
|
||||||
// ── Home Page ──────────────────────────────────────
|
// ── Home Page ──────────────────────────────────────
|
||||||
home: {
|
home: {
|
||||||
heroTitle1: '最大',
|
heroEyebrow: '适合正在问“我到底该看哪里?”的买家',
|
||||||
heroTitle2: '价值',
|
heroTitle1: '找到真正',
|
||||||
heroTitle3: '最小妥协。',
|
heroTitle2: '适合您生活的邮编',
|
||||||
heroSubtitle: '正在找房?让您最大的投资成为最明智的决定。',
|
heroTitle3: '不只局限于您已经知道的区域。',
|
||||||
|
heroSubtitle: '从伦敦街区到通勤城镇和英格兰各地城市,可研究的地方太多,无法一个个筛查。',
|
||||||
heroDescription:
|
heroDescription:
|
||||||
'选择太多,找到合适的可能让人不知所措。我们的交互式地图让一切变得简单:选择您的必要条件,立即看到符合的区域。',
|
'设定预算、通勤、学校、安全、噪音、宽带和生活方式需求。Perfect Postcode 会扫描英格兰的邮编,显示真正匹配的地方,包括您从未想过要在房源网站上搜索的区域。',
|
||||||
exploreTheMap: '探索地图',
|
exploreTheMap: '找到匹配的邮编',
|
||||||
seeTheDifference: '看看有何不同',
|
seeTheDifference: '查看使用方式',
|
||||||
statProperties: '处房产',
|
showcaseHeader: '产品展示',
|
||||||
statFilters: '项筛选条件',
|
showcaseContext: '英格兰买家搜索示例',
|
||||||
|
showcaseStep1Tab: '描述',
|
||||||
|
showcaseStep1Title: '描述您想要的生活',
|
||||||
|
showcaseStep1Body: '用自然语言或筛选条件,把复杂的买房需求变成一次搜索。',
|
||||||
|
showcaseStep1Prompt: '2房,£525k以内,45分钟到工作地点,安静街道,好学校',
|
||||||
|
showcaseStep1Chip1: '<= £525k',
|
||||||
|
showcaseStep1Chip2: '2+卧室',
|
||||||
|
showcaseStep1Chip3: '45分钟通勤',
|
||||||
|
showcaseStep1Chip4: '低道路噪音',
|
||||||
|
showcaseStep2Tab: '发现',
|
||||||
|
showcaseStep2Title: '发现您没有考虑过的地方',
|
||||||
|
showcaseStep2Body: '地图会点亮匹配的邮编,包括您原本候选范围之外的区域。',
|
||||||
|
showcaseStep2Metric: '47个匹配邮编',
|
||||||
|
showcaseStep2Note: '超出显而易见的候选范围',
|
||||||
|
showcaseKnownAreas: '熟悉区域',
|
||||||
|
showcaseNewMatches: '新匹配',
|
||||||
|
showcaseKnownAreaStatus: '匹配较少',
|
||||||
|
showcaseStep3Tab: '检查',
|
||||||
|
showcaseStep3Title: '了解每个邮编为什么匹配',
|
||||||
|
showcaseStep3Body: '打开结果,在周末看房前先检查证据。',
|
||||||
|
showcaseStep3Postcode: '邮编示例',
|
||||||
|
showcaseStep3Area: 'Penge',
|
||||||
|
showcaseStep3Code: 'SE20',
|
||||||
|
showcaseStep3Score: '高度匹配',
|
||||||
|
showcaseEvidence1: '42分钟通勤',
|
||||||
|
showcaseEvidence2: '较低道路噪音',
|
||||||
|
showcaseEvidence3: '不错的小学选择',
|
||||||
|
showcaseEvidence4: '成交价符合预算',
|
||||||
|
showcaseStep4Tab: '比较',
|
||||||
|
showcaseStep4Title: '看房前比较取舍',
|
||||||
|
showcaseStep4Body: '根据得到什么和放弃什么来筛选,而不是只看区域名声。',
|
||||||
|
showcaseCompare1: 'Penge:伦敦铁路连接,空间更大',
|
||||||
|
showcaseCompare2: 'Totterdown:布里斯托可步行街区',
|
||||||
|
showcaseCompare3: 'Walkley:更大住房,更高性价比',
|
||||||
|
showcaseMapLabel: '匹配邮编',
|
||||||
|
showcaseSaveLabel: '候选名单已准备好',
|
||||||
|
showcaseMatchPenge: '伦敦预算匹配',
|
||||||
|
showcaseMatchAbbeyWood: 'Elizabeth line + 绿地',
|
||||||
|
showcaseMatchTotterdown: '布里斯托步行便利',
|
||||||
|
showcaseMatchWalkley: '谢菲尔德空间 + 学校',
|
||||||
|
statProperties: '历史成交记录',
|
||||||
|
statFilters: '可组合筛选条件',
|
||||||
statEvery: '覆盖',
|
statEvery: '覆盖',
|
||||||
statPostcodeInEngland: '英格兰每个邮编',
|
statPostcodeInEngland: '英格兰每个邮编',
|
||||||
ourPhilosophy: '我们的理念',
|
ourPhilosophy: '从生活需求出发,而不是从邮编出发',
|
||||||
philosophyP1:
|
philosophyP1:
|
||||||
'在 Rightmove 上,您需要先选一个区域,然后期望它足够好。最终您不得不在十几个标签页中交叉对比犯罪数据、学校报告和宽带速度,一个邮编一个邮编地查。',
|
'大多数房产网站先问您想住哪里。在伦敦这个问题尤其困难,但英格兰各地都有同样的问题:买家通常只能从几个熟悉的地方开始,然后分别查询通勤、学校、犯罪率、街景、宽带和成交价。',
|
||||||
philosophyP2:
|
philosophyP2:
|
||||||
'我们反其道而行。告诉我们您的需求(预算、通勤、学校、安全),我们为您展示英格兰所有符合条件的区域。不用猜测,不浪费看房时间。',
|
'Perfect Postcode 反过来做搜索。告诉地图什么重要,它会显示符合条件的邮编,并解释为什么值得查看。先看数据,再去现场感受。',
|
||||||
|
streetTitle: '每条街都可能不同',
|
||||||
|
streetIntro:
|
||||||
|
'大的区域名称会掩盖关键细节:车站哪一侧、道路噪音、学校组合、真实通勤时间,以及类似房产的实际成交价。',
|
||||||
|
streetCard1Title: '发现您可能错过的区域',
|
||||||
|
streetCard1Body:
|
||||||
|
'根据您的条件找出匹配的邮编,而不是只依赖熟悉的地名、朋友推荐或“潜力区域”的宣传。',
|
||||||
|
streetCard2Title: '看房前先看清取舍',
|
||||||
|
streetCard2Body:
|
||||||
|
'在把周末花在看房之前,先比较价格、空间、通勤、安全、学校、宽带、噪音和能源评级。',
|
||||||
howToUseIt: '使用方法',
|
howToUseIt: '使用方法',
|
||||||
howStep1Title: '设定必要条件',
|
howStep1Title: '描述您需要的生活',
|
||||||
howStep1Desc: '预算、通勤、学校——地图只显示符合条件的区域。',
|
howStep1Desc: '预算、通勤、房产类型、学校、安全、空间和日常生活设施。',
|
||||||
howStep2Title: '探索区域,发现隐藏的好地方',
|
howStep2Title: '显示匹配的邮编',
|
||||||
howStep2Desc: '放大查看,深入了解细节和加分项。',
|
howStep2Desc: '地图会高亮通过筛选的地方,包括不熟悉的区域。',
|
||||||
howStep3Title: '深入邮编级别',
|
howStep3Title: '查看证据',
|
||||||
howStep3Desc: '查看单个房产、成交价、建筑面积,并进行比较。',
|
howStep3Desc: '查看成交价、建筑面积、EPC、道路噪音、宽带、犯罪率和学校。',
|
||||||
howStep4Title: '自信地列出候选名单',
|
howStep4Title: '先筛区域,再看房源',
|
||||||
howStep4Desc: '您名单上的每个区域都满足您的实际需求——而不只是当周恰好有房源。',
|
howStep4Desc: '带着更好的搜索区域去 Rightmove、Zoopla、中介和看房。',
|
||||||
othersVs: '其他平台 vs',
|
othersVs: '其他平台 vs',
|
||||||
checkMyPostcode: '"查查我的邮编"类网站',
|
checkMyPostcode: '房源门户',
|
||||||
areaGuides: '区域指南',
|
areaGuides: '邮编报告',
|
||||||
compSearchWithout: '无需先选区域即可搜索',
|
compSearchWithout: '在知道名称前先发现区域',
|
||||||
compSearchWithoutSub: '(从需求出发,而非地点)',
|
compSearchWithoutSub: '(先需求,后地点)',
|
||||||
compAreaData: '区域数据',
|
compAreaData: '邮编级社区证据',
|
||||||
compAreaDataSub: '(犯罪率、学校、噪音、宽带)',
|
compAreaDataSub: '(犯罪率、学校、噪音、宽带、设施)',
|
||||||
compPropertyData: '房产专属数据',
|
compPropertyData: '房产级历史记录',
|
||||||
compPropertyDataSub: '(价格、能源性能证书、建筑面积)',
|
compPropertyDataSub: '(成交价、EPC、面积、估值)',
|
||||||
compFilters: '56 项可组合筛选条件,尽在一处',
|
compFilters: '56 项联动筛选',
|
||||||
compFiltersSub: '(所有信息,一张交互式地图)',
|
compFiltersSub: '(不是一次查一个邮编或一个房源)',
|
||||||
ctaTitle: '让您最大的投资成为最明智的 决定。',
|
ctaTitle: '别再猜哪里值得买。',
|
||||||
ctaDescription: '这值得用专业的工具来做,别全靠运气。',
|
ctaDescription: '先建立符合真实生活需求的邮编候选名单,再去实地感受。',
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── Pricing Page ───────────────────────────────────
|
// ── Pricing Page ───────────────────────────────────
|
||||||
|
|
@ -441,6 +494,10 @@ const zh: Translations = {
|
||||||
dsOsmName: 'OpenStreetMap POIs',
|
dsOsmName: 'OpenStreetMap POIs',
|
||||||
dsOsmOrigin: 'OpenStreetMap contributors / Geofabrik',
|
dsOsmOrigin: 'OpenStreetMap contributors / Geofabrik',
|
||||||
dsOsmUse: '涵盖大不列颠地区的商店、餐厅、医疗、休闲、旅游等兴趣点。',
|
dsOsmUse: '涵盖大不列颠地区的商店、餐厅、医疗、休闲、旅游等兴趣点。',
|
||||||
|
dsGeolytixRetailName: 'GEOLYTIX Grocery Retail Points',
|
||||||
|
dsGeolytixRetailOrigin: 'GEOLYTIX',
|
||||||
|
dsGeolytixRetailUse:
|
||||||
|
'英国超市和便利店位置数据,包括 Waitrose、Tesco、Sainsbury’s、Asda、Morrisons、Aldi、Lidl、Co-op、M&S、Iceland 和 Spar 等连锁品牌。',
|
||||||
dsGreenspaceName: 'OS Open Greenspace',
|
dsGreenspaceName: 'OS Open Greenspace',
|
||||||
dsGreenspaceOrigin: 'Ordnance Survey',
|
dsGreenspaceOrigin: 'Ordnance Survey',
|
||||||
dsGreenspaceUse:
|
dsGreenspaceUse:
|
||||||
|
|
|
||||||
|
|
@ -79,6 +79,28 @@ h3 {
|
||||||
transform: translateY(0);
|
transform: translateY(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes showcase-progress {
|
||||||
|
from {
|
||||||
|
transform: scaleX(0);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: scaleX(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.showcase-progress {
|
||||||
|
animation-name: showcase-progress;
|
||||||
|
animation-timing-function: linear;
|
||||||
|
animation-fill-mode: forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.showcase-progress {
|
||||||
|
animation: none !important;
|
||||||
|
transform: scaleX(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* Cereal aside — hover to reveal */
|
/* Cereal aside — hover to reveal */
|
||||||
@keyframes cereal-wobble {
|
@keyframes cereal-wobble {
|
||||||
0%,
|
0%,
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,8 @@
|
||||||
<meta name="theme-color" content="#fafaf9" media="(prefers-color-scheme: light)" />
|
<meta name="theme-color" content="#fafaf9" media="(prefers-color-scheme: light)" />
|
||||||
<meta name="theme-color" content="#0a0e1a" media="(prefers-color-scheme: dark)" />
|
<meta name="theme-color" content="#0a0e1a" media="(prefers-color-scheme: dark)" />
|
||||||
<meta name="referrer" content="no-referrer" />
|
<meta name="referrer" content="no-referrer" />
|
||||||
<title>Perfect Postcode - Every neighbourhood in England</title>
|
<title>Perfect Postcode - Find where to buy before browsing listings</title>
|
||||||
<meta name="description" content="Explore property prices, energy ratings, crime stats, school ratings, and more across England on one interactive map." />
|
<meta name="description" content="Search every postcode by budget, commute, schools, safety, noise, broadband, prices and more. Build a better home-buying shortlist before viewings." />
|
||||||
<meta name="x-og-placeholder" content="__PERFECT_POSTCODE_OG_TAGS__" />
|
<meta name="x-og-placeholder" content="__PERFECT_POSTCODE_OG_TAGS__" />
|
||||||
<script>
|
<script>
|
||||||
(function() {
|
(function() {
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import type { FeatureMeta } from '../types';
|
import type { FeatureMeta } from '../types';
|
||||||
import { apiUrl, assertOk, buildFilterString, isAbortError } from './api';
|
import { apiUrl, assertOk, buildFilterString, isAbortError } from './api';
|
||||||
|
import { createSchoolFilterKey } from './school-filter';
|
||||||
|
|
||||||
describe('api utilities', () => {
|
describe('api utilities', () => {
|
||||||
it('builds API URLs from endpoint names, paths, and params', () => {
|
it('builds API URLs from endpoint names, paths, and params', () => {
|
||||||
|
|
@ -64,4 +65,20 @@ describe('api utilities', () => {
|
||||||
)
|
)
|
||||||
).toBe('Property type:Flat');
|
).toBe('Property type:Flat');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('deduplicates repeated synthetic school filters before backend routes', () => {
|
||||||
|
const features: FeatureMeta[] = [
|
||||||
|
{ name: 'Good+ primary schools within 2km', type: 'numeric', min: 0, max: 10 },
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(
|
||||||
|
buildFilterString(
|
||||||
|
{
|
||||||
|
[createSchoolFilterKey('primary', 'good', 2, 1)]: [1, 10],
|
||||||
|
[createSchoolFilterKey('primary', 'good', 2, 2)]: [2, 8],
|
||||||
|
},
|
||||||
|
features
|
||||||
|
)
|
||||||
|
).toBe('Good+ primary schools within 2km:2:8');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import type { FeatureMeta, FeatureFilters } from '../types';
|
import type { FeatureMeta, FeatureFilters } from '../types';
|
||||||
import { INITIAL_RETRY_MS, MAX_RETRY_MS } from './consts';
|
import { INITIAL_RETRY_MS, MAX_RETRY_MS } from './consts';
|
||||||
import pb from './pocketbase';
|
import pb from './pocketbase';
|
||||||
|
import { getSchoolBackendFeatureName } from './school-filter';
|
||||||
|
|
||||||
export function logNonAbortError(label: string, error: unknown): void {
|
export function logNonAbortError(label: string, error: unknown): void {
|
||||||
if (error instanceof Error && error.name === 'AbortError') {
|
if (error instanceof Error && error.name === 'AbortError') {
|
||||||
|
|
@ -82,8 +83,29 @@ export function buildFilterString(
|
||||||
): string {
|
): string {
|
||||||
const entries = Object.entries(filters);
|
const entries = Object.entries(filters);
|
||||||
if (entries.length === 0) return '';
|
if (entries.length === 0) return '';
|
||||||
return entries
|
|
||||||
.filter(([name]) => name !== exclude)
|
const merged = new Map<string, [number, number] | string[]>();
|
||||||
|
for (const [name, value] of entries) {
|
||||||
|
if (name === exclude) continue;
|
||||||
|
const backendName = getSchoolBackendFeatureName(name) ?? name;
|
||||||
|
const prev = merged.get(backendName);
|
||||||
|
if (
|
||||||
|
prev &&
|
||||||
|
Array.isArray(prev) &&
|
||||||
|
Array.isArray(value) &&
|
||||||
|
typeof prev[0] === 'number' &&
|
||||||
|
typeof value[0] === 'number'
|
||||||
|
) {
|
||||||
|
merged.set(backendName, [
|
||||||
|
Math.max(prev[0] as number, value[0] as number),
|
||||||
|
Math.min(prev[1] as number, value[1] as number),
|
||||||
|
]);
|
||||||
|
} else if (!prev) {
|
||||||
|
merged.set(backendName, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...merged.entries()]
|
||||||
.map(([name, value]) => {
|
.map(([name, value]) => {
|
||||||
const meta = features.find((f) => f.name === name);
|
const meta = features.find((f) => f.name === name);
|
||||||
if (meta?.type === 'enum') {
|
if (meta?.type === 'enum') {
|
||||||
|
|
|
||||||
|
|
@ -126,6 +126,47 @@ export const POI_GROUP_COLORS: Record<string, [number, number, number]> = {
|
||||||
/** Default color for unknown POI groups */
|
/** Default color for unknown POI groups */
|
||||||
export const POI_DEFAULT_COLOR: [number, number, number] = [107, 114, 128];
|
export const POI_DEFAULT_COLOR: [number, number, number] = [107, 114, 128];
|
||||||
|
|
||||||
|
/** POI category → icon/logo URL for branded and transport categories */
|
||||||
|
export const POI_CATEGORY_LOGOS: Record<string, string> = {
|
||||||
|
Airport: '/assets/twemoji/2708.png',
|
||||||
|
Aldi: 'https://geolytix.github.io/MapIcons/brands/aldi_24px.svg',
|
||||||
|
Amazon: 'https://geolytix.github.io/MapIcons/brands/amazon_fresh_alt_24px.svg',
|
||||||
|
Asda: 'https://geolytix.github.io/MapIcons/asda/asda_primary.svg',
|
||||||
|
Bakery: '/assets/twemoji/1f950.png',
|
||||||
|
Booths: 'https://geolytix.github.io/MapIcons/brands/booths_24px.svg',
|
||||||
|
Budgens: 'https://geolytix.github.io/MapIcons/brands/budgens_24px.svg',
|
||||||
|
'Bus station': '/assets/twemoji/1f68c.png',
|
||||||
|
'Bus stop': '/assets/twemoji/1f68f.png',
|
||||||
|
'Butcher & Fishmonger': '/assets/twemoji/1f969.png',
|
||||||
|
Centra: 'https://geolytix.github.io/MapIcons/brands/centra_24px.svg',
|
||||||
|
'Co-op': 'https://geolytix.github.io/MapIcons/brands/coop_24px.svg',
|
||||||
|
COOK: 'https://geolytix.github.io/MapIcons/brands/cook.svg',
|
||||||
|
'Convenience Store': '/assets/twemoji/1f3ea.png',
|
||||||
|
Costco: 'https://geolytix.github.io/MapIcons/brands/costco_24px.svg',
|
||||||
|
'Deli & Specialty': '/assets/twemoji/1f9c6.png',
|
||||||
|
'Dunnes Stores': 'https://geolytix.github.io/MapIcons/brands/dunnes_stores_24px.svg',
|
||||||
|
Farmfoods: 'https://geolytix.github.io/MapIcons/brands/farmfoods_updated_24px.svg',
|
||||||
|
Ferry: '/assets/twemoji/26f4.png',
|
||||||
|
Greengrocer: '/assets/twemoji/1f96c.png',
|
||||||
|
'Heron Foods': 'https://geolytix.github.io/MapIcons/brands/heron_24px.svg',
|
||||||
|
Iceland: 'https://geolytix.github.io/MapIcons/brands/iceland_24px.svg',
|
||||||
|
Lidl: 'https://geolytix.github.io/MapIcons/brands/lidl_24px.svg',
|
||||||
|
Makro: 'https://geolytix.github.io/MapIcons/brands/makro_24px.svg',
|
||||||
|
'M&S': 'https://geolytix.github.io/MapIcons/brands/mns_24px.svg',
|
||||||
|
Morrisons: 'https://geolytix.github.io/MapIcons/brands/morrisons_24px.svg',
|
||||||
|
'Off-Licence': '/assets/twemoji/1f377.png',
|
||||||
|
'Planet Organic': 'https://geolytix.github.io/MapIcons/logos/planet_organic_24px.svg',
|
||||||
|
'Rail station': '/assets/twemoji/1f686.png',
|
||||||
|
"Sainsbury's": 'https://geolytix.github.io/MapIcons/brands/sainsburys_24px.svg',
|
||||||
|
Spar: 'https://geolytix.github.io/MapIcons/brands/spar_24px.svg',
|
||||||
|
Supermarket: '/assets/twemoji/1f6d2.png',
|
||||||
|
Tesco: 'https://geolytix.github.io/MapIcons/brands/tesco_24px.svg',
|
||||||
|
'Taxi rank': '/assets/twemoji/1f695.png',
|
||||||
|
'Tube station': 'https://geolytix.github.io/MapIcons/public_transport/london_tube.svg',
|
||||||
|
Waitrose: 'https://geolytix.github.io/MapIcons/brands/waitrose_24px.svg',
|
||||||
|
'Whole Foods Market': 'https://geolytix.github.io/MapIcons/brands/wholefoods_24px.svg',
|
||||||
|
};
|
||||||
|
|
||||||
/** Categories only shown when zoomed in past MINOR_POI_ZOOM_THRESHOLD */
|
/** Categories only shown when zoomed in past MINOR_POI_ZOOM_THRESHOLD */
|
||||||
export const MINOR_POI_CATEGORIES = new Set(['Bus stop', 'Taxi rank', 'EV Charging', 'Playground']);
|
export const MINOR_POI_CATEGORIES = new Set(['Bus stop', 'Taxi rank', 'EV Charging', 'Playground']);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -103,8 +103,7 @@ export function buildPropertySearchUrls({
|
||||||
|
|
||||||
const radiusMiles = isPostcode ? 0 : (H3_RADIUS_MILES[resolution] ?? 1);
|
const radiusMiles = isPostcode ? 0 : (H3_RADIUS_MILES[resolution] ?? 1);
|
||||||
|
|
||||||
const priceFilter =
|
const priceFilter = filters['Estimated current price'] ?? filters['Last known price'];
|
||||||
filters['Estimated current price'] ?? filters['Last known price'];
|
|
||||||
const minPrice =
|
const minPrice =
|
||||||
Array.isArray(priceFilter) && typeof priceFilter[0] === 'number' ? priceFilter[0] : undefined;
|
Array.isArray(priceFilter) && typeof priceFilter[0] === 'number' ? priceFilter[0] : undefined;
|
||||||
const maxPrice =
|
const maxPrice =
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import {
|
||||||
enumIndexToColor,
|
enumIndexToColor,
|
||||||
getBoundsFromViewState,
|
getBoundsFromViewState,
|
||||||
getFeatureFillColor,
|
getFeatureFillColor,
|
||||||
|
getPoiIconUrl,
|
||||||
zoomToResolution,
|
zoomToResolution,
|
||||||
} from './map-utils';
|
} from './map-utils';
|
||||||
|
|
||||||
|
|
@ -36,6 +37,13 @@ describe('map utilities', () => {
|
||||||
expect(enumIndexToColor(ENUM_PALETTE.length)).toEqual(ENUM_PALETTE[0]);
|
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', () => {
|
it('returns fallback, filtered, enum, feature, and density colors', () => {
|
||||||
expect(
|
expect(
|
||||||
getFeatureFillColor(
|
getFeatureFillColor(
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import {
|
||||||
TWEMOJI_BASE,
|
TWEMOJI_BASE,
|
||||||
BUFFER_MULTIPLIER,
|
BUFFER_MULTIPLIER,
|
||||||
ENUM_PALETTE,
|
ENUM_PALETTE,
|
||||||
|
POI_CATEGORY_LOGOS,
|
||||||
type GradientStop,
|
type GradientStop,
|
||||||
} from './consts';
|
} from './consts';
|
||||||
const ROAD_OPACITY = 0.4;
|
const ROAD_OPACITY = 0.4;
|
||||||
|
|
@ -196,6 +197,10 @@ export function emojiToTwemojiUrl(emoji: string): string {
|
||||||
return `${TWEMOJI_BASE}${hex}.png`;
|
return `${TWEMOJI_BASE}${hex}.png`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getPoiIconUrl(category: string, emoji: string): string {
|
||||||
|
return POI_CATEGORY_LOGOS[category] ?? emojiToTwemojiUrl(emoji);
|
||||||
|
}
|
||||||
|
|
||||||
/** Look up a discrete color from the enum palette by index (wraps if > palette size). */
|
/** Look up a discrete color from the enum palette by index (wraps if > palette size). */
|
||||||
export function enumIndexToColor(
|
export function enumIndexToColor(
|
||||||
index: number,
|
index: number,
|
||||||
|
|
|
||||||
216
frontend/src/lib/school-filter.ts
Normal file
216
frontend/src/lib/school-filter.ts
Normal file
|
|
@ -0,0 +1,216 @@
|
||||||
|
import type { FeatureFilters, FeatureMeta } from '../types';
|
||||||
|
|
||||||
|
export const SCHOOL_FILTER_NAME = 'Schools';
|
||||||
|
export const SCHOOL_FILTER_KEY_PREFIX = `${SCHOOL_FILTER_NAME}:`;
|
||||||
|
|
||||||
|
export type SchoolPhase = 'primary' | 'secondary';
|
||||||
|
export type SchoolRating = 'good' | 'outstanding';
|
||||||
|
export type SchoolDistance = 2 | 5;
|
||||||
|
|
||||||
|
export interface SchoolFilterConfig {
|
||||||
|
phase: SchoolPhase;
|
||||||
|
rating: SchoolRating;
|
||||||
|
distance: SchoolDistance;
|
||||||
|
featureName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SCHOOL_FILTERS: SchoolFilterConfig[] = [
|
||||||
|
{
|
||||||
|
phase: 'primary',
|
||||||
|
rating: 'good',
|
||||||
|
distance: 2,
|
||||||
|
featureName: 'Good+ primary schools within 2km',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
phase: 'secondary',
|
||||||
|
rating: 'good',
|
||||||
|
distance: 2,
|
||||||
|
featureName: 'Good+ secondary schools within 2km',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
phase: 'primary',
|
||||||
|
rating: 'outstanding',
|
||||||
|
distance: 2,
|
||||||
|
featureName: 'Outstanding primary schools within 2km',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
phase: 'secondary',
|
||||||
|
rating: 'outstanding',
|
||||||
|
distance: 2,
|
||||||
|
featureName: 'Outstanding secondary schools within 2km',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
phase: 'primary',
|
||||||
|
rating: 'good',
|
||||||
|
distance: 5,
|
||||||
|
featureName: 'Good+ primary schools within 5km',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
phase: 'secondary',
|
||||||
|
rating: 'good',
|
||||||
|
distance: 5,
|
||||||
|
featureName: 'Good+ secondary schools within 5km',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
phase: 'primary',
|
||||||
|
rating: 'outstanding',
|
||||||
|
distance: 5,
|
||||||
|
featureName: 'Outstanding primary schools within 5km',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
phase: 'secondary',
|
||||||
|
rating: 'outstanding',
|
||||||
|
distance: 5,
|
||||||
|
featureName: 'Outstanding secondary schools within 5km',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const SCHOOL_FEATURE_NAMES = new Set(SCHOOL_FILTERS.map((filter) => filter.featureName));
|
||||||
|
|
||||||
|
export function isBackendSchoolFeatureName(name: string): boolean {
|
||||||
|
return SCHOOL_FEATURE_NAMES.has(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isSchoolFilterName(name: string): boolean {
|
||||||
|
return isBackendSchoolFeatureName(name) || name.startsWith(SCHOOL_FILTER_KEY_PREFIX);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSchoolFilterConfig(name: string): SchoolFilterConfig | null {
|
||||||
|
const synthetic = parseSchoolFilterKey(name);
|
||||||
|
if (synthetic) return synthetic;
|
||||||
|
return SCHOOL_FILTERS.find((filter) => filter.featureName === name) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSchoolFeatureName(
|
||||||
|
phase: SchoolPhase,
|
||||||
|
rating: SchoolRating,
|
||||||
|
distance: SchoolDistance
|
||||||
|
): string {
|
||||||
|
return (
|
||||||
|
SCHOOL_FILTERS.find(
|
||||||
|
(filter) => filter.phase === phase && filter.rating === rating && filter.distance === distance
|
||||||
|
)?.featureName ?? SCHOOL_FILTERS[0].featureName
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createSchoolFilterKey(
|
||||||
|
phase: SchoolPhase,
|
||||||
|
rating: SchoolRating,
|
||||||
|
distance: SchoolDistance,
|
||||||
|
id: number | string
|
||||||
|
): string {
|
||||||
|
return `${SCHOOL_FILTER_KEY_PREFIX}${phase}:${rating}:${distance}:${id}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSchoolFilterKeyId(name: string): string | null {
|
||||||
|
if (!name.startsWith(SCHOOL_FILTER_KEY_PREFIX)) return null;
|
||||||
|
return name.split(':')[4] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseSchoolFilterKey(name: string): SchoolFilterConfig | null {
|
||||||
|
if (!name.startsWith(SCHOOL_FILTER_KEY_PREFIX)) return null;
|
||||||
|
const [, phaseRaw, ratingRaw, distanceRaw] = name.split(':');
|
||||||
|
const phase = phaseRaw as SchoolPhase;
|
||||||
|
const rating = ratingRaw as SchoolRating;
|
||||||
|
const distance = Number(distanceRaw) as SchoolDistance;
|
||||||
|
if (
|
||||||
|
(phase !== 'primary' && phase !== 'secondary') ||
|
||||||
|
(rating !== 'good' && rating !== 'outstanding') ||
|
||||||
|
(distance !== 2 && distance !== 5)
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
phase,
|
||||||
|
rating,
|
||||||
|
distance,
|
||||||
|
featureName: getSchoolFeatureName(phase, rating, distance),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSchoolBackendFeatureName(name: string): string | null {
|
||||||
|
if (isBackendSchoolFeatureName(name)) return name;
|
||||||
|
return parseSchoolFilterKey(name)?.featureName ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function replaceSchoolFilterKeySelection(
|
||||||
|
key: string,
|
||||||
|
next: {
|
||||||
|
phase?: SchoolPhase;
|
||||||
|
rating?: SchoolRating;
|
||||||
|
distance?: SchoolDistance;
|
||||||
|
}
|
||||||
|
): string {
|
||||||
|
const config = getSchoolFilterConfig(key) ?? SCHOOL_FILTERS[0];
|
||||||
|
const parts = key.startsWith(SCHOOL_FILTER_KEY_PREFIX) ? key.split(':') : [];
|
||||||
|
const id = parts[4] ?? '0';
|
||||||
|
return createSchoolFilterKey(
|
||||||
|
next.phase ?? config.phase,
|
||||||
|
next.rating ?? config.rating,
|
||||||
|
next.distance ?? config.distance,
|
||||||
|
id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDefaultSchoolFeatureName(features: FeatureMeta[]): string | null {
|
||||||
|
return (
|
||||||
|
SCHOOL_FILTERS.find((filter) => features.some((feature) => feature.name === filter.featureName))
|
||||||
|
?.featureName ?? null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getActiveSchoolFeatureName(filters: FeatureFilters): string | null {
|
||||||
|
return Object.keys(filters).find(isSchoolFilterName) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeSchoolFilters(filters: FeatureFilters): FeatureFilters {
|
||||||
|
let changed = false;
|
||||||
|
const next: FeatureFilters = {};
|
||||||
|
for (const [name, value] of Object.entries(filters)) {
|
||||||
|
if (isBackendSchoolFeatureName(name)) {
|
||||||
|
const config = getSchoolFilterConfig(name);
|
||||||
|
if (!config) continue;
|
||||||
|
next[
|
||||||
|
createSchoolFilterKey(
|
||||||
|
config.phase,
|
||||||
|
config.rating,
|
||||||
|
config.distance,
|
||||||
|
Object.keys(next).length
|
||||||
|
)
|
||||||
|
] = value;
|
||||||
|
changed = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
next[name] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return changed ? next : filters;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSchoolFilterMeta(features: FeatureMeta[]): FeatureMeta {
|
||||||
|
const sourceFeatureName = getDefaultSchoolFeatureName(features);
|
||||||
|
const sourceFeature = sourceFeatureName
|
||||||
|
? features.find((feature) => feature.name === sourceFeatureName)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: SCHOOL_FILTER_NAME,
|
||||||
|
type: 'numeric',
|
||||||
|
group: 'Education',
|
||||||
|
min: sourceFeature?.min ?? 0,
|
||||||
|
max: sourceFeature?.max ?? 10,
|
||||||
|
step: 1,
|
||||||
|
description: 'Rated primary and secondary schools nearby',
|
||||||
|
detail:
|
||||||
|
'Filter by primary or secondary schools, Ofsted rating, and whether schools are within 2km or 5km.',
|
||||||
|
source: 'ofsted',
|
||||||
|
raw: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clampSchoolRange(value: [number, number], feature?: FeatureMeta): [number, number] {
|
||||||
|
const min = feature?.histogram?.min ?? feature?.min ?? 0;
|
||||||
|
const max = feature?.histogram?.max ?? feature?.max ?? Math.max(1, value[1]);
|
||||||
|
return [Math.max(min, Math.min(value[0], max)), Math.max(min, Math.min(value[1], max))];
|
||||||
|
}
|
||||||
|
|
@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import type { FeatureMeta } from '../types';
|
import type { FeatureMeta } from '../types';
|
||||||
import { parseUrlState, stateToParams } from './url-state';
|
import { parseUrlState, stateToParams } from './url-state';
|
||||||
|
import { createSchoolFilterKey } from './school-filter';
|
||||||
|
|
||||||
describe('url-state', () => {
|
describe('url-state', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
|
@ -79,6 +80,36 @@ describe('url-state', () => {
|
||||||
expect(params.getAll('tt')).toEqual(['bicycle:bank:Bank:5:25']);
|
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', () => {
|
it('omits the default area tab', () => {
|
||||||
const params = stateToParams(null, {}, [], new Set(), 'area');
|
const params = stateToParams(null, {}, [], new Set(), 'area');
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,10 +5,20 @@ import {
|
||||||
type TravelTimeEntry,
|
type TravelTimeEntry,
|
||||||
type TravelTimeInitial,
|
type TravelTimeInitial,
|
||||||
} from '../hooks/useTravelTime';
|
} from '../hooks/useTravelTime';
|
||||||
|
import {
|
||||||
|
SCHOOL_FILTER_NAME,
|
||||||
|
createSchoolFilterKey,
|
||||||
|
getSchoolFilterConfig,
|
||||||
|
isSchoolFilterName,
|
||||||
|
type SchoolDistance,
|
||||||
|
type SchoolPhase,
|
||||||
|
type SchoolRating,
|
||||||
|
} from './school-filter';
|
||||||
|
|
||||||
function parseFilters(params: URLSearchParams): FeatureFilters | undefined {
|
function parseFilters(params: URLSearchParams): FeatureFilters | undefined {
|
||||||
const filterParams = params.getAll('filter');
|
const filterParams = params.getAll('filter');
|
||||||
if (filterParams.length === 0) return undefined;
|
const schoolParams = params.getAll('school');
|
||||||
|
if (filterParams.length === 0 && schoolParams.length === 0) return undefined;
|
||||||
|
|
||||||
const filters: FeatureFilters = {};
|
const filters: FeatureFilters = {};
|
||||||
for (const entry of filterParams) {
|
for (const entry of filterParams) {
|
||||||
|
|
@ -29,6 +39,27 @@ function parseFilters(params: URLSearchParams): FeatureFilters | undefined {
|
||||||
filters[name] = [rest];
|
filters[name] = [rest];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
schoolParams.forEach((entry, index) => {
|
||||||
|
const parts = entry.split(':');
|
||||||
|
if (parts.length !== 5) return;
|
||||||
|
const phase = parts[0] as SchoolPhase;
|
||||||
|
const rating = parts[1] as SchoolRating;
|
||||||
|
const distance = Number(parts[2]) as SchoolDistance;
|
||||||
|
const min = Number(parts[3]);
|
||||||
|
const max = Number(parts[4]);
|
||||||
|
if (
|
||||||
|
(phase !== 'primary' && phase !== 'secondary') ||
|
||||||
|
(rating !== 'good' && rating !== 'outstanding') ||
|
||||||
|
(distance !== 2 && distance !== 5) ||
|
||||||
|
isNaN(min) ||
|
||||||
|
isNaN(max)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
filters[createSchoolFilterKey(phase, rating, distance, index)] = [min, max];
|
||||||
|
});
|
||||||
|
|
||||||
return Object.keys(filters).length > 0 ? filters : undefined;
|
return Object.keys(filters).length > 0 ? filters : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -126,6 +157,16 @@ export function stateToParams(
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const [name, value] of Object.entries(filters)) {
|
for (const [name, value] of Object.entries(filters)) {
|
||||||
|
const schoolConfig = getSchoolFilterConfig(name);
|
||||||
|
if (schoolConfig && isSchoolFilterName(name)) {
|
||||||
|
const [min, max] = value as [number, number];
|
||||||
|
params.append(
|
||||||
|
'school',
|
||||||
|
`${schoolConfig.phase}:${schoolConfig.rating}:${schoolConfig.distance}:${min}:${max}`
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
const meta = features.find((f) => f.name === name);
|
const meta = features.find((f) => f.name === name);
|
||||||
if (meta?.type === 'enum') {
|
if (meta?.type === 'enum') {
|
||||||
params.append('filter', `${name}:${(value as string[]).join('|')}`);
|
params.append('filter', `${name}:${(value as string[]).join('|')}`);
|
||||||
|
|
@ -170,13 +211,15 @@ export function summarizeParams(queryString: string): string {
|
||||||
const parts: string[] = [];
|
const parts: string[] = [];
|
||||||
|
|
||||||
const filterParams = params.getAll('filter');
|
const filterParams = params.getAll('filter');
|
||||||
if (filterParams.length > 0) {
|
const schoolParams = params.getAll('school');
|
||||||
|
if (filterParams.length > 0 || schoolParams.length > 0) {
|
||||||
const filterNames = filterParams
|
const filterNames = filterParams
|
||||||
.map((entry) => {
|
.map((entry) => {
|
||||||
const colonIdx = entry.indexOf(':');
|
const colonIdx = entry.indexOf(':');
|
||||||
return colonIdx > 0 ? entry.substring(0, colonIdx) : entry;
|
return colonIdx > 0 ? entry.substring(0, colonIdx) : entry;
|
||||||
})
|
})
|
||||||
.filter((n) => n);
|
.filter((n) => n);
|
||||||
|
for (let i = 0; i < schoolParams.length; i++) filterNames.push(SCHOOL_FILTER_NAME);
|
||||||
if (filterNames.length > 0) {
|
if (filterNames.length > 0) {
|
||||||
parts.push(
|
parts.push(
|
||||||
filterNames.length <= 2
|
filterNames.length <= 2
|
||||||
|
|
|
||||||
|
|
@ -221,7 +221,7 @@ def main() -> None:
|
||||||
deleted = _delete_files(args.travel_times, bad_files)
|
deleted = _delete_files(args.travel_times, bad_files)
|
||||||
print(f"Deleted {deleted}/{len(bad_files)} files.")
|
print(f"Deleted {deleted}/{len(bad_files)} files.")
|
||||||
else:
|
else:
|
||||||
print(f"\nRun with --delete to remove these files so R5 can recompute them.")
|
print("\nRun with --delete to remove these files so R5 can recompute them.")
|
||||||
else:
|
else:
|
||||||
print("\nNo corrupted files found.")
|
print("\nNo corrupted files found.")
|
||||||
|
|
||||||
|
|
|
||||||
98
pipeline/download/geolytix_retail_points.py
Normal file
98
pipeline/download/geolytix_retail_points.py
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
"""Download GEOLYTIX Grocery Retail Points and keep the latest CSV release."""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
from tempfile import TemporaryDirectory
|
||||||
|
from zipfile import ZipFile
|
||||||
|
|
||||||
|
import polars as pl
|
||||||
|
|
||||||
|
from pipeline.utils.download import download
|
||||||
|
|
||||||
|
|
||||||
|
GEOLYTIX_RETAIL_POINTS_FILE_ID = "1B8M7m86rQg2sx2TsHhFa2d-x-dZ1DbSy"
|
||||||
|
GEOLYTIX_RETAIL_POINTS_URL = (
|
||||||
|
"https://drive.usercontent.google.com/download"
|
||||||
|
f"?id={GEOLYTIX_RETAIL_POINTS_FILE_ID}&export=download&confirm=t"
|
||||||
|
)
|
||||||
|
|
||||||
|
CSV_NAME_RE = re.compile(
|
||||||
|
r"^geolytix_retailpoints_v(?P<version>\d+)_(?P<release>\d{6})\.csv$"
|
||||||
|
)
|
||||||
|
|
||||||
|
REQUIRED_COLUMNS = {
|
||||||
|
"id",
|
||||||
|
"retailer",
|
||||||
|
"fascia",
|
||||||
|
"store_name",
|
||||||
|
"postcode",
|
||||||
|
"long_wgs",
|
||||||
|
"lat_wgs",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def select_latest_csv_name(names: list[str]) -> str:
|
||||||
|
"""Return the latest root-level retail points CSV from a ZIP namelist."""
|
||||||
|
candidates: list[tuple[str, int, str]] = []
|
||||||
|
for name in names:
|
||||||
|
path = Path(name)
|
||||||
|
if path.parent != Path("."):
|
||||||
|
continue
|
||||||
|
match = CSV_NAME_RE.match(path.name)
|
||||||
|
if not match:
|
||||||
|
continue
|
||||||
|
candidates.append(
|
||||||
|
(match.group("release"), int(match.group("version")), name)
|
||||||
|
)
|
||||||
|
|
||||||
|
if not candidates:
|
||||||
|
raise ValueError("No root-level GEOLYTIX retail points CSV found")
|
||||||
|
|
||||||
|
return max(candidates)[2]
|
||||||
|
|
||||||
|
|
||||||
|
def read_latest_csv(zip_path: Path) -> pl.DataFrame:
|
||||||
|
"""Read the latest root-level CSV from a GEOLYTIX ZIP file."""
|
||||||
|
with ZipFile(zip_path) as zip_file:
|
||||||
|
csv_name = select_latest_csv_name(zip_file.namelist())
|
||||||
|
with zip_file.open(csv_name) as csv_file:
|
||||||
|
df = pl.read_csv(csv_file, infer_schema_length=10_000)
|
||||||
|
|
||||||
|
missing = REQUIRED_COLUMNS - set(df.columns)
|
||||||
|
if missing:
|
||||||
|
raise ValueError(
|
||||||
|
f"GEOLYTIX retail points CSV is missing columns: {sorted(missing)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return df
|
||||||
|
|
||||||
|
|
||||||
|
def download_geolytix_retail_points(output_path: Path) -> None:
|
||||||
|
"""Download the GEOLYTIX ZIP, extract the latest CSV, and write parquet."""
|
||||||
|
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
with TemporaryDirectory(prefix="geolytix_retail_points_") as tmp:
|
||||||
|
zip_path = Path(tmp) / "geolytix_retail_points.zip"
|
||||||
|
download(GEOLYTIX_RETAIL_POINTS_URL, zip_path, timeout=300)
|
||||||
|
df = read_latest_csv(zip_path)
|
||||||
|
|
||||||
|
df.write_parquet(output_path)
|
||||||
|
size_mb = output_path.stat().st_size / (1024 * 1024)
|
||||||
|
print(f"Wrote {output_path} ({size_mb:.1f} MB, {len(df):,} stores)")
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Download GEOLYTIX Grocery Retail Points"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--output", type=Path, required=True, help="Output parquet file path"
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
download_geolytix_retail_points(args.output)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
41
pipeline/download/test_geolytix_retail_points.py
Normal file
41
pipeline/download/test_geolytix_retail_points.py
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
from zipfile import ZipFile
|
||||||
|
|
||||||
|
import polars as pl
|
||||||
|
|
||||||
|
from pipeline.download.geolytix_retail_points import (
|
||||||
|
read_latest_csv,
|
||||||
|
select_latest_csv_name,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_select_latest_csv_ignores_previous_versions():
|
||||||
|
names = [
|
||||||
|
"README.txt",
|
||||||
|
"geolytix_retailpoints_v41_202602.csv",
|
||||||
|
"geolytix_retailpoints_v43_202603.csv",
|
||||||
|
"Previous Versions/geolytix_retailpoints_v99_209901.csv",
|
||||||
|
]
|
||||||
|
|
||||||
|
assert select_latest_csv_name(names) == "geolytix_retailpoints_v43_202603.csv"
|
||||||
|
|
||||||
|
|
||||||
|
def test_read_latest_csv_validates_required_columns(tmp_path):
|
||||||
|
zip_path = tmp_path / "retail_points.zip"
|
||||||
|
df = pl.DataFrame(
|
||||||
|
{
|
||||||
|
"id": [1],
|
||||||
|
"retailer": ["Waitrose"],
|
||||||
|
"fascia": ["Waitrose"],
|
||||||
|
"store_name": ["Waitrose Test"],
|
||||||
|
"postcode": ["SW1A 1AA"],
|
||||||
|
"long_wgs": [-0.1],
|
||||||
|
"lat_wgs": [51.5],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
with ZipFile(zip_path, "w") as zip_file:
|
||||||
|
zip_file.writestr("geolytix_retailpoints_v1_202401.csv", "not,the,latest\n")
|
||||||
|
with zip_file.open("geolytix_retailpoints_v2_202402.csv", "w") as csv_file:
|
||||||
|
df.write_csv(csv_file)
|
||||||
|
|
||||||
|
assert read_latest_csv(zip_path).to_dicts() == df.to_dicts()
|
||||||
59
pipeline/transform/test_transform_poi.py
Normal file
59
pipeline/transform/test_transform_poi.py
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
import polars as pl
|
||||||
|
|
||||||
|
from pipeline.transform.transform_poi import transform_grocery_retail_points
|
||||||
|
|
||||||
|
|
||||||
|
def test_transform_grocery_retail_points_outputs_chain_categories():
|
||||||
|
raw = pl.DataFrame(
|
||||||
|
{
|
||||||
|
"id": [101, 102, 103],
|
||||||
|
"retailer": ["Waitrose", "Sainsburys", "The Co-operative Group"],
|
||||||
|
"fascia": ["Waitrose", "Sainsbury's Local", "Co-op Food"],
|
||||||
|
"store_name": ["Waitrose Test", "Sainsbury''s Test", "Co-op Test"],
|
||||||
|
"long_wgs": [-0.141, -0.142, -0.143],
|
||||||
|
"lat_wgs": [51.515, 51.516, 51.517],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
pois = transform_grocery_retail_points(raw)
|
||||||
|
|
||||||
|
assert pois.select("id", "name", "category", "group", "emoji").to_dicts() == [
|
||||||
|
{
|
||||||
|
"id": "glx-101",
|
||||||
|
"name": "Waitrose Test",
|
||||||
|
"category": "Waitrose",
|
||||||
|
"group": "Groceries",
|
||||||
|
"emoji": "🛒",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "glx-102",
|
||||||
|
"name": "Sainsbury's Test",
|
||||||
|
"category": "Sainsbury's",
|
||||||
|
"group": "Groceries",
|
||||||
|
"emoji": "🛒",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "glx-103",
|
||||||
|
"name": "Co-op Test",
|
||||||
|
"category": "Co-op",
|
||||||
|
"group": "Groceries",
|
||||||
|
"emoji": "🛒",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_transform_grocery_retail_points_drops_invalid_rows():
|
||||||
|
raw = pl.DataFrame(
|
||||||
|
{
|
||||||
|
"id": [101, 102],
|
||||||
|
"retailer": ["Waitrose", ""],
|
||||||
|
"fascia": ["Waitrose", "Tesco"],
|
||||||
|
"store_name": ["Waitrose Test", "Tesco Test"],
|
||||||
|
"long_wgs": [-0.141, -0.142],
|
||||||
|
"lat_wgs": [51.515, 51.516],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
pois = transform_grocery_retail_points(raw)
|
||||||
|
|
||||||
|
assert pois["category"].to_list() == ["Waitrose"]
|
||||||
|
|
@ -1058,10 +1058,91 @@ NAPTAN_EMOJIS: dict[str, str] = {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
COOP_RETAILERS = {
|
||||||
|
"Allendale Co-operative Society",
|
||||||
|
"Central England Co-operative",
|
||||||
|
"Channel Islands Co-operative Society",
|
||||||
|
"Chelmsford Star Co-operative Society",
|
||||||
|
"Clydebank Co-operative",
|
||||||
|
"Coniston Co-operative Society",
|
||||||
|
"East of England Co-operative",
|
||||||
|
"Heart of England Co-operative",
|
||||||
|
"Langdale Co-operative Society",
|
||||||
|
"Lincolnshire Co-operative",
|
||||||
|
"Midcounties Co-operative",
|
||||||
|
"Scottish Midland Co-operative",
|
||||||
|
"Tamworth Co-operative Society",
|
||||||
|
"The Co-operative Group",
|
||||||
|
"The Radstock Co-operative Society",
|
||||||
|
"The Southern Co-operative",
|
||||||
|
}
|
||||||
|
|
||||||
|
GROCERY_RETAILER_DISPLAY_NAMES: dict[str, str] = {
|
||||||
|
"Cook": "COOK",
|
||||||
|
"Heron": "Heron Foods",
|
||||||
|
"Marks and Spencer": "M&S",
|
||||||
|
"Sainsburys": "Sainsbury's",
|
||||||
|
**{retailer: "Co-op" for retailer in COOP_RETAILERS},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_grocery_retailer(retailer: str | None) -> str:
|
||||||
|
if retailer is None:
|
||||||
|
return ""
|
||||||
|
return GROCERY_RETAILER_DISPLAY_NAMES.get(retailer, retailer)
|
||||||
|
|
||||||
|
|
||||||
|
def transform_grocery_retail_points(
|
||||||
|
grocery_df: pl.DataFrame,
|
||||||
|
boundary_path: Path | None = None,
|
||||||
|
) -> pl.DataFrame:
|
||||||
|
"""Convert GEOLYTIX Grocery Retail Points into the POI parquet schema."""
|
||||||
|
required = {"id", "retailer", "fascia", "store_name", "long_wgs", "lat_wgs"}
|
||||||
|
missing = required - set(grocery_df.columns)
|
||||||
|
if missing:
|
||||||
|
raise ValueError(
|
||||||
|
f"GEOLYTIX retail points missing columns: {sorted(missing)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
df = (
|
||||||
|
grocery_df.select(
|
||||||
|
pl.col("id").cast(pl.String),
|
||||||
|
pl.col("retailer").cast(pl.String),
|
||||||
|
pl.col("fascia").cast(pl.String),
|
||||||
|
pl.col("store_name").cast(pl.String),
|
||||||
|
pl.col("lat_wgs").cast(pl.Float64).alias("lat"),
|
||||||
|
pl.col("long_wgs").cast(pl.Float64).alias("lng"),
|
||||||
|
)
|
||||||
|
.drop_nulls(["id", "retailer", "lat", "lng"])
|
||||||
|
.filter(pl.col("retailer").str.len_chars() > 0)
|
||||||
|
)
|
||||||
|
|
||||||
|
if boundary_path is not None and len(df) > 0:
|
||||||
|
mask = in_england_mask(
|
||||||
|
boundary_path,
|
||||||
|
df["lat"].to_numpy(),
|
||||||
|
df["lng"].to_numpy(),
|
||||||
|
)
|
||||||
|
df = df.filter(pl.Series(mask))
|
||||||
|
|
||||||
|
return df.with_columns(
|
||||||
|
pl.concat_str([pl.lit("glx-"), pl.col("id")]).alias("id"),
|
||||||
|
pl.coalesce(["store_name", "fascia", "retailer"])
|
||||||
|
.str.replace_all("''", "'")
|
||||||
|
.alias("name"),
|
||||||
|
pl.col("retailer")
|
||||||
|
.map_elements(normalize_grocery_retailer, return_dtype=pl.String)
|
||||||
|
.alias("category"),
|
||||||
|
pl.lit("Groceries").alias("group"),
|
||||||
|
pl.lit("🛒").alias("emoji"),
|
||||||
|
).select("id", "name", "category", "group", "lat", "lng", "emoji")
|
||||||
|
|
||||||
|
|
||||||
def transform(
|
def transform(
|
||||||
input_path: Path,
|
input_path: Path,
|
||||||
naptan_path: Path | None = None,
|
naptan_path: Path | None = None,
|
||||||
boundary_path: Path | None = None,
|
boundary_path: Path | None = None,
|
||||||
|
grocery_retail_points_path: Path | None = None,
|
||||||
) -> pl.LazyFrame:
|
) -> pl.LazyFrame:
|
||||||
lf = pl.scan_parquet(input_path)
|
lf = pl.scan_parquet(input_path)
|
||||||
|
|
||||||
|
|
@ -1123,7 +1204,14 @@ def transform(
|
||||||
pl.col("category").replace_strict(NAPTAN_EMOJIS).alias("emoji"),
|
pl.col("category").replace_strict(NAPTAN_EMOJIS).alias("emoji"),
|
||||||
pl.lit("Public Transport").alias("group"),
|
pl.lit("Public Transport").alias("group"),
|
||||||
)
|
)
|
||||||
return pl.concat([lf, naptan], how="diagonal_relaxed")
|
|
||||||
|
frames = [lf, naptan]
|
||||||
|
if grocery_retail_points_path is not None:
|
||||||
|
grocery_df = pl.read_parquet(grocery_retail_points_path)
|
||||||
|
grocery_pois = transform_grocery_retail_points(grocery_df, boundary_path)
|
||||||
|
frames.append(grocery_pois.lazy())
|
||||||
|
|
||||||
|
return pl.concat(frames, how="diagonal_relaxed")
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
|
|
@ -1142,12 +1230,22 @@ def main():
|
||||||
required=True,
|
required=True,
|
||||||
help="England boundary GeoJSON file",
|
help="England boundary GeoJSON file",
|
||||||
)
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--grocery-retail-points",
|
||||||
|
type=Path,
|
||||||
|
help="GEOLYTIX Grocery Retail Points parquet",
|
||||||
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--output", type=Path, required=True, help="Output filtered POIs parquet file"
|
"--output", type=Path, required=True, help="Output filtered POIs parquet file"
|
||||||
)
|
)
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
df = transform(args.input, args.naptan, args.boundary).collect(engine="streaming")
|
df = transform(
|
||||||
|
args.input,
|
||||||
|
args.naptan,
|
||||||
|
args.boundary,
|
||||||
|
args.grocery_retail_points,
|
||||||
|
).collect(engine="streaming")
|
||||||
|
|
||||||
df.write_parquet(args.output)
|
df.write_parquet(args.output)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -26,10 +26,8 @@ dependencies = [
|
||||||
"pyproj>=3.7.2",
|
"pyproj>=3.7.2",
|
||||||
"pyshp>=2.3.0",
|
"pyshp>=2.3.0",
|
||||||
"folium>=0.20.0",
|
"folium>=0.20.0",
|
||||||
"flask",
|
|
||||||
"httpx",
|
"httpx",
|
||||||
"polars",
|
"polars",
|
||||||
"fake-useragent>=2.2.0",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.uv]
|
[tool.uv]
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import express, { type Request } from 'express';
|
import express, { type Request, type Response } from 'express';
|
||||||
import { ScreenshotCache } from './cache.js';
|
import { ScreenshotCache } from './cache.js';
|
||||||
import { takeScreenshot, checkWebGL, closeBrowser, initialize } from './screenshot.js';
|
import { takeScreenshot, checkWebGL, closeBrowser, initialize } from './screenshot.js';
|
||||||
import { buildScreenshotRequest, ValidationError } from './validation.js';
|
import { buildScreenshotRequest, ValidationError } from './validation.js';
|
||||||
|
|
@ -27,25 +27,63 @@ app.set('trust proxy', true);
|
||||||
let activeScreenshots = 0;
|
let activeScreenshots = 0;
|
||||||
let lastRateLimitPrune = 0;
|
let lastRateLimitPrune = 0;
|
||||||
const rateLimitBuckets = new Map<string, { count: number; resetAt: number }>();
|
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 {
|
function parsePositiveIntEnv(name: string, fallback: number): number {
|
||||||
const value = Number.parseInt(process.env[name] || '', 10);
|
const value = Number.parseInt(process.env[name] || '', 10);
|
||||||
return Number.isFinite(value) && value > 0 ? value : fallback;
|
return Number.isFinite(value) && value > 0 ? value : fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
function acquireScreenshotSlot(): (() => void) | null {
|
function grantScreenshotSlot(): ReleaseScreenshotSlot {
|
||||||
if (activeScreenshots >= SCREENSHOT_CONCURRENCY) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
activeScreenshots += 1;
|
activeScreenshots += 1;
|
||||||
let released = false;
|
let released = false;
|
||||||
return () => {
|
return () => {
|
||||||
if (released) return;
|
if (released) return;
|
||||||
released = true;
|
released = true;
|
||||||
activeScreenshots = Math.max(0, activeScreenshots - 1);
|
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 {
|
function rateLimitKey(req: Request): string {
|
||||||
const forwardedFor = req.get('x-forwarded-for')?.split(',')[0]?.trim();
|
const forwardedFor = req.get('x-forwarded-for')?.split(',')[0]?.trim();
|
||||||
return forwardedFor || req.ip || req.socket.remoteAddress || 'unknown';
|
return forwardedFor || req.ip || req.socket.remoteAddress || 'unknown';
|
||||||
|
|
@ -117,9 +155,8 @@ app.get('/screenshot', async (req, res) => {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
releaseSlot = acquireScreenshotSlot();
|
releaseSlot = await acquireScreenshotSlot(res);
|
||||||
if (!releaseSlot) {
|
if (!releaseSlot) {
|
||||||
res.status(503).json({ error: 'Screenshot service busy' });
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.
|
/// The server will panic at startup if the data contains groups not in this list or vice versa.
|
||||||
pub const POI_GROUP_ORDER: &[&str] = &[
|
pub const POI_GROUP_ORDER: &[&str] = &[
|
||||||
"Public Transport",
|
"Public Transport",
|
||||||
|
"Groceries",
|
||||||
"Leisure",
|
"Leisure",
|
||||||
"Education",
|
"Education",
|
||||||
"Health",
|
"Health",
|
||||||
"Emergency Services",
|
"Emergency Services",
|
||||||
"Other",
|
"Other",
|
||||||
"Groceries",
|
|
||||||
"Local Businesses",
|
"Local Businesses",
|
||||||
"Culture",
|
"Culture",
|
||||||
"Services",
|
"Services",
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,38 @@ use tracing_subscriber::EnvFilter;
|
||||||
|
|
||||||
use state::{AppState, SharedState};
|
use state::{AppState, SharedState};
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
fn resident_memory_kib() -> Option<u64> {
|
||||||
|
let status = std::fs::read_to_string("/proc/self/status").ok()?;
|
||||||
|
status.lines().find_map(|line| {
|
||||||
|
line.strip_prefix("VmRSS:")?
|
||||||
|
.split_whitespace()
|
||||||
|
.next()?
|
||||||
|
.parse()
|
||||||
|
.ok()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
fn trim_allocator(label: &'static str) {
|
||||||
|
let before = resident_memory_kib();
|
||||||
|
let trimmed = unsafe { libc::malloc_trim(0) };
|
||||||
|
let after = resident_memory_kib();
|
||||||
|
if let (Some(before), Some(after)) = (before, after) {
|
||||||
|
info!(
|
||||||
|
label,
|
||||||
|
trimmed = trimmed != 0,
|
||||||
|
rss_before_mib = format_args!("{:.1}", before as f64 / 1024.0),
|
||||||
|
rss_after_mib = format_args!("{:.1}", after as f64 / 1024.0),
|
||||||
|
released_mib = format_args!("{:.1}", before.saturating_sub(after) as f64 / 1024.0),
|
||||||
|
"Allocator trim"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "linux"))]
|
||||||
|
fn trim_allocator(_label: &'static str) {}
|
||||||
|
|
||||||
#[derive(Parser)]
|
#[derive(Parser)]
|
||||||
#[command(
|
#[command(
|
||||||
name = "perfect-postcode",
|
name = "perfect-postcode",
|
||||||
|
|
@ -166,6 +198,7 @@ async fn main() -> anyhow::Result<()> {
|
||||||
cli.postcode_features.display(),
|
cli.postcode_features.display(),
|
||||||
);
|
);
|
||||||
let property_data = data::PropertyData::load(&cli.properties, &cli.postcode_features)?;
|
let property_data = data::PropertyData::load(&cli.properties, &cli.postcode_features)?;
|
||||||
|
trim_allocator("property data load");
|
||||||
info!(
|
info!(
|
||||||
rows = property_data.lat.len(),
|
rows = property_data.lat.len(),
|
||||||
features = property_data.num_features,
|
features = property_data.num_features,
|
||||||
|
|
@ -194,6 +227,7 @@ async fn main() -> anyhow::Result<()> {
|
||||||
|
|
||||||
info!("Loading POI data from {}", poi_path.display());
|
info!("Loading POI data from {}", poi_path.display());
|
||||||
let poi_data = data::POIData::load(&poi_path)?;
|
let poi_data = data::POIData::load(&poi_path)?;
|
||||||
|
trim_allocator("poi data load");
|
||||||
info!(pois = poi_data.lat.len(), "POI data loaded");
|
info!(pois = poi_data.lat.len(), "POI data loaded");
|
||||||
|
|
||||||
info!("Building POI spatial grid index");
|
info!("Building POI spatial grid index");
|
||||||
|
|
@ -206,6 +240,7 @@ async fn main() -> anyhow::Result<()> {
|
||||||
}
|
}
|
||||||
info!("Loading place data from {}", places_path.display());
|
info!("Loading place data from {}", places_path.display());
|
||||||
let place_data = data::PlaceData::load(places_path)?;
|
let place_data = data::PlaceData::load(places_path)?;
|
||||||
|
trim_allocator("place data load");
|
||||||
info!(places = place_data.name.len(), "Place data loaded");
|
info!(places = place_data.name.len(), "Place data loaded");
|
||||||
|
|
||||||
// Load postcode boundaries
|
// Load postcode boundaries
|
||||||
|
|
@ -221,6 +256,7 @@ async fn main() -> anyhow::Result<()> {
|
||||||
postcodes_path.display()
|
postcodes_path.display()
|
||||||
);
|
);
|
||||||
let postcode_data = data::PostcodeData::load(postcodes_path)?;
|
let postcode_data = data::PostcodeData::load(postcodes_path)?;
|
||||||
|
trim_allocator("postcode boundary load");
|
||||||
info!(
|
info!(
|
||||||
postcodes = postcode_data.postcodes.len(),
|
postcodes = postcode_data.postcodes.len(),
|
||||||
"Postcode boundaries loaded"
|
"Postcode boundaries loaded"
|
||||||
|
|
|
||||||
|
|
@ -85,79 +85,80 @@ pub async fn get_filter_counts(
|
||||||
let has_travel = !travel_entries.is_empty();
|
let has_travel = !travel_entries.is_empty();
|
||||||
let (pc_interner, pc_keys) = state.data.postcode_parts();
|
let (pc_interner, pc_keys) = state.data.postcode_parts();
|
||||||
|
|
||||||
let rows = state.grid.query(south, west, north, east);
|
let row_count = state.grid.count_in_bounds(south, west, north, east);
|
||||||
let row_count = rows.len();
|
|
||||||
|
|
||||||
let mut total_passing: u32 = 0;
|
let mut total_passing: u32 = 0;
|
||||||
let mut impacts = vec![0u32; num_total_filters];
|
let mut impacts = vec![0u32; num_total_filters];
|
||||||
|
|
||||||
for row_idx in rows {
|
state
|
||||||
let row = row_idx as usize;
|
.grid
|
||||||
let base = row * num_features;
|
.for_each_in_bounds(south, west, north, east, |row_idx| {
|
||||||
let mut fail_count: u32 = 0;
|
let row = row_idx as usize;
|
||||||
let mut fail_index: usize = 0;
|
let base = row * num_features;
|
||||||
|
let mut fail_count: u32 = 0;
|
||||||
|
let mut fail_index: usize = 0;
|
||||||
|
|
||||||
// Test numeric filters
|
// Test numeric filters
|
||||||
for (i, f) in parsed_filters.iter().enumerate() {
|
for (i, f) in parsed_filters.iter().enumerate() {
|
||||||
let raw = feature_data[base + f.feat_idx];
|
|
||||||
if raw == NAN_U16 || raw < f.min_u16 || raw > f.max_u16 {
|
|
||||||
fail_count += 1;
|
|
||||||
fail_index = i;
|
|
||||||
if fail_count > 1 {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test enum filters
|
|
||||||
if fail_count <= 1 {
|
|
||||||
for (i, f) in parsed_enum_filters.iter().enumerate() {
|
|
||||||
let raw = feature_data[base + f.feat_idx];
|
let raw = feature_data[base + f.feat_idx];
|
||||||
if raw == NAN_U16 || !f.allowed.contains(&raw) {
|
if raw == NAN_U16 || raw < f.min_u16 || raw > f.max_u16 {
|
||||||
fail_count += 1;
|
fail_count += 1;
|
||||||
fail_index = parsed_filters.len() + i;
|
fail_index = i;
|
||||||
if fail_count > 1 {
|
if fail_count > 1 {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Test travel time filters
|
// Test enum filters
|
||||||
if fail_count <= 1 && has_travel {
|
if fail_count <= 1 {
|
||||||
let postcode = pc_interner.resolve(&pc_keys[row]);
|
for (i, f) in parsed_enum_filters.iter().enumerate() {
|
||||||
for (slot, &ti) in travel_filter_indices.iter().enumerate() {
|
let raw = feature_data[base + f.feat_idx];
|
||||||
let entry = &travel_entries[ti];
|
if raw == NAN_U16 || !f.allowed.contains(&raw) {
|
||||||
let minutes = travel_data[ti].get(postcode).map(|r| {
|
fail_count += 1;
|
||||||
if entry.use_best {
|
fail_index = parsed_filters.len() + i;
|
||||||
r.best_minutes.unwrap_or(r.minutes)
|
if fail_count > 1 {
|
||||||
} else {
|
break;
|
||||||
r.minutes
|
}
|
||||||
}
|
|
||||||
});
|
|
||||||
let passes = match (minutes, entry.filter_min, entry.filter_max) {
|
|
||||||
(Some(mins), Some(fmin), Some(fmax)) => {
|
|
||||||
(mins as f32) >= fmin && (mins as f32) <= fmax
|
|
||||||
}
|
|
||||||
(None, Some(_), Some(_)) => false,
|
|
||||||
_ => true,
|
|
||||||
};
|
|
||||||
if !passes {
|
|
||||||
fail_count += 1;
|
|
||||||
fail_index = num_regular + slot;
|
|
||||||
if fail_count > 1 {
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
match fail_count {
|
// Test travel time filters
|
||||||
0 => total_passing += 1,
|
if fail_count <= 1 && has_travel {
|
||||||
1 => impacts[fail_index] += 1,
|
let postcode = pc_interner.resolve(&pc_keys[row]);
|
||||||
_ => {}
|
for (slot, &ti) in travel_filter_indices.iter().enumerate() {
|
||||||
}
|
let entry = &travel_entries[ti];
|
||||||
}
|
let minutes = travel_data[ti].get(postcode).map(|r| {
|
||||||
|
if entry.use_best {
|
||||||
|
r.best_minutes.unwrap_or(r.minutes)
|
||||||
|
} else {
|
||||||
|
r.minutes
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let passes = match (minutes, entry.filter_min, entry.filter_max) {
|
||||||
|
(Some(mins), Some(fmin), Some(fmax)) => {
|
||||||
|
(mins as f32) >= fmin && (mins as f32) <= fmax
|
||||||
|
}
|
||||||
|
(None, Some(_), Some(_)) => false,
|
||||||
|
_ => true,
|
||||||
|
};
|
||||||
|
if !passes {
|
||||||
|
fail_count += 1;
|
||||||
|
fail_index = num_regular + slot;
|
||||||
|
if fail_count > 1 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
match fail_count {
|
||||||
|
0 => total_passing += 1,
|
||||||
|
1 => impacts[fail_index] += 1,
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Map filter indices back to feature/travel names
|
// Map filter indices back to feature/travel names
|
||||||
let mut impact_map: FxHashMap<String, u32> = FxHashMap::default();
|
let mut impact_map: FxHashMap<String, u32> = FxHashMap::default();
|
||||||
|
|
|
||||||
60
uv.lock
generated
60
uv.lock
generated
|
|
@ -140,15 +140,6 @@ css = [
|
||||||
{ name = "tinycss2", marker = "python_full_version < '3.14' and sys_platform == 'linux'" },
|
{ 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]]
|
[[package]]
|
||||||
name = "branca"
|
name = "branca"
|
||||||
version = "0.8.2"
|
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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "fastexcel"
|
name = "fastexcel"
|
||||||
version = "0.19.0"
|
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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "folium"
|
name = "folium"
|
||||||
version = "0.20.0"
|
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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "jedi"
|
name = "jedi"
|
||||||
version = "0.19.2"
|
version = "0.19.2"
|
||||||
|
|
@ -1411,9 +1367,7 @@ name = "property-map"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = { virtual = "." }
|
source = { virtual = "." }
|
||||||
dependencies = [
|
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 = "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 = "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 = "httpx", extra = ["socks"], marker = "python_full_version < '3.14' and sys_platform == 'linux'" },
|
||||||
{ name = "ipywidgets", marker = "python_full_version < '3.14' and sys_platform == 'linux'" },
|
{ name = "ipywidgets", marker = "python_full_version < '3.14' and sys_platform == 'linux'" },
|
||||||
|
|
@ -1443,9 +1397,7 @@ dev = [
|
||||||
|
|
||||||
[package.metadata]
|
[package.metadata]
|
||||||
requires-dist = [
|
requires-dist = [
|
||||||
{ name = "fake-useragent", specifier = ">=2.2.0" },
|
|
||||||
{ name = "fastexcel", specifier = ">=0.19.0" },
|
{ name = "fastexcel", specifier = ">=0.19.0" },
|
||||||
{ name = "flask" },
|
|
||||||
{ name = "folium", specifier = ">=0.20.0" },
|
{ name = "folium", specifier = ">=0.20.0" },
|
||||||
{ name = "httpx" },
|
{ name = "httpx" },
|
||||||
{ name = "httpx", extras = ["socks"], specifier = ">=0.28.1" },
|
{ 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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "widgetsnbextension"
|
name = "widgetsnbextension"
|
||||||
version = "4.0.15"
|
version = "4.0.15"
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue