This commit is contained in:
Ruby 2026-02-10 21:26:25 +00:00
commit 1397b6afd5
217 changed files with 24403 additions and 5858 deletions

View file

@ -1,5 +1,3 @@
data/
data_sources/
.venv
**/node_modules
**/dist
@ -8,6 +6,5 @@ server-rs/target
.task
.claude
__pycache__
*.parquet
analyses/
*.log

View file

@ -80,3 +80,49 @@ jobs:
- name: Build
run: npm run build
lint-rust:
name: Lint Rust
runs-on: ubuntu-latest
defaults:
run:
working-directory: server-rs
steps:
- uses: actions/checkout@v4
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
with:
components: clippy, rustfmt
- name: Cache cargo
uses: Swatinem/rust-cache@v2
with:
workspaces: server-rs
- name: Run clippy
run: cargo clippy -- -D warnings
- name: Check formatting
run: cargo fmt --check
test-rust:
name: Test Rust
runs-on: ubuntu-latest
needs: [lint-rust]
defaults:
run:
working-directory: server-rs
steps:
- uses: actions/checkout@v4
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
- name: Cache cargo
uses: Swatinem/rust-cache@v2
with:
workspaces: server-rs
- name: Run tests
run: cargo test

1
.gitignore vendored
View file

@ -8,3 +8,4 @@ tfl_journey_client
server-rs/target
.task
data
frontend/public/assets

10
.vscode/settings.json vendored
View file

@ -3,8 +3,10 @@
"*.venv": true,
"**/__pycache__": true,
"**/node_modules": true,
"**/.ruff_cache":true,
"**/.pytest_cache":true,
"**/target":true
"**/.ruff_cache": true,
"**/.pytest_cache": true,
"**/target": true,
"frontend/dist": true,
"**/.task": true
}
}
}

160
CLAUDE.md
View file

@ -15,11 +15,14 @@ All commands use [Task](https://taskfile.dev) runner. Python uses `uv run`. Fron
```bash
# Development servers
task dev:server # Rust backend on :8001 (cargo run --release)
task dev:frontend # Webpack dev server on :3030 (proxies /api to :8001)
task dev:frontend # Webpack dev server on :3001 (proxies /api to :8001)
# Data pipeline
task prepare # Build wide.parquet from all pre-downloaded sources
# 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
@ -83,41 +86,89 @@ The server and frontend must handle these human-readable names. See the full ren
Rust + Axum. Loads parquet into memory at startup.
**Structure:**
- `data/property.rs` — Loads `wide.parquet`, auto-discovers numeric + enum features, computes histograms, sorts rows by spatial locality, precomputes H3 cells (resolutions 412)
- `data/poi.rs` — Loads `filtered_uk_pois.parquet`
- `index.rs``GridIndex`: 0.01° spatial grid for O(1) cell lookup
- `filter.rs` — Parses filter strings and checks rows. Format: `name:min:max` (numeric), `name:val1|val2` (enum)
- `routes/` — One file per endpoint
**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
- `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=` — H3 aggregates (min/max per feature per hex)
- `GET /api/hexagons?resolution=&bounds=&filters=&fields=` — H3 aggregates (min/max per feature per hex), AABB-filtered to bounds
- `GET /api/postcodes?bounds=&filters=&fields=` — Postcode polygon aggregates, AABB-filtered to bounds
- `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/pois?bounds=&categories=` — POIs by bounds (max 5000)
- `GET /api/poi-categories` — Available POI category names
Serves `frontend/dist/` as static fallback in production.
**Data representation:**
- Numeric features: row-major flat `Vec<f64>`, NaN = null
- Enum features: `Vec<u8>` indices into value list, 255 = null
- String fields (address, postcode): `Vec<String>`, empty = null
**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
- String fields (address, postcode): interned/packed for memory efficiency
- The server accepts the parquet path as a CLI argument (defaults to `data_sources/processed/wide.parquet`)
### 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 (home/dashboard/data-sources/faq)
- `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
- `usePOIData` — POI fetching with debounce
- `usePaneResize` — Reusable pane resize handlers
- `useTheme` — Theme state with localStorage persistence
- `useUrlSync` — URL state synchronization
**Key patterns:**
- `App.tsx` manages all state, API fetching (150ms debounce), and URL state sync (300ms debounce)
- URL encodes view/filters/POI categories/active tab as query params for shareable links
- AbortControllers cancel in-flight requests on new queries
- Zoom → H3 resolution: `<7→7, <9.5→8, <11→9, <13→10, ≥13→11`
- Bounds quantized to 0.01° to match backend caching
- 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 :3030 proxies `/api` to :8001; also handles VS Code `/proxy/PORT` patterns
- Proxy: dev server on :3001 proxies `/api` to :8001; also handles VS Code `/proxy/PORT` patterns
**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, ...keys)` for getting numeric property values with fallback field names.
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
**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)
@ -173,9 +224,11 @@ Every UI element must use the correct token from this table. Do not invent new p
- Deck.gl postcode labels (RGB arrays): `[220,220,220,220]` text / `[30,30,30,200]` outline in dark; inverse in light.
**Map basemaps:**
- Light: `https://basemaps.cartocdn.com/gl/voyager-gl-style/style.json`
- Dark: `https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json`
- `handleMapLoad` must only apply label/water tweaks in light mode. Dark Matter has good defaults.
- 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`
@ -216,14 +269,77 @@ Every UI element must use the correct token from this table. Do not invent new p
- [ ] Sidebars, dropdowns, and popups are readable in both modes
- [ ] HomePage and DataSourcesPage adapt correctly
## Coding Preferences
- **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 for one property are contiguous
- **Row-major layout**: `feature_data[row * num_features + feat_idx]` — all features (numeric and enum) for one property are contiguous
- **H3 precomputation**: Resolutions 412 computed in parallel (rayon) at startup
- **Histogram percentiles without sorting**: O(n) two-pass algorithm — build histogram, interpolate percentiles
- **Direct JSON writing**: Hexagon endpoint writes JSON via string buffer, avoids serde_json::Value allocations
- **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 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`.
- **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
## Rust Performance Patterns (server-rs)
**Lookup optimization:**
- `AppState.feature_name_to_index: FxHashMap<String, usize>` for O(1) feature lookups (used in filter parsing, field selection)
- Never use `.position()` on feature_names in hot paths — always use the prebuilt HashMap
- Enum filters use `FxHashSet<u32>` (f32 bits) for O(1) contains checks instead of `Vec::contains`
**Hot loop patterns:**
- Hoist conditional branches outside loops when possible (e.g., `if has_selective` check moved outside aggregation loop in hexagons.rs)
- Use `into_par_iter()` for file I/O (postcode GeoJSON loading) and CPU-bound startup work (H3 precomputation)
**Cardinality counting:**
- Use `FxHashSet` with `f32::to_bits()` for O(n) unique value counting instead of collect→sort→dedup O(n log n)
- For enum ordering, convert order slice to `FxHashSet` before filtering to get O(1) contains
**Data structure choices:**
- CSR (Compressed Sparse Row) for GridIndex — single flat `values` array + `offsets` array eliminates per-cell Vec overhead
- `Box<[f32]>` for fixed-size aggregation arrays — avoids Vec capacity field (8 bytes saved per cell)
- Bit-packed booleans for flags like `is_approx_build_date` — 8x memory savings vs `Vec<bool>`
**What NOT to optimize:**
- String cloning in JSON responses (~10-20 small strings) — negligible vs serialization overhead
- GridIndex 3-pass build (min/max → count → fill) — necessary for CSR without O(n) extra memory
- Arc<str> for enum values — complexity not worth modest benefit

View file

@ -4,7 +4,7 @@ WORKDIR /app/frontend
COPY frontend/package.json frontend/package-lock.json ./
RUN npm ci
COPY frontend/ ./
RUN npm run build
RUN npm run build:no-prerender
# Stage 2: Build Rust server
FROM rust:1.83-bookworm AS server
@ -20,6 +20,11 @@ WORKDIR /app
COPY --from=server /app/server-rs/target/release/property-map-server ./
COPY --from=frontend /app/frontend/dist ./dist/
COPY property-data/wide.parquet ./data/
COPY property-data/filtered_uk_pois.parquet ./data/
COPY property-data/uk.pmtiles ./data/
COPY manual-data/postcode_boundaries ./data/postcode_boundaries/
EXPOSE 8001
ENTRYPOINT ["./property-map-server"]
CMD ["--data", "/data/wide.parquet", "--pois", "/data/filtered_uk_pois.parquet"]
CMD ["--data", "/app/data/wide.parquet", "--pois", "/app/data/filtered_uk_pois.parquet", "--tiles", "/app/data/uk.pmtiles", "--postcodes", "/app/data/postcode_boundaries"]

250
Makefile.data Normal file
View file

@ -0,0 +1,250 @@
# Data pipeline — download sources and build wide.parquet
#
# Usage:
# make -f Makefile.data prepare # Build wide.parquet (+ all deps)
# make -f Makefile.data tiles # Download UK map tiles
#
# Or include from the main Makefile and use targets directly.
SHELL := /bin/bash
.DELETE_ON_ERROR:
DATA_DIR := ./property-data
MANUAL_DATA := ./manual-data
# ── Output files ──────────────────────────────────────────────────────────────
TILES := $(DATA_DIR)/uk.pmtiles
ARCGIS := $(DATA_DIR)/arcgis_data.parquet
PRICE_PAID := $(DATA_DIR)/price-paid-complete.parquet
IOD := $(DATA_DIR)/IoD2025_Scores.parquet
POIS_RAW := $(DATA_DIR)/uk_pois.parquet
POIS_FILTERED := $(DATA_DIR)/filtered_uk_pois.parquet
POI_PROXIMITY := $(DATA_DIR)/poi_proximity.parquet
EPC_PP := $(DATA_DIR)/epc_pp.parquet
WIDE := $(DATA_DIR)/wide.parquet
PRICE_INDEX := $(DATA_DIR)/price_index.parquet
PRICES_STAMP := $(DATA_DIR)/.prices_done
EPC := $(MANUAL_DATA)/certificates.csv
JT_BANK := $(MANUAL_DATA)/journey_times_bank.parquet
JT_FITZROVIA := $(MANUAL_DATA)/journey_times_fitzrovia.parquet
ETHNICITY := $(DATA_DIR)/ethnicity_by_la.parquet
CRIME_DIR := $(MANUAL_DATA)/crime
CRIME := $(DATA_DIR)/crime_by_lsoa.parquet
NOISE := $(DATA_DIR)/road_noise.parquet
OFSTED := $(DATA_DIR)/ofsted.parquet
NAPTAN := $(DATA_DIR)/naptan.parquet
BROADBAND := $(DATA_DIR)/broadband.parquet
SCHOOL_PROX := $(DATA_DIR)/school_proximity.parquet
GEOSURE_DIR := $(DATA_DIR)/geosure
GEOSURE := $(DATA_DIR)/geosure.parquet
INSPIRE_DIR := $(DATA_DIR)/inspire
OA_BOUNDARIES := $(DATA_DIR)/oa_boundaries.gpkg
UPRN_LOOKUP := $(DATA_DIR)/uprn_lookup.parquet
PC_BOUNDARIES := $(MANUAL_DATA)/postcode_boundaries
# Sentinel files for directory targets (Make doesn't track directories well)
GEOSURE_STAMP := $(GEOSURE_DIR)/.done
INSPIRE_STAMP := $(INSPIRE_DIR)/.done
PMTILES_VERSION := 1.22.3
# ── Phony aliases ─────────────────────────────────────────────────────────────
.PHONY: prepare wide tiles \
download-arcgis download-price-paid download-deprivation download-ethnicity \
download-naptan download-pois download-ofsted download-broadband \
download-postcodes download-geosure download-noise download-inspire \
download-oa-boundaries download-uprn-lookup \
transform-pois transform-epc-pp transform-crime transform-poi-proximity \
transform-school-proximity transform-geosure transform-postcode-boundaries \
generate-postcode-boundaries \
journey-times
prepare: $(DATA_DIR)/.prices_done
wide: $(WIDE)
tiles: $(TILES)
download-arcgis: $(ARCGIS)
download-price-paid: $(PRICE_PAID)
download-deprivation: $(IOD)
download-ethnicity: $(ETHNICITY)
download-naptan: $(NAPTAN)
download-pois: $(POIS_RAW)
download-ofsted: $(OFSTED)
download-broadband: $(BROADBAND)
download-postcodes: $(POSTCODES)
download-geosure: $(GEOSURE_STAMP)
download-noise: $(NOISE)
download-inspire: $(INSPIRE_STAMP)
download-oa-boundaries: $(OA_BOUNDARIES)
download-uprn-lookup: $(UPRN_LOOKUP)
transform-pois: $(POIS_FILTERED)
transform-epc-pp: $(EPC_PP)
transform-crime: $(CRIME)
transform-poi-proximity: $(POI_PROXIMITY)
transform-school-proximity: $(SCHOOL_PROX)
transform-geosure: $(GEOSURE)
transform-postcode-boundaries: $(PC_BOUNDARIES)
generate-postcode-boundaries: $(OA_BOUNDARIES) $(INSPIRE_STAMP) $(UPRN_LOOKUP)
uv run python -m pipeline.transform.postcode_boundaries \
--uprn $(UPRN_LOOKUP) \
--oa-boundaries $(OA_BOUNDARIES) \
--inspire $(INSPIRE_DIR) \
--output $(PC_BOUNDARIES)
# ── Downloads ─────────────────────────────────────────────────────────────────
$(TILES):
uv run -m pipeline.download.tiles --output $@ --pmtiles-version $(PMTILES_VERSION)
# EPC requires manual registration — fail with instructions
$(EPC):
@echo ""
@echo "=== EPC dataset not found ==="
@echo "The EPC certificates file is required: $@"
@echo ""
@echo "To obtain it, register at https://epc.opendatacommunities.org/login"
@echo "and place certificates.csv in manual-data/"
@echo ""
@exit 1
$(ARCGIS):
uv run python -m pipeline.download.arcgis --output $@
$(PRICE_PAID):
uv run python -m pipeline.download.price_paid --output $@
$(IOD):
uv run python -m pipeline.download.deprivation_data --output $@
$(ETHNICITY):
uv run python -m pipeline.download.ethnicity --output $@
$(NAPTAN):
uv run python -m pipeline.download.naptan --output $@
$(POIS_RAW):
uv run python -m pipeline.download.pois --output $@
$(OFSTED):
uv run python -m pipeline.download.ofsted --output $@
$(BROADBAND):
uv run python -m pipeline.download.broadband --output $@
$(POSTCODES):
uv run python -m pipeline.download.postcodes --output $@
$(GEOSURE_STAMP):
uv run python -m pipeline.download.geosure --output $(GEOSURE_DIR)
@touch $@
$(NOISE): $(ARCGIS)
uv run python -m pipeline.download.noise --arcgis $(ARCGIS) --output $@
$(INSPIRE_STAMP):
uv run python -m pipeline.download.inspire --output $(INSPIRE_DIR)
@touch $@
$(OA_BOUNDARIES):
uv run python -m pipeline.download.oa_boundaries --output $@
$(UPRN_LOOKUP):
uv run python -m pipeline.download.uprn_lookup --output $@
# ── Journey times (requires TFL_API_KEY) ──────────────────────────────────────
$(JT_BANK):
@echo ""
@echo "=== TFL journey times (bank) not found ==="
@echo "Place journey_times_bank.parquet in $(MANUAL_DATA)/"
@echo "or register for a TFL API key at https://api-portal.tfl.gov.uk/signin"
@echo "and run: TFL_API_KEY=... make -f Makefile.data journey-times DEST=bank"
@echo ""
@exit 1
$(JT_FITZROVIA):
@echo ""
@echo "=== TFL journey times (fitzrovia) not found ==="
@echo "Place journey_times_fitzrovia.parquet in $(MANUAL_DATA)/"
@echo "or register for a TFL API key at https://api-portal.tfl.gov.uk/signin"
@echo "and run: TFL_API_KEY=... make -f Makefile.data journey-times DEST=fitzrovia"
@echo ""
@exit 1
journey-times: $(ARCGIS)
ifndef DEST
$(error DEST required — e.g. make journey-times DEST=bank)
endif
uv run python -m pipeline.journey_times --destination $(DEST) --output-dir $(DATA_DIR) --postcodes $(ARCGIS)
# ── Transforms ────────────────────────────────────────────────────────────────
$(POIS_FILTERED): $(POIS_RAW) $(NAPTAN)
uv run python -m pipeline.transform.transform_poi --input $(POIS_RAW) --naptan $(NAPTAN) --output $@
$(EPC_PP): $(PRICE_PAID) $(EPC)
uv run python -m pipeline.transform.join_epc_pp --epc $(EPC) --price-paid $(PRICE_PAID) --output $@
$(CRIME):
@if [ ! -d "$(CRIME_DIR)" ]; then \
echo ""; \
echo "=== Crime dataset not found ==="; \
echo "Place police.uk crime CSVs in $(CRIME_DIR)/"; \
echo "Download from https://data.police.uk/data/"; \
echo ""; \
exit 1; \
fi
uv run python -m pipeline.transform.crime --input $(CRIME_DIR) --output $@
$(POI_PROXIMITY): $(ARCGIS) $(POIS_FILTERED)
uv run python -m pipeline.transform.poi_proximity --arcgis $(ARCGIS) --pois $(POIS_FILTERED) --output $@
$(SCHOOL_PROX): $(OFSTED) $(ARCGIS)
uv run python -m pipeline.transform.school_proximity --ofsted $(OFSTED) --arcgis $(ARCGIS) --output $@
$(GEOSURE): $(GEOSURE_STAMP) $(ARCGIS)
uv run python -m pipeline.transform.transform_geosure --geosure $(GEOSURE_DIR) --arcgis $(ARCGIS) --output $@
# Postcode boundaries require manual generation — fail with instructions
$(PC_BOUNDARIES):
@echo ""
@echo "=== Postcode boundaries not found ==="
@echo "The postcode boundaries directory is required: $@"
@echo ""
@echo "Generate it with:"
@echo " uv run python -m pipeline.transform.postcode_boundaries \\"
@echo " --uprn $(UPRN_LOOKUP) \\"
@echo " --oa-boundaries $(OA_BOUNDARIES) \\"
@echo " --inspire $(INSPIRE_DIR) \\"
@echo " --output $@"
@echo ""
@exit 1
# ── Final merge ───────────────────────────────────────────────────────────────
$(WIDE): $(EPC_PP) $(ARCGIS) $(IOD) $(POI_PROXIMITY) $(JT_BANK) $(JT_FITZROVIA) \
$(ETHNICITY) $(CRIME) $(NOISE) $(SCHOOL_PROX) $(BROADBAND) $(GEOSURE)
uv run python -m pipeline.transform.merge \
--epc-pp $(EPC_PP) \
--arcgis $(ARCGIS) \
--iod $(IOD) \
--poi-proximity $(POI_PROXIMITY) \
--journey-times-bank $(JT_BANK) \
--journey-times-fitzrovia $(JT_FITZROVIA) \
--ethnicity $(ETHNICITY) \
--crime $(CRIME) \
--noise $(NOISE) \
--school-proximity $(SCHOOL_PROX) \
--broadband $(BROADBAND) \
--geosure $(GEOSURE) \
--output $@
# ── Price estimation (post-merge) ────────────────────────────────────────────
$(PRICE_INDEX): $(WIDE)
uv run python -m pipeline.transform.price_index --input $(WIDE) --output $@
$(PRICES_STAMP): $(WIDE) $(PRICE_INDEX)
uv run python -m pipeline.transform.price_estimate --input $(WIDE) --index $(PRICE_INDEX)
@touch $@

126
README.md
View file

@ -4,74 +4,17 @@
```sh
curl -1sLf 'https://dl.cloudsmith.io/public/task/task/setup.deb.sh' | sudo -E bash
apt install task
task prepare
```
## Area
1. 45 min commute (perhaps near train station)
- elizabeth line
- train frequency
- train strikes
2. affluency scores
- crime rates - violent, antisocial
- good rated schools?
- employment rates / medium income
- health
- ethnic group - <https://datashine.org.uk/#table=QS201EW&col=QS201EW0002&ramp=YlOrRd&layers=BTTT&zoom=14&lon=-0.0143&lat=51.5010>
3. services
- driving distance within several schools
- city/town centre within 10-15 min drive
- gp
- bigger town/city centre than bath/york/oxford
4. ambiance
- nature / greenery within 5 mins walk
- not noisy (e.g. right next to A/B roads or highstreet)
5. fibre optic availability
6. between london and bournemouth ish
7. [Y] historical prices
8. current listings
## Action plan
1. use openstreetmap api to get the map
## Data Sources
- [Price Paid](https://www.gov.uk/government/statistical-data-sets/price-paid-data-downloads)
- [English Indices of Deprevation 2025](https://www.gov.uk/government/statistics/english-indices-of-deprivation-2025)
- The English Indices of Deprivation (IoD25) measure relative levels of deprivation in 33,755 small areas or neighbourhoods, called Lower-layer Super Output Areas (LSOAs), in England.
- [Population by Ethnicity and Region 2021](https://www.ethnicity-facts-figures.service.gov.uk/uk-population-by-ethnicity/national-and-regional-populations/regional-ethnic-diversity/latest/#download-the-data)
- [Crime](https://data.police.uk/data/)
- [Postcode -> GPS](https://www.arcgis.com/sharing/rest/content/items/077631e063eb4e1ab43575d01381ec33/data)
Nice to haves?
- <https://www.gov.uk/government/statistics/english-indices-of-deprivation-2025> - file 8!
## Backend Data Sources
- [UK Regions](https://www.ons.gov.uk/methodology/geography/ukgeographies/statisticalgeographies)
![alt text](image.png)
- [Lower Level Super Output Area (LSOAs)](https://communitiesopendata-communities.hub.arcgis.com/datasets/4da63019f25546aa92a922a5ea682950_0/explore?location=51.506508%2C-0.041229%2C13.82)
- [Local Authority (Lower Tier)](https://communitiesopendata-communities.hub.arcgis.com/datasets/f3954cc3ded54a08b6fffbb361f5ee76_0/explore?location=52.522271%2C-2.489913%2C7.17)
- [Local Autheority (Upper Tier)](https://communitiesopendata-communities.hub.arcgis.com/datasets/6e8edb2974da4834bbafa09644a5b02d_0/explore?location=52.684195%2C-2.489482%2C7.17)
- [Open Geography](https://geoportal.statistics.gov.uk/)
- [CommunitiesOpenData](https://communitiesopendata-communities.hub.arcgis.com/)
- [PlanetOSM](https://planet.openstreetmap.org/) for open street map POI
- [TFL api](https://api-portal.tfl.gov.uk/signin)
- [EPC](https://epc.opendatacommunities.org/login) - <https://epc.opendatacommunities.org/downloads/domestic>
rightmove:
curl '<https://www.rightmove.co.uk/api/property-search/listing/search?searchLocation=E14&useLocationIdentifier=true&locationIdentifier=OUTCODE%5E749&buy=For+sale&radius=20.0&_includeSSTC=on&index=0&sortType=2&channel=BUY&transactionType=BUY>'
@ -81,3 +24,70 @@ curl '<https://www.onthemarket.com/async/search/properties-v2/?search-type=for-s
interesting links
- https://propertydata.co.uk/videos/quick-overview
- https://osdatahub.os.uk/data/downloads/open
mkdir -p data/crime
unzip data/d29f0314840ef7dcbb5cde66e383fe08059dab5a.zip -d data/crime/
rm data/d29f0314840ef7dcbb5cde66e383fe08059dab5a.zip
https://xploria.co.uk/data-sources/
epc oopt out
We all care about different things in our homes and living environments. Some of us are weary of noise and would like to avoid living next to a loud airfield as much as possible. And some of us are avid plane spotters.
We will help you find the best places to live within your budget regardless if theres a property listed there right now. The best things come to those whore patient. We will justify your patience. But we will also show you if your expectations are impossible to meet. Id much rather be told upfront then spend months of my life looking for something that cant possibly exist.
We give you all the data and tools to become an Well-informed Buyer through the click of a button.
[button]
---
- fix frontend
- map hexagons
- stripe
- update texts
- move data to raid
- extract all user-facing texts into a yaml file for easy editing
- register for email
FAQ:
- Why hexagons?
- Why the price tag?
- contact support
-
make -f Makefile.data prepare
make -f Makefile.data tiles
## outstadning prompts
Add licensing to the app. By default, anonymous users can use the map but only in central london. if they try zooming out, the server refuses to provide data and the users will be prompted to buy a lifetime license to continue (or zoom back in). Just before buying a license, they have to register by providing their email address and password, then they need to complete the stripe check out workflow. Implement the full pocketbase/server/frontend integration. For admins, give an option to generate an invite link, opening which prompts you to register and gives you a free license forever. Have a cool animation with party poppers on the successful acquiring of a license. For non-admin users, allow inviting friends for 30% off the price. Also add a support page that shows my email address, and add a FAQ on the same page too.
-
- the area stastics are missing for postcodes, they only work for hexagons
- add blue/green rollout
Stop wrapping everything in cards. Be bold and stop being lazy around text formatting.
uv run python scripts/remove_bg.py house-og.png 200 house.png

View file

@ -1,191 +0,0 @@
version: '3'
vars:
DATA_DIR: /bulk/property-data
ARCGIS_OUTPUT: "{{.DATA_DIR}}/arcgis_data.parquet"
PRICE_PAID_OUTPUT: "{{.DATA_DIR}}/price-paid-complete.parquet"
IOD_OUTPUT: "{{.DATA_DIR}}/IoD2025_Scores.parquet"
POIS_RAW_OUTPUT: "{{.DATA_DIR}}/uk_pois.parquet"
POIS_FILTERED_OUTPUT: "{{.DATA_DIR}}/filtered_uk_pois.parquet"
POI_PROXIMITY_OUTPUT: "{{.DATA_DIR}}/poi_proximity.parquet"
EPC_PP_OUTPUT: "{{.DATA_DIR}}/epc_pp.parquet"
WIDE_OUTPUT: "{{.DATA_DIR}}/wide.parquet"
EPC: "{{.DATA_DIR}}/certificates.csv"
JOURNEY_TIMES: "./data_sources/processed/journey_times_bank_checkpoint.parquet"
ETHNICITY_OUTPUT: "{{.DATA_DIR}}/ethnicity_by_la.parquet"
CRIME_DIR: "{{.DATA_DIR}}/crime"
CRIME_OUTPUT: "{{.DATA_DIR}}/crime_by_lsoa.parquet"
NOISE_OUTPUT: "{{.DATA_DIR}}/road_noise.parquet"
OFSTED_OUTPUT: "{{.DATA_DIR}}/ofsted.parquet"
NAPTAN_OUTPUT: "{{.DATA_DIR}}/naptan.parquet"
BROADBAND_OUTPUT: "{{.DATA_DIR}}/broadband.parquet"
SCHOOL_PROXIMITY_OUTPUT: "{{.DATA_DIR}}/school_proximity.parquet"
tasks:
prompt:epc:
desc: Prompt user to download EPC dataset (requires registration)
status:
- test -f {{.EPC}}
cmds:
- |
echo ""
echo "=== EPC dataset not found ==="
echo "The EPC certificates file is required: {{.EPC}}"
echo ""
echo "To obtain it, register at https://epc.opendatacommunities.org/login"
echo ""
exit 1
prompt:journey-times:
desc: Download TFL journey times if missing (requires API key registration)
status:
- test -f {{.JOURNEY_TIMES}}
deps:
- download:arcgis
cmds:
- |
echo ""
echo "=== TFL journey times not found ==="
echo "Register for a TFL API key at https://api-portal.tfl.gov.uk/signin"
echo "Then set the TFL_API_KEY environment variable and re-run this task."
echo ""
exit 1
download:arcgis:
desc: Download and convert ArcGIS postcode data
status:
- test -f {{.ARCGIS_OUTPUT}}
cmds:
- uv run python -m pipeline.download.arcgis --output {{.ARCGIS_OUTPUT}}
download:price-paid:
desc: Download and convert Land Registry price-paid data
status:
- test -f {{.PRICE_PAID_OUTPUT}}
cmds:
- uv run python -m pipeline.download.price_paid --output {{.PRICE_PAID_OUTPUT}}
download:deprivation:
desc: Download and convert Index of Deprivation data
status:
- test -f {{.IOD_OUTPUT}}
cmds:
- uv run python -m pipeline.download.deprivation_data --output {{.IOD_OUTPUT}}
download:ethnicity:
desc: Download ethnicity by local authority data
status:
- test -f {{.ETHNICITY_OUTPUT}}
cmds:
- uv run python -m pipeline.download.ethnicity --output {{.ETHNICITY_OUTPUT}}
download:naptan:
desc: Download NaPTAN station data
status:
- test -f {{.NAPTAN_OUTPUT}}
cmds:
- uv run python -m pipeline.download.naptan --output {{.NAPTAN_OUTPUT}}
download:pois:
desc: Download and extract POIs from OpenStreetMap
status:
- test -f {{.POIS_RAW_OUTPUT}}
cmds:
- uv run python -m pipeline.download.pois --output {{.POIS_RAW_OUTPUT}}
download:ofsted:
desc: Download Ofsted school inspection outcomes
status:
- test -f {{.OFSTED_OUTPUT}}
cmds:
- uv run python -m pipeline.download.ofsted --output {{.OFSTED_OUTPUT}}
download:broadband:
desc: Download Ofcom broadband performance data
status:
- test -f {{.BROADBAND_OUTPUT}}
cmds:
- uv run python -m pipeline.download.broadband --output {{.BROADBAND_OUTPUT}}
download:noise:
desc: Download Defra noise data (road, rail, airport) sampled at postcode centroids
deps:
- download:arcgis
status:
- test -f {{.NOISE_OUTPUT}}
cmds:
- uv run python -m pipeline.download.noise --arcgis {{.ARCGIS_OUTPUT}} --output {{.NOISE_OUTPUT}}
transform:pois:
desc: Transform raw POIs to filtered version with friendly names
deps:
- download:pois
- download:naptan
status:
- test -f {{.POIS_FILTERED_OUTPUT}}
cmds:
- uv run python -m pipeline.transform.transform_poi --input {{.POIS_RAW_OUTPUT}} --naptan {{.NAPTAN_OUTPUT}} --output {{.POIS_FILTERED_OUTPUT}}
transform:epc-pp:
desc: Fuzzy join EPC and Price Paid data
deps:
- download:price-paid
- prompt:epc
status:
- test -f {{.EPC_PP_OUTPUT}}
cmds:
- uv run python -m pipeline.transform.join_epc_pp --epc {{.EPC}} --price-paid {{.PRICE_PAID_OUTPUT}} --output {{.EPC_PP_OUTPUT}}
transform:crime:
desc: Transform crime CSVs into yearly averages by LSOA
status:
- test -f {{.CRIME_OUTPUT}}
cmds:
- uv run python -m pipeline.transform.crime --input {{.CRIME_DIR}} --output {{.CRIME_OUTPUT}}
transform:poi-proximity:
desc: Compute POI proximity counts per postcode
deps:
- download:arcgis
- transform:pois
status:
- test -f {{.POI_PROXIMITY_OUTPUT}}
cmds:
- uv run python -m pipeline.transform.poi_proximity --arcgis {{.ARCGIS_OUTPUT}} --pois {{.POIS_FILTERED_OUTPUT}} --output {{.POI_PROXIMITY_OUTPUT}}
transform:school-proximity:
desc: Compute good+ school proximity counts per postcode
deps:
- download:ofsted
- download:arcgis
status:
- test -f {{.SCHOOL_PROXIMITY_OUTPUT}}
cmds:
- uv run python -m pipeline.transform.school_proximity --ofsted {{.OFSTED_OUTPUT}} --arcgis {{.ARCGIS_OUTPUT}} --output {{.SCHOOL_PROXIMITY_OUTPUT}}
download:journey-times:
desc: Fetch TfL journey times for all postcodes
deps:
- download:arcgis
status:
- test -f {{.JOURNEY_TIMES}}
cmds:
- uv run python -m pipeline.journey_times
prepare:
desc: Build wide property dataframe with all joins
deps:
- transform:epc-pp
- download:arcgis
- download:deprivation
- download:ethnicity
- download:broadband
- download:noise
- transform:crime
- transform:poi-proximity
- transform:school-proximity
- prompt:journey-times
status:
- test -f {{.WIDE_OUTPUT}}
cmds:
- uv run python -m pipeline.transform.merge --epc-pp {{.EPC_PP_OUTPUT}} --arcgis {{.ARCGIS_OUTPUT}} --iod {{.IOD_OUTPUT}} --poi-proximity {{.POI_PROXIMITY_OUTPUT}} --journey-times {{.JOURNEY_TIMES}} --ethnicity {{.ETHNICITY_OUTPUT}} --crime {{.CRIME_OUTPUT}} --noise {{.NOISE_OUTPUT}} --school-proximity {{.SCHOOL_PROXIMITY_OUTPUT}} --broadband {{.BROADBAND_OUTPUT}} --output {{.WIDE_OUTPUT}}

View file

@ -1,14 +1,5 @@
version: '3'
includes:
data:
taskfile: ./Taskfile.data.yml
flatten: true
vars:
DATA_DIR: /bulk/property-data
WIDE_OUTPUT: "{{.DATA_DIR}}/wide.parquet"
POIS_FILTERED_OUTPUT: "{{.DATA_DIR}}/filtered_uk_pois.parquet"
tasks:
install:
@ -17,7 +8,21 @@ tasks:
- 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 -m pipeline.utils.test_fuzzy_join
- uv run pytest pipeline/utils/test_haversine.py
@ -29,25 +34,14 @@ tasks:
cmds:
- cargo test
dev:server:
desc: Run Rust backend on port 8001 (debug build, fast compile)
dir: server-rs
dev:
desc: Start all services (server, frontend, pocketbase) via Docker Compose
cmds:
- cargo run -- --data {{.WIDE_OUTPUT}} --pois {{.POIS_FILTERED_OUTPUT}}
- docker compose up --build
dev:server:release:
desc: Run Rust backend on port 8001 (release build)
dir: server-rs
cmds:
- cargo run --release -- --data {{.WIDE_OUTPUT}} --pois {{.POIS_FILTERED_OUTPUT}}
dev:frontend:
desc: Run frontend dev server on port 3030 (proxies /api to :8001)
dir: frontend
deps:
- install
cmds:
- npm run dev
build:server:
desc: Build server for production

View file

@ -77,7 +77,7 @@
"import polars as pl\n",
"\n",
"\n",
"pl.scan_parquet(\"../data/wide.parquet\").head(20).collect()\n"
"pl.scan_parquet(\"../data/wide.parquet\").head(20).collect()"
]
},
{

95
docker-compose.yml Normal file
View file

@ -0,0 +1,95 @@
services:
server:
image: rust:1.84
working_dir: /app/server-rs
command: >
bash -c "
cargo install cargo-watch &&
cargo watch -x 'run -- --data /app/data/wide.parquet --pois /app/data/filtered_uk_pois.parquet --tiles /app/data/uk.pmtiles --postcodes /app/data/postcode_boundaries'
"
ports:
- "8001:8001"
networks:
- dev-network
extra_hosts:
- "host.docker.internal:host-gateway"
volumes:
- .:/app
- cargo-registry:/usr/local/cargo/registry
- cargo-target:/app/server-rs/target
- ./property-data:/app/data:ro
environment:
POCKETBASE_URL: http://pocketbase:8090
SCREENSHOT_URL: http://screenshot:8002
OLLAMA_URL: http://host.docker.internal:11434
depends_on:
pocketbase:
condition: service_healthy
screenshot:
build: /volumes/syncthing/Projects/property-map/screenshot
environment:
APP_URL: http://frontend:3001
CACHE_DIR: /cache
volumes:
- screenshot-cache:/cache
networks:
- dev-network
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8002/health"]
interval: 10s
timeout: 5s
retries: 3
start_period: 30s
deploy:
resources:
reservations:
devices:
- driver: nvidia
capabilities: [ gpu ]
count: 1
frontend:
image: node:22-slim
working_dir: /app/frontend
command: >
bash -c "
npm install &&
npm run dev
"
ports:
- "3001:3001"
networks:
- dev-network
volumes:
- .:/app
- frontend-node-modules:/app/frontend/node_modules
environment:
API_PROXY_TARGET: http://server:8001
PB_PROXY_TARGET: http://pocketbase:8090
pocketbase:
image: ghcr.io/muchobien/pocketbase:latest
ports:
- "8090:8090"
volumes:
- pb-data:/pb/pb_data
networks:
- dev-network
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8090/api/health"]
interval: 10s
timeout: 5s
retries: 3
start_period: 5s
volumes:
pb-data:
cargo-registry:
cargo-target:
frontend-node-modules:
screenshot-cache:
networks:
dev-network:

File diff suppressed because it is too large Load diff

View file

@ -2,8 +2,10 @@
"name": "property-map-frontend",
"version": "1.0.0",
"scripts": {
"dev": "webpack serve --mode development --port 3030",
"build": "webpack --mode production",
"dev": "webpack serve --mode development --port 3001",
"build": "webpack --mode production && node scripts/prerender.mjs",
"build:no-prerender": "webpack --mode production",
"prerender": "node scripts/prerender.mjs",
"typecheck": "tsc --noEmit",
"lint": "eslint src --ext .ts,.tsx",
"lint:fix": "eslint src --ext .ts,.tsx --fix",
@ -16,31 +18,42 @@
"@deck.gl/layers": "^9.0.0",
"@deck.gl/mapbox": "^9.2.6",
"@deck.gl/react": "^9.0.0",
"@plausible-analytics/tracker": "^0.4.4",
"@protomaps/basemaps": "^5.7.0",
"@radix-ui/react-select": "^2.0.0",
"@radix-ui/react-slider": "^1.1.0",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.0",
"maplibre-gl": "^4.0.0",
"pocketbase": "^0.26.8",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-map-gl": "^7.1.0",
"tailwind-merge": "^2.2.0",
"tailwindcss-animate": "^1.0.7"
"react-map-gl": "^7.1.0"
},
"devDependencies": {
"@babel/core": "^7.29.0",
"@babel/preset-env": "^7.29.0",
"@babel/preset-react": "^7.28.5",
"@babel/preset-typescript": "^7.28.5",
"@pmmmwh/react-refresh-webpack-plugin": "^0.6.2",
"@types/react": "^18.2.0",
"@types/react-dom": "^18.2.0",
"@typescript-eslint/eslint-plugin": "^7.0.0",
"@typescript-eslint/parser": "^7.0.0",
"autoprefixer": "^10.4.0",
"babel-loader": "^10.0.0",
"copy-webpack-plugin": "^13.0.1",
"css-loader": "^7.0.0",
"eslint": "^8.57.0",
"eslint-plugin-react": "^7.34.0",
"eslint-plugin-react-hooks": "^4.6.0",
"favicons": "^7.2.0",
"favicons-webpack-plugin": "^6.0.1",
"html-webpack-plugin": "^5.6.0",
"mini-css-extract-plugin": "^2.9.0",
"postcss": "^8.4.0",
"postcss-loader": "^8.0.0",
"prettier": "^3.2.0",
"puppeteer": "^24.0.0",
"react-refresh": "^0.18.0",
"style-loader": "^4.0.0",
"tailwindcss": "^3.4.0",
"ts-loader": "^9.5.0",

BIN
frontend/public/cereal.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#1de4c3" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2L20.7 7v10L12 22l-8.7-5V7z"/><path d="M8.5 12.5l2.5 2.5 4.5-5"/></svg>

After

Width:  |  Height:  |  Size: 238 B

BIN
frontend/public/house.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

View file

@ -0,0 +1,140 @@
import { createServer } from 'http';
import { readFileSync, writeFileSync, existsSync, statSync } from 'fs';
import { join, extname } from 'path';
import { launch } from 'puppeteer';
const DIST_DIR = join(import.meta.dirname, '..', 'dist');
const INDEX_PATH = join(DIST_DIR, 'index.html');
const MIME_TYPES = {
'.html': 'text/html',
'.js': 'application/javascript',
'.css': 'text/css',
'.json': 'application/json',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.svg': 'image/svg+xml',
};
function startServer() {
return new Promise((resolve) => {
const server = createServer((req, res) => {
const url = new URL(req.url, 'http://localhost');
let filePath = join(DIST_DIR, url.pathname === '/' ? 'index.html' : url.pathname);
if (!existsSync(filePath) || !statSync(filePath).isFile()) {
// SPA fallback
filePath = INDEX_PATH;
}
const ext = extname(filePath);
const mime = MIME_TYPES[ext] || 'application/octet-stream';
const content = readFileSync(filePath);
res.writeHead(200, { 'Content-Type': mime });
res.end(content);
});
server.listen(0, '127.0.0.1', () => {
const port = server.address().port;
resolve({ server, port });
});
});
}
async function prerender() {
console.log('Starting prerender...');
const { server, port } = await startServer();
console.log(`Static server on port ${port}`);
const browser = await launch({
headless: true,
args: ['--no-sandbox', '--disable-setuid-sandbox'],
});
try {
const page = await browser.newPage();
// Intercept API requests to prevent real fetches and retry loops
await page.setRequestInterception(true);
page.on('request', (req) => {
const url = req.url();
if (url.includes('/api/features')) {
req.respond({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ groups: [] }),
});
} else if (url.includes('/api/poi-categories')) {
req.respond({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ groups: [] }),
});
} else if (url.includes('/api/')) {
req.respond({
status: 200,
contentType: 'application/json',
body: '{}',
});
} else {
req.continue();
}
});
await page.goto(`http://127.0.0.1:${port}/`, {
waitUntil: 'networkidle0',
timeout: 30000,
});
// Wait for the home page heading to render
await page.waitForSelector('h1', { timeout: 10000 });
// Extract and clean the rendered HTML
const html = await page.evaluate(() => {
const root = document.getElementById('root');
if (!root) return '';
// Strip fade-in-visible classes (added by IntersectionObserver effects)
root.querySelectorAll('.fade-in-visible').forEach((el) => {
el.classList.remove('fade-in-visible');
});
// Clean canvas elements (dimensions set by ResizeObserver effect)
root.querySelectorAll('canvas').forEach((canvas) => {
canvas.removeAttribute('width');
canvas.removeAttribute('height');
canvas.style.removeProperty('width');
canvas.style.removeProperty('height');
});
return root.innerHTML;
});
if (!html || html.length < 100) {
throw new Error('Prerender produced too little HTML — something went wrong');
}
// Inject into dist/index.html
const indexHtml = readFileSync(INDEX_PATH, 'utf-8');
const updated = indexHtml.replace(
'<div id="root"></div>',
`<div id="root">${html}</div>`
);
if (updated === indexHtml) {
throw new Error('Could not find <div id="root"></div> in index.html');
}
writeFileSync(INDEX_PATH, updated);
console.log(`Prerendered ${html.length} chars into dist/index.html`);
} finally {
await browser.close();
server.close();
}
}
prerender().catch((err) => {
console.error('Prerender failed:', err);
process.exit(1);
});

File diff suppressed because it is too large Load diff

View file

@ -1,243 +0,0 @@
import { useMemo } from 'react';
import type { FeatureMeta, HexagonStatsResponse } from '../types';
interface AreaPaneProps {
stats: HexagonStatsResponse | null;
globalFeatures: FeatureMeta[];
loading: boolean;
hexagonId: string | null;
isHoveredPreview: boolean;
hoverMode: boolean;
onHoverModeChange: (enabled: boolean) => void;
onViewProperties: () => void;
onClose: () => void;
}
function formatValue(value: number): string {
if (Math.abs(value) >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`;
if (Math.abs(value) >= 1_000) return `${(value / 1_000).toFixed(0)}k`;
if (Number.isInteger(value)) return value.toLocaleString();
return value.toFixed(1);
}
// Group features by their group field from globalFeatures
function groupFeatures(
globalFeatures: FeatureMeta[]
): { name: string; features: FeatureMeta[] }[] {
const groups: { name: string; features: FeatureMeta[] }[] = [];
const seen = new Set<string>();
for (const feature of globalFeatures) {
const groupName = feature.group || 'Other';
if (!seen.has(groupName)) {
seen.add(groupName);
groups.push({ name: groupName, features: [] });
}
groups.find((group) => group.name === groupName)!.features.push(feature);
}
return groups;
}
function MiniHistogram({ counts, maxCount }: { counts: number[]; maxCount: number }) {
if (maxCount === 0) return null;
// Downsample to ~20 bars for display
const targetBars = 20;
const step = Math.max(1, Math.floor(counts.length / targetBars));
const bars: number[] = [];
for (let index = 0; index < counts.length; index += step) {
let sum = 0;
for (let offset = 0; offset < step && index + offset < counts.length; offset++) {
sum += counts[index + offset];
}
bars.push(sum);
}
const barMax = Math.max(...bars, 1);
return (
<div className="flex items-end gap-px h-8 mt-1">
{bars.map((count, index) => (
<div
key={index}
className="flex-1 bg-teal-500 dark:bg-teal-400 rounded-t-sm min-w-[2px]"
style={{ height: `${(count / barMax) * 100}%`, opacity: count > 0 ? 1 : 0.1 }}
/>
))}
</div>
);
}
function EnumBarChart({ counts }: { counts: Record<string, number> }) {
const entries = Object.entries(counts).sort(([, countA], [, countB]) => countB - countA);
const maxCount = Math.max(...entries.map(([, count]) => count), 1);
return (
<div className="space-y-1 mt-1">
{entries.map(([label, count]) => (
<div key={label} className="flex items-center gap-2 text-xs">
<span className="w-16 truncate text-warm-500 dark:text-warm-400 text-right shrink-0">
{label}
</span>
<div className="flex-1 h-3 bg-warm-100 dark:bg-navy-700 rounded overflow-hidden">
<div
className="h-full bg-teal-500 dark:bg-teal-400 rounded"
style={{ width: `${(count / maxCount) * 100}%` }}
/>
</div>
<span className="w-8 text-warm-500 dark:text-warm-400 text-right shrink-0">{count}</span>
</div>
))}
</div>
);
}
export default function AreaPane({
stats,
globalFeatures,
loading,
hexagonId,
isHoveredPreview,
hoverMode,
onHoverModeChange,
onViewProperties,
onClose,
}: AreaPaneProps) {
const featureGroups = useMemo(() => groupFeatures(globalFeatures), [globalFeatures]);
// Build lookup maps from stats
const numericByName = useMemo(() => {
if (!stats) return new Map();
return new Map(stats.numeric_features.map((feature) => [feature.name, feature]));
}, [stats]);
const enumByName = useMemo(() => {
if (!stats) return new Map();
return new Map(stats.enum_features.map((feature) => [feature.name, feature]));
}, [stats]);
if (!hexagonId) {
return (
<div className="flex items-center justify-center h-full text-warm-500 dark:text-warm-400 px-4 text-center text-sm">
Click a hexagon to view area statistics
</div>
);
}
return (
<div className="flex flex-col h-full">
{/* Header */}
<div className="p-3 border-b border-warm-200 dark:border-navy-700">
<div className="flex justify-between items-center">
<div className="flex items-center gap-2">
<h2 className="text-sm font-semibold dark:text-warm-100">Area Statistics</h2>
{isHoveredPreview && (
<span className="text-xs px-1.5 py-0.5 rounded bg-teal-50 dark:bg-teal-900/30 text-teal-600 dark:text-teal-400">
Preview
</span>
)}
</div>
<div className="flex items-center gap-1">
<button
onClick={() => onHoverModeChange(!hoverMode)}
className={`p-1 rounded ${
hoverMode
? 'text-teal-600 dark:text-teal-400 bg-teal-50 dark:bg-teal-900/30'
: 'text-warm-400 hover:text-warm-700 dark:hover:text-warm-300'
}`}
title={hoverMode ? 'Live preview on (click to lock)' : 'Live preview off (click to enable)'}
>
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
</button>
<button
onClick={onClose}
className="text-warm-400 hover:text-warm-700 dark:hover:text-warm-300 p-1"
>
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
{stats && (
<p className="text-sm text-warm-600 dark:text-warm-400 mt-1">
{stats.count.toLocaleString()} properties
</p>
)}
{stats && (
<button
onClick={onViewProperties}
className="mt-2 w-full text-sm py-1.5 rounded bg-teal-600 hover:bg-teal-700 text-white font-medium"
>
View {stats.count.toLocaleString()} Properties
</button>
)}
</div>
{/* Stats content */}
<div className="flex-1 overflow-y-auto">
{loading && !stats ? (
<div className="p-4 text-warm-500 dark:text-warm-400 text-sm">Loading...</div>
) : stats ? (
<div className="p-3 space-y-4">
{featureGroups.map((group) => {
// Check if any feature in this group has data
const hasData = group.features.some(
(feature) => numericByName.has(feature.name) || enumByName.has(feature.name)
);
if (!hasData) return null;
return (
<div key={group.name}>
<h3 className="text-xs font-semibold text-warm-500 dark:text-warm-400 uppercase tracking-wider mb-2">
{group.name}
</h3>
<div className="space-y-3">
{group.features.map((feature) => {
const numericStats = numericByName.get(feature.name);
const enumStats = enumByName.get(feature.name);
if (numericStats) {
const maxCount = Math.max(...numericStats.histogram.counts);
return (
<div key={feature.name} className="bg-warm-50 dark:bg-navy-800 rounded p-2">
<div className="flex justify-between items-baseline">
<span className="text-xs text-warm-700 dark:text-warm-300 truncate mr-2">
{feature.name}
</span>
<span className="text-xs font-semibold text-teal-700 dark:text-teal-400 whitespace-nowrap">
{formatValue(numericStats.mean)}
</span>
</div>
<div className="flex justify-between text-[10px] text-warm-400 dark:text-warm-500 mt-0.5">
<span>{formatValue(numericStats.min)}</span>
<span>{formatValue(numericStats.max)}</span>
</div>
<MiniHistogram counts={numericStats.histogram.counts} maxCount={maxCount} />
</div>
);
}
if (enumStats) {
return (
<div key={feature.name} className="bg-warm-50 dark:bg-navy-800 rounded p-2">
<span className="text-xs text-warm-700 dark:text-warm-300">
{feature.name}
</span>
<EnumBarChart counts={enumStats.counts} />
</div>
);
}
return null;
})}
</div>
</div>
);
})}
</div>
) : null}
</div>
</div>
);
}

View file

@ -1,466 +0,0 @@
import { memo, useState, useRef, useCallback, useMemo, useEffect } from 'react';
import { Slider } from './ui/slider';
import { Label } from './ui/label';
import type { FeatureMeta, FeatureFilters } from '../types';
interface FiltersProps {
features: FeatureMeta[];
filters: FeatureFilters;
activeFeature: string | null;
dragValue: [number, number] | null;
enabledFeatures: Set<string>;
onAddFilter: (name: string) => void;
onRemoveFilter: (name: string) => void;
onFilterChange: (name: string, value: [number, number] | string[]) => void;
onDragStart: (name: string) => void;
onDragChange: (value: [number, number]) => void;
onDragEnd: () => void;
zoom: number;
pinnedFeature: string | null;
onTogglePin: (name: string) => void;
onCancelPin: () => void;
onNavigateToSource?: (slug: string, featureName: string) => void;
openInfoFeature?: string | null;
onClearOpenInfoFeature?: () => void;
}
function EyeIcon({ filled, className }: { filled: boolean; className?: string }) {
return (
<svg
className={className || 'w-3.5 h-3.5'}
viewBox="0 0 24 24"
fill={filled ? 'currentColor' : 'none'}
stroke="currentColor"
strokeWidth={2}
>
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
<circle cx="12" cy="12" r="3" />
</svg>
);
}
function InfoPopup({
feature,
onClose,
onNavigateToSource,
}: {
feature: FeatureMeta;
onClose: () => void;
onNavigateToSource?: (slug: string, featureName: string) => void;
}) {
const popupRef = useRef<HTMLDivElement>(null);
useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (popupRef.current && !popupRef.current.contains(e.target as Node)) {
onClose();
}
}
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [onClose]);
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30">
<div
ref={popupRef}
className="bg-white dark:bg-navy-800 border border-warm-200 dark:border-navy-700 rounded-lg shadow-xl max-w-md w-full mx-4 p-5"
>
<div className="flex items-start justify-between mb-3">
<h3 className="text-sm font-semibold text-warm-900 dark:text-warm-100 pr-4">
{feature.name}
</h3>
<button
onClick={onClose}
className="text-warm-400 hover:text-warm-700 dark:hover:text-warm-300 shrink-0"
>
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{feature.description && (
<p className="text-xs text-warm-500 dark:text-warm-400 mb-2">{feature.description}</p>
)}
{feature.detail && (
<p className="text-sm text-warm-700 dark:text-warm-300 mb-4 leading-relaxed">{feature.detail}</p>
)}
{feature.source && onNavigateToSource && (
<button
onClick={() => {
onNavigateToSource(feature.source!, feature.name);
onClose();
}}
className="text-sm text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300 hover:underline"
>
View data source
</button>
)}
</div>
</div>
);
}
function FeatureBrowser({
availableFeatures,
allFeatures,
pinnedFeature,
onAddFilter,
onTogglePin,
onNavigateToSource,
openInfoFeature,
onClearOpenInfoFeature,
}: {
availableFeatures: FeatureMeta[];
allFeatures: FeatureMeta[];
pinnedFeature: string | null;
onAddFilter: (name: string) => void;
onTogglePin: (name: string) => void;
onNavigateToSource?: (slug: string, featureName: string) => void;
openInfoFeature?: string | null;
onClearOpenInfoFeature?: () => void;
}) {
const [search, setSearch] = useState('');
const [infoFeature, setInfoFeature] = useState<FeatureMeta | null>(null);
// Auto-open info popup when navigating back
useEffect(() => {
if (openInfoFeature) {
const feat = allFeatures.find((f) => f.name === openInfoFeature);
if (feat) setInfoFeature(feat);
onClearOpenInfoFeature?.();
}
}, [openInfoFeature, allFeatures, onClearOpenInfoFeature]);
const filtered = useMemo(() => {
if (!search) return availableFeatures;
const lower = search.toLowerCase();
return availableFeatures.filter((f) => f.name.toLowerCase().includes(lower));
}, [availableFeatures, search]);
const grouped = useMemo(() => {
const groups: { name: string; features: FeatureMeta[] }[] = [];
const seen = new Map<string, FeatureMeta[]>();
for (const f of filtered) {
const g = f.group || 'Other';
let arr = seen.get(g);
if (!arr) {
arr = [];
seen.set(g, arr);
groups.push({ name: g, features: arr });
}
arr.push(f);
}
return groups;
}, [filtered]);
return (
<>
<div className="p-2 border-b border-warm-200 dark:border-navy-700">
<input
type="text"
placeholder="Search features..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full px-2 py-1 text-sm border rounded bg-white dark:bg-navy-800 dark:text-warm-200 border-warm-200 dark:border-navy-700 placeholder-warm-400 dark:placeholder-warm-500 focus:outline-none focus:ring-1 focus:ring-teal-400"
/>
</div>
<div className="flex-1 overflow-y-auto">
{grouped.map((group) => (
<div key={group.name}>
<div className="px-3 py-1.5 text-xs font-bold text-warm-500 bg-warm-50 dark:bg-navy-950 dark:text-warm-400 sticky top-0">
{group.name}
</div>
{group.features.map((f) => {
const isPinned = pinnedFeature === f.name;
return (
<div
key={f.name}
className="flex items-start justify-between px-3 py-1.5 hover:bg-teal-50 dark:hover:bg-teal-900/30 dark:text-warm-300"
>
<div className="min-w-0 mr-2">
<span className="text-sm truncate block">{f.name}</span>
{f.description && (
<span className="text-xs text-warm-400 dark:text-warm-500 truncate block">{f.description}</span>
)}
</div>
<div className="flex items-center gap-1 shrink-0 mt-0.5">
{f.detail && (
<button
onClick={() => setInfoFeature(f)}
className="text-warm-400 hover:text-warm-700 dark:hover:text-warm-300 p-0.5 rounded"
title="Feature info"
>
<svg className="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<circle cx="12" cy="12" r="10" />
<path strokeLinecap="round" d="M12 16v-4m0-4h.01" />
</svg>
</button>
)}
<button
onClick={() => onTogglePin(f.name)}
className={`p-0.5 rounded ${isPinned ? 'text-teal-600 dark:text-teal-400' : 'text-warm-400 hover:text-warm-700 dark:hover:text-warm-300'}`}
title={isPinned ? 'Unpin color view' : 'Color map by this feature'}
>
<EyeIcon filled={isPinned} />
</button>
<button
onClick={() => onAddFilter(f.name)}
className="text-warm-400 hover:text-warm-700 dark:hover:text-warm-300 p-0.5 rounded"
title="Add filter"
>
<svg className="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 5v14m-7-7h14" />
</svg>
</button>
</div>
</div>
);
})}
</div>
))}
{grouped.length === 0 && (
<div className="px-3 py-4 text-sm text-warm-400 dark:text-warm-500 text-center">
{search ? 'No matching features' : 'All features are active'}
</div>
)}
</div>
{infoFeature && (
<InfoPopup
feature={infoFeature}
onClose={() => setInfoFeature(null)}
onNavigateToSource={onNavigateToSource}
/>
)}
</>
);
}
function formatValue(value: number): string {
if (Math.abs(value) >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`;
if (Math.abs(value) >= 1_000) return `${(value / 1_000).toFixed(1)}k`;
if (Number.isInteger(value)) return value.toString();
return value.toFixed(2);
}
export default memo(function Filters({
features,
filters,
activeFeature,
dragValue,
enabledFeatures,
onAddFilter,
onRemoveFilter,
onFilterChange,
onDragStart,
onDragChange,
onDragEnd,
zoom,
pinnedFeature,
onTogglePin,
onCancelPin,
onNavigateToSource,
openInfoFeature,
onClearOpenInfoFeature,
}: FiltersProps) {
const availableFeatures = features.filter((f) => !enabledFeatures.has(f.name));
const enabledFeatureList = features.filter((f) => enabledFeatures.has(f.name));
const containerRef = useRef<HTMLDivElement>(null);
const [splitFraction, setSplitFraction] = useState(0.65);
const draggingRef = useRef(false);
const handleSeparatorPointerDown = useCallback(
(e: React.PointerEvent) => {
e.preventDefault();
(e.target as HTMLElement).setPointerCapture(e.pointerId);
draggingRef.current = true;
},
[]
);
const handleSeparatorPointerMove = useCallback(
(e: React.PointerEvent) => {
if (!draggingRef.current || !containerRef.current) return;
const rect = containerRef.current.getBoundingClientRect();
const y = e.clientY - rect.top;
const fraction = Math.min(0.8, Math.max(0.15, y / rect.height));
setSplitFraction(fraction);
},
[]
);
const handleSeparatorPointerUp = useCallback(() => {
draggingRef.current = false;
}, []);
return (
<div ref={containerRef} className="w-80 flex flex-col bg-white dark:bg-navy-950 shadow-lg overflow-hidden">
{/* Top: Active filters — user-resizable, scrollable */}
<div className="min-h-0 flex flex-col" style={{ height: `${splitFraction * 100}%` }}>
{/* Active Filters header */}
<div className="shrink-0 flex items-center justify-between px-3 py-2 border-b border-warm-200 dark:border-navy-700">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-navy-950 dark:text-warm-100">Active Filters</span>
{enabledFeatureList.length > 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">
{enabledFeatureList.length}
</span>
)}
</div>
<span className="text-xs text-warm-500 dark:text-warm-400">Zoom {zoom.toFixed(1)}</span>
</div>
<div className="flex-1 overflow-y-auto p-3 space-y-3">
{enabledFeatureList.length === 0 && (
<div className="flex flex-col items-center justify-center py-8 text-center">
<svg className="w-8 h-8 text-warm-300 dark:text-warm-600 mb-2" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 3c2.755 0 5.455.232 8.083.678.533.09.917.556.917 1.096v1.044a2.25 2.25 0 01-.659 1.591l-5.432 5.432a2.25 2.25 0 00-.659 1.591v2.927a2.25 2.25 0 01-1.244 2.013L9.75 21v-6.568a2.25 2.25 0 00-.659-1.591L3.659 7.409A2.25 2.25 0 013 5.818V4.774c0-.54.384-1.006.917-1.096A48.32 48.32 0 0112 3z" />
</svg>
<span className="text-sm font-medium text-warm-400 dark:text-warm-500">No active filters</span>
<span className="text-xs text-warm-400 dark:text-warm-500 mt-1">Browse features below and click + to add a filter</span>
</div>
)}
{enabledFeatureList.map((feature) => {
if (feature.type === 'enum') {
const selectedValues = (filters[feature.name] as string[]) || [];
const allValues = feature.values || [];
return (
<div key={feature.name} className={`space-y-1 p-3 rounded ${pinnedFeature === feature.name ? 'ring-2 ring-teal-400 bg-teal-50/50 dark:bg-teal-900/20' : ''}`}>
<div className="flex items-center justify-between">
<Label>{feature.name}</Label>
<div className="flex items-center gap-0.5">
<button
onClick={() => onTogglePin(feature.name)}
className={`p-0.5 rounded ${pinnedFeature === feature.name ? 'text-teal-600 dark:text-teal-400' : 'text-warm-400 hover:text-warm-700 dark:hover:text-warm-300'}`}
title={pinnedFeature === feature.name ? 'Unpin color view' : 'Color map by this feature'}
>
<EyeIcon filled={pinnedFeature === feature.name} />
</button>
<button
onClick={() => onRemoveFilter(feature.name)}
className="text-warm-400 hover:text-warm-700 dark:hover:text-warm-300 text-sm px-1"
title="Remove filter"
>
x
</button>
</div>
</div>
<div className="flex gap-2 text-sm mb-1">
<button
className="text-teal-600 dark:text-teal-400 hover:underline"
onClick={() => onFilterChange(feature.name, [...allValues])}
>
All
</button>
<button
className="text-teal-600 dark:text-teal-400 hover:underline"
onClick={() => onFilterChange(feature.name, [])}
>
None
</button>
</div>
<div className="space-y-0.5 max-h-40 overflow-y-auto">
{allValues.map((val) => (
<label key={val} className="flex items-center gap-1.5 text-sm cursor-pointer dark:text-warm-300">
<input
type="checkbox"
checked={selectedValues.includes(val)}
onChange={() => {
const next = selectedValues.includes(val)
? selectedValues.filter((v) => v !== val)
: [...selectedValues, val];
onFilterChange(feature.name, next);
}}
className="rounded accent-teal-600"
/>
{val}
</label>
))}
</div>
</div>
);
}
// Numeric feature
const isActive = activeFeature === feature.name;
const isPinned = pinnedFeature === feature.name;
const displayValue =
isActive && dragValue
? dragValue
: (filters[feature.name] as [number, number]) || [feature.min!, feature.max!];
const step = feature.step ?? (feature.max! - feature.min!) / 100;
return (
<div
key={feature.name}
className={`space-y-1 p-3 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="flex items-center justify-between">
<Label>
{feature.name}: {formatValue(displayValue[0])} - {formatValue(displayValue[1])}
</Label>
<div className="flex items-center gap-0.5">
<button
onClick={() => onTogglePin(feature.name)}
className={`p-0.5 rounded ${isPinned ? 'text-teal-600 dark:text-teal-400' : 'text-warm-400 hover:text-warm-700 dark:hover:text-warm-300'}`}
title={isPinned ? 'Unpin color view' : 'Color map by this feature'}
>
<EyeIcon filled={isPinned} />
</button>
<button
onClick={() => onRemoveFilter(feature.name)}
className="text-warm-400 hover:text-warm-700 dark:hover:text-warm-300 text-sm px-1"
title="Remove filter"
>
x
</button>
</div>
</div>
<Slider
min={feature.min!}
max={feature.max!}
step={step}
value={[displayValue[0], displayValue[1]]}
onValueChange={([min, max]) => onDragChange([min, max])}
onPointerDown={() => onDragStart(feature.name)}
onPointerUp={() => onDragEnd()}
/>
</div>
);
})}
</div>
</div>
{/* Draggable separator */}
<div
className="shrink-0 h-1.5 cursor-row-resize flex items-center justify-center bg-warm-100 dark:bg-navy-800 hover:bg-warm-200 dark:hover:bg-navy-700 border-y border-warm-200 dark:border-navy-700"
onPointerDown={handleSeparatorPointerDown}
onPointerMove={handleSeparatorPointerMove}
onPointerUp={handleSeparatorPointerUp}
>
<div className="w-8 h-0.5 rounded bg-warm-300 dark:bg-navy-600" />
</div>
{/* Bottom: Feature browser — fills remaining space */}
<div className="min-h-0 flex-1 flex flex-col">
<div className="shrink-0 px-3 py-2 border-b border-warm-200 dark:border-navy-700">
<span className="text-sm font-semibold text-navy-950 dark:text-warm-100">Add Filter</span>
</div>
<div className="min-h-0 flex-1 flex flex-col">
<FeatureBrowser
availableFeatures={availableFeatures}
allFeatures={features}
pinnedFeature={pinnedFeature}
onAddFilter={onAddFilter}
onTogglePin={onTogglePin}
onNavigateToSource={onNavigateToSource}
openInfoFeature={openInfoFeature}
onClearOpenInfoFeature={onClearOpenInfoFeature}
/>
</div>
</div>
</div>
);
});

View file

@ -1,367 +0,0 @@
import { useRef, useState, useEffect, useCallback } from 'react';
// --- Floating hex particle canvas that reacts to scroll ---
const HEX_COUNT = 60;
const TAU = Math.PI * 2;
interface Hex {
x: number;
y: number;
baseY: number;
size: number;
opacity: number;
speed: number; // horizontal drift px/s
phase: number; // for gentle bob
}
function initHexes(w: number, h: number): Hex[] {
const hexes: Hex[] = [];
for (let i = 0; i < HEX_COUNT; i++) {
const y = Math.random() * h;
hexes.push({
x: Math.random() * w,
y,
baseY: y,
size: 8 + Math.random() * 20,
opacity: 0.06 + Math.random() * 0.12,
speed: 6 + Math.random() * 14,
phase: Math.random() * TAU,
});
}
return hexes;
}
function drawHex(ctx: CanvasRenderingContext2D, cx: number, cy: number, r: number) {
ctx.beginPath();
for (let i = 0; i < 6; i++) {
const angle = (TAU / 6) * i - Math.PI / 6;
const px = cx + r * Math.cos(angle);
const py = cy + r * Math.sin(angle);
if (i === 0) ctx.moveTo(px, py);
else ctx.lineTo(px, py);
}
ctx.closePath();
}
function HexCanvas({ scrollProgress, isDark = false }: { scrollProgress: number; isDark?: boolean }) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const hexesRef = useRef<Hex[]>([]);
const animRef = useRef(0);
const scrollRef = useRef(scrollProgress);
scrollRef.current = scrollProgress;
const isDarkRef = useRef(isDark);
isDarkRef.current = isDark;
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
let w = 0;
let h = 0;
function resize() {
const dpr = window.devicePixelRatio || 1;
const rect = canvas!.parentElement!.getBoundingClientRect();
w = rect.width;
h = rect.height;
canvas!.width = w * dpr;
canvas!.height = h * dpr;
canvas!.style.width = `${w}px`;
canvas!.style.height = `${h}px`;
ctx!.setTransform(dpr, 0, 0, dpr, 0, 0);
hexesRef.current = initHexes(w, h);
}
resize();
const ro = new ResizeObserver(resize);
ro.observe(canvas.parentElement!);
let prev = performance.now();
function frame(now: number) {
const dt = (now - prev) / 1000;
prev = now;
const scroll = scrollRef.current;
ctx!.clearRect(0, 0, w, h);
// Teal accent color, fade to 0 as user scrolls down
const globalAlpha = Math.max(0, 1 - scroll * 2);
for (const hex of hexesRef.current) {
// drift right, wrap
hex.x = (hex.x + hex.speed * dt) % (w + hex.size * 2);
// gentle vertical bob + parallax push from scroll
const bob = Math.sin(now / 1000 + hex.phase) * 8;
const parallax = scroll * h * 0.3 * (hex.speed / 20);
hex.y = hex.baseY + bob - parallax;
// wrap vertically
if (hex.y < -hex.size * 2) hex.y += h + hex.size * 4;
if (hex.y > h + hex.size * 2) hex.y -= h + hex.size * 4;
const dark = isDarkRef.current;
ctx!.globalAlpha = hex.opacity * globalAlpha * (dark ? 0.6 : 1);
ctx!.fillStyle = dark ? '#058172' : '#00a28c';
drawHex(ctx!, hex.x, hex.y, hex.size);
ctx!.fill();
ctx!.globalAlpha = hex.opacity * 0.5 * globalAlpha * (dark ? 0.6 : 1);
ctx!.strokeStyle = dark ? '#0a665b' : '#05c9aa';
ctx!.lineWidth = 1;
drawHex(ctx!, hex.x, hex.y, hex.size);
ctx!.stroke();
}
animRef.current = requestAnimationFrame(frame);
}
animRef.current = requestAnimationFrame(frame);
return () => {
cancelAnimationFrame(animRef.current);
ro.disconnect();
};
}, []);
return (
<canvas
ref={canvasRef}
className="absolute inset-0 pointer-events-none"
style={{ zIndex: 0 }}
/>
);
}
// --- Fade-in hook ---
function useFadeInRef() {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const el = ref.current;
if (!el) return;
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
el.classList.add('fade-in-visible');
observer.unobserve(el);
}
},
{ threshold: 0.15 }
);
observer.observe(el);
return () => observer.disconnect();
}, []);
return ref;
}
// --- Page ---
export default function HomePage({ onOpenDashboard, theme = 'light' }: { onOpenDashboard: () => void; theme?: 'light' | 'dark' }) {
const scrollRef = useRef<HTMLDivElement>(null);
const [scrollProgress, setScrollProgress] = useState(0);
const handleScroll = useCallback(() => {
const el = scrollRef.current;
if (!el) return;
const max = el.scrollHeight - el.clientHeight;
if (max <= 0) return;
setScrollProgress(el.scrollTop / max);
}, []);
useEffect(() => {
const el = scrollRef.current;
if (!el) return;
el.addEventListener('scroll', handleScroll, { passive: true });
return () => el.removeEventListener('scroll', handleScroll);
}, [handleScroll]);
const heroRef = useFadeInRef();
const problemRef = useFadeInRef();
const filtersRef = useFadeInRef();
const howRef = useFadeInRef();
const numbersRef = useFadeInRef();
const ctaRef = useFadeInRef();
return (
<div ref={scrollRef} className="flex-1 overflow-y-auto bg-warm-50 dark:bg-navy-950 relative">
<HexCanvas scrollProgress={scrollProgress} isDark={theme === 'dark'} />
<div className="relative" style={{ zIndex: 1 }}>
{/* Hero */}
<div className="max-w-3xl mx-auto px-6 pt-20 pb-24">
<div
ref={heroRef}
className="fade-in-section backdrop-blur-sm bg-warm-50/60 dark:bg-navy-950/60 rounded-2xl p-8 -mx-2"
>
<p className="text-teal-600 font-semibold tracking-wide uppercase text-sm mb-4">
Find where to live, not just what&apos;s for sale
</p>
<h1 className="text-5xl font-extrabold text-navy-950 dark:text-warm-100 mb-6 leading-[1.1] tracking-tight">
Every neighbourhood
<br />
in England &amp; Wales.
<br />
<span className="text-teal-600">One map. Your&nbsp;rules.</span>
</h1>
<p className="text-xl text-warm-600 dark:text-warm-400 mb-8 leading-relaxed max-w-xl">
Set the commute, budget, school rating, noise level, and crime threshold you&apos;ll
accept. Narrowit shows you every area that qualifies &mdash; instantly.
</p>
<div className="flex items-center gap-4">
<button
onClick={onOpenDashboard}
className="px-7 py-3.5 bg-coral-500 text-white rounded-lg font-semibold hover:bg-coral-600 transition-colors text-base shadow-lg shadow-coral-500/25"
>
Explore the map
</button>
<span className="text-warm-400 text-sm">
No signup &middot; Free &middot; Open data
</span>
</div>
</div>
</div>
{/* The flip */}
<div className="max-w-3xl mx-auto px-6 pb-20">
<div ref={problemRef} className="fade-in-section">
<div className="rounded-2xl backdrop-blur-sm bg-warm-50/40 dark:bg-navy-800/40 border border-warm-200/50 dark:border-navy-700/50 p-8">
<div className="grid md:grid-cols-2 gap-8">
<div>
<h3 className="text-sm font-semibold text-warm-400 uppercase tracking-wide mb-2">
The old way
</h3>
<p className="text-warm-700 dark:text-warm-300 leading-relaxed">
Pick a postcode. Google the schools. Check crime stats on another site. Look up
commute times. Realise it&apos;s too expensive. Start over. Repeat 40 times.
</p>
</div>
<div>
<h3 className="text-sm font-semibold text-teal-600 uppercase tracking-wide mb-2">
With Narrowit
</h3>
<p className="text-warm-700 dark:text-warm-300 leading-relaxed">
Tell the map what you need. Every hexagon that lights up is a place worth
looking at. Drill into any one to see individual properties, prices, and energy
ratings.
</p>
</div>
</div>
</div>
</div>
</div>
{/* Filter showcase */}
<div className="max-w-4xl mx-auto px-6 pb-20">
<div ref={filtersRef} className="fade-in-section">
<h2 className="text-3xl font-bold text-navy-950 dark:text-warm-100 mb-2 text-center">
12 datasets. One slider&nbsp;each.
</h2>
<p className="text-warm-500 dark:text-warm-400 text-center mb-10 max-w-lg mx-auto">
Every filter narrows the map in real time. Combine as many as you like.
</p>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
{FILTERS.map((f) => (
<div
key={f.label}
className="rounded-xl bg-white dark:bg-navy-800 border border-warm-200 dark:border-navy-700 p-4 shadow-sm hover:shadow-md hover:border-teal-300 dark:hover:border-teal-600 transition-all"
>
<div className="text-2xl mb-2">{f.icon}</div>
<div className="font-semibold text-navy-950 dark:text-warm-100 text-sm">{f.label}</div>
<div className="text-xs text-warm-500 dark:text-warm-400 mt-0.5">{f.example}</div>
</div>
))}
</div>
</div>
</div>
{/* How it works */}
<div className="max-w-3xl mx-auto px-6 pb-20">
<div ref={howRef} className="fade-in-section">
<h2 className="text-3xl font-bold text-navy-950 dark:text-warm-100 mb-10 text-center">
Three clicks to clarity
</h2>
<div className="space-y-6">
{STEPS.map((step, i) => (
<div key={i} className="flex gap-5 items-start">
<span className="shrink-0 w-10 h-10 rounded-full bg-teal-600 text-white flex items-center justify-center text-lg font-bold">
{i + 1}
</span>
<div>
<h3 className="font-semibold text-navy-950 dark:text-warm-100 text-lg">{step.title}</h3>
<p className="text-warm-600 dark:text-warm-400 mt-0.5">{step.body}</p>
</div>
</div>
))}
</div>
</div>
</div>
{/* Numbers */}
<div className="max-w-3xl mx-auto px-6 pb-20">
<div ref={numbersRef} className="fade-in-section">
<div className="grid grid-cols-3 gap-6 text-center">
{STATS.map((s) => (
<div key={s.label}>
<div className="text-3xl font-extrabold text-teal-600">{s.value}</div>
<div className="text-sm text-warm-500 dark:text-warm-400 mt-1">{s.label}</div>
</div>
))}
</div>
</div>
</div>
{/* Final CTA */}
<div className="max-w-3xl mx-auto px-6 pb-24">
<div ref={ctaRef} className="fade-in-section text-center">
<h2 className="text-3xl font-bold text-navy-950 dark:text-warm-100 mb-3">Ready to narrow it down?</h2>
<p className="text-warm-500 dark:text-warm-400 mb-8 max-w-md mx-auto">
100% open data. No account required. Just set your filters and go.
</p>
<button
onClick={onOpenDashboard}
className="px-8 py-4 bg-coral-500 text-white rounded-lg font-semibold hover:bg-coral-600 transition-colors text-lg shadow-lg shadow-coral-500/25"
>
Open the map
</button>
</div>
</div>
</div>
</div>
);
}
// --- Data ---
const FILTERS = [
{ icon: '\u00A3', label: 'Sale price', example: 'e.g. under \u00A3400k' },
{ icon: '\uD83D\uDE86', label: 'Commute time', example: 'e.g. < 45 min to Bank' },
{ icon: '\uD83C\uDFEB', label: 'School quality', example: 'Ofsted Outstanding' },
{ icon: '\uD83D\uDEA8', label: 'Crime rate', example: 'Low burglary areas' },
{ icon: '\u26A1', label: 'Energy rating', example: 'EPC band A\u2013C' },
{ icon: '\uD83D\uDCCF', label: 'Floor area', example: 'e.g. 80+ sqm' },
{ icon: '\uD83D\uDD07', label: 'Road noise', example: 'Below 55 dB Lden' },
{ icon: '\uD83C\uDF10', label: 'Broadband speed', example: '100+ Mbps available' },
];
const STEPS = [
{
title: 'Add your deal-breakers',
body: 'Slide the filters for everything you care about \u2014 price cap, max commute, school quality, noise. The map updates as you drag.',
},
{
title: 'Spot the clusters',
body: 'Hexagons light up where properties match. Zoom in and they split into finer cells. At street level you see individual postcode boundaries.',
},
{
title: 'Dive into a neighbourhood',
body: 'Click any hexagon to see every property inside it \u2014 sale prices, floor plans, energy ratings, tenure. Layer on cafes, GP surgeries, and parks from OpenStreetMap.',
},
];
const STATS = [
{ value: '26M+', label: 'property records' },
{ value: '12', label: 'open datasets' },
{ value: '1.7M', label: 'postcodes mapped' },
];

View file

@ -1,691 +0,0 @@
import { useCallback, useRef, useEffect, useState, useMemo, memo } from 'react';
import { Map as MapGL, useControl } from 'react-map-gl/maplibre';
import type { MapRef } from 'react-map-gl/maplibre';
import { MapboxOverlay } from '@deck.gl/mapbox';
import { H3HexagonLayer } from '@deck.gl/geo-layers';
import { IconLayer, TextLayer } from '@deck.gl/layers';
import type { PickingInfo } from '@deck.gl/core';
import 'maplibre-gl/dist/maplibre-gl.css';
import type { HexagonData, ViewState, ViewChangeParams, Bounds, POI, FeatureMeta } from '../types';
interface MapProps {
data: HexagonData[];
pois: POI[];
onViewChange: (params: ViewChangeParams) => void;
viewFeature: string | null;
colorRange: [number, number] | null;
filterRange: [number, number] | null;
viewSource: 'drag' | 'eye' | null;
onCancelPin: () => void;
features: FeatureMeta[];
selectedHexagonId: string | null;
hoveredHexagonId: string | null;
onHexagonClick: (h3: string) => void;
onHexagonHover: (h3: string | null) => void;
initialViewState?: ViewState;
theme?: 'light' | 'dark';
}
// Twemoji CDN base URL
const TWEMOJI_BASE = 'https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/';
// Convert emoji to Twemoji URL
function emojiToTwemojiUrl(emoji: string): string {
// Convert emoji to Unicode codepoint hex
const codePoint = emoji.codePointAt(0);
if (!codePoint) return `${TWEMOJI_BASE}1f4cd.png`; // Default pin
const hex = codePoint.toString(16);
return `${TWEMOJI_BASE}${hex}.png`;
}
const INITIAL_VIEW: ViewState = {
longitude: -1.5,
latitude: 53.5,
zoom: 6,
pitch: 0,
};
const MAP_STYLE_LIGHT = 'https://basemaps.cartocdn.com/gl/voyager-gl-style/style.json';
const MAP_STYLE_DARK = 'https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json';
// Gradient stops for normalized [0,1] values
const GRADIENT: { t: number; color: [number, number, number] }[] = [
{ t: 0, color: [46, 204, 113] }, // Green
{ t: 0.33, color: [241, 196, 15] }, // Yellow
{ t: 0.66, color: [231, 76, 60] }, // Red
{ t: 1, color: [142, 68, 173] }, // Purple
];
function normalizedToColor(t: number): [number, number, number] {
if (t <= 0) return GRADIENT[0].color;
if (t >= 1) return GRADIENT[GRADIENT.length - 1].color;
for (let i = 0; i < GRADIENT.length - 1; i++) {
const lo = GRADIENT[i];
const hi = GRADIENT[i + 1];
if (t >= lo.t && t <= hi.t) {
const frac = (t - lo.t) / (hi.t - lo.t);
return [
Math.round(lo.color[0] + (hi.color[0] - lo.color[0]) * frac),
Math.round(lo.color[1] + (hi.color[1] - lo.color[1]) * frac),
Math.round(lo.color[2] + (hi.color[2] - lo.color[2]) * frac),
];
}
}
return GRADIENT[GRADIENT.length - 1].color;
}
function zoomToResolution(zoom: number): number {
if (zoom < 6) return 5;
if (zoom < 7) return 6;
if (zoom < 9.5) return 8;
if (zoom < 11) return 9;
if (zoom < 13) return 10;
if (zoom < 15) return 11;
return 12;
}
function getBoundsFromViewState(viewState: ViewState, width: number, height: number): Bounds {
const { longitude, latitude, zoom } = viewState;
// Clamp latitude to valid Mercator range to avoid math errors
const clampedLat = Math.max(-85, Math.min(85, latitude));
// Web Mercator projection math
const TILE_SIZE = 256;
const scale = Math.pow(2, zoom);
const worldSize = TILE_SIZE * scale;
// Longitude is linear
const degreesPerPixelLng = 360 / worldSize;
const halfWidthDeg = (width / 2) * degreesPerPixelLng;
// Latitude uses Mercator projection (non-linear)
const latRad = (clampedLat * Math.PI) / 180;
const mercatorY = (1 - Math.log(Math.tan(latRad) + 1 / Math.cos(latRad)) / Math.PI) / 2;
const centerPixelY = mercatorY * worldSize;
const topPixelY = centerPixelY - height / 2;
const bottomPixelY = centerPixelY + height / 2;
// Convert pixel Y back to latitude
const pixelYToLat = (pixelY: number): number => {
const mercY = Math.max(0.001, Math.min(0.999, pixelY / worldSize));
const latRadians = Math.atan(Math.sinh(Math.PI * (1 - 2 * mercY)));
return (latRadians * 180) / Math.PI;
};
const north = Math.min(85, pixelYToLat(topPixelY));
const south = Math.max(-85, pixelYToLat(bottomPixelY));
const west = Math.max(-180, longitude - halfWidthDeg);
const east = Math.min(180, longitude + halfWidthDeg);
return { south, west, north, east };
}
interface Dimensions {
width: number;
height: number;
}
function DeckOverlay({
layers,
getTooltip,
}: {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
layers: any[];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
getTooltip: any;
}) {
const overlay = useControl(() => new MapboxOverlay({ interleaved: true }));
const prevLayersRef = useRef(layers);
const prevTooltipRef = useRef(getTooltip);
if (layers !== prevLayersRef.current || getTooltip !== prevTooltipRef.current) {
prevLayersRef.current = layers;
prevTooltipRef.current = getTooltip;
overlay.setProps({ layers, getTooltip });
}
return null;
}
// Vibrant density scale: light cyan → teal → deep indigo
const DENSITY_GRADIENT: { t: number; color: [number, number, number] }[] = [
{ t: 0, color: [130, 234, 220] }, // Light cyan (few)
{ t: 0.5, color: [20, 140, 180] }, // Ocean blue (moderate)
{ t: 1, color: [88, 28, 140] }, // Deep indigo (many)
];
function countToColor(t: number): [number, number, number] {
if (t <= 0) return DENSITY_GRADIENT[0].color;
if (t >= 1) return DENSITY_GRADIENT[DENSITY_GRADIENT.length - 1].color;
for (let i = 0; i < DENSITY_GRADIENT.length - 1; i++) {
const lo = DENSITY_GRADIENT[i];
const hi = DENSITY_GRADIENT[i + 1];
if (t >= lo.t && t <= hi.t) {
const frac = (t - lo.t) / (hi.t - lo.t);
return [
Math.round(lo.color[0] + (hi.color[0] - lo.color[0]) * frac),
Math.round(lo.color[1] + (hi.color[1] - lo.color[1]) * frac),
Math.round(lo.color[2] + (hi.color[2] - lo.color[2]) * frac),
];
}
}
return DENSITY_GRADIENT[DENSITY_GRADIENT.length - 1].color;
}
function PostcodeSearch({
onFlyTo,
}: {
onFlyTo: (lat: number, lng: number, zoom: number) => void;
}) {
const [query, setQuery] = useState('');
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const handleSubmit = useCallback(
async (e: React.FormEvent) => {
e.preventDefault();
const trimmed = query.trim();
if (!trimmed) return;
setError(null);
setLoading(true);
try {
const res = await fetch(
`https://api.postcodes.io/postcodes/${encodeURIComponent(trimmed)}`
);
if (!res.ok) {
setError('Postcode not found');
return;
}
const json = await res.json();
if (json.status === 200 && json.result) {
onFlyTo(json.result.latitude, json.result.longitude, 14);
setQuery('');
} else {
setError('Postcode not found');
}
} catch {
setError('Lookup failed');
} finally {
setLoading(false);
}
},
[query, onFlyTo]
);
return (
<form onSubmit={handleSubmit} className="absolute top-3 left-3 z-10 flex flex-col gap-1">
<div className="flex shadow-lg rounded overflow-hidden">
<input
type="text"
value={query}
onChange={(e) => {
setQuery(e.target.value);
setError(null);
}}
placeholder="Search postcode..."
className="px-3 py-2 text-sm w-40 border-none outline-none bg-white dark:bg-navy-800 dark:text-warm-100 dark:placeholder-warm-500"
/>
<button
type="submit"
disabled={loading}
className="px-3 py-2 bg-teal-600 text-white text-sm hover:bg-teal-700 disabled:opacity-50"
>
{loading ? '...' : 'Go'}
</button>
</div>
{error && (
<span className="text-xs text-red-600 dark:text-red-400 bg-white/90 dark:bg-navy-800/90 rounded px-2 py-0.5 shadow">{error}</span>
)}
</form>
);
}
function MapLegend({
featureLabel,
range,
showCancel,
onCancel,
mode,
enumValues,
}: {
featureLabel: string;
range: [number, number];
showCancel: boolean;
onCancel: () => void;
mode: 'feature' | 'density';
enumValues?: string[];
}) {
const formatVal = (v: number) => {
if (Math.abs(v) >= 1_000_000) return `${(v / 1_000_000).toFixed(1)}M`;
if (Math.abs(v) >= 1_000) return `${(v / 1_000).toFixed(1)}k`;
if (Number.isInteger(v)) return v.toString();
return v.toFixed(1);
};
const gradientStyle =
mode === 'density'
? 'linear-gradient(to right, rgb(130, 234, 220), rgb(20, 140, 180), rgb(88, 28, 140))'
: 'linear-gradient(to right, rgb(46, 204, 113), rgb(241, 196, 15), rgb(231, 76, 60), rgb(142, 68, 173))';
return (
<div className="absolute top-3 right-3 z-10 bg-white dark:bg-navy-800 dark:text-warm-200 rounded shadow-lg p-3 text-xs min-w-[160px]">
<div className="flex items-center justify-between mb-2">
<span className="font-semibold text-sm">{featureLabel}</span>
{showCancel && (
<button
onClick={onCancel}
className="text-warm-400 hover:text-warm-700 dark:hover:text-warm-300 ml-2"
title="Clear color view"
>
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
)}
</div>
<div
className="h-3 rounded"
style={{ background: gradientStyle }}
/>
<div className="flex justify-between mt-1 text-warm-600 dark:text-warm-400">
{mode === 'density' ? (
<>
<span>Few</span>
<span>Many</span>
</>
) : enumValues && enumValues.length > 0 ? (
<>
<span>{enumValues[0]}</span>
<span>{enumValues[enumValues.length - 1]}</span>
</>
) : (
<>
<span>{formatVal(range[0])}</span>
<span>{formatVal(range[1])}</span>
</>
)}
</div>
</div>
);
}
export default memo(function Map({
data,
pois,
onViewChange,
viewFeature,
colorRange,
filterRange,
viewSource,
onCancelPin,
features,
selectedHexagonId,
hoveredHexagonId,
onHexagonClick,
onHexagonHover,
initialViewState,
theme = 'light',
}: MapProps) {
const containerRef = useRef<HTMLDivElement>(null);
const [viewState, setViewState] = useState<ViewState>(initialViewState || INITIAL_VIEW);
const [dimensions, setDimensions] = useState<Dimensions>({ width: 0, height: 0 });
// Track container dimensions with ResizeObserver
useEffect(() => {
const container = containerRef.current;
if (!container) return;
const observer = new ResizeObserver((entries) => {
const { width, height } = entries[0].contentRect;
if (width > 0 && height > 0) {
setDimensions({ width, height });
}
});
observer.observe(container);
return () => observer.disconnect();
}, []);
// Notify parent when view or dimensions change
useEffect(() => {
if (dimensions.width === 0 || dimensions.height === 0) return;
const raw = getBoundsFromViewState(viewState, dimensions.width, dimensions.height);
const resolution = zoomToResolution(viewState.zoom);
// Quantize bounds to 0.01° to reduce state churn and improve backend cache hits
const QUANT = 0.01;
const bounds: Bounds = {
south: Math.floor(raw.south / QUANT) * QUANT,
west: Math.floor(raw.west / QUANT) * QUANT,
north: Math.ceil(raw.north / QUANT) * QUANT,
east: Math.ceil(raw.east / QUANT) * QUANT,
};
onViewChange({
resolution,
bounds,
zoom: viewState.zoom,
latitude: viewState.latitude,
longitude: viewState.longitude,
});
}, [viewState, dimensions, onViewChange]);
const handleMove = useCallback((evt: { viewState: ViewState }) => {
setViewState(evt.viewState);
}, []);
const handleFlyTo = useCallback((lat: number, lng: number, zoom: number) => {
setViewState((prev) => ({ ...prev, latitude: lat, longitude: lng, zoom }));
}, []);
const themeRef = useRef(theme);
themeRef.current = theme;
// Make place labels more legible over the colored hexagons
const handleMapLoad = useCallback(
(evt: { target: MapRef['getMap'] extends () => infer M ? M : never }) => {
const map = evt.target;
if (themeRef.current === 'light') {
for (const layer of map.getStyle().layers || []) {
if (layer.type !== 'symbol') continue;
map.setPaintProperty(layer.id, 'text-halo-color', 'rgba(255,255,255,1)');
map.setPaintProperty(layer.id, 'text-halo-width', 2);
map.setPaintProperty(layer.id, 'text-color', '#222');
}
// Make water more prominent
for (const layer of map.getStyle().layers || []) {
if (layer.id === 'water' || layer.id.startsWith('water')) {
map.setPaintProperty(layer.id, 'fill-color', '#6baed6');
}
}
}
try {
map.setLayoutProperty('building', 'visibility', 'none');
map.setLayoutProperty('building-top', 'visibility', 'none');
} catch {
// layers may not exist in dark style
}
},
[]
);
const mapStyle = theme === 'dark' ? MAP_STYLE_DARK : MAP_STYLE_LIGHT;
// Popup state for POI hover
const [popupInfo, setPopupInfo] = useState<{
x: number;
y: number;
name: string;
category: string;
} | null>(null);
const handlePoiHover = useCallback((info: PickingInfo<POI>) => {
if (info.object && info.x !== undefined && info.y !== undefined) {
setPopupInfo({
x: info.x,
y: info.y,
name: info.object.name,
category: info.object.category,
});
} else {
setPopupInfo(null);
}
}, []);
// Compute count range for count-based coloring
const countRange = useMemo(() => {
if (data.length === 0) return { min: 0, max: 1 };
let min = Infinity;
let max = -Infinity;
for (const d of data) {
const c = d.count as number;
if (c < min) min = c;
if (c > max) max = c;
}
if (min === max) return { min, max: min + 1 };
return { min, max };
}, [data]);
// Memoize feature lookup to avoid new reference each render
const colorFeatureMeta = useMemo(
() => (viewFeature ? features.find((f) => f.name === viewFeature) || null : null),
[viewFeature, features]
);
// Use refs for values that change during drag so layers aren't recreated
const viewFeatureRef = useRef(viewFeature);
viewFeatureRef.current = viewFeature;
const colorRangeRef = useRef(colorRange);
colorRangeRef.current = colorRange;
const filterRangeRef = useRef(filterRange);
filterRangeRef.current = filterRange;
const colorFeatureMetaRef = useRef(colorFeatureMeta);
colorFeatureMetaRef.current = colorFeatureMeta;
const countRangeRef = useRef(countRange);
countRangeRef.current = countRange;
const selectedHexagonIdRef = useRef(selectedHexagonId);
selectedHexagonIdRef.current = selectedHexagonId;
const hoveredHexagonIdRef = useRef(hoveredHexagonId);
hoveredHexagonIdRef.current = hoveredHexagonId;
// Stable click handler using ref
const onHexagonClickRef = useRef(onHexagonClick);
onHexagonClickRef.current = onHexagonClick;
const handleHexagonClick = useCallback((info: PickingInfo<HexagonData>) => {
if (info.object && 'h3' in info.object) {
onHexagonClickRef.current(info.object.h3);
}
}, []);
// Stable hover handler using ref
const onHexagonHoverRef = useRef(onHexagonHover);
onHexagonHoverRef.current = onHexagonHover;
const handleHexagonHover = useCallback((info: PickingInfo<HexagonData>) => {
if (info.object && 'h3' in info.object) {
onHexagonHoverRef.current(info.object.h3);
} else {
onHexagonHoverRef.current(null);
}
}, []);
// Stable hover handler using ref
const handlePoiHoverRef = useRef(handlePoiHover);
handlePoiHoverRef.current = handlePoiHover;
const stablePoiHover = useCallback((info: PickingInfo<POI>) => {
handlePoiHoverRef.current(info);
}, []);
// Derive a trigger value from color-affecting state — avoids useEffect+setState double-render
const colorTrigger = `${viewFeature}|${colorRange?.[0]}|${colorRange?.[1]}|${filterRange?.[0]}|${filterRange?.[1]}|${countRange.min}|${countRange.max}|${selectedHexagonId}|${hoveredHexagonId}`;
// Hexagon layer — only recreated when data or color trigger changes
const hexLayer = useMemo(
() =>
new H3HexagonLayer<HexagonData>({
id: 'h3-hexagons',
data,
getHexagon: (d) => d.h3,
getFillColor: (d) => {
const vf = viewFeatureRef.current;
const clr = colorRangeRef.current;
const fr = filterRangeRef.current;
const cfm = colorFeatureMetaRef.current;
if (vf && clr && cfm) {
const val = d[`min_${vf}`];
if (val == null) return [128, 128, 128, 80] as [number, number, number, number];
// Gray out hexagons outside filter range
if (fr) {
const minVal = d[`min_${vf}`] as number;
const maxVal = d[`max_${vf}`] as number;
if (maxVal < fr[0] || minVal > fr[1]) {
return [180, 180, 180, 60] as [number, number, number, number];
}
}
// Color using full slider range
const range = clr[1] - clr[0];
if (range === 0) return [...GRADIENT[0].color, 200] as [number, number, number, number];
const t = ((val as number) - clr[0]) / range;
const rgb = normalizedToColor(Math.max(0, Math.min(1, t)));
return [...rgb, 200] as [number, number, number, number];
}
const cr = countRangeRef.current;
const c = d.count as number;
const t = (c - cr.min) / (cr.max - cr.min);
return [...countToColor(Math.max(0, Math.min(1, t))), 200] as [
number,
number,
number,
number,
];
},
getLineColor: (d) => {
if (d.h3 === selectedHexagonIdRef.current) return [255, 255, 255, 255] as [number, number, number, number];
if (d.h3 === hoveredHexagonIdRef.current) return [29, 228, 195, 200] as [number, number, number, number];
return [0, 0, 0, 0] as [number, number, number, number];
},
getLineWidth: (d) => {
if (d.h3 === selectedHexagonIdRef.current) return 3;
if (d.h3 === hoveredHexagonIdRef.current) return 2;
return 0;
},
lineWidthUnits: 'pixels',
updateTriggers: {
getFillColor: [colorTrigger],
getLineColor: [colorTrigger],
getLineWidth: [colorTrigger],
},
extruded: false,
pickable: true,
opacity: 1,
highPrecision: true,
onClick: handleHexagonClick,
onHover: handleHexagonHover,
// @ts-expect-error beforeId is a MapboxOverlay interleave prop, not typed in LayerProps
beforeId: 'waterway_label',
}),
[data, colorTrigger, handleHexagonClick, handleHexagonHover]
);
// POI layer — independent, only recreated when POI data changes
const poiLayer = useMemo(
() =>
new IconLayer<POI>({
id: 'poi-icons',
data: pois,
getPosition: (d) => [d.lng, d.lat],
getIcon: (d) => ({
url: emojiToTwemojiUrl(d.emoji),
width: 72,
height: 72,
}),
getSize: 24,
sizeMinPixels: 20,
sizeMaxPixels: 40,
pickable: true,
onHover: stablePoiHover,
}),
[pois, stablePoiHover]
);
// Postcode labels on high-res hexagons (resolution 11+, zoom >= 13)
const postcodeData = useMemo(
() => data.filter((d) => d.postcode && d.lat != null && d.lon != null),
[data]
);
const showPostcodes = viewState.zoom >= 13;
const postcodeLayer = useMemo(
() =>
showPostcodes
? new TextLayer<HexagonData>({
id: 'postcode-labels',
data: postcodeData,
getPosition: (d) => [d.lon as number, d.lat as number],
getText: (d) => d.postcode as string,
getSize: 11,
getColor: theme === 'dark' ? [220, 220, 220, 220] : [30, 30, 30, 220],
getTextAnchor: 'middle',
getAlignmentBaseline: 'center',
fontFamily: 'Inter, system-ui, sans-serif',
fontWeight: 600,
outlineWidth: 2,
outlineColor: theme === 'dark' ? [30, 30, 30, 200] : [255, 255, 255, 200],
billboard: false,
sizeUnits: 'pixels',
sizeMinPixels: 10,
sizeMaxPixels: 14,
})
: null,
[postcodeData, showPostcodes, theme]
);
const layers = useMemo(
() => [hexLayer, poiLayer, ...(postcodeLayer ? [postcodeLayer] : [])],
[hexLayer, poiLayer, postcodeLayer]
);
return (
<div className="flex-1 h-full relative" ref={containerRef}>
<MapGL
{...viewState}
onMove={handleMove}
onLoad={handleMapLoad as never}
mapStyle={mapStyle}
style={{ width: '100%', height: '100%' }}
attributionControl={false}
dragRotate={false}
touchZoomRotate={true}
touchPitch={false}
keyboard={true}
pitchWithRotate={false}
minZoom={5}
maxBounds={[-12, 49, 4, 62]}
>
<DeckOverlay layers={layers} getTooltip={null} />
</MapGL>
<PostcodeSearch onFlyTo={handleFlyTo} />
{viewFeature && colorRange && colorFeatureMeta ? (
<MapLegend
featureLabel={colorFeatureMeta.name}
range={colorRange}
showCancel={viewSource === 'eye'}
onCancel={onCancelPin}
mode="feature"
enumValues={colorFeatureMeta.type === 'enum' ? colorFeatureMeta.values : undefined}
/>
) : (
<MapLegend
featureLabel="Property density"
range={[0, 0]}
showCancel={false}
onCancel={onCancelPin}
mode="density"
/>
)}
{popupInfo && (
<div
className="absolute pointer-events-none bg-white dark:bg-navy-800 rounded shadow-lg p-2 text-sm dark:text-warm-200"
style={{
left: popupInfo.x,
top: popupInfo.y - 40,
transform: 'translateX(-50%)',
zIndex: 9999,
}}
>
<strong>{popupInfo.name}</strong>
<div className="text-gray-500 dark:text-warm-400 text-xs">{popupInfo.category}</div>
</div>
)}
</div>
);
});

View file

@ -1,297 +0,0 @@
import { useState, useRef, useEffect, useCallback } from 'react';
import type { POICategoryGroup } from '../types';
interface POIPaneProps {
groups: POICategoryGroup[];
selectedCategories: Set<string>;
onCategoriesChange: (categories: Set<string>) => void;
poiCount: number;
onNavigateToSource?: (slug: string) => void;
}
export default function POIPane({
groups,
selectedCategories,
onCategoriesChange,
poiCount,
onNavigateToSource,
}: POIPaneProps) {
const [dropdownOpen, setDropdownOpen] = useState(false);
const [searchTerm, setSearchTerm] = useState('');
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set());
const [showInfo, setShowInfo] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
const infoPopupRef = useRef<HTMLDivElement>(null);
// Close dropdown when clicking outside
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setDropdownOpen(false);
}
}
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
// Close info popup when clicking outside
useEffect(() => {
if (!showInfo) return;
function handleClickOutside(e: MouseEvent) {
if (infoPopupRef.current && !infoPopupRef.current.contains(e.target as Node)) {
setShowInfo(false);
}
}
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [showInfo]);
const allCategories = groups.flatMap((g) => g.categories);
const toggleCategory = (category: string) => {
const newSet = new Set(selectedCategories);
if (newSet.has(category)) {
newSet.delete(category);
} else {
newSet.add(category);
}
onCategoriesChange(newSet);
};
const selectAll = () => {
onCategoriesChange(new Set(allCategories));
};
const selectNone = () => {
onCategoriesChange(new Set());
};
const toggleGroup = useCallback(
(groupName: string) => {
const group = groups.find((g) => g.name === groupName);
if (!group) return;
const allSelected = group.categories.every((c) => selectedCategories.has(c));
const newSet = new Set(selectedCategories);
if (allSelected) {
group.categories.forEach((c) => newSet.delete(c));
} else {
group.categories.forEach((c) => newSet.add(c));
}
onCategoriesChange(newSet);
},
[groups, selectedCategories, onCategoriesChange]
);
const toggleCollapse = (groupName: string) => {
setCollapsedGroups((prev) => {
const next = new Set(prev);
if (next.has(groupName)) {
next.delete(groupName);
} else {
next.add(groupName);
}
return next;
});
};
const lowerSearch = searchTerm.toLowerCase();
// Filter groups and categories by search term
const filteredGroups = groups
.map((group) => {
if (!searchTerm) return group;
const matchingCats = group.categories.filter((c) => c.toLowerCase().includes(lowerSearch));
const groupMatches = group.name.toLowerCase().includes(lowerSearch);
if (groupMatches) return group;
if (matchingCats.length === 0) return null;
return { ...group, categories: matchingCats };
})
.filter(Boolean) as POICategoryGroup[];
const selectedCount = selectedCategories.size;
return (
<div className="w-72 p-4 bg-white dark:bg-navy-950 shadow-lg space-y-4 overflow-y-auto max-h-screen">
<div className="flex items-center gap-2">
<h2 className="text-xl font-bold dark:text-warm-100">Points of Interest</h2>
<button
onClick={() => setShowInfo(true)}
className="text-warm-400 hover:text-warm-700 dark:hover:text-warm-300 p-0.5 rounded"
title="Data source info"
>
<svg className="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<circle cx="12" cy="12" r="10" />
<path strokeLinecap="round" d="M12 16v-4m0-4h.01" />
</svg>
</button>
</div>
{showInfo && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30">
<div
ref={infoPopupRef}
className="bg-white dark:bg-navy-800 border border-warm-200 dark:border-navy-700 rounded-lg shadow-xl max-w-md w-full mx-4 p-5"
>
<div className="flex items-start justify-between mb-3">
<h3 className="text-sm font-semibold text-warm-900 dark:text-warm-100 pr-4">
Points of Interest
</h3>
<button
onClick={() => setShowInfo(false)}
className="text-warm-400 hover:text-warm-700 dark:hover:text-warm-300 shrink-0"
>
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<p className="text-sm text-warm-700 dark:text-warm-300 mb-4 leading-relaxed">
Points of interest are sourced from OpenStreetMap via Geofabrik extracts.
Categories include public transport stops, shops, restaurants, healthcare
facilities, leisure venues, and more. Data is filtered and mapped to
friendly names with exhaustive category coverage.
</p>
{onNavigateToSource && (
<button
onClick={() => {
onNavigateToSource('osm-pois');
setShowInfo(false);
}}
className="text-sm text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300 hover:underline"
>
View data source
</button>
)}
</div>
</div>
)}
<div className="space-y-2" ref={dropdownRef}>
<button
onClick={() => setDropdownOpen(!dropdownOpen)}
className="w-full flex items-center justify-between px-3 py-2 text-sm border border-warm-300 dark:border-navy-700 rounded hover:border-warm-400 bg-white dark:bg-navy-800 dark:text-warm-200"
>
<span className="truncate text-left">
{selectedCount === 0
? 'Select categories...'
: selectedCount === allCategories.length
? 'All categories'
: `${selectedCount} selected`}
</span>
<svg
className={`w-4 h-4 ml-2 flex-shrink-0 transition-transform ${dropdownOpen ? 'rotate-180' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{dropdownOpen && (
<div className="border border-warm-300 dark:border-navy-700 rounded shadow-lg bg-white dark:bg-navy-800">
<div className="flex gap-2 px-3 py-2 border-b border-warm-200 dark:border-navy-700">
<button onClick={selectAll} className="text-xs text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300">
All
</button>
<span className="text-xs text-warm-300 dark:text-warm-600">|</span>
<button onClick={selectNone} className="text-xs text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300">
None
</button>
</div>
<div className="px-3 py-2 border-b border-warm-200 dark:border-navy-700">
<input
type="text"
placeholder="Search categories..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full px-2 py-1 text-sm border border-warm-300 dark:border-navy-700 rounded bg-white dark:bg-navy-950 dark:text-warm-200 dark:placeholder-warm-500"
/>
</div>
<div className="max-h-96 overflow-y-auto py-1">
{filteredGroups.map((group) => {
const groupSelected = group.categories.filter((c) =>
selectedCategories.has(c)
).length;
const allInGroupSelected = groupSelected === group.categories.length;
const someInGroupSelected = groupSelected > 0 && !allInGroupSelected;
const isCollapsed = collapsedGroups.has(group.name) && !searchTerm;
return (
<div key={group.name}>
<div className="flex items-center gap-1 px-3 py-1.5 bg-warm-50 dark:bg-navy-950 border-y border-warm-100 dark:border-navy-700">
<button
onClick={() => toggleCollapse(group.name)}
className="p-0.5 text-warm-400 hover:text-warm-600"
>
<svg
className={`w-3 h-3 transition-transform ${isCollapsed ? '' : 'rotate-90'}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 5l7 7-7 7"
/>
</svg>
</button>
<label className="flex items-center gap-2 flex-1 cursor-pointer">
<input
type="checkbox"
checked={allInGroupSelected}
ref={(el) => {
if (el) el.indeterminate = someInGroupSelected;
}}
onChange={() => toggleGroup(group.name)}
className="rounded accent-teal-600"
/>
<span className="text-xs font-semibold text-warm-700 dark:text-warm-300">{group.name}</span>
</label>
<span className="text-xs text-warm-400">
{groupSelected}/{group.categories.length}
</span>
</div>
{!isCollapsed &&
group.categories.map((category) => (
<label
key={category}
className="flex items-center gap-2 px-3 pl-8 py-1.5 hover:bg-warm-50 dark:hover:bg-navy-700 cursor-pointer dark:text-warm-300"
>
<input
type="checkbox"
checked={selectedCategories.has(category)}
onChange={() => toggleCategory(category)}
className="rounded accent-teal-600"
/>
<span className="text-sm flex-1">{category}</span>
</label>
))}
</div>
);
})}
</div>
</div>
)}
</div>
{selectedCount > 0 && (
<div className="p-3 bg-teal-50 dark:bg-teal-900/30 rounded text-sm">
<div className="font-medium text-teal-900 dark:text-teal-300">
{poiCount.toLocaleString()} POI{poiCount !== 1 ? 's' : ''} visible
</div>
<div className="text-xs text-teal-700 dark:text-teal-400 mt-1">
{selectedCount} categor{selectedCount !== 1 ? 'ies' : 'y'} selected
</div>
</div>
)}
<div className="p-3 bg-warm-100 dark:bg-navy-800 rounded text-xs text-warm-600 dark:text-warm-400">
<p>Select categories to display POIs on the map.</p>
<p className="mt-2">Zoom in for better visibility of individual locations.</p>
</div>
</div>
);
}

View file

@ -1,316 +0,0 @@
import React, { useMemo, useState, useRef, useEffect } from 'react';
import { Property } from '../types';
interface PropertiesPaneProps {
properties: Property[];
total: number;
loading: boolean;
hexagonId: string | null;
onLoadMore: () => void;
onClose: () => void;
onNavigateToSource?: (slug: string) => void;
isHoveredPreview?: boolean;
hoverMode?: boolean;
onHoverModeChange?: (enabled: boolean) => void;
}
type SortBy = 'price' | 'size' | 'energy';
export function PropertiesPane({
properties,
total,
loading,
hexagonId,
onLoadMore,
onClose,
onNavigateToSource,
isHoveredPreview,
hoverMode,
onHoverModeChange,
}: PropertiesPaneProps) {
const [sortBy, setSortBy] = useState<SortBy>('price');
const [search, setSearch] = useState('');
const [showInfo, setShowInfo] = useState(false);
const infoPopupRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!showInfo) return;
function handleClickOutside(e: MouseEvent) {
if (infoPopupRef.current && !infoPopupRef.current.contains(e.target as Node)) {
setShowInfo(false);
}
}
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [showInfo]);
// Filter and sort properties
const filteredAndSorted = useMemo(() => {
const query = search.trim().toLowerCase();
const filtered = query
? properties.filter((p) => {
const addr = (p.address || '').toLowerCase();
const pc = (p.postcode || '').toLowerCase();
return addr.includes(query) || pc.includes(query);
})
: properties;
return [...filtered].sort((a, b) => {
switch (sortBy) {
case 'price':
return ((b.latest_price as number) || 0) - ((a.latest_price as number) || 0);
case 'size':
return ((b.total_floor_area as number) || 0) - ((a.total_floor_area as number) || 0);
case 'energy':
return (a.current_energy_rating || 'Z').localeCompare(b.current_energy_rating || 'Z');
}
});
}, [properties, sortBy, search]);
if (!hexagonId) {
return (
<div className="flex items-center justify-center h-full text-warm-500 dark:text-warm-400">
Click a hexagon to view properties
</div>
);
}
return (
<div className="flex flex-col h-full">
{/* Header */}
<div className="p-4 border-b border-warm-200 dark:border-navy-700">
<div className="flex justify-between items-center">
<div className="flex items-center gap-2">
<h2 className="text-lg font-semibold dark:text-warm-100">Properties</h2>
{isHoveredPreview && (
<span className="text-xs px-1.5 py-0.5 rounded bg-teal-50 dark:bg-teal-900/30 text-teal-600 dark:text-teal-400">
Preview
</span>
)}
<button
onClick={() => setShowInfo(true)}
className="text-warm-400 hover:text-warm-700 dark:hover:text-warm-300 p-0.5 rounded"
title="Data source info"
>
<svg className="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<circle cx="12" cy="12" r="10" />
<path strokeLinecap="round" d="M12 16v-4m0-4h.01" />
</svg>
</button>
</div>
<div className="flex items-center gap-1">
{onHoverModeChange && (
<button
onClick={() => onHoverModeChange(!hoverMode)}
className={`p-1 rounded ${
hoverMode
? 'text-teal-600 dark:text-teal-400 bg-teal-50 dark:bg-teal-900/30'
: 'text-warm-400 hover:text-warm-700 dark:hover:text-warm-300'
}`}
title={hoverMode ? 'Live preview on (click to lock)' : 'Live preview off (click to enable)'}
>
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
</button>
)}
<button
onClick={onClose}
className="text-warm-400 hover:text-warm-700 dark:hover:text-warm-300 p-1"
>
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
<p className="text-sm text-warm-600 dark:text-warm-400">
{search.trim()
? `${filteredAndSorted.length} match${filteredAndSorted.length !== 1 ? 'es' : ''} in ${properties.length} loaded`
: `Showing ${properties.length} of ${total} properties`}
</p>
{showInfo && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30">
<div
ref={infoPopupRef}
className="bg-white dark:bg-navy-800 border border-warm-200 dark:border-navy-700 rounded-lg shadow-xl max-w-md w-full mx-4 p-5"
>
<div className="flex items-start justify-between mb-3">
<h3 className="text-sm font-semibold text-warm-900 dark:text-warm-100 pr-4">
Property Data
</h3>
<button
onClick={() => setShowInfo(false)}
className="text-warm-400 hover:text-warm-700 dark:hover:text-warm-300 shrink-0"
>
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<p className="text-sm text-warm-700 dark:text-warm-300 mb-4 leading-relaxed">
Property data combines Energy Performance Certificates (EPC) with HM Land
Registry Price Paid records, fuzzy-matched by address within each postcode.
Includes floor area, energy ratings, construction age, and tenure from EPC
surveys, plus the most recent sale price from the Land Registry.
</p>
{onNavigateToSource && (
<button
onClick={() => {
onNavigateToSource('epc');
setShowInfo(false);
}}
className="text-sm text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300 hover:underline"
>
View data source
</button>
)}
</div>
</div>
)}
</div>
{/* Search and sort controls */}
<div className="p-2 border-b border-warm-200 dark:border-navy-700 space-y-2">
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search by address or postcode..."
className="w-full p-2 border border-warm-300 dark:border-navy-700 rounded text-sm bg-white dark:bg-navy-800 dark:text-warm-200 placeholder-warm-400 dark:placeholder-warm-500"
/>
<select
value={sortBy}
onChange={(e) => setSortBy(e.target.value as SortBy)}
className="w-full p-2 border border-warm-300 dark:border-navy-700 rounded text-sm bg-white dark:bg-navy-800 dark:text-warm-200"
>
<option value="price">Price (High to Low)</option>
<option value="size">Size (Large to Small)</option>
<option value="energy">Energy Rating (Best to Worst)</option>
</select>
</div>
{/* Properties list */}
<div className="flex-1 overflow-y-auto">
{loading && properties.length === 0 ? (
<div className="p-4 dark:text-warm-400">Loading...</div>
) : (
<>
{filteredAndSorted.map((property, idx) => (
<PropertyCard key={idx} property={property} />
))}
{properties.length < total && (
<button
onClick={onLoadMore}
disabled={loading}
className="w-full p-4 text-teal-600 dark:text-teal-400 hover:bg-teal-50 dark:hover:bg-teal-900/30 disabled:opacity-50"
>
{loading ? 'Loading...' : `Load More (${total - properties.length} remaining)`}
</button>
)}
</>
)}
</div>
</div>
);
}
function formatDuration(d: string): string {
if (d === 'F') return 'Freehold';
if (d === 'L') return 'Leasehold';
return d;
}
function formatAge(value: number, approximate = true): string {
if (value >= 1000) return approximate ? `~${Math.round(value)}` : `${Math.round(value)}`;
return Math.round(value).toString();
}
// Helper to get a numeric value from a property, trying multiple field names
function getNum(property: Property, ...keys: string[]): number | undefined {
for (const key of keys) {
const v = property[key];
if (v !== undefined && v !== null && typeof v === 'number') return v;
}
return undefined;
}
// Property card component showing all fields
function PropertyCard({ property }: { property: Property }) {
const fmt = (value: number | undefined, decimals = 0): string => {
if (value === undefined) return '';
return decimals > 0 ? value.toFixed(decimals) : Math.round(value).toLocaleString();
};
const price = getNum(property, 'Last known price', 'latest_price');
const pricePerSqm = getNum(property, 'Price per sqm', 'price_per_sqm');
const floorArea = getNum(property, 'Total floor area (sqm)', 'total_floor_area');
const rooms = getNum(
property,
'Rooms (including bedrooms & bathrooms)',
'number_habitable_rooms'
);
const age = getNum(property, 'Approximate construction age', 'construction_age_band');
return (
<div className="p-4 border-b border-warm-100 dark:border-navy-800 hover:bg-warm-50 dark:hover:bg-navy-800">
{/* Address & postcode */}
<div className="font-semibold dark:text-warm-100">{property.address || 'Unknown Address'}</div>
<div className="text-sm text-warm-600 dark:text-warm-400">{property.postcode}</div>
{/* Price */}
{price !== undefined && (
<div className="mt-2 text-lg font-bold text-teal-700 dark:text-teal-400">
£{fmt(price)}
{pricePerSqm !== undefined && (
<span className="text-sm font-normal text-warm-600 dark:text-warm-400"> (£{fmt(pricePerSqm)}/m²)</span>
)}
</div>
)}
{/* Property details grid */}
<div className="mt-2 grid grid-cols-2 gap-x-4 gap-y-1 text-sm dark:text-warm-300">
{property.property_type && (
<div>
<span className="text-warm-500 dark:text-warm-400">Type:</span> {property.property_type}
</div>
)}
{property.built_form && (
<div>
<span className="text-warm-500 dark:text-warm-400">Built form:</span> {property.built_form}
</div>
)}
{property.duration && (
<div>
<span className="text-warm-500 dark:text-warm-400">Tenure:</span> {formatDuration(property.duration)}
</div>
)}
{floorArea !== undefined && (
<div>
<span className="text-warm-500 dark:text-warm-400">Floor area:</span> {fmt(floorArea)}m²
</div>
)}
{rooms !== undefined && (
<div>
<span className="text-warm-500 dark:text-warm-400">Rooms:</span> {fmt(rooms)}
</div>
)}
{age !== undefined && (
<div>
<span className="text-warm-500 dark:text-warm-400">Built:</span> {formatAge(age, property.is_construction_date_approximate ?? true)}
</div>
)}
{property.current_energy_rating && (
<div>
<span className="text-warm-500 dark:text-warm-400">EPC rating:</span> {property.current_energy_rating}
</div>
)}
{property.potential_energy_rating && (
<div>
<span className="text-warm-500 dark:text-warm-400">EPC potential:</span> {property.potential_energy_rating}
</div>
)}
</div>
</div>
);
}

View file

@ -2,7 +2,7 @@ export default function DataSources({ onNavigate }: { onNavigate: () => void })
return (
<button
onClick={onNavigate}
className="absolute bottom-2 right-2 bg-white/90 dark:bg-navy-800/90 backdrop-blur-sm px-3 py-2 rounded shadow-lg text-xs text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300 hover:underline font-semibold transition-colors"
className="absolute bottom-2 right-2 bg-white/90 dark:bg-warm-800/90 backdrop-blur-sm px-3 py-2 rounded shadow-lg text-xs text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300 hover:underline font-semibold transition-colors"
>
Data Sources
</button>

View file

@ -13,7 +13,8 @@ const DATA_SOURCES = [
id: 'epc',
name: 'Energy Performance Certificates (EPC)',
origin: 'Ministry of Housing, Communities & Local Government',
use: 'Domestic Energy Performance Certificates providing floor area, number of rooms, construction age, energy ratings, property type, and built form. Fuzzy-joined with Price Paid records by address within postcode buckets.',
use: 'Domestic Energy Performance Certificates providing floor area, number of rooms, construction age, energy ratings, property type, and built form. Fuzzy-joined with Price Paid records by address within postcode buckets. Property owners can opt out of public disclosure.',
optOutUrl: 'https://www.gov.uk/guidance/energy-performance-certificates-opt-out-of-public-disclosure',
url: 'https://epc.opendatacommunities.org/downloads/domestic',
license: 'Open Government Licence v3.0',
},
@ -97,6 +98,22 @@ const DATA_SOURCES = [
url: 'https://www.ofcom.org.uk/phones-and-broadband/coverage-and-speeds/connected-nations-20252/data-downloads-2025',
license: 'Open Government Licence v3.0',
},
{
id: 'geosure',
name: 'GeoSure Ground Stability',
origin: 'Ordnance Survey',
use: 'Ground stability hazard ratings on a 5km hex grid covering Great Britain. Six risk categories (collapsible deposits, compressible ground, landslides, running sand, shrink-swell, and soluble rocks) rated Low, Moderate, or Significant. Spatial-joined to postcodes via centroid intersection.',
url: 'https://osdatahub.os.uk/downloads/open/GeoSure',
license: 'Open Government Licence v3.0',
},
{
id: 'council-tax',
name: 'Council Tax Levels 2025-26',
origin: 'Ministry of Housing, Communities & Local Government',
use: 'Annual council tax rates for Bands A-H for all 296 billing authorities in England, for a dwelling occupied by two adults. Joined to properties via local authority district code from the NSPL postcode lookup.',
url: 'https://www.gov.uk/government/statistics/council-tax-levels-set-by-local-authorities-in-england-2025-to-2026',
license: 'Open Government Licence v3.0',
},
];
export default function DataSourcesPage() {
@ -135,7 +152,9 @@ export default function DataSourcesPage() {
<div
key={source.id}
id={source.id}
ref={(el) => { cardRefs.current[source.id] = el; }}
ref={(el) => {
cardRefs.current[source.id] = el;
}}
className={`bg-white dark:bg-navy-800 rounded-lg border p-5 ${
highlightedId === source.id
? 'border-teal-400 ring-2 ring-teal-400'
@ -143,12 +162,16 @@ export default function DataSourcesPage() {
}`}
>
<div className="flex items-start justify-between gap-4 mb-2">
<h2 className="text-lg font-semibold text-warm-900 dark:text-warm-100">{source.name}</h2>
<h2 className="text-lg font-semibold text-warm-900 dark:text-warm-100">
{source.name}
</h2>
<span className="shrink-0 text-xs bg-warm-100 dark:bg-navy-700 text-warm-600 dark:text-warm-300 px-2 py-1 rounded">
{source.license}
</span>
</div>
<p className="text-sm text-warm-500 dark:text-warm-400 mb-2">Source: {source.origin}</p>
<p className="text-sm text-warm-500 dark:text-warm-400 mb-2">
Source: {source.origin}
</p>
<p className="text-sm text-warm-700 dark:text-warm-300 mb-3">{source.use}</p>
<a
href={source.url}
@ -158,6 +181,18 @@ export default function DataSourcesPage() {
>
{source.url}
</a>
{'optOutUrl' in source && source.optOutUrl && (
<div className="mt-2">
<a
href={source.optOutUrl}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300 hover:underline"
>
Opt out of public disclosure
</a>
</div>
)}
</div>
))}
</div>

View file

@ -1,4 +1,5 @@
import { useState } from 'react';
import { ChevronIcon } from '../ui/icons/ChevronIcon';
interface FAQItem {
question: string;
@ -9,7 +10,7 @@ const FAQ_ITEMS: FAQItem[] = [
{
question: 'What is this application?',
answer:
'Narrowit is an interactive map that visualises property-level data across England and Wales. It combines Land Registry sale prices, EPC energy certificates, TfL journey times, deprivation indices, crime statistics, broadband speeds, school ratings, road noise levels, ethnicity demographics, and OpenStreetMap points of interest into a single explorable view.',
'Perfect Postcodes is an interactive map that visualises property-level data across England and Wales. It combines Land Registry sale prices, EPC energy certificates, TfL journey times, deprivation indices, crime statistics, broadband speeds, school ratings, road noise levels, ethnicity demographics, and OpenStreetMap points of interest into a single explorable view.',
},
{
question: 'Where does the data come from?',
@ -29,7 +30,7 @@ const FAQ_ITEMS: FAQItem[] = [
{
question: 'What does the eye icon do on a filter?',
answer:
'The eye icon pins a feature as the colour source for the hexagon layer. When pinned, hexagons are coloured by that feature\'s value range even when you are not actively dragging its slider. This lets you visualise one feature while filtering on others. Click the eye icon again to unpin.',
"The eye icon pins a feature as the colour source for the hexagon layer. When pinned, hexagons are coloured by that feature's value range even when you are not actively dragging its slider. This lets you visualise one feature while filtering on others. Click the eye icon again to unpin.",
},
{
question: 'How fresh is the data?',
@ -39,7 +40,7 @@ const FAQ_ITEMS: FAQItem[] = [
{
question: 'How are EPC records matched to Land Registry sales?',
answer:
'EPC and Land Registry records don\'t share a common identifier, so they are fuzzy-joined by address within each postcode bucket. The pipeline uses token-sorted string similarity with special handling for numeric tokens (house numbers, flat numbers). Matches are assigned greedily from highest similarity score downward so each record is used at most once.',
"EPC and Land Registry records don't share a common identifier, so they are fuzzy-joined by address within each postcode bucket. The pipeline uses token-sorted string similarity with special handling for numeric tokens (house numbers, flat numbers). Matches are assigned greedily from highest similarity score downward so each record is used at most once.",
},
{
question: 'What are Points of Interest (POIs)?',
@ -64,7 +65,7 @@ const FAQ_ITEMS: FAQItem[] = [
{
question: 'Does this work on mobile?',
answer:
'The app is designed for desktop browsers where you have enough screen space for the map, filter panel, and POI/properties panel side by side. It will load on mobile but the experience is best on a larger screen.',
'Yes. On mobile, the dashboard uses a vertical split layout with the map on top and a tabbed panel below for filters, area stats, properties, and POIs. Tapping a hexagon opens a full-screen drawer with the details. The full desktop experience with side-by-side panels is available on screens 768px and wider.',
},
];
@ -78,15 +79,10 @@ function FAQItemCard({ item }: { item: FAQItem }) {
onClick={() => setOpen(!open)}
>
<span className="font-medium text-warm-900 dark:text-warm-100">{item.question}</span>
<svg
<ChevronIcon
direction="down"
className={`w-5 h-5 shrink-0 text-warm-400 dark:text-warm-500 transform ${open ? 'rotate-180' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
</svg>
/>
</button>
{open && (
<div className="px-5 pb-4">
@ -105,7 +101,7 @@ export default function FAQPage() {
Frequently Asked Questions
</h1>
<p className="text-warm-600 dark:text-warm-400 mb-6">
Common questions about how Narrowit works, where the data comes from, and how to use the
Common questions about how Perfect Postcodes works, where the data comes from, and how to use the
map.
</p>
<div className="space-y-3">

View file

@ -0,0 +1,22 @@
interface Props {
isDark: boolean;
}
export default function BottomIllustration({ isDark }: Props) {
const hillColor = isDark ? '#16a34a' : '#22c55e';
return (
<div className="w-full">
<svg viewBox="0 100 1600 250" className="w-full block" preserveAspectRatio="xMidYMax meet">
{/* Green hill */}
<path d="M0,350 C400,150 1200,150 1600,350 Z" fill={hillColor} />
{/* Inner shadow for depth */}
<path d="M100,350 C450,180 1150,180 1500,350 Z" fill="#000" opacity="0.08" />
<path d="M250,350 C550,220 1050,220 1350,350 Z" fill="#000" opacity="0.06" />
{/* House */}
<image href="/house.png" x="735" y="100" width="130" height="120" />
</svg>
</div>
);
}

View file

@ -0,0 +1,118 @@
/**
* Decorative mini SVGs for homepage category cards.
* Purely visual rendered at low opacity in the corner of each card.
*/
export default function CategoryArt({
category,
className = '',
}: {
category: string;
className?: string;
}) {
const props = { className, width: 36, height: 36, viewBox: '0 0 36 36', fill: 'none' };
switch (category) {
case 'Property':
// Ascending bar chart
return (
<svg {...props} xmlns="http://www.w3.org/2000/svg">
<rect x="4" y="22" width="6" height="10" rx="1" fill="currentColor" opacity="0.5" />
<rect x="13" y="14" width="6" height="18" rx="1" fill="currentColor" opacity="0.65" />
<rect x="22" y="6" width="6" height="26" rx="1" fill="currentColor" opacity="0.8" />
</svg>
);
case 'Transport':
// Converging route lines
return (
<svg {...props} xmlns="http://www.w3.org/2000/svg">
<path d="M4 6 Q18 18 32 12" stroke="currentColor" strokeWidth="2" opacity="0.6" />
<path d="M4 18 Q18 18 32 18" stroke="currentColor" strokeWidth="2" opacity="0.7" />
<path d="M4 30 Q18 18 32 24" stroke="currentColor" strokeWidth="2" opacity="0.6" />
<circle cx="32" cy="18" r="3" fill="currentColor" opacity="0.5" />
</svg>
);
case 'Crime':
// Shield outline
return (
<svg {...props} xmlns="http://www.w3.org/2000/svg">
<path
d="M18 4 L30 10 V20 C30 26 24 32 18 34 C12 32 6 26 6 20 V10 Z"
stroke="currentColor"
strokeWidth="2"
opacity="0.6"
/>
<path d="M14 18 L17 21 L23 14" stroke="currentColor" strokeWidth="2" opacity="0.5" />
</svg>
);
case 'Education':
// Mortarboard / books
return (
<svg {...props} xmlns="http://www.w3.org/2000/svg">
<path d="M18 8 L4 16 L18 24 L32 16 Z" fill="currentColor" opacity="0.5" />
<path d="M10 19 V27 L18 31 L26 27 V19" stroke="currentColor" strokeWidth="2" opacity="0.6" />
<line x1="30" y1="16" x2="30" y2="28" stroke="currentColor" strokeWidth="2" opacity="0.4" />
</svg>
);
case 'Amenities':
// Scattered dots (map pins)
return (
<svg {...props} xmlns="http://www.w3.org/2000/svg">
<circle cx="8" cy="10" r="3" fill="currentColor" opacity="0.5" />
<circle cx="22" cy="7" r="2.5" fill="currentColor" opacity="0.4" />
<circle cx="30" cy="16" r="2" fill="currentColor" opacity="0.5" />
<circle cx="14" cy="22" r="3.5" fill="currentColor" opacity="0.6" />
<circle cx="26" cy="28" r="2.5" fill="currentColor" opacity="0.45" />
<circle cx="6" cy="30" r="2" fill="currentColor" opacity="0.35" />
</svg>
);
case 'Demographics':
// Pie/donut segment
return (
<svg {...props} xmlns="http://www.w3.org/2000/svg">
<circle cx="18" cy="18" r="13" stroke="currentColor" strokeWidth="3" opacity="0.3" />
<path
d="M18 5 A13 13 0 0 1 30 14 L18 18 Z"
fill="currentColor"
opacity="0.6"
/>
<path
d="M18 5 A13 13 0 0 0 8 12 L18 18 Z"
fill="currentColor"
opacity="0.4"
/>
</svg>
);
case 'Environment':
// Terrain wave lines
return (
<svg {...props} xmlns="http://www.w3.org/2000/svg">
<path d="M2 20 Q9 12 18 18 Q27 24 34 16" stroke="currentColor" strokeWidth="2" opacity="0.6" />
<path d="M2 26 Q9 18 18 24 Q27 30 34 22" stroke="currentColor" strokeWidth="2" opacity="0.45" />
<path d="M2 14 Q9 6 18 12 Q27 18 34 10" stroke="currentColor" strokeWidth="2" opacity="0.35" />
</svg>
);
case 'Broadband':
// Signal waves (wifi)
return (
<svg {...props} xmlns="http://www.w3.org/2000/svg">
<path d="M6 16 Q18 4 30 16" stroke="currentColor" strokeWidth="2" fill="none" opacity="0.4" />
<path d="M10 21 Q18 12 26 21" stroke="currentColor" strokeWidth="2" fill="none" opacity="0.55" />
<path d="M14 26 Q18 20 22 26" stroke="currentColor" strokeWidth="2" fill="none" opacity="0.7" />
<circle cx="18" cy="30" r="2.5" fill="currentColor" opacity="0.7" />
</svg>
);
case 'Deprivation':
// Scale / balance
return (
<svg {...props} xmlns="http://www.w3.org/2000/svg">
<line x1="18" y1="6" x2="18" y2="30" stroke="currentColor" strokeWidth="2" opacity="0.4" />
<line x1="6" y1="14" x2="30" y2="14" stroke="currentColor" strokeWidth="2" opacity="0.5" />
<path d="M6 14 L3 24 H12 Z" fill="currentColor" opacity="0.4" />
<path d="M30 14 L27 22 H33 Z" fill="currentColor" opacity="0.5" />
<rect x="14" y="28" width="8" height="3" rx="1" fill="currentColor" opacity="0.3" />
</svg>
);
default:
return null;
}
}

View file

@ -0,0 +1,131 @@
import { useRef, useEffect } from 'react';
const HEX_COUNT = 70;
const TAU = Math.PI * 2;
interface Hex {
x: number;
y: number;
baseY: number;
size: number;
opacity: number;
speed: number;
phase: number;
}
function initHexes(w: number, h: number): Hex[] {
const hexes: Hex[] = [];
for (let i = 0; i < HEX_COUNT; i++) {
const y = Math.random() * h;
const side = Math.random() < 0.5 ? 'left' : 'right';
const x = side === 'left' ? Math.random() * w * 0.3 : w * 0.7 + Math.random() * w * 0.3;
hexes.push({
x,
y,
baseY: y,
size: 8 + Math.random() * 20,
opacity: 0.08 + Math.random() * 0.15,
speed: 6 + Math.random() * 14,
phase: Math.random() * TAU,
});
}
return hexes;
}
function drawHex(ctx: CanvasRenderingContext2D, cx: number, cy: number, r: number) {
ctx.beginPath();
for (let i = 0; i < 6; i++) {
const angle = (TAU / 6) * i - Math.PI / 6;
const px = cx + r * Math.cos(angle);
const py = cy + r * Math.sin(angle);
if (i === 0) ctx.moveTo(px, py);
else ctx.lineTo(px, py);
}
ctx.closePath();
}
export default function HexCanvas({ isDark = false }: { isDark?: boolean }) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const hexesRef = useRef<Hex[]>([]);
const animRef = useRef(0);
const isDarkRef = useRef(isDark);
isDarkRef.current = isDark;
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
let w = 0;
let h = 0;
function resize() {
const dpr = window.devicePixelRatio || 1;
const rect = canvas!.parentElement!.getBoundingClientRect();
w = rect.width;
h = rect.height;
canvas!.width = w * dpr;
canvas!.height = h * dpr;
canvas!.style.width = `${w}px`;
canvas!.style.height = `${h}px`;
ctx!.setTransform(dpr, 0, 0, dpr, 0, 0);
hexesRef.current = initHexes(w, h);
}
resize();
const ro = new ResizeObserver(resize);
ro.observe(canvas.parentElement!);
let prev = performance.now();
function frame(now: number) {
const dt = (now - prev) / 1000;
prev = now;
ctx!.clearRect(0, 0, w, h);
for (const hex of hexesRef.current) {
hex.x += hex.speed * dt * 0.3;
if (hex.x > w * 0.3 + hex.size && hex.x < w * 0.7 - hex.size) {
hex.x = w * 0.7 + hex.size;
}
if (hex.x > w + hex.size * 2) {
hex.x = -hex.size * 2;
hex.y = Math.random() * h;
hex.baseY = hex.y;
}
const bob = Math.sin(now / 1000 + hex.phase) * 8;
hex.y = hex.baseY + bob;
const dark = isDarkRef.current;
ctx!.globalAlpha = hex.opacity * (dark ? 0.6 : 1);
ctx!.fillStyle = dark ? '#058172' : '#00a28c';
drawHex(ctx!, hex.x, hex.y, hex.size);
ctx!.fill();
ctx!.globalAlpha = hex.opacity * 0.5 * (dark ? 0.6 : 1);
ctx!.strokeStyle = dark ? '#0a665b' : '#05c9aa';
ctx!.lineWidth = 1;
drawHex(ctx!, hex.x, hex.y, hex.size);
ctx!.stroke();
}
animRef.current = requestAnimationFrame(frame);
}
animRef.current = requestAnimationFrame(frame);
return () => {
cancelAnimationFrame(animRef.current);
ro.disconnect();
};
}, []);
return (
<canvas
ref={canvasRef}
className="absolute inset-0 pointer-events-none"
style={{ zIndex: 0 }}
/>
);
}

View file

@ -0,0 +1,243 @@
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import MapComponent from '../map/Map';
import { Slider } from '../ui/Slider';
import { apiUrl, authHeaders } from '../../lib/api';
import { formatValue } from '../../lib/format';
import { FEATURE_GRADIENT, DENSITY_GRADIENT, DENSITY_GRADIENT_DARK } from '../../lib/consts';
import { gradientToCss } from '../../lib/utils';
import { TickerValue } from '../ui/TickerValue';
import type { FeatureMeta, HexagonData } from '../../types';
const DEMO_VIEW = { longitude: -1.9, latitude: 52.2, zoom: 5.5, pitch: 0 };
const DEMO_FEATURE_NAMES = ['Estimated current price', 'Good+ primary schools within 5km', 'Number of restaurants within 2km'];
const DEMO_BOUNDS = '49,-9.5,57,5';
const DEMO_RESOLUTION = 5;
const noop = () => {};
const featureGradientStyle = gradientToCss(FEATURE_GRADIENT);
interface HomeDemoProps {
features: FeatureMeta[];
theme: 'light' | 'dark';
}
export default function HomeDemo({ features, theme }: HomeDemoProps) {
const [hexData, setHexData] = useState<HexagonData[]>([]);
const [sliderValues, setSliderValues] = useState<Record<string, [number, number]>>({});
const [activeFeature, setActiveFeature] = useState<string | null>(null);
const [dragValue, setDragValue] = useState<[number, number] | null>(null);
const [dragHexData, setDragHexData] = useState<HexagonData[] | null>(null);
const fetchTimeoutRef = useRef<ReturnType<typeof setTimeout>>();
const abortRef = useRef<AbortController>();
const dragAbortRef = useRef<AbortController>();
const activeFeatureRef = useRef<string | null>(null);
activeFeatureRef.current = activeFeature;
const demoFeatures = useMemo(
() =>
DEMO_FEATURE_NAMES.map((name) => features.find((f) => f.name === name)).filter(
Boolean
) as FeatureMeta[],
[features]
);
// Initialize slider values when features arrive
useEffect(() => {
if (demoFeatures.length === 0) return;
const initial: Record<string, [number, number]> = {};
for (const f of demoFeatures) {
if (f.min != null && f.max != null) {
initial[f.name] = [f.min, f.max];
}
}
setSliderValues(initial);
}, [demoFeatures]);
// Feature coloring only during drag; density (property count) otherwise
const viewFeatureName = activeFeature;
const viewMeta = viewFeatureName ? features.find((f) => f.name === viewFeatureName) : null;
const colorRange: [number, number] | null =
viewMeta?.min != null && viewMeta?.max != null ? [viewMeta.min, viewMeta.max] : null;
const filterRange: [number, number] | null = activeFeature && dragValue ? dragValue : null;
const displayData = dragHexData ?? hexData;
// Fetch hexagons (debounced) — skipped while dragging
const fetchHexagons = useCallback(() => {
if (activeFeatureRef.current) return;
if (features.length === 0 || Object.keys(sliderValues).length === 0) return;
const params = new URLSearchParams({
resolution: String(DEMO_RESOLUTION),
bounds: DEMO_BOUNDS,
});
const filterParts: string[] = [];
for (const [name, [min, max]] of Object.entries(sliderValues)) {
const meta = features.find((f) => f.name === name);
if (meta?.min != null && meta?.max != null) {
if (min !== meta.min || max !== meta.max) {
filterParts.push(`${name}:${min}:${max}`);
}
}
}
if (filterParts.length > 0) {
params.set('filters', filterParts.join(','));
}
abortRef.current?.abort();
abortRef.current = new AbortController();
fetch(apiUrl('hexagons', params), authHeaders({ signal: abortRef.current.signal }))
.then((res) => res.json())
.then((data: { features: HexagonData[] }) => setHexData(data.features))
.catch(() => {});
}, [features, sliderValues]);
useEffect(() => {
clearTimeout(fetchTimeoutRef.current);
fetchTimeoutRef.current = setTimeout(fetchHexagons, 200);
return () => clearTimeout(fetchTimeoutRef.current);
}, [fetchHexagons]);
useEffect(() => {
return () => {
abortRef.current?.abort();
dragAbortRef.current?.abort();
clearTimeout(fetchTimeoutRef.current);
};
}, []);
// Drag start: fetch preview data with other filters only, fields=dragged feature
const handleDragStart = useCallback(
(name: string) => {
setActiveFeature(name);
const currentVal = sliderValues[name];
const meta = features.find((f) => f.name === name);
setDragValue(currentVal || (meta?.min != null ? [meta.min, meta.max!] : null));
const params = new URLSearchParams({
resolution: String(DEMO_RESOLUTION),
bounds: DEMO_BOUNDS,
});
const otherFilterParts: string[] = [];
for (const [n, [min, max]] of Object.entries(sliderValues)) {
if (n === name) continue;
const m = features.find((f) => f.name === n);
if (m?.min != null && m?.max != null && (min !== m.min || max !== m.max)) {
otherFilterParts.push(`${n}:${min}:${max}`);
}
}
if (otherFilterParts.length > 0) {
params.set('filters', otherFilterParts.join(','));
}
params.set('fields', name);
dragAbortRef.current?.abort();
dragAbortRef.current = new AbortController();
fetch(apiUrl('hexagons', params), authHeaders({ signal: dragAbortRef.current.signal }))
.then((res) => res.json())
.then((data: { features: HexagonData[] }) => setDragHexData(data.features))
.catch(() => {});
},
[features, sliderValues]
);
const handleSliderChange = useCallback(
(name: string, value: [number, number]) => {
setSliderValues((prev) => ({ ...prev, [name]: value }));
if (activeFeatureRef.current === name) {
setDragValue(value);
}
},
[]
);
const handleDragEnd = useCallback(() => {
setActiveFeature(null);
setDragValue(null);
setDragHexData(null);
}, []);
return (
<div className="flex flex-col md:flex-row gap-6">
{/* Map */}
<div className="relative rounded-xl overflow-hidden shadow-sm aspect-[4/3] md:w-3/5">
<div className="absolute inset-0 z-50 cursor-default" />
<div className="absolute inset-0">
<MapComponent
data={displayData}
postcodeData={[]}
usePostcodeView={false}
pois={[]}
onViewChange={noop}
viewFeature={viewFeatureName}
colorRange={colorRange}
filterRange={filterRange}
viewSource={activeFeature ? 'drag' : null}
onCancelPin={noop}
features={features}
selectedHexagonId={null}
hoveredHexagonId={null}
onHexagonClick={noop}
onHexagonHover={noop}
initialViewState={DEMO_VIEW}
theme={theme}
screenshotMode={true}
hideLegend={true}
/>
</div>
{/* Colour spectrum legend */}
<div className="absolute bottom-3 left-3 right-3 z-50 pointer-events-none">
<div className="bg-white/90 dark:bg-warm-800/90 rounded-lg px-3 py-2 backdrop-blur-sm text-xs">
<div className="font-semibold text-navy-950 dark:text-warm-100 mb-1 truncate">
{activeFeature ? viewMeta?.name || activeFeature : 'Property density'}
</div>
<div
className="h-2.5 rounded-full"
style={{
background: activeFeature
? featureGradientStyle
: gradientToCss(theme === 'dark' ? DENSITY_GRADIENT_DARK : DENSITY_GRADIENT),
}}
/>
{colorRange && (
<div className="flex justify-between mt-0.5 text-warm-500 dark:text-warm-400">
<TickerValue text={formatValue(colorRange[0], viewMeta ?? undefined)} />
<TickerValue text={formatValue(colorRange[1], viewMeta ?? undefined)} />
</div>
)}
</div>
</div>
</div>
{/* Sliders */}
<div className="md:w-2/5 flex flex-col justify-center space-y-6">
{demoFeatures.map((feature) => {
const value = sliderValues[feature.name];
if (!value || feature.min == null || feature.max == null) return null;
const isActive = activeFeature === feature.name;
return (
<div
key={feature.name}
className={`rounded-lg p-3 ${isActive ? 'ring-2 ring-teal-400 bg-teal-50 dark:bg-teal-900/30' : ''}`}
>
<div className="flex justify-between mb-2">
<span className="text-sm font-semibold text-navy-950 dark:text-warm-100">
{feature.name}
</span>
<span className="text-sm text-warm-500 dark:text-warm-400">
{formatValue(value[0], feature)} &ndash; {formatValue(value[1], feature)}
</span>
</div>
<Slider
min={feature.min}
max={feature.max}
step={feature.step || 1}
value={[value[0], value[1]]}
onValueChange={([min, max]) => handleSliderChange(feature.name, [min, max])}
onPointerDown={() => handleDragStart(feature.name)}
onPointerUp={() => handleDragEnd()}
/>
</div>
);
})}
</div>
</div>
);
}

View file

@ -0,0 +1,326 @@
import { useRef, useState, useEffect } from 'react';
import { useFadeInRef } from '../../hooks/useFadeIn';
import HexCanvas from './HexCanvas';
import HomeDemo from './HomeDemo';
import BottomIllustration from './BottomIllustration';
import CategoryArt from './CategoryArt';
import { TickerValue } from '../ui/TickerValue';
import type { FeatureMeta } from '../../types';
export default function HomePage({
onOpenDashboard,
onOpenPricing,
theme = 'light',
features = [],
}: {
onOpenDashboard: () => void;
onOpenPricing: () => void;
theme?: 'light' | 'dark';
features?: FeatureMeta[];
}) {
const scrollRef = useRef<HTMLDivElement>(null);
const [statsActive, setStatsActive] = useState(false);
useEffect(() => {
const timer = setTimeout(() => setStatsActive(true), 300);
return () => clearTimeout(timer);
}, []);
const heroRef = useFadeInRef();
const demoRef = useFadeInRef();
const scaleRef = useFadeInRef();
const problemRef = useFadeInRef();
const ctaRef = useFadeInRef();
return (
<div ref={scrollRef} className="flex-1 overflow-y-auto bg-warm-50 dark:bg-navy-950 relative">
<div className="relative" style={{ zIndex: 1 }}>
{/* Hero — full-bleed */}
<div
ref={heroRef}
className="fade-in-section relative overflow-hidden bg-gradient-to-br from-navy-950 via-navy-900 to-teal-900 dark:from-navy-950 dark:via-navy-900 dark:to-teal-900/60 pt-16 pb-20 md:pt-24 md:pb-28 shadow-[0_12px_50px_0px_rgba(13,148,136,0.5)] dark:shadow-[0_12px_50px_0px_rgba(13,148,136,0.4)]"
>
<HexCanvas isDark={theme === 'dark'} />
{/* Radial teal glow */}
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[600px] h-[400px] bg-teal-500/[0.07] rounded-full blur-3xl pointer-events-none" />
<div className="relative z-10 max-w-4xl mx-auto px-6">
<p className="text-teal-400 font-semibold tracking-wide uppercase text-sm mb-4">
Browsing listings is not a strategy. Knowing what you want is.
</p>
<h1 className="text-3xl md:text-5xl font-extrabold text-white mb-6 leading-[1.1] tracking-tight">
Find your{' '}
<span className="text-teal-400">perfect postcode</span>
<br />
<span className="text-warm-300">before you find your&nbsp;property.</span>
</h1>
<p className="text-xl text-warm-300 mb-8 leading-relaxed max-w-xl">
Set the sliders to your expectations and the map highlights the areas that actually
match. Instantly.
</p>
<div className="flex items-center gap-4 mb-12">
<button
onClick={onOpenDashboard}
className="px-7 py-3.5 bg-coral-500 text-white rounded-lg font-semibold hover:bg-coral-600 transition-colors text-base shadow-lg shadow-coral-500/25"
>
Explore the map
</button>
<button
onClick={onOpenPricing}
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"
>
Get a lifetime license
</button>
</div>
<div className="flex gap-12 pt-6 border-t border-white/10">
<div>
<div className="text-2xl md:text-3xl font-bold text-white">
<TickerValue text="13M" active={statsActive} />
</div>
<div className="text-sm text-warm-400">properties</div>
</div>
<div>
<div className="text-2xl md:text-3xl font-bold text-white">
<TickerValue text="56" active={statsActive} />
</div>
<div className="text-sm text-warm-400">data layers</div>
</div>
<div>
<div className="text-2xl md:text-3xl font-bold text-white">Every</div>
<div className="text-sm text-warm-400">postcode in England</div>
</div>
</div>
</div>
</div>
{/* Map + Slider demo */}
<div className="max-w-4xl mx-auto px-6 pt-16 pb-20">
<div ref={demoRef} className="fade-in-section">
<h2 className="text-2xl md:text-3xl font-bold text-navy-950 dark:text-warm-100 mb-2">
See it in action
</h2>
<p className="text-warm-500 dark:text-warm-400 mb-5 max-w-lg">
Drag the sliders and watch the map respond. Every postcode scored, every filter instant.
</p>
<div className="rounded-2xl backdrop-blur-sm bg-warm-50/40 dark:bg-navy-800/40 border border-warm-200/50 dark:border-navy-700/50 p-4 md:p-6">
<HomeDemo features={features} theme={theme} />
</div>
</div>
</div>
{/* Scale — "That's just two" + category cards */}
<div className="max-w-4xl mx-auto px-6 pb-20">
<div ref={scaleRef} className="fade-in-section">
<h2 className="text-3xl font-bold text-navy-950 dark:text-warm-100 mb-2 text-center">
That&apos;s just three. We&apos;ve built&nbsp;43.
</h2>
<p className="text-warm-500 dark:text-warm-400 text-center mb-10 max-w-lg mx-auto">
Spanning transport links, amenities, demographics, environment risk, broadband speeds,
crime, and more.
</p>
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
{CATEGORIES.map((c) => (
<div
key={c.label}
className={`rounded-xl border-l-4 border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 p-4 shadow-sm hover:shadow-md transition-shadow ${c.borderClass} ${c.hoverBgClass}`}
>
<div className="flex items-start justify-between gap-2">
<div className="flex items-center gap-2.5 min-w-0">
<div
className={`shrink-0 flex items-center justify-center rounded-lg w-8 h-8 text-base ${c.iconBgClass}`}
>
{c.icon}
</div>
<span className="font-semibold text-navy-950 dark:text-warm-100 text-sm">
{c.label}
</span>
</div>
<CategoryArt
category={c.group === 'Environment' && c.label === 'Broadband' ? 'Broadband' : c.group}
className={`shrink-0 ${c.artColorClass} opacity-40`}
/>
</div>
</div>
))}
</div>
</div>
</div>
{/* Problem / solution / philosophy */}
<div className="max-w-4xl mx-auto px-6 pb-20 relative">
{/* Cereal box — quirky margin note, hidden on narrow screens */}
<div className="hidden lg:block group absolute -right-44 top-8 cursor-pointer">
<div className="cereal-wobble">
<img src="/cereal.png" alt="Discounted cereal box" className="w-36 h-auto" />
</div>
<p className="cereal-text text-sm italic text-warm-500 dark:text-warm-400 mt-2 w-[9rem] leading-snug">
Your home is not a box of cereal. Don&apos;t let a discount on the wrong
property distract you from finding the right one.
</p>
</div>
<div ref={problemRef} className="fade-in-section">
<p className="text-lg text-warm-700 dark:text-warm-300 leading-relaxed mb-6">
Here&apos;s the problem with property search: listings only show you what&apos;s on
the market{' '}
<strong className="font-semibold text-navy-950 dark:text-warm-100">right now</strong>{' '}
&mdash; a thin slice of what an area is actually like. And even if you could look
beyond them, there are{' '}
<strong className="font-semibold text-navy-950 dark:text-warm-100">
millions of postcodes
</strong>{' '}
across England. You can&apos;t research them all yourself.
</p>
<p className="text-lg text-warm-700 dark:text-warm-300 leading-relaxed mb-6">
We built this for you &mdash; years of historical transactions and public records,
extended with proprietary algorithms so the map doesn&apos;t just show raw data, it{' '}
<strong className="font-semibold text-navy-950 dark:text-warm-100">
surfaces the patterns that matter
</strong>
.
</p>
<p className="text-xl font-bold text-navy-950 dark:text-warm-100 leading-relaxed">
Understand areas first. Then find the right property within them, with expectations
you&apos;ve set &mdash; not ones the market set for you.
</p>
</div>
</div>
{/* Final CTA */}
<div className="max-w-3xl mx-auto px-6 pb-12">
<div ref={ctaRef} className="fade-in-section text-center">
<h2 className="text-3xl font-bold text-navy-950 dark:text-warm-100 mb-3">
The biggest financial decision of your life
<br />
deserves proper tools behind&nbsp;it.
</h2>
<p className="text-warm-500 dark:text-warm-400 mb-8 max-w-md mx-auto">
One payment, lifetime access. Set your filters and go.
</p>
<div className="flex items-center justify-center gap-4">
<button
onClick={onOpenDashboard}
className="px-8 py-4 bg-coral-500 text-white rounded-lg font-semibold hover:bg-coral-600 transition-colors text-lg shadow-lg shadow-coral-500/25"
>
Give your journey a headstart
</button>
<button
onClick={onOpenPricing}
className="px-[30px] py-[14px] border-2 border-navy-950 dark:border-warm-300 text-navy-950 dark:text-warm-300 rounded-lg font-semibold hover:bg-navy-950/5 dark:hover:bg-warm-300/5 transition-colors text-lg"
>
See pricing
</button>
</div>
</div>
</div>
{/* Bottom illustration */}
<BottomIllustration isDark={theme === 'dark'} />
</div>
</div>
);
}
interface Category {
icon: string;
label: string;
group: string;
borderClass: string;
hoverBgClass: string;
iconBgClass: string;
artColorClass: string;
}
const CATEGORIES: Category[] = [
{
icon: '\u{1F3E0}',
label: 'Property',
group: 'Property',
borderClass: 'border-l-teal-400 dark:border-l-teal-500',
hoverBgClass: 'hover:bg-teal-50/50 dark:hover:bg-teal-900/20',
iconBgClass: 'bg-teal-100 dark:bg-teal-900/40',
artColorClass: 'text-teal-400 dark:text-teal-600',
},
{
icon: '\u{1F686}',
label: 'Transport',
group: 'Transport',
borderClass: 'border-l-blue-400 dark:border-l-blue-500',
hoverBgClass: 'hover:bg-blue-50/50 dark:hover:bg-blue-900/20',
iconBgClass: 'bg-blue-100 dark:bg-blue-900/40',
artColorClass: 'text-blue-400 dark:text-blue-600',
},
{
icon: '\u{1F3EB}',
label: 'Schools',
group: 'Education',
borderClass: 'border-l-amber-400 dark:border-l-amber-500',
hoverBgClass: 'hover:bg-amber-50/50 dark:hover:bg-amber-900/20',
iconBgClass: 'bg-amber-100 dark:bg-amber-900/40',
artColorClass: 'text-amber-400 dark:text-amber-600',
},
{
icon: '\u{1F6A8}',
label: 'Crime',
group: 'Crime',
borderClass: 'border-l-rose-400 dark:border-l-rose-500',
hoverBgClass: 'hover:bg-rose-50/50 dark:hover:bg-rose-900/20',
iconBgClass: 'bg-rose-100 dark:bg-rose-900/40',
artColorClass: 'text-rose-400 dark:text-rose-600',
},
{
icon: '\u{1F465}',
label: 'Demographics',
group: 'Demographics',
borderClass: 'border-l-violet-400 dark:border-l-violet-500',
hoverBgClass: 'hover:bg-violet-50/50 dark:hover:bg-violet-900/20',
iconBgClass: 'bg-violet-100 dark:bg-violet-900/40',
artColorClass: 'text-violet-400 dark:text-violet-600',
},
{
icon: '\u{1F3EA}',
label: 'Amenities',
group: 'Amenities',
borderClass: 'border-l-emerald-400 dark:border-l-emerald-500',
hoverBgClass: 'hover:bg-emerald-50/50 dark:hover:bg-emerald-900/20',
iconBgClass: 'bg-emerald-100 dark:bg-emerald-900/40',
artColorClass: 'text-emerald-400 dark:text-emerald-600',
},
{
icon: '\u{1F30D}',
label: 'Environment',
group: 'Environment',
borderClass: 'border-l-orange-400 dark:border-l-orange-500',
hoverBgClass: 'hover:bg-orange-50/50 dark:hover:bg-orange-900/20',
iconBgClass: 'bg-orange-100 dark:bg-orange-900/40',
artColorClass: 'text-orange-400 dark:text-orange-600',
},
{
icon: '\u{1F4E1}',
label: 'Broadband',
group: 'Environment',
borderClass: 'border-l-sky-400 dark:border-l-sky-500',
hoverBgClass: 'hover:bg-sky-50/50 dark:hover:bg-sky-900/20',
iconBgClass: 'bg-sky-100 dark:bg-sky-900/40',
artColorClass: 'text-sky-400 dark:text-sky-600',
},
{
icon: '\u{1F4CA}',
label: 'Deprivation',
group: 'Deprivation',
borderClass: 'border-l-fuchsia-400 dark:border-l-fuchsia-500',
hoverBgClass: 'hover:bg-fuchsia-50/50 dark:hover:bg-fuchsia-900/20',
iconBgClass: 'bg-fuchsia-100 dark:bg-fuchsia-900/40',
artColorClass: 'text-fuchsia-400 dark:text-fuchsia-600',
},
];

View file

@ -0,0 +1,58 @@
import { ChevronIcon } from '../ui/icons';
import { LightbulbIcon } from '../ui/icons/LightbulbIcon';
interface AISummaryCardProps {
summary?: string;
loading?: boolean;
error?: string | null;
expanded: boolean;
onToggleExpanded: () => void;
}
export default function AISummaryCard({
summary,
loading,
error,
expanded,
onToggleExpanded,
}: AISummaryCardProps) {
if (!summary && !loading && !error) return null;
return (
<div className="px-3 pt-3 pb-1">
<div className="bg-teal-50 dark:bg-teal-900/20 border border-teal-200 dark:border-teal-800/50 rounded p-2.5">
<button
onClick={onToggleExpanded}
className="w-full flex items-center justify-between gap-1.5 mb-1.5"
>
<div className="flex items-center gap-1.5">
<LightbulbIcon className="w-3.5 h-3.5 text-teal-600 dark:text-teal-400" />
<span className="text-xs font-semibold text-teal-700 dark:text-teal-400">
AI Summary
</span>
</div>
<ChevronIcon
direction={expanded ? 'down' : 'right'}
className="w-3.5 h-3.5 text-teal-600 dark:text-teal-400"
/>
</button>
{expanded && (
<>
{error ? (
<div className="text-xs text-warm-600 dark:text-warm-400">
Failed to generate summary.
</div>
) : loading ? (
<div className="space-y-1.5">
<div className="h-3 bg-teal-200/60 dark:bg-teal-800/40 rounded animate-pulse w-full" />
<div className="h-3 bg-teal-200/60 dark:bg-teal-800/40 rounded animate-pulse w-4/5" />
</div>
) : (
<p className="text-xs text-warm-700 dark:text-warm-300 leading-relaxed">{summary}</p>
)}
</>
)}
</div>
</div>
);
}

View file

@ -0,0 +1,412 @@
import { useMemo, useState } from 'react';
import { useCollapsibleGroups } from '../../hooks/useCollapsibleGroups';
import type {
FeatureFilters,
FeatureMeta,
HexagonStatsResponse,
PostcodeFeature,
} from '../../types';
import type { HexagonLocation } from '../../lib/external-search';
import { formatValue, formatFilterValue, calculateHistogramMean } from '../../lib/format';
import { groupFeaturesByCategory } from '../../lib/features';
import { STACKED_GROUPS, STACKED_ENUM_GROUPS } from '../../lib/consts';
import { DualHistogram, LoadingSkeleton } from './DualHistogram';
import EnumBarChart from './EnumBarChart';
import StackedBarChart from './StackedBarChart';
import StackedEnumChart from './StackedEnumChart';
import PriceHistoryChart from './PriceHistoryChart';
import ExternalSearchLinks from './ExternalSearchLinks';
import { InfoIcon, CloseIcon } from '../ui/icons';
import { CollapsibleGroupHeader } from '../ui/CollapsibleGroupHeader';
import { IconButton } from '../ui/IconButton';
import { FeatureInfoPopup } from '../ui/FeatureInfoPopup';
import { EmptyState } from '../ui/EmptyState';
import { FeatureLabel } from '../ui/FeatureLabel';
import AISummaryCard from './AISummaryCard';
import StreetViewEmbed from './StreetViewEmbed';
import HistogramLegend from './HistogramLegend';
interface AreaPaneProps {
stats: HexagonStatsResponse | null;
globalFeatures: FeatureMeta[];
loading: boolean;
hexagonId: string | null;
isPostcode?: boolean;
postcodeData?: PostcodeFeature | null;
onViewProperties: () => void;
onClose: () => void;
hexagonLocation: HexagonLocation | null;
filters: FeatureFilters;
onNavigateToSource?: (slug: string, featureName: string) => void;
aiSummary?: string;
aiSummaryLoading?: boolean;
aiSummaryError?: string | null;
}
export default function AreaPane({
stats,
globalFeatures,
loading,
hexagonId,
isPostcode = false,
postcodeData,
onViewProperties,
onClose,
hexagonLocation,
filters,
onNavigateToSource,
aiSummary,
aiSummaryLoading,
aiSummaryError,
}: AreaPaneProps) {
// For postcodes, use local data for count
const propertyCount = isPostcode && postcodeData ? postcodeData.properties.count : stats?.count;
const featureGroups = useMemo(() => groupFeaturesByCategory(globalFeatures), [globalFeatures]);
const [infoFeature, setInfoFeature] = useState<FeatureMeta | null>(null);
const [collapsedGroups, toggleGroup] = useCollapsibleGroups();
const [aiSummaryExpanded, setAiSummaryExpanded] = useState(true);
const numericByName = useMemo(() => {
if (!stats) return new Map();
return new Map(stats.numeric_features.map((feature) => [feature.name, feature]));
}, [stats]);
const enumByName = useMemo(() => {
if (!stats) return new Map();
return new Map(stats.enum_features.map((feature) => [feature.name, feature]));
}, [stats]);
const globalFeatureByName = useMemo(
() => new Map(globalFeatures.map((f) => [f.name, f])),
[globalFeatures]
);
if (!hexagonId) {
return (
<EmptyState
icon={<InfoIcon className="w-8 h-8 text-warm-300 dark:text-warm-600" />}
title="No area selected"
description="Click a hexagon or postcode to view area statistics"
centered
/>
);
}
return (
<div className="flex flex-col h-full">
<div className="p-3 border-b border-warm-200 dark:border-navy-700">
<div className="flex justify-between items-center">
<div className="flex items-center gap-2">
<div>
<h2 className="text-sm font-semibold dark:text-warm-100">
{isPostcode ? hexagonId : 'Area Statistics'}
</h2>
{isPostcode && (
<span className="text-xs text-warm-500 dark:text-warm-400">Postcode</span>
)}
</div>
{loading && stats && (
<div className="w-3 h-3 border-2 border-teal-600 dark:border-teal-400 border-t-transparent rounded-full animate-spin" />
)}
</div>
<IconButton onClick={onClose} title="Close">
<CloseIcon />
</IconButton>
</div>
{propertyCount != null && (
<p className="text-sm text-warm-600 dark:text-warm-400 mt-1">
{propertyCount.toLocaleString()} properties
</p>
)}
{!isPostcode && stats && (
<button
onClick={onViewProperties}
className="mt-2 w-full text-sm py-1.5 rounded bg-teal-600 hover:bg-teal-700 text-white font-medium"
>
View {stats.count.toLocaleString()} Properties
</button>
)}
</div>
{hexagonLocation && stats && (
<ExternalSearchLinks location={hexagonLocation} filters={filters} />
)}
<div className="flex-1 overflow-y-auto">
<AISummaryCard
summary={aiSummary}
loading={aiSummaryLoading}
error={aiSummaryError}
expanded={aiSummaryExpanded}
onToggleExpanded={() => setAiSummaryExpanded(!aiSummaryExpanded)}
/>
{loading && !stats ? (
<LoadingSkeleton />
) : stats ? (
<div>
<HistogramLegend />
{featureGroups.map((group) => {
const hasData = group.features.some(
(feature) => numericByName.has(feature.name) || enumByName.has(feature.name)
);
if (!hasData) return null;
const stackedCharts = STACKED_GROUPS[group.name];
const stackedEnumCharts = STACKED_ENUM_GROUPS[group.name];
// Features that are part of a stacked enum config (rendered as compact charts)
const stackedEnumFeatureNames = new Set(
(stackedEnumCharts?.flatMap((c) =>
[c.feature, ...c.components].filter(Boolean)
) as string[]) ?? []
);
const isExpanded = !collapsedGroups.has(group.name);
return (
<div key={group.name}>
<CollapsibleGroupHeader
name={group.name}
expanded={isExpanded}
onToggle={() => toggleGroup(group.name)}
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 hover:bg-warm-100 dark:hover:bg-warm-800"
/>
{isExpanded && (
<div className="px-3 py-2 space-y-3">
{/* Price History in Property group */}
{group.name === 'Property' &&
stats.price_history &&
(() => {
// Only show chart if there are at least 2 unique years
const uniqueYears = new Set(
stats.price_history.map((p) => Math.floor(p.year))
);
return uniqueYears.size > 1;
})() && (
<div className="bg-warm-50 dark:bg-warm-800 rounded p-2">
<span className="text-xs text-warm-700 dark:text-warm-300">
Price History
</span>
<PriceHistoryChart points={stats.price_history} />
</div>
)}
{stackedCharts
? // Render stacked charts for this group
stackedCharts.map((chart) => {
const segments = chart.components
.map((name) => ({
name,
value: numericByName.get(name)?.mean ?? 0,
}))
.filter((s) => s.value > 0);
// Use aggregate feature stats if available, otherwise sum components
const aggregateStats = chart.feature
? numericByName.get(chart.feature)
: undefined;
const total = aggregateStats
? aggregateStats.mean
: segments.reduce((sum, s) => sum + s.value, 0);
const featureMeta = chart.feature
? globalFeatureByName.get(chart.feature)
: undefined;
if (total === 0) return null;
return (
<div
key={chart.label}
className="bg-warm-50 dark:bg-warm-800 rounded p-2"
>
<div className="flex justify-between items-baseline mb-1.5">
{featureMeta ? (
<FeatureLabel
feature={{ ...featureMeta, name: chart.label }}
onShowInfo={setInfoFeature}
className="mr-2"
/>
) : (
<span className="text-xs text-warm-700 dark:text-warm-300 truncate mr-2">
{chart.label}
</span>
)}
<span className="text-xs font-semibold text-teal-700 dark:text-teal-400 whitespace-nowrap">
{formatValue(total)}
{chart.unit ? ` ${chart.unit}` : ''}
</span>
</div>
<StackedBarChart segments={segments} total={total} />
</div>
);
})
: // Default: render each feature individually (skip stacked enum features)
group.features
.filter((f) => !stackedEnumFeatureNames.has(f.name))
.map((feature) => {
const numericStats = numericByName.get(feature.name);
const enumStats = enumByName.get(feature.name);
if (numericStats) {
const globalFeature = globalFeatureByName.get(feature.name);
const globalHistogram = globalFeature?.histogram;
const globalMean = globalHistogram
? calculateHistogramMean(globalHistogram)
: undefined;
return (
<div
key={feature.name}
className="bg-warm-50 dark:bg-warm-800 rounded p-2"
>
<div className="flex justify-between items-baseline">
<FeatureLabel
feature={feature}
onShowInfo={setInfoFeature}
className="mr-2"
/>
<span className="text-xs font-semibold text-teal-700 dark:text-teal-400 whitespace-nowrap">
{formatValue(numericStats.mean, feature)}
</span>
</div>
{numericStats.histogram &&
(globalHistogram ? (
<DualHistogram
localCounts={numericStats.histogram.counts}
globalCounts={globalHistogram.counts}
p1={numericStats.histogram.p1}
p99={numericStats.histogram.p99}
globalMean={globalMean}
formatLabel={formatFilterValue}
/>
) : (
<DualHistogram
localCounts={numericStats.histogram.counts}
globalCounts={numericStats.histogram.counts}
p1={numericStats.histogram.p1}
p99={numericStats.histogram.p99}
formatLabel={formatFilterValue}
/>
))}
</div>
);
}
if (enumStats) {
return (
<div
key={feature.name}
className="bg-warm-50 dark:bg-warm-800 rounded p-2"
>
<FeatureLabel feature={feature} onShowInfo={setInfoFeature} />
<EnumBarChart counts={enumStats.counts} />
</div>
);
}
return null;
})}
{/* Stacked enum charts */}
{stackedEnumCharts?.map((chart) => {
const featureMeta = chart.feature
? globalFeatureByName.get(chart.feature)
: undefined;
// Single component: render as a stacked bar (like crime charts)
if (chart.components.length === 1) {
const stats = enumByName.get(chart.components[0]);
if (!stats) return null;
const segments = chart.valueOrder
.map((value) => ({ name: value, value: stats.counts[value] ?? 0 }))
.filter((s) => s.value > 0);
const total = segments.reduce((sum, s) => sum + s.value, 0);
if (total === 0) return null;
return (
<div
key={chart.label}
className="bg-warm-50 dark:bg-warm-800 rounded p-2"
>
<div className="flex justify-between items-baseline mb-1.5">
{featureMeta ? (
<FeatureLabel
feature={featureMeta}
onShowInfo={setInfoFeature}
className="mr-2"
/>
) : (
<span className="text-xs text-warm-700 dark:text-warm-300 truncate mr-2">
{chart.label}
</span>
)}
<span className="text-xs font-semibold text-teal-700 dark:text-teal-400 whitespace-nowrap">
{total.toLocaleString()}
</span>
</div>
<StackedBarChart
segments={segments}
total={total}
colorMap={Object.fromEntries(
chart.valueOrder.map((v, i) => [v, chart.valueColors[i]])
)}
/>
</div>
);
}
// Multi-component: render as compact multi-row chart (like risk features)
const components = chart.components
.map((name) => {
const stats = enumByName.get(name);
return stats ? { label: name, stats } : null;
})
.filter((c): c is NonNullable<typeof c> => c !== null);
if (components.length === 0) return null;
return (
<div
key={chart.label}
className="bg-warm-50 dark:bg-warm-800 rounded p-2"
>
<div className="mb-1.5">
{featureMeta ? (
<FeatureLabel
feature={{ ...featureMeta, name: chart.label }}
onShowInfo={setInfoFeature}
/>
) : (
<span className="text-xs text-warm-700 dark:text-warm-300">
{chart.label}
</span>
)}
</div>
<StackedEnumChart
components={components}
valueOrder={chart.valueOrder}
valueColors={chart.valueColors}
/>
</div>
);
})}
</div>
)}
</div>
);
})}
{hexagonLocation && <StreetViewEmbed location={hexagonLocation} />}
</div>
) : null}
</div>
{infoFeature && (
<FeatureInfoPopup
feature={infoFeature}
onClose={() => setInfoFeature(null)}
onNavigateToSource={onNavigateToSource}
/>
)}
</div>
);
}

View file

@ -0,0 +1,173 @@
function downsampleBars(counts: number[], targetBars: number): number[] {
const step = Math.max(1, Math.floor(counts.length / targetBars));
const bars: number[] = [];
for (let index = 0; index < counts.length; index += step) {
let sum = 0;
for (let offset = 0; offset < step && index + offset < counts.length; offset++) {
sum += counts[index + offset];
}
bars.push(sum);
}
return bars;
}
function pickTicks(min: number, max: number, count: number): number[] {
if (max <= min) return [min];
const range = max - min;
const rawStep = range / (count - 1);
const magnitude = Math.pow(10, Math.floor(Math.log10(rawStep)));
const nice = [1, 2, 2.5, 3, 4, 5, 10].find((n) => n * magnitude >= rawStep) ?? 10;
const step = nice * magnitude;
const start = Math.ceil(min / step) * step;
const ticks: number[] = [];
for (let v = start; v <= max + step * 0.01; v += step) {
ticks.push(v);
}
// Ensure at least min and max are represented
if (ticks.length === 0) return [min, max];
return ticks;
}
export function DualHistogram({
localCounts,
globalCounts,
p1,
p99,
globalMean,
formatLabel,
}: {
localCounts: number[];
globalCounts: number[];
p1: number;
p99: number;
globalMean?: number;
formatLabel?: (value: number) => string;
}) {
const targetBars = 25;
const localBars = downsampleBars(localCounts, targetBars);
const globalBars = downsampleBars(globalCounts, targetBars);
const barCount = Math.min(localBars.length, globalBars.length);
const localMax = Math.max(...localBars, 1);
const globalMax = Math.max(...globalBars, 1);
const fmt =
formatLabel ?? ((v: number) => (Number.isInteger(v) ? v.toLocaleString() : v.toFixed(1)));
// Compute center value for each bar.
// Bar 0 = low outlier, bars 1..n-2 = middle (p1 to p99), bar n-1 = high outlier.
const middleBins = Math.max(barCount - 2, 0);
const middleWidth = middleBins > 0 && p99 > p1 ? (p99 - p1) / middleBins : 0;
const barCenters: number[] = Array.from({ length: barCount }, (_, i) => {
if (i === 0) return p1; // outlier bin, label as p1
if (i === barCount - 1) return p99; // outlier bin, label as p99
return p1 + (i - 1 + 0.5) * middleWidth;
});
// Pick nice tick values and assign each to the nearest bar
const ticks = p99 > p1 ? pickTicks(p1, p99, 6) : [];
const tickBars = new Map<number, string>(); // bar index → label
for (const v of ticks) {
let bestBar = 1;
let bestDist = Infinity;
for (let i = 1; i < barCount - 1; i++) {
const dist = Math.abs(barCenters[i] - v);
if (dist < bestDist) {
bestDist = dist;
bestBar = i;
}
}
if (!tickBars.has(bestBar)) tickBars.set(bestBar, fmt(v));
}
// Mean line: position as fraction across the bar area
const meanFrac = globalMean != null && p99 > p1 ? (globalMean - p1) / (p99 - p1) : null;
// Account for outlier bins: middle region spans bars 1..n-2
const meanPct = meanFrac != null ? ((1 + meanFrac * middleBins) / barCount) * 100 : null;
return (
<div className="mt-1">
<div className="relative flex items-end gap-px h-10">
{Array.from({ length: barCount }).map((_, index) => {
const globalHeight = (globalBars[index] / globalMax) * 100;
const localHeight = (localBars[index] / localMax) * 100;
return (
<div key={index} className="flex-1 relative min-w-[2px] h-full flex items-end">
<div
className="absolute bottom-0 left-0 right-0 bg-warm-300/40 dark:bg-warm-600/40 rounded-t-sm"
style={{ height: `${globalHeight}%` }}
/>
<div
className="absolute bottom-0 left-0 right-0 bg-teal-500 dark:bg-teal-400 rounded-t-sm"
style={{
height: `${localHeight}%`,
opacity: localBars[index] > 0 ? 1 : 0.1,
}}
/>
</div>
);
})}
{meanPct != null && meanPct >= 0 && meanPct <= 100 && (
<div
className="absolute bottom-0 top-0 w-px border-l border-dashed border-warm-400 dark:border-warm-500"
style={{ left: `${meanPct}%` }}
/>
)}
</div>
{tickBars.size > 0 && (
<div className="flex gap-px mt-0.5">
{Array.from({ length: barCount }).map((_, index) => (
<div key={index} className="flex-1 min-w-[2px] text-center">
{tickBars.has(index) && (
<span className="text-[9px] leading-none text-warm-400 dark:text-warm-500">
{tickBars.get(index)}
</span>
)}
</div>
))}
</div>
)}
</div>
);
}
export function SkeletonHistogram() {
return (
<div className="bg-warm-50 dark:bg-warm-800 rounded p-2 animate-pulse">
<div className="flex justify-between items-baseline">
<div className="h-3 w-24 bg-warm-200 dark:bg-warm-700 rounded" />
<div className="h-3 w-10 bg-warm-200 dark:bg-warm-700 rounded" />
</div>
<div className="flex items-end gap-px h-10 mt-2">
{Array.from({ length: 15 }).map((_, i) => (
<div
key={i}
className="flex-1 bg-warm-200 dark:bg-warm-700 rounded-t-sm min-w-[2px]"
style={{ height: `${20 + Math.sin(i * 0.7) * 30 + 30}%` }}
/>
))}
</div>
<div className="flex justify-between mt-1">
<div className="h-2 w-6 bg-warm-200 dark:bg-warm-700 rounded" />
<div className="h-2 w-6 bg-warm-200 dark:bg-warm-700 rounded" />
</div>
</div>
);
}
export function LoadingSkeleton() {
return (
<div className="p-3 space-y-4">
{[0, 1, 2].map((groupIdx) => (
<div key={groupIdx}>
<div className="h-3 w-20 bg-warm-200 dark:bg-warm-700 rounded animate-pulse mb-2" />
<div className="space-y-3">
{Array.from({ length: groupIdx === 0 ? 3 : 2 }).map((_, i) => (
<SkeletonHistogram key={i} />
))}
</div>
</div>
))}
</div>
);
}

View file

@ -0,0 +1,23 @@
export default function EnumBarChart({ counts }: { counts: Record<string, number> }) {
const entries = Object.entries(counts).sort(([, countA], [, countB]) => countB - countA);
const maxCount = Math.max(...entries.map(([, count]) => count), 1);
return (
<div className="space-y-1 mt-1">
{entries.map(([label, count]) => (
<div key={label} className="flex items-center gap-2 text-xs">
<span className="w-16 truncate text-warm-500 dark:text-warm-400 text-right shrink-0">
{label}
</span>
<div className="flex-1 h-3 bg-warm-100 dark:bg-navy-700 rounded overflow-hidden">
<div
className="h-full bg-teal-500 dark:bg-teal-400 rounded"
style={{ width: `${(count / maxCount) * 100}%` }}
/>
</div>
<span className="w-8 text-warm-500 dark:text-warm-400 text-right shrink-0">{count}</span>
</div>
))}
</div>
);
}

View file

@ -0,0 +1,53 @@
import { useMemo } from 'react';
import type { FeatureFilters } from '../../types';
import {
buildPropertySearchUrls,
H3_RADIUS_MILES,
type HexagonLocation,
} from '../../lib/external-search';
export default function ExternalSearchLinks({
location,
filters,
}: {
location: HexagonLocation;
filters: FeatureFilters;
}) {
const urls = useMemo(() => buildPropertySearchUrls(location, filters), [location, filters]);
const radiusMiles = H3_RADIUS_MILES[location.resolution] ?? 1;
const label = `${radiusMiles}mi radius`;
return (
<div className="p-3 border-b border-warm-200 dark:border-navy-700">
<h3 className="text-xs font-semibold text-warm-500 dark:text-warm-400 uppercase tracking-wider mb-2">
Search {label} on
</h3>
<div className="flex gap-2">
<a
href={urls.rightmove}
target="_blank"
rel="noopener noreferrer"
className="flex-1 text-center text-xs py-1.5 px-2 rounded border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 text-teal-600 dark:text-teal-400 hover:bg-warm-50 dark:hover:bg-warm-700 font-medium"
>
Rightmove
</a>
<a
href={urls.onthemarket}
target="_blank"
rel="noopener noreferrer"
className="flex-1 text-center text-xs py-1.5 px-2 rounded border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 text-teal-600 dark:text-teal-400 hover:bg-warm-50 dark:hover:bg-warm-700 font-medium"
>
OnTheMarket
</a>
<a
href={urls.zoopla}
target="_blank"
rel="noopener noreferrer"
className="flex-1 text-center text-xs py-1.5 px-2 rounded border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 text-teal-600 dark:text-teal-400 hover:bg-warm-50 dark:hover:bg-warm-700 font-medium"
>
Zoopla
</a>
</div>
</div>
);
}

View file

@ -0,0 +1,129 @@
import { useState, useMemo, useEffect } from 'react';
import { useCollapsibleGroups } from '../../hooks/useCollapsibleGroups';
import { SearchInput } from '../ui/SearchInput';
import { FilterIcon } from '../ui/icons';
import { CollapsibleGroupHeader } from '../ui/CollapsibleGroupHeader';
import { EmptyState } from '../ui/EmptyState';
import type { FeatureMeta } from '../../types';
import { groupFeaturesByCategory } from '../../lib/features';
import { FeatureInfoPopup } from '../ui/FeatureInfoPopup';
import { FeatureActions } from '../ui/FeatureIcons';
import { FeatureLabel } from '../ui/FeatureLabel';
interface FeatureBrowserProps {
availableFeatures: FeatureMeta[];
allFeatures: FeatureMeta[];
pinnedFeature: string | null;
onAddFilter: (name: string) => void;
onTogglePin: (name: string) => void;
onNavigateToSource?: (slug: string, featureName: string) => void;
openInfoFeature?: string | null;
onClearOpenInfoFeature?: () => void;
}
export default function FeatureBrowser({
availableFeatures,
allFeatures,
pinnedFeature,
onAddFilter,
onTogglePin,
onNavigateToSource,
openInfoFeature,
onClearOpenInfoFeature,
}: FeatureBrowserProps) {
const [search, setSearch] = useState('');
const [infoFeature, setInfoFeature] = useState<FeatureMeta | null>(null);
const [expandedGroups, toggleGroup] = useCollapsibleGroups();
useEffect(() => {
if (openInfoFeature) {
const feat = allFeatures.find((f) => f.name === openInfoFeature);
if (feat) setInfoFeature(feat);
onClearOpenInfoFeature?.();
}
}, [openInfoFeature, allFeatures, onClearOpenInfoFeature]);
const filtered = useMemo(() => {
if (!search) return availableFeatures;
const lower = search.toLowerCase();
return availableFeatures.filter((f) => f.name.toLowerCase().includes(lower));
}, [availableFeatures, search]);
const grouped = useMemo(() => groupFeaturesByCategory(filtered), [filtered]);
// When searching, expand all groups so results are visible
const isSearching = search.length > 0;
return (
<>
<div className="shrink-0 p-2 border-b border-warm-200 dark:border-navy-700">
<SearchInput value={search} onChange={setSearch} placeholder="Search features..." />
</div>
<div className="md:min-h-0 md:flex-1 md:overflow-y-auto flex flex-col">
{grouped.map((group) => {
const isExpanded = isSearching || expandedGroups.has(group.name);
return (
<div key={group.name} className="shrink-0">
<CollapsibleGroupHeader
name={group.name}
expanded={isExpanded}
onToggle={() => toggleGroup(group.name)}
className="px-3 py-1.5 text-xs font-bold text-warm-500 bg-warm-50 dark:bg-navy-950 dark:text-warm-400 sticky top-0 hover:bg-warm-100 dark:hover:bg-warm-800"
>
<span className="text-[10px] font-medium text-warm-400 dark:text-warm-500">
{group.features.length}
</span>
</CollapsibleGroupHeader>
{isExpanded &&
group.features.map((f) => {
const isPinned = pinnedFeature === f.name;
return (
<div
key={f.name}
className="flex items-start justify-between px-3 py-1.5 hover:bg-teal-50 dark:hover:bg-teal-900/30 dark:text-warm-300"
>
<div className="min-w-0 mr-2">
<FeatureLabel feature={f} onShowInfo={setInfoFeature} size="sm" />
{f.description && (
<span className="text-xs text-warm-400 dark:text-warm-500 truncate block">
{f.description}
</span>
)}
</div>
<FeatureActions
feature={f}
isPinned={isPinned}
onTogglePin={onTogglePin}
onAdd={onAddFilter}
/>
</div>
);
})}
</div>
);
})}
{grouped.length === 0 ? (
<EmptyState
icon={<FilterIcon className="w-8 h-8 text-warm-300 dark:text-warm-600" />}
title={search ? 'No matching features' : 'All features are active'}
description={
search ? 'Try a different search term' : 'Remove a filter to see available features'
}
className="px-3 py-4"
/>
) : (
<p className="flex-1 flex items-center justify-center px-4 py-6 text-xs text-center text-warm-400 dark:text-warm-500">
Everyone cares about different things. Pick the filters that matter most to you.
</p>
)}
</div>
{infoFeature && (
<FeatureInfoPopup
feature={infoFeature}
onClose={() => setInfoFeature(null)}
onNavigateToSource={onNavigateToSource}
/>
)}
</>
);
}

View file

@ -0,0 +1,273 @@
import { memo, useState } from 'react';
import { Slider } from '../ui/Slider';
import { FilterIcon, LightbulbIcon } from '../ui/icons';
import { EmptyState } from '../ui/EmptyState';
import type { FeatureMeta, FeatureFilters } from '../../types';
import { formatFilterValue } from '../../lib/format';
import InfoPopup from '../ui/InfoPopup';
import { FeatureInfoPopup } from '../ui/FeatureInfoPopup';
import { FeatureActions } from '../ui/FeatureIcons';
import { FeatureLabel } from '../ui/FeatureLabel';
import FeatureBrowser from './FeatureBrowser';
interface FiltersProps {
features: FeatureMeta[];
filters: FeatureFilters;
activeFeature: string | null;
dragValue: [number, number] | null;
enabledFeatures: Set<string>;
onAddFilter: (name: string) => void;
onRemoveFilter: (name: string) => void;
onFilterChange: (name: string, value: [number, number] | string[]) => void;
onDragStart: (name: string) => void;
onDragChange: (value: [number, number]) => void;
onDragEnd: () => void;
zoom: number;
itemCount: number;
usePostcodeView: boolean;
pinnedFeature: string | null;
onTogglePin: (name: string) => void;
onCancelPin: () => void;
onNavigateToSource?: (slug: string, featureName: string) => void;
openInfoFeature?: string | null;
onClearOpenInfoFeature?: () => void;
}
export default memo(function Filters({
features,
filters,
activeFeature,
dragValue,
enabledFeatures,
onAddFilter,
onRemoveFilter,
onFilterChange,
onDragStart,
onDragChange,
onDragEnd,
zoom,
itemCount,
usePostcodeView,
pinnedFeature,
onTogglePin,
onCancelPin: _onCancelPin,
onNavigateToSource,
openInfoFeature,
onClearOpenInfoFeature,
}: FiltersProps) {
const availableFeatures = features.filter((f) => !enabledFeatures.has(f.name));
const enabledFeatureList = features.filter((f) => enabledFeatures.has(f.name));
const [showPhilosophy, setShowPhilosophy] = useState(false);
const [activeInfoFeature, setActiveInfoFeature] = useState<FeatureMeta | null>(null);
return (
<div className="flex flex-col bg-white dark:bg-navy-950 overflow-y-auto md:overflow-hidden h-full">
<div className="shrink-0 flex items-center gap-2 px-3 py-2 border-b border-warm-200 dark:border-navy-700">
<button
onClick={() => setShowPhilosophy(true)}
className="flex-1 px-3 py-1.5 rounded-lg bg-gradient-to-r from-teal-500 to-teal-600 hover:from-teal-600 hover:to-teal-700 text-white font-medium text-sm shadow-sm hover:shadow-md flex items-center justify-center gap-2"
>
<LightbulbIcon />
Finding the Perfect Postcode
</button>
</div>
<div className="shrink-0 md:shrink md:min-h-0 flex flex-col md:max-h-[65%]">
<div className="shrink-0 flex items-center justify-between px-3 py-2 border-b border-warm-200 dark:border-navy-700">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-navy-950 dark:text-warm-100">
Active Filters
</span>
{enabledFeatureList.length > 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">
{enabledFeatureList.length}
</span>
)}
</div>
<span className="text-xs text-warm-500 dark:text-warm-400">
{itemCount.toLocaleString()} {usePostcodeView ? 'postcodes' : 'hexagons'} · z
{zoom.toFixed(1)}
</span>
</div>
<div className="md:flex-1 md:overflow-y-auto p-3 space-y-3">
{enabledFeatureList.length === 0 && (
<EmptyState
icon={<FilterIcon className="w-8 h-8 text-warm-300 dark:text-warm-600" />}
title="No active filters"
description="Browse features below and click + to add a filter"
/>
)}
{enabledFeatureList.map((feature) => {
if (feature.type === 'enum') {
const selectedValues = (filters[feature.name] as string[]) || [];
const allValues = feature.values || [];
return (
<div
key={feature.name}
className={`space-y-1 p-3 rounded ${pinnedFeature === feature.name ? 'ring-2 ring-teal-400 bg-teal-50/50 dark:bg-teal-900/20' : ''}`}
>
<div className="flex items-center justify-between">
<FeatureLabel feature={feature} onShowInfo={setActiveInfoFeature} size="sm" />
<FeatureActions
feature={feature}
isPinned={pinnedFeature === feature.name}
onTogglePin={onTogglePin}
onRemove={onRemoveFilter}
/>
</div>
<div className="space-y-0.5 max-h-40 overflow-y-auto">
{allValues.map((val) => (
<label
key={val}
className="flex items-center gap-1.5 text-sm cursor-pointer dark:text-warm-300"
>
<input
type="checkbox"
checked={selectedValues.includes(val)}
onChange={() => {
const next = selectedValues.includes(val)
? selectedValues.filter((v) => v !== val)
: [...selectedValues, val];
onFilterChange(feature.name, next);
}}
className="rounded accent-teal-600"
/>
{val}
</label>
))}
</div>
</div>
);
}
const isActive = activeFeature === feature.name;
const isPinned = pinnedFeature === feature.name;
const displayValue =
isActive && dragValue
? dragValue
: (filters[feature.name] as [number, number]) || [feature.min!, feature.max!];
const step = feature.step ?? (feature.max! - feature.min!) / 100;
return (
<div
key={feature.name}
className={`space-y-1 p-3 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' : ''}`}
>
<FeatureLabel feature={feature} onShowInfo={setActiveInfoFeature} size="sm" />
<div className="flex items-center justify-between">
<span className="text-sm text-warm-500 dark:text-warm-400">
{formatFilterValue(displayValue[0])} - {formatFilterValue(displayValue[1])}
</span>
<FeatureActions
feature={feature}
isPinned={isPinned}
onTogglePin={onTogglePin}
onRemove={onRemoveFilter}
/>
</div>
<Slider
min={feature.min!}
max={feature.max!}
step={step}
value={[displayValue[0], displayValue[1]]}
onValueChange={([min, max]) => onDragChange([min, max])}
onPointerDown={() => onDragStart(feature.name)}
onPointerUp={() => onDragEnd()}
/>
</div>
);
})}
</div>
</div>
<div className="shrink-0 md:shrink md:min-h-0 md:flex-1 flex flex-col border-t border-warm-200 dark:border-warm-700">
<div className="shrink-0 px-3 py-2 border-b border-warm-200 dark:border-navy-700">
<span className="text-sm font-semibold text-navy-950 dark:text-warm-100">Add Filter</span>
</div>
<div className="md:min-h-0 md:flex-1 flex flex-col">
<FeatureBrowser
availableFeatures={availableFeatures}
allFeatures={features}
pinnedFeature={pinnedFeature}
onAddFilter={onAddFilter}
onTogglePin={onTogglePin}
onNavigateToSource={onNavigateToSource}
openInfoFeature={openInfoFeature}
onClearOpenInfoFeature={onClearOpenInfoFeature}
/>
</div>
</div>
{showPhilosophy && (
<InfoPopup title="Finding the Perfect Postcode" onClose={() => setShowPhilosophy(false)}>
<div className="space-y-4 text-sm">
<div>
<h4 className="font-semibold text-navy-950 dark:text-warm-100 mb-1">
Be intentional, not reactive
</h4>
<p className="text-warm-600 dark:text-warm-300">
Your future home isn&apos;t a box of cereal you grab because it&apos;s on sale.
Don&apos;t let a seemingly good deal turn into lifelong regret. Instead of waiting
for listings to appear, define what you actually want and go find it.
</p>
</div>
<div>
<h4 className="font-semibold text-navy-950 dark:text-warm-100 mb-1">
See the full picture
</h4>
<p className="text-warm-600 dark:text-warm-300">
Current listings show only a fraction of the market. There are too few to give you a
complete picture, yet too many to evaluate one by one. We aggregate millions of
historical sales so you can understand what&apos;s truly available in any area.
</p>
</div>
<div>
<h4 className="font-semibold text-navy-950 dark:text-warm-100 mb-1">
Your priorities, your filters
</h4>
<p className="text-warm-600 dark:text-warm-300">
We all care about different things. Some want peace and quiet; others want to be
near the action. Use our filters to define exactly what matters to you and discover
postcodes that match.
</p>
</div>
<div>
<h4 className="font-semibold text-navy-950 dark:text-warm-100 mb-1">
Find the right place, not just the right listing
</h4>
<p className="text-warm-600 dark:text-warm-300">
The best areas to live don&apos;t always have properties listed right now. We help
you identify where you should be looking, so when something does come up,
you&apos;re ready.
</p>
</div>
<div>
<h4 className="font-semibold text-navy-950 dark:text-warm-100 mb-1">
Know what&apos;s possible
</h4>
<p className="text-warm-600 dark:text-warm-300">
We&apos;d rather tell you upfront if your expectations are unrealistic than have you
spend months searching for something that doesn&apos;t exist.
</p>
</div>
</div>
</InfoPopup>
)}
{activeInfoFeature && (
<FeatureInfoPopup
feature={activeInfoFeature}
onClose={() => setActiveInfoFeature(null)}
onNavigateToSource={onNavigateToSource}
/>
)}
</div>
);
});

View file

@ -0,0 +1,29 @@
export default function HistogramLegend() {
return (
<div className="mx-3 mt-3 bg-teal-50 dark:bg-teal-900/20 border border-teal-200 dark:border-teal-800/50 rounded p-2.5 text-xs">
<div className="space-y-1.5">
<div className="flex items-center gap-2">
<div className="w-3 h-3 bg-teal-500 dark:bg-teal-400 rounded" />
<span className="text-warm-700 dark:text-warm-300">
<span className="font-medium text-warm-900 dark:text-warm-100">Teal bars</span> show the
distribution in this selected area
</span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-3 bg-warm-300/60 dark:bg-warm-600/60 rounded" />
<span className="text-warm-700 dark:text-warm-300">
<span className="font-medium text-warm-900 dark:text-warm-100">Gray bars</span> show the
overall distribution across all areas
</span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-px border-t border-dashed border-warm-500 dark:border-warm-400" />
<span className="text-warm-700 dark:text-warm-300">
<span className="font-medium text-warm-900 dark:text-warm-100">Dashed line</span>{' '}
indicates the global average
</span>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,99 @@
import { memo } from 'react';
import type { FeatureFilters } from '../../types';
import { formatValue } from '../../lib/format';
interface HoverCardData {
count: number;
[key: string]: string | number | [number, number] | null;
}
interface HoverCardProps {
x: number;
y: number;
id: string;
isPostcode: boolean;
data: HoverCardData | null;
filters: FeatureFilters;
}
export default memo(function HoverCard({ x, y, id, isPostcode, data, filters }: HoverCardProps) {
const activeFilterNames = Object.keys(filters);
// Get key stats to show from local data (min_<feature> values)
const getDisplayStats = () => {
if (!data) return [];
const results: { name: string; value: string }[] = [];
// Show stats for active filters (up to 4)
for (const name of activeFilterNames.slice(0, 4)) {
const val = data[`avg_${name}`] ?? data[`min_${name}`];
if (val != null && typeof val === 'number') {
results.push({ name, value: formatValue(val) });
}
}
return results;
};
const displayStats = getDisplayStats();
const count = data?.count;
return (
<div
className="absolute bg-white dark:bg-warm-800 rounded-lg shadow-lg p-3 text-sm dark:text-white pointer-events-none z-50 min-w-[180px] max-w-[260px]"
style={{
left: x,
top: y - 12,
transform: 'translate(-50%, -100%)',
}}
>
{/* Arrow */}
<div
className="absolute w-3 h-3 bg-white dark:bg-warm-800 rotate-45"
style={{
left: '50%',
bottom: -6,
transform: 'translateX(-50%)',
}}
/>
<div className="relative">
{/* Header */}
<div className="flex items-center justify-between gap-2 mb-1">
<span className="font-semibold text-navy-950 dark:text-white truncate">
{isPostcode ? id : 'Area'}
</span>
</div>
{/* Property count */}
{count != null && (
<div className="text-xs text-warm-500 dark:text-warm-300 mb-2">
{count.toLocaleString()} {count === 1 ? 'property' : 'properties'}
</div>
)}
{/* Quick stats */}
{displayStats.length > 0 && (
<div className="space-y-1 border-t border-warm-200 dark:border-warm-700 pt-2">
{displayStats.map((stat) => (
<div key={stat.name} className="flex justify-between gap-2 text-xs">
<span className="text-warm-500 dark:text-warm-300 truncate">{stat.name}</span>
<span className="font-medium text-teal-700 dark:text-teal-300 whitespace-nowrap">
{stat.value}
</span>
</div>
))}
</div>
)}
{/* Hint */}
{data && (
<div className="text-[10px] text-warm-400 dark:text-warm-400 mt-2 text-center">
Click for details
</div>
)}
</div>
</div>
);
});

View file

@ -0,0 +1,286 @@
import { useCallback, useRef, useEffect, useState, useMemo, memo } from 'react';
import { Map as MapGL, useControl } from 'react-map-gl/maplibre';
import type { MapRef } from 'react-map-gl/maplibre';
import { MapboxOverlay } from '@deck.gl/mapbox';
import 'maplibre-gl/dist/maplibre-gl.css';
import type {
HexagonData,
PostcodeFeature,
ViewState,
ViewChangeParams,
POI,
FeatureMeta,
Bounds,
} from '../../types';
import { zoomToResolution, getBoundsFromViewState, getMapStyle } from '../../lib/map-utils';
import { INITIAL_VIEW_STATE, MAP_MIN_ZOOM, MAP_BOUNDS } from '../../lib/consts';
import PostcodeSearch, { type SearchedPostcode } from './PostcodeSearch';
import MapLegend from './MapLegend';
import HoverCard from './HoverCard';
import type { FeatureFilters } from '../../types';
import { useDeckLayers, osmIdToUrl } from '../../hooks/useDeckLayers';
interface MapProps {
data: HexagonData[];
postcodeData: PostcodeFeature[];
usePostcodeView: boolean;
pois: POI[];
onViewChange: (params: ViewChangeParams) => void;
viewFeature: string | null;
colorRange: [number, number] | null;
filterRange: [number, number] | null;
viewSource: 'drag' | 'eye' | null;
onCancelPin: () => void;
features: FeatureMeta[];
selectedHexagonId: string | null;
hoveredHexagonId: string | null;
onHexagonClick: (id: string, isPostcode?: boolean) => void;
onHexagonHover: (h3: string | null, x?: number, y?: number) => void;
initialViewState?: ViewState;
theme?: 'light' | 'dark';
screenshotMode?: boolean;
ogMode?: boolean;
filters?: FeatureFilters;
searchedPostcode?: SearchedPostcode | null;
onPostcodeSearched?: (postcode: SearchedPostcode | null) => void;
bounds?: Bounds | null;
hideLegend?: boolean;
}
interface Dimensions {
width: number;
height: number;
}
function DeckOverlay({
layers,
getTooltip,
}: {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
layers: any[];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
getTooltip: any;
}) {
const overlay = useControl(() => new MapboxOverlay({ interleaved: true }));
const prevLayersRef = useRef(layers);
const prevTooltipRef = useRef(getTooltip);
if (layers !== prevLayersRef.current || getTooltip !== prevTooltipRef.current) {
prevLayersRef.current = layers;
prevTooltipRef.current = getTooltip;
overlay.setProps({ layers, getTooltip });
}
return null;
}
export default memo(function Map({
data,
postcodeData,
usePostcodeView,
pois,
onViewChange,
viewFeature,
colorRange,
filterRange,
viewSource,
onCancelPin,
features,
selectedHexagonId,
hoveredHexagonId,
onHexagonClick,
onHexagonHover,
initialViewState,
theme = 'light',
screenshotMode = false,
ogMode = false,
filters = {},
searchedPostcode,
onPostcodeSearched,
bounds: viewportBounds,
hideLegend = false,
}: MapProps) {
const containerRef = useRef<HTMLDivElement>(null);
const [viewState, setViewState] = useState<ViewState>(initialViewState || INITIAL_VIEW_STATE);
const [dimensions, setDimensions] = useState<Dimensions>({ width: 0, height: 0 });
useEffect(() => {
const container = containerRef.current;
if (!container) return;
const observer = new ResizeObserver((entries) => {
const { width, height } = entries[0].contentRect;
if (width > 0 && height > 0) {
setDimensions({ width, height });
}
});
observer.observe(container);
return () => observer.disconnect();
}, []);
useEffect(() => {
if (dimensions.width === 0 || dimensions.height === 0) return;
// Send exact viewport bounds - server will filter to only return
// hexagons/postcodes that intersect this precise AABB
const bounds = getBoundsFromViewState(viewState, dimensions.width, dimensions.height);
const resolution = zoomToResolution(viewState.zoom);
onViewChange({
resolution,
bounds,
zoom: viewState.zoom,
latitude: viewState.latitude,
longitude: viewState.longitude,
});
}, [viewState, dimensions, onViewChange]);
const handleMove = useCallback((evt: { viewState: ViewState }) => {
setViewState(evt.viewState);
}, []);
const handleFlyTo = useCallback((lat: number, lng: number, zoom: number) => {
setViewState((prev) => ({ ...prev, latitude: lat, longitude: lng, zoom }));
}, []);
const handleMapLoad = useCallback(
(_evt: { target: MapRef['getMap'] extends () => infer M ? M : never }) => {
// Road opacity is set in getMapStyle
},
[]
);
const mapStyle = useMemo(() => getMapStyle(theme), [theme]);
const {
layers,
popupInfo,
hoverPosition,
countRange,
postcodeCountRange,
colorFeatureMeta,
handleMouseLeave,
} = useDeckLayers({
data,
postcodeData,
usePostcodeView,
pois,
viewFeature,
colorRange,
filterRange,
features,
selectedHexagonId,
hoveredHexagonId,
onHexagonClick,
onHexagonHover,
theme,
searchedPostcode,
bounds: viewportBounds,
});
return (
<div className="flex-1 h-full relative" ref={containerRef} onMouseLeave={handleMouseLeave}>
<MapGL
{...viewState}
onMove={handleMove}
onLoad={handleMapLoad as never}
mapStyle={mapStyle}
style={{ width: '100%', height: '100%' }}
attributionControl={false}
dragRotate={false}
touchZoomRotate={true}
touchPitch={false}
keyboard={true}
pitchWithRotate={false}
minZoom={MAP_MIN_ZOOM}
maxBounds={MAP_BOUNDS}
>
<DeckOverlay layers={layers} getTooltip={null} />
</MapGL>
{screenshotMode ? (
ogMode ? (
<div className="absolute inset-0 flex items-center justify-center z-20 pointer-events-none">
<h1
className="text-5xl font-bold text-white drop-shadow-lg"
style={{ textShadow: '0 2px 8px rgba(0,0,0,0.6)' }}
>
Your perfect postcodes
</h1>
</div>
) : null
) : (
<>
<PostcodeSearch onFlyTo={handleFlyTo} onPostcodeSearched={onPostcodeSearched} />
{!hideLegend &&
(viewFeature && colorRange && colorFeatureMeta ? (
<MapLegend
featureLabel={
viewSource === 'eye'
? `Previewing \u201c${colorFeatureMeta.name}\u201d`
: colorFeatureMeta.name
}
range={colorRange}
showCancel={viewSource === 'eye'}
onCancel={onCancelPin}
mode="feature"
enumValues={colorFeatureMeta.type === 'enum' ? colorFeatureMeta.values : undefined}
theme={theme}
/>
) : (
<MapLegend
featureLabel="Property density"
range={
usePostcodeView
? [postcodeCountRange.min, postcodeCountRange.max]
: [countRange.min, countRange.max]
}
showCancel={false}
onCancel={onCancelPin}
mode="density"
theme={theme}
/>
))}
{popupInfo && (
<div
className="absolute bg-white dark:bg-warm-800 rounded shadow-lg p-2 text-sm dark:text-white"
style={{
left: popupInfo.x,
top: popupInfo.y - 40,
transform: 'translateX(-50%)',
zIndex: 9999,
}}
>
<strong className="dark:text-white">{popupInfo.name}</strong>
<div className="text-warm-500 dark:text-warm-300 text-xs">{popupInfo.category}</div>
{osmIdToUrl(popupInfo.id) && (
<a
href={osmIdToUrl(popupInfo.id)!}
target="_blank"
rel="noopener noreferrer"
className="text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300 text-xs"
>
View on OSM
</a>
)}
</div>
)}
{hoverPosition && hoveredHexagonId && hoveredHexagonId !== selectedHexagonId && (
<HoverCard
x={hoverPosition.x}
y={hoverPosition.y}
id={hoveredHexagonId}
isPostcode={usePostcodeView}
data={
usePostcodeView
? postcodeData.find((f) => f.properties.postcode === hoveredHexagonId)
?.properties || null
: data.find((d) => d.h3 === hoveredHexagonId) || null
}
filters={filters}
/>
)}
</>
)}
</div>
);
});

View file

@ -0,0 +1,71 @@
import { formatValue } from '../../lib/format';
import { FEATURE_GRADIENT, DENSITY_GRADIENT, DENSITY_GRADIENT_DARK } from '../../lib/consts';
import { gradientToCss } from '../../lib/utils';
import { CloseIcon } from '../ui/icons/CloseIcon';
import { TickerValue } from '../ui/TickerValue';
export default function MapLegend({
featureLabel,
range,
showCancel,
onCancel,
mode,
enumValues,
theme = 'light',
inline = false,
}: {
featureLabel: string;
range: [number, number];
showCancel: boolean;
onCancel: () => void;
mode: 'feature' | 'density';
enumValues?: string[];
theme?: 'light' | 'dark';
inline?: boolean;
}) {
const densityGradient = theme === 'dark' ? DENSITY_GRADIENT_DARK : DENSITY_GRADIENT;
const gradientStyle =
mode === 'density' ? gradientToCss(densityGradient) : gradientToCss(FEATURE_GRADIENT);
return (
<div
className={
inline
? 'bg-white dark:bg-warm-800 dark:text-white p-3 text-xs border-b border-warm-200 dark:border-warm-700'
: 'absolute top-3 right-3 z-10 bg-white dark:bg-navy-800 dark:text-white rounded shadow-lg p-3 text-xs min-w-[160px]'
}
>
<div className="flex items-center justify-between mb-2">
<span className="font-semibold text-sm dark:text-white">{featureLabel}</span>
{showCancel && (
<button
onClick={onCancel}
className="text-warm-400 hover:text-warm-700 dark:hover:text-warm-300 ml-2"
title="Clear color view"
>
<CloseIcon className="w-4 h-4" />
</button>
)}
</div>
<div className="h-3 rounded" style={{ background: gradientStyle }} />
<div className="flex justify-between mt-1 text-warm-600 dark:text-warm-200">
{mode === 'density' ? (
<>
<TickerValue text={formatValue(range[0])} />
<TickerValue text={formatValue(range[1])} />
</>
) : enumValues && enumValues.length > 0 ? (
<>
<span>{enumValues[0]}</span>
<span>{enumValues[enumValues.length - 1]}</span>
</>
) : (
<>
<TickerValue text={formatValue(range[0])} />
<TickerValue text={formatValue(range[1])} />
</>
)}
</div>
</div>
);
}

View file

@ -0,0 +1,569 @@
import { useState, useEffect, useMemo, useCallback } from 'react';
import type { FeatureMeta, FeatureFilters, POICategoryGroup, ViewState } from '../../types';
import type { SearchedPostcode } from './PostcodeSearch';
import type { Page } from '../ui/Header';
import Map from './Map';
import Filters from './Filters';
import POIPane from './POIPane';
import { PropertiesPane } from './PropertiesPane';
import AreaPane from './AreaPane';
import MobileDrawer from './MobileDrawer';
import DataSources from '../data-sources/DataSources';
import MapLegend from './MapLegend';
import { TabButton } from '../ui/TabButton';
import { useMapData } from '../../hooks/useMapData';
import { usePOIData } from '../../hooks/usePOIData';
import { useFilters } from '../../hooks/useFilters';
import { useHexagonSelection } from '../../hooks/useHexagonSelection';
import { usePaneResize } from '../../hooks/usePaneResize';
import { useAreaSummary } from '../../hooks/useAreaSummary';
import { useUrlSync } from '../../hooks/useUrlSync';
import { apiUrl, buildFilterString } from '../../lib/api';
import { SpinnerIcon } from '../ui/icons/SpinnerIcon';
export interface ExportState {
onExport: () => void;
exporting: boolean;
}
type MobileBottomTab = 'filters' | 'pois' | 'area';
interface MapPageProps {
features: FeatureMeta[];
poiCategoryGroups: POICategoryGroup[];
initialFilters: FeatureFilters;
initialViewState: ViewState;
initialPOICategories: Set<string>;
initialTab: 'pois' | 'properties' | 'area';
initialLoading: boolean;
theme: 'light' | 'dark';
pendingInfoFeature: string | null;
onClearPendingInfoFeature: () => void;
onNavigateTo: (page: Page, hash?: string, infoFeature?: string) => void;
onExportStateChange?: (state: ExportState) => void;
screenshotMode?: boolean;
ogMode?: boolean;
isMobile?: boolean;
}
export default function MapPage({
features,
poiCategoryGroups,
initialFilters,
initialViewState,
initialPOICategories,
initialTab,
initialLoading,
theme,
pendingInfoFeature,
onClearPendingInfoFeature,
onNavigateTo,
onExportStateChange,
screenshotMode,
ogMode,
isMobile = false,
}: MapPageProps) {
const [searchedPostcode, setSearchedPostcode] = useState<SearchedPostcode | null>(null);
const [selectedPOICategories, setSelectedPOICategories] =
useState<Set<string>>(initialPOICategories);
const [leftPaneWidth, leftPaneHandlers] = usePaneResize(384, 200, 600, 'left');
const [rightPaneWidth, rightPaneHandlers] = usePaneResize(288, 200, 500, 'right');
// Mobile state
const [mobileBottomTab, setMobileBottomTab] = useState<MobileBottomTab>('filters');
const [mobileDrawerOpen, setMobileDrawerOpen] = useState(false);
// Initialize filters first
const {
filters,
activeFeature,
dragValue,
dragData,
pinnedFeature,
enabledFeatures,
viewFeature,
viewSource,
filterRange,
handleAddFilter,
handleFilterChange,
handleRemoveFilter,
handleDragStart,
handleDragChange,
handleDragEnd,
handleTogglePin,
handleCancelPin,
updateBoundsInfo,
} = useFilters({
initialFilters,
features,
});
// Map data hook
const mapData = useMapData({
filters,
features,
viewFeature,
activeFeature,
dragValue,
dragData,
});
// Keep filter bounds in sync with map data
useEffect(() => {
updateBoundsInfo(mapData.bounds, mapData.resolution);
}, [mapData.bounds, mapData.resolution, updateBoundsInfo]);
// Hexagon selection hook
const selection = useHexagonSelection({
filters,
features,
resolution: mapData.resolution,
});
// POI data
const pois = usePOIData(mapData.bounds, selectedPOICategories);
// Sync current state to URL
useUrlSync(mapData.currentView, filters, features, selectedPOICategories, selection.rightPaneTab);
// Set initial view and tab from URL state
useEffect(() => {
mapData.setInitialView(initialViewState);
selection.setRightPaneTab(initialTab);
}, []); // eslint-disable-line react-hooks/exhaustive-deps
// On mobile, open drawer and switch tab when hexagon is clicked
const { handleHexagonClick } = selection;
const handleMobileHexagonClick = useCallback(
(id: string, isPostcode?: boolean) => {
handleHexagonClick(id, isPostcode);
if (id) {
setMobileDrawerOpen(true);
setMobileBottomTab('area');
}
},
[handleHexagonClick]
);
// Compute hexagon location for external links
const hexagonLocation = useMemo(() => {
const hexId = selection.selectedHexagon?.id;
const isPostcode = selection.selectedHexagon?.type === 'postcode';
if (isPostcode) {
// For postcodes, get centroid from postcodeData
const postcodeFeature = mapData.postcodeData.find((f) => f.properties.postcode === hexId);
if (!postcodeFeature?.properties.centroid) return null;
const [lon, lat] = postcodeFeature.properties.centroid;
return { lat, lon, resolution: mapData.resolution };
} else {
// For hexagons, get lat/lon from hexagon data
const hex = hexId ? mapData.data.find((d) => d.h3 === hexId) : null;
if (!hex || typeof hex.lat !== 'number' || typeof hex.lon !== 'number') return null;
return { lat: hex.lat as number, lon: hex.lon as number, resolution: mapData.resolution };
}
}, [
selection.selectedHexagon?.id,
selection.selectedHexagon?.type,
mapData.data,
mapData.postcodeData,
mapData.resolution,
]);
// AI area summary
const aiSummary = useAreaSummary({
stats: selection.areaStats,
hexagonId: selection.selectedHexagon?.id || null,
isPostcode: selection.selectedHexagon?.type === 'postcode',
filters,
features,
});
// Export to Excel
const [exporting, setExporting] = useState(false);
const handleExport = useCallback(() => {
if (!mapData.bounds || exporting) return;
const { south, west, north, east } = mapData.bounds;
const params = new URLSearchParams({
bounds: `${south},${west},${north},${east}`,
});
const filterStr = buildFilterString(filters, features);
if (filterStr) params.set('filters', filterStr);
const url = apiUrl('export', params);
setExporting(true);
fetch(url)
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.blob();
})
.then((blob) => {
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = 'perfect-postcodes-export.xlsx';
link.click();
URL.revokeObjectURL(link.href);
})
.catch((err) => console.error('Export failed:', err))
.finally(() => setExporting(false));
}, [mapData.bounds, filters, features, exporting]);
// Report export state to parent (Header)
useEffect(() => {
onExportStateChange?.({ onExport: handleExport, exporting });
}, [handleExport, exporting, onExportStateChange]);
// Mobile legend data (computed from API-fetched data, which is already viewport-scoped)
const mobileLegendMeta = useMemo(
() => (viewFeature ? features.find((f) => f.name === viewFeature) || null : null),
[viewFeature, features]
);
const mobileDensityRange = useMemo((): [number, number] => {
const items = mapData.usePostcodeView ? mapData.postcodeData : mapData.data;
if (items.length === 0) return [0, 1];
let min = Infinity;
let max = -Infinity;
for (const d of items) {
const c =
'count' in d
? (d as { count: number }).count
: (d as { properties: { count: number } }).properties.count;
if (c < min) min = c;
if (c > max) max = c;
}
if (min === Infinity) return [0, 1];
if (min === max) return [min, min + 1];
return [min, max];
}, [mapData.data, mapData.postcodeData, mapData.usePostcodeView]);
// Signal screenshot readiness once map data has loaded
useEffect(() => {
if (screenshotMode && !mapData.loading && mapData.data.length > 0) {
window.__screenshot_ready = true;
}
}, [screenshotMode, mapData.loading, mapData.data.length]);
if (screenshotMode) {
return (
<div className="h-full w-full">
<Map
data={mapData.data}
postcodeData={mapData.postcodeData}
usePostcodeView={mapData.usePostcodeView}
pois={[]}
onViewChange={mapData.handleViewChange}
viewFeature={viewFeature}
colorRange={mapData.colorRange}
filterRange={filterRange}
viewSource={viewSource}
onCancelPin={() => {}}
features={features}
selectedHexagonId={null}
hoveredHexagonId={null}
onHexagonClick={() => {}}
onHexagonHover={() => {}}
initialViewState={initialViewState}
theme={theme}
screenshotMode
ogMode={ogMode}
bounds={mapData.bounds}
/>
</div>
);
}
// Shared pane content renderers
const renderAreaPane = () => (
<AreaPane
stats={selection.areaStats}
globalFeatures={features}
loading={selection.loadingAreaStats}
hexagonId={selection.selectedHexagon?.id || null}
isPostcode={selection.selectedHexagon?.type === 'postcode'}
postcodeData={
selection.selectedHexagon?.type === 'postcode'
? mapData.postcodeData.find(
(f) => f.properties.postcode === selection.selectedHexagon?.id
) || null
: null
}
onViewProperties={selection.handleViewPropertiesFromArea}
onClose={selection.handleCloseSelection}
hexagonLocation={hexagonLocation}
filters={filters}
onNavigateToSource={(slug, featureName) => onNavigateTo('data-sources', slug, featureName)}
aiSummary={aiSummary.summary}
aiSummaryLoading={aiSummary.loading}
aiSummaryError={aiSummary.error}
/>
);
const renderPropertiesPane = () => (
<PropertiesPane
properties={selection.properties}
total={selection.propertiesTotal}
loading={selection.loadingProperties}
hexagonId={selection.selectedHexagon?.id || null}
onLoadMore={selection.handleLoadMoreProperties}
onClose={selection.handleCloseSelection}
onNavigateToSource={(slug) => onNavigateTo('data-sources', slug)}
/>
);
const renderPOIPane = () => (
<POIPane
groups={poiCategoryGroups}
selectedCategories={selectedPOICategories}
onCategoriesChange={setSelectedPOICategories}
poiCount={pois.length}
onNavigateToSource={(slug) => onNavigateTo('data-sources', slug)}
/>
);
const renderFilters = () => (
<Filters
features={features}
filters={filters}
activeFeature={activeFeature}
dragValue={dragValue}
enabledFeatures={enabledFeatures}
onAddFilter={handleAddFilter}
onRemoveFilter={handleRemoveFilter}
onFilterChange={handleFilterChange}
onDragStart={handleDragStart}
onDragChange={handleDragChange}
onDragEnd={handleDragEnd}
zoom={mapData.zoom}
itemCount={mapData.usePostcodeView ? mapData.postcodeData.length : mapData.data.length}
usePostcodeView={mapData.usePostcodeView}
pinnedFeature={pinnedFeature}
onTogglePin={handleTogglePin}
onCancelPin={handleCancelPin}
onNavigateToSource={(slug, featureName) => onNavigateTo('data-sources', slug, featureName)}
openInfoFeature={pendingInfoFeature}
onClearOpenInfoFeature={onClearPendingInfoFeature}
/>
);
// Mobile layout
if (isMobile) {
return (
<div className="flex-1 flex flex-col overflow-hidden relative">
{initialLoading && (
<div className="absolute inset-0 z-50 flex items-center justify-center bg-warm-50/80 dark:bg-navy-950/80 backdrop-blur-sm">
<div className="flex flex-col items-center gap-4">
<SpinnerIcon className="w-12 h-12 text-teal-600 dark:text-teal-400 animate-spin" />
<p className="text-warm-600 dark:text-warm-300 text-sm font-medium">
Connecting to server...
</p>
</div>
</div>
)}
{/* Map — 45% */}
<div className="relative overflow-hidden" style={{ flex: '45 0 0' }}>
<Map
data={mapData.data}
postcodeData={mapData.postcodeData}
usePostcodeView={mapData.usePostcodeView}
pois={pois}
onViewChange={mapData.handleViewChange}
viewFeature={viewFeature}
colorRange={mapData.colorRange}
filterRange={filterRange}
viewSource={viewSource}
onCancelPin={handleCancelPin}
features={features}
selectedHexagonId={selection.selectedHexagon?.id || null}
hoveredHexagonId={selection.hoveredHexagon}
onHexagonClick={handleMobileHexagonClick}
onHexagonHover={selection.handleHexagonHover}
initialViewState={initialViewState}
theme={theme}
filters={filters}
searchedPostcode={searchedPostcode}
onPostcodeSearched={setSearchedPostcode}
bounds={mapData.bounds}
hideLegend
/>
{mapData.loading && (
<div className="absolute bottom-2 left-2 bg-white dark:bg-navy-800 dark:text-warm-200 px-2 py-1 rounded shadow text-xs">
Loading...
</div>
)}
<DataSources onNavigate={() => onNavigateTo('data-sources')} />
</div>
{/* Bottom panel — 55% */}
<div
className="bg-white dark:bg-warm-900 border-t border-warm-200 dark:border-warm-700 overflow-hidden flex flex-col"
style={{ flex: '55 0 0' }}
>
{/* Legend */}
{viewFeature && mapData.colorRange && mobileLegendMeta ? (
<MapLegend
featureLabel={
viewSource === 'eye'
? `Previewing \u201c${mobileLegendMeta.name}\u201d`
: mobileLegendMeta.name
}
range={mapData.colorRange}
showCancel={viewSource === 'eye'}
onCancel={handleCancelPin}
mode="feature"
enumValues={mobileLegendMeta.type === 'enum' ? mobileLegendMeta.values : undefined}
theme={theme}
inline
/>
) : (
<MapLegend
featureLabel="Property density"
range={mobileDensityRange}
showCancel={false}
onCancel={handleCancelPin}
mode="density"
theme={theme}
inline
/>
)}
{/* Tab bar */}
<div className="flex shrink-0 border-b border-warm-200 dark:border-warm-700 text-sm">
<TabButton
label="Filters"
isActive={mobileBottomTab === 'filters'}
onClick={() => setMobileBottomTab('filters')}
/>
<TabButton
label="POIs"
isActive={mobileBottomTab === 'pois'}
onClick={() => setMobileBottomTab('pois')}
/>
</div>
{/* Tab content */}
<div className="flex-1 min-h-0">
{mobileBottomTab === 'pois' ? (
<div className="h-full overflow-y-auto">{renderPOIPane()}</div>
) : (
renderFilters()
)}
</div>
</div>
{/* Mobile drawer for full-screen hexagon details */}
{mobileDrawerOpen && selection.selectedHexagon && (
<MobileDrawer
onClose={() => setMobileDrawerOpen(false)}
renderArea={renderAreaPane}
renderProperties={renderPropertiesPane}
renderPOIs={renderPOIPane}
/>
)}
</div>
);
}
// Desktop layout (unchanged)
return (
<div className="flex-1 flex overflow-hidden relative">
{initialLoading && (
<div className="absolute inset-0 z-50 flex items-center justify-center bg-warm-50/80 dark:bg-navy-950/80 backdrop-blur-sm">
<div className="flex flex-col items-center gap-4">
<SpinnerIcon className="w-12 h-12 text-teal-600 dark:text-teal-400 animate-spin" />
<p className="text-warm-600 dark:text-warm-300 text-sm font-medium">
Connecting to server...
</p>
</div>
</div>
)}
{/* Left Pane */}
<div
className="flex bg-white dark:bg-navy-950 shadow-lg overflow-hidden"
style={{ width: leftPaneWidth }}
>
<div className="flex-1 flex flex-col overflow-hidden">{renderFilters()}</div>
<div
className="w-1.5 cursor-col-resize flex items-center justify-center bg-warm-100 dark:bg-navy-800 hover:bg-warm-200 dark:hover:bg-navy-700 border-x border-warm-200 dark:border-navy-700"
{...leftPaneHandlers}
>
<div className="w-0.5 h-8 rounded bg-warm-300 dark:bg-navy-600" />
</div>
</div>
{/* Map */}
<div className="flex-1 relative">
<Map
data={mapData.data}
postcodeData={mapData.postcodeData}
usePostcodeView={mapData.usePostcodeView}
pois={pois}
onViewChange={mapData.handleViewChange}
viewFeature={viewFeature}
colorRange={mapData.colorRange}
filterRange={filterRange}
viewSource={viewSource}
onCancelPin={handleCancelPin}
features={features}
selectedHexagonId={selection.selectedHexagon?.id || null}
hoveredHexagonId={selection.hoveredHexagon}
onHexagonClick={selection.handleHexagonClick}
onHexagonHover={selection.handleHexagonHover}
initialViewState={initialViewState}
theme={theme}
filters={filters}
searchedPostcode={searchedPostcode}
onPostcodeSearched={setSearchedPostcode}
bounds={mapData.bounds}
/>
{mapData.loading && (
<div className="absolute bottom-4 left-4 bg-white dark:bg-navy-800 dark:text-warm-200 px-3 py-1 rounded shadow">
Loading...
</div>
)}
<DataSources onNavigate={() => onNavigateTo('data-sources')} />
</div>
{/* Right Pane */}
<div
className="flex bg-white dark:bg-navy-950 shadow-lg z-10"
style={{ width: rightPaneWidth }}
>
<div
className="w-1.5 cursor-col-resize flex items-center justify-center bg-warm-100 dark:bg-navy-800 hover:bg-warm-200 dark:hover:bg-navy-700 border-x border-warm-200 dark:border-navy-700"
{...rightPaneHandlers}
>
<div className="w-0.5 h-8 rounded bg-warm-300 dark:bg-navy-600" />
</div>
<div className="flex-1 flex flex-col overflow-hidden">
<div className="flex border-b border-warm-200 dark:border-navy-700 text-sm">
<TabButton
label="Area"
isActive={selection.rightPaneTab === 'area'}
onClick={() => selection.setRightPaneTab('area')}
/>
<TabButton
label="Properties"
isActive={selection.rightPaneTab === 'properties'}
onClick={selection.handlePropertiesTabClick}
/>
<TabButton
label="POIs"
isActive={selection.rightPaneTab === 'pois'}
onClick={() => selection.setRightPaneTab('pois')}
/>
</div>
<div className="flex-1 overflow-hidden">
{selection.rightPaneTab === 'area'
? renderAreaPane()
: selection.rightPaneTab === 'properties'
? renderPropertiesPane()
: renderPOIPane()}
</div>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,63 @@
import { useState, useEffect } from 'react';
import { CloseIcon } from '../ui/icons/CloseIcon';
import { TabButton } from '../ui/TabButton';
type DrawerTab = 'area' | 'properties' | 'pois';
interface MobileDrawerProps {
onClose: () => void;
renderArea: () => React.ReactNode;
renderProperties: () => React.ReactNode;
renderPOIs: () => React.ReactNode;
}
export default function MobileDrawer({
onClose,
renderArea,
renderProperties,
renderPOIs,
}: MobileDrawerProps) {
const [tab, setTab] = useState<DrawerTab>('area');
// Close on Escape
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, [onClose]);
return (
<div className="fixed inset-0 z-50 flex flex-col">
{/* Backdrop — top 10% */}
<div className="h-[10%] bg-black/50" onClick={onClose} />
{/* Panel — bottom 90% */}
<div className="h-[90%] bg-white dark:bg-navy-950 rounded-t-2xl flex flex-col shadow-xl overflow-hidden">
{/* Tab bar + close */}
<div className="flex border-b border-warm-200 dark:border-navy-700 text-sm shrink-0">
<TabButton label="Area" isActive={tab === 'area'} onClick={() => setTab('area')} />
<TabButton
label="Properties"
isActive={tab === 'properties'}
onClick={() => setTab('properties')}
/>
<TabButton label="POIs" isActive={tab === 'pois'} onClick={() => setTab('pois')} />
<button
onClick={onClose}
className="ml-auto flex items-center justify-center w-10 h-10 rounded-lg hover:bg-warm-100 dark:hover:bg-navy-800"
aria-label="Close drawer"
>
<CloseIcon className="w-5 h-5 text-warm-500 dark:text-warm-400" />
</button>
</div>
{/* Content */}
<div className="flex-1 overflow-hidden">
{tab === 'area' ? renderArea() : tab === 'properties' ? renderProperties() : renderPOIs()}
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,204 @@
import { useState, useCallback } from 'react';
import { useCollapsibleGroups } from '../../hooks/useCollapsibleGroups';
import type { POICategoryGroup } from '../../types';
import InfoPopup from '../ui/InfoPopup';
import { SearchInput } from '../ui/SearchInput';
import { InfoIcon, ChevronIcon } from '../ui/icons';
import { IconButton } from '../ui/IconButton';
interface POIPaneProps {
groups: POICategoryGroup[];
selectedCategories: Set<string>;
onCategoriesChange: (categories: Set<string>) => void;
poiCount: number;
onNavigateToSource?: (slug: string) => void;
}
export default function POIPane({
groups,
selectedCategories,
onCategoriesChange,
poiCount,
onNavigateToSource,
}: POIPaneProps) {
const [searchTerm, setSearchTerm] = useState('');
const [collapsedGroups, toggleCollapse] = useCollapsibleGroups();
const [showInfo, setShowInfo] = useState(false);
const allCategories = groups.flatMap((g) => g.categories);
const toggleCategory = (category: string) => {
const newSet = new Set(selectedCategories);
if (newSet.has(category)) {
newSet.delete(category);
} else {
newSet.add(category);
}
onCategoriesChange(newSet);
};
const selectAll = () => {
onCategoriesChange(new Set(allCategories));
};
const selectNone = () => {
onCategoriesChange(new Set());
};
const toggleGroup = useCallback(
(groupName: string) => {
const group = groups.find((g) => g.name === groupName);
if (!group) return;
const allSelected = group.categories.every((c) => selectedCategories.has(c));
const newSet = new Set(selectedCategories);
if (allSelected) {
group.categories.forEach((c) => newSet.delete(c));
} else {
group.categories.forEach((c) => newSet.add(c));
}
onCategoriesChange(newSet);
},
[groups, selectedCategories, onCategoriesChange]
);
const lowerSearch = searchTerm.toLowerCase();
const filteredGroups = groups
.map((group) => {
if (!searchTerm) return group;
const matchingCats = group.categories.filter((c) => c.toLowerCase().includes(lowerSearch));
const groupMatches = group.name.toLowerCase().includes(lowerSearch);
if (groupMatches) return group;
if (matchingCats.length === 0) return null;
return { ...group, categories: matchingCats };
})
.filter(Boolean) as POICategoryGroup[];
const selectedCount = selectedCategories.size;
return (
<div className="flex flex-col h-full bg-white dark:bg-navy-950 shadow-lg overflow-hidden">
<div className="flex-shrink-0 px-4 pt-4 pb-2 space-y-3">
<div className="flex items-center gap-2">
<h2 className="text-xl font-bold dark:text-warm-100">Points of Interest</h2>
<IconButton onClick={() => setShowInfo(true)} title="Data source info">
<InfoIcon />
</IconButton>
</div>
{showInfo && (
<InfoPopup
title="Points of Interest"
onClose={() => setShowInfo(false)}
sourceLink={
onNavigateToSource
? {
label: 'View data source',
onClick: () => {
onNavigateToSource('osm-pois');
setShowInfo(false);
},
}
: undefined
}
>
<p className="text-sm text-warm-700 dark:text-warm-300 mb-4 leading-relaxed">
Points of interest are sourced from OpenStreetMap via Geofabrik extracts. Categories
include public transport stops, shops, restaurants, healthcare facilities, leisure
venues, and more. Data is filtered and mapped to friendly names with exhaustive
category coverage.
</p>
</InfoPopup>
)}
<SearchInput
value={searchTerm}
onChange={setSearchTerm}
placeholder="Search categories..."
/>
<div className="flex items-center justify-between">
<div className="flex gap-1">
<button
onClick={selectAll}
className="px-2 py-0.5 text-xs rounded border border-warm-300 dark:border-warm-700 text-warm-600 dark:text-warm-400 hover:bg-warm-50 dark:hover:bg-warm-700"
>
All
</button>
<button
onClick={selectNone}
className="px-2 py-0.5 text-xs rounded border border-warm-300 dark:border-warm-700 text-warm-600 dark:text-warm-400 hover:bg-warm-50 dark:hover:bg-warm-700"
>
None
</button>
</div>
<span className="text-xs text-warm-500 dark:text-warm-400">
{selectedCount}/{allCategories.length} selected
</span>
</div>
{selectedCount > 0 && (
<div className="px-3 py-2 bg-teal-50 dark:bg-teal-900/30 rounded text-sm flex items-center justify-between">
<span className="font-medium text-teal-900 dark:text-teal-300">
{poiCount.toLocaleString()} POI{poiCount !== 1 ? 's' : ''} visible
</span>
</div>
)}
</div>
<div className="flex-1 overflow-y-auto border-t border-warm-200 dark:border-warm-700">
{filteredGroups.map((group) => {
const groupSelected = group.categories.filter((c) => selectedCategories.has(c)).length;
const allInGroupSelected = groupSelected === group.categories.length;
const someInGroupSelected = groupSelected > 0 && !allInGroupSelected;
const isCollapsed = collapsedGroups.has(group.name) && !searchTerm;
return (
<div key={group.name}>
<div className="flex items-center gap-1 px-3 py-1.5 bg-warm-50 dark:bg-navy-950 border-b border-warm-100 dark:border-navy-700 sticky top-0 z-10">
<button
onClick={() => toggleCollapse(group.name)}
className={`p-0.5 text-warm-400 hover:text-warm-600 transition-transform ${isCollapsed ? '' : 'rotate-90'}`}
>
<ChevronIcon direction="right" className="w-3 h-3" />
</button>
<label className="flex items-center gap-2 flex-1 cursor-pointer">
<input
type="checkbox"
checked={allInGroupSelected}
ref={(el) => {
if (el) el.indeterminate = someInGroupSelected;
}}
onChange={() => toggleGroup(group.name)}
className="rounded accent-teal-600"
/>
<span className="text-xs font-semibold text-warm-700 dark:text-warm-300">
{group.name}
</span>
</label>
<span className="text-xs text-warm-400">
{groupSelected}/{group.categories.length}
</span>
</div>
{!isCollapsed &&
group.categories.map((category) => (
<label
key={category}
className="flex items-center gap-2 px-3 pl-8 py-1.5 hover:bg-warm-50 dark:hover:bg-navy-700 cursor-pointer dark:text-warm-300"
>
<input
type="checkbox"
checked={selectedCategories.has(category)}
onChange={() => toggleCategory(category)}
className="rounded accent-teal-600"
/>
<span className="text-sm flex-1">{category}</span>
</label>
))}
</div>
);
})}
</div>
</div>
);
}

View file

@ -0,0 +1,81 @@
import { useState, useCallback } from 'react';
import type { PostcodeGeometry } from '../../types';
import { authHeaders } from '../../lib/api';
export interface SearchedPostcode {
postcode: string;
geometry: PostcodeGeometry;
}
export default function PostcodeSearch({
onFlyTo,
onPostcodeSearched,
}: {
onFlyTo: (lat: number, lng: number, zoom: number) => void;
onPostcodeSearched?: (postcode: SearchedPostcode | null) => void;
}) {
const [query, setQuery] = useState('');
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const handleSubmit = useCallback(
async (e: React.FormEvent) => {
e.preventDefault();
const trimmed = query.trim();
if (!trimmed) return;
setError(null);
setLoading(true);
try {
const res = await fetch(`/api/postcode/${encodeURIComponent(trimmed)}`, authHeaders());
if (!res.ok) {
setError('Postcode not found');
return;
}
const json: {
postcode: string;
latitude: number;
longitude: number;
geometry: PostcodeGeometry;
} = await res.json();
onFlyTo(json.latitude, json.longitude, 16);
onPostcodeSearched?.({ postcode: json.postcode, geometry: json.geometry });
setQuery('');
} catch {
setError('Lookup failed');
} finally {
setLoading(false);
}
},
[query, onFlyTo, onPostcodeSearched]
);
return (
<form onSubmit={handleSubmit} className="absolute top-3 left-3 z-10 flex flex-col gap-1">
<div className="flex shadow-lg rounded overflow-hidden">
<input
type="text"
value={query}
onChange={(e) => {
setQuery(e.target.value);
setError(null);
}}
placeholder="Search postcode..."
className="px-3 py-2 text-sm w-40 border-none outline-none bg-white dark:bg-navy-800 dark:text-white dark:placeholder-warm-400"
/>
<button
type="submit"
disabled={loading}
className="px-3 py-2 bg-teal-600 text-white text-sm hover:bg-teal-700 disabled:opacity-50"
>
{loading ? '...' : 'Go'}
</button>
</div>
{error && (
<span className="text-xs text-red-600 dark:text-red-300 bg-white/90 dark:bg-navy-800/90 rounded px-2 py-0.5 shadow">
{error}
</span>
)}
</form>
);
}

View file

@ -0,0 +1,183 @@
import { useMemo, useRef, useState, useEffect } from 'react';
import type { PricePoint } from '../../types';
import { formatValue } from '../../lib/format';
interface PriceHistoryChartProps {
points: PricePoint[];
}
const PADDING = { top: 8, right: 8, bottom: 20, left: 42 };
const HEIGHT = 120;
const priceFmt = { prefix: '£' };
export default function PriceHistoryChart({ points }: PriceHistoryChartProps) {
const containerRef = useRef<HTMLDivElement>(null);
const [width, setWidth] = useState(0);
useEffect(() => {
const el = containerRef.current;
if (!el) return;
const observer = new ResizeObserver((entries) => {
const w = entries[0].contentRect.width;
if (w > 0) setWidth(w);
});
observer.observe(el);
return () => observer.disconnect();
}, []);
const { yearMin, yearMax, priceMin, priceMax, medians, priceTicks } = useMemo(() => {
let yMin = Infinity,
yMax = -Infinity;
for (const p of points) {
if (p.year < yMin) yMin = p.year;
if (p.year > yMax) yMax = p.year;
}
// Use p5/p95 to clip outliers
const sorted = points.map((p) => p.price).sort((a, b) => a - b);
const p5 = sorted[Math.floor(sorted.length * 0.05)];
const p95 = sorted[Math.min(sorted.length - 1, Math.ceil(sorted.length * 0.95))];
const pRange = p95 - p5 || 1;
const pMin = Math.max(0, p5 - pRange * 0.1);
const pMax = p95 + pRange * 0.1;
// Yearly medians (robust to outliers)
const byYear = new Map<number, number[]>();
for (const p of points) {
const yr = Math.floor(p.year);
const arr = byYear.get(yr);
if (arr) arr.push(p.price);
else byYear.set(yr, [p.price]);
}
const meds = Array.from(byYear.entries())
.map(([yr, prices]) => {
prices.sort((a, b) => a - b);
const mid = Math.floor(prices.length / 2);
const median = prices.length % 2 ? prices[mid] : (prices[mid - 1] + prices[mid]) / 2;
return { year: yr + 0.5, price: median };
})
.sort((a, b) => a.year - b.year);
const ticks = niceTicksForRange(pMin, pMax, 4);
return {
yearMin: yMin,
yearMax: yMax,
priceMin: pMin,
priceMax: pMax,
medians: meds,
priceTicks: ticks,
};
}, [points]);
const plotW = width - PADDING.left - PADDING.right;
const plotH = HEIGHT - PADDING.top - PADDING.bottom;
const yearRange = yearMax - yearMin || 1;
const scaleX = (year: number) => PADDING.left + ((year - yearMin) / yearRange) * plotW;
const scaleY = (price: number) => {
const t = (price - priceMin) / (priceMax - priceMin || 1);
return PADDING.top + (1 - Math.max(0, Math.min(1, t))) * plotH;
};
// Year labels: every 5 years
const yearStart = Math.ceil(yearMin / 5) * 5;
const yearLabels: number[] = [];
for (let y = yearStart; y <= yearMax; y += 5) yearLabels.push(y);
const medianPolyline = medians.map((a) => `${scaleX(a.year)},${scaleY(a.price)}`).join(' ');
return (
<div ref={containerRef} style={{ height: HEIGHT }}>
{width > 0 && (
<svg width={width} height={HEIGHT}>
{/* Grid lines */}
{priceTicks.map((tick) => (
<line
key={tick}
x1={PADDING.left}
y1={scaleY(tick)}
x2={width - PADDING.right}
y2={scaleY(tick)}
className="stroke-warm-200 dark:stroke-warm-700"
strokeWidth={1}
/>
))}
{/* Dots (clamp outliers to visible range) */}
{points.map((p, i) => (
<circle
key={i}
cx={scaleX(p.year)}
cy={scaleY(p.price)}
r={3}
className="fill-teal-500 dark:fill-teal-400"
opacity={0.35}
/>
))}
{/* Median line */}
{medians.length > 1 && (
<polyline
points={medianPolyline}
fill="none"
className="stroke-teal-600 dark:stroke-teal-400"
strokeWidth={2}
strokeLinejoin="round"
/>
)}
{/* Y-axis labels */}
{priceTicks.map((tick) => (
<text
key={`label-${tick}`}
x={PADDING.left - 4}
y={scaleY(tick)}
textAnchor="end"
dominantBaseline="middle"
className="fill-warm-500 dark:fill-warm-400"
fontSize={10}
>
{formatValue(tick, priceFmt)}
</text>
))}
{/* X-axis year labels */}
{yearLabels.map((yr) => (
<text
key={yr}
x={scaleX(yr)}
y={HEIGHT - 2}
textAnchor="middle"
className="fill-warm-500 dark:fill-warm-400"
fontSize={10}
>
{yr}
</text>
))}
</svg>
)}
</div>
);
}
/** Generate ~count nice round tick values spanning [min, max]. */
function niceTicksForRange(min: number, max: number, count: number): number[] {
const range = max - min;
if (range <= 0) return [min];
const rough = range / count;
const magnitude = Math.pow(10, Math.floor(Math.log10(rough)));
let step: number;
const normalized = rough / magnitude;
if (normalized <= 1.5) step = magnitude;
else if (normalized <= 3.5) step = 2 * magnitude;
else if (normalized <= 7.5) step = 5 * magnitude;
else step = 10 * magnitude;
const ticks: number[] = [];
const start = Math.ceil(min / step) * step;
for (let t = start; t <= max; t += step) {
ticks.push(t);
}
return ticks;
}

View file

@ -0,0 +1,246 @@
import React, { useMemo, useState } from 'react';
import { Property } from '../../types';
import { formatDuration, formatAge, formatNumber } from '../../lib/format';
import { getNum } from '../../lib/property-fields';
import InfoPopup from '../ui/InfoPopup';
import { SearchInput } from '../ui/SearchInput';
import { EmptyState } from '../ui/EmptyState';
import { InfoIcon } from '../ui/icons';
interface PropertiesPaneProps {
properties: Property[];
total: number;
loading: boolean;
hexagonId: string | null;
onLoadMore: () => void;
onClose: () => void;
onNavigateToSource?: (slug: string) => void;
}
export function PropertiesPane({
properties,
total,
loading,
hexagonId,
onLoadMore,
onClose: _onClose,
onNavigateToSource,
}: PropertiesPaneProps) {
const [search, setSearch] = useState('');
const [showInfo, setShowInfo] = useState(false);
const filtered = useMemo(() => {
const query = search.trim().toLowerCase();
return query
? properties.filter((p) => {
const addr = (p.address || '').toLowerCase();
const pc = (p.postcode || '').toLowerCase();
return addr.includes(query) || pc.includes(query);
})
: properties;
}, [properties, search]);
if (!hexagonId) {
return (
<EmptyState
icon={<InfoIcon className="w-8 h-8 text-warm-300 dark:text-warm-600" />}
title="No area selected"
description="Click a hexagon or postcode to view area statistics"
centered
/>
);
}
return (
<div className="flex flex-col h-full">
{showInfo && (
<InfoPopup
title="Property Data"
onClose={() => setShowInfo(false)}
sourceLink={
onNavigateToSource
? {
label: 'View data source',
onClick: () => {
onNavigateToSource('epc');
setShowInfo(false);
},
}
: undefined
}
>
<p className="text-sm text-warm-700 dark:text-warm-300 mb-4 leading-relaxed">
Property data combines Energy Performance Certificates (EPC) with HM Land Registry Price
Paid records, fuzzy-matched by address within each postcode. Includes floor area, energy
ratings, construction age, and tenure from EPC surveys, plus the most recent sale price
from the Land Registry.
</p>
</InfoPopup>
)}
<div className="p-2 border-b border-warm-200 dark:border-navy-700">
<SearchInput
value={search}
onChange={setSearch}
placeholder="Search by address or postcode..."
className="p-2"
/>
</div>
<div className="flex-1 overflow-y-auto">
{loading && properties.length === 0 ? (
<PropertyLoadingSkeleton />
) : (
<>
{filtered.map((property, idx) => (
<PropertyCard key={idx} property={property} />
))}
{properties.length < total && (
<button
onClick={onLoadMore}
disabled={loading}
className="w-full p-4 text-teal-600 dark:text-teal-400 hover:bg-teal-50 dark:hover:bg-teal-900/30 disabled:opacity-50 transition-colors"
>
{loading ? (
<span className="flex items-center justify-center gap-2">
<span className="inline-block w-4 h-4 border-2 border-teal-600 dark:border-teal-400 border-t-transparent rounded-full animate-spin" />
Loading...
</span>
) : (
`Load More (${total - properties.length} remaining)`
)}
</button>
)}
</>
)}
</div>
</div>
);
}
function PropertyLoadingSkeleton() {
return (
<div className="space-y-0">
{Array.from({ length: 5 }).map((_, idx) => (
<div key={idx} className="p-4 border-b border-warm-100 dark:border-navy-800 animate-pulse">
{/* Address */}
<div className="h-5 w-3/4 bg-warm-200 dark:bg-warm-700 rounded mb-2" />
{/* Postcode */}
<div className="h-4 w-24 bg-warm-200 dark:bg-warm-700 rounded mb-3" />
{/* Price */}
<div className="h-6 w-32 bg-warm-200 dark:bg-warm-700 rounded mb-3" />
{/* Property details grid */}
<div className="grid grid-cols-2 gap-x-4 gap-y-2">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="h-4 bg-warm-200 dark:bg-warm-700 rounded" />
))}
</div>
</div>
))}
</div>
);
}
function PropertyCard({ property }: { property: Property }) {
const price = getNum(property, 'Last known price', 'latest_price');
const estimatedPrice = getNum(property, 'Estimated current price');
const pricePerSqm = getNum(property, 'Price per sqm', 'price_per_sqm');
const floorArea = getNum(property, 'Total floor area (sqm)', 'total_floor_area');
const rooms = getNum(
property,
'Rooms (including bedrooms & bathrooms)',
'number_habitable_rooms'
);
const age = getNum(property, 'Approximate construction age', 'construction_age_band');
const councilTax = getNum(property, 'Council tax (£/yr)');
const councilTaxD = getNum(property, 'Council tax Band D (£/yr)');
return (
<div className="p-4 border-b border-warm-100 dark:border-navy-800 hover:bg-warm-50 dark:hover:bg-navy-800">
<div className="font-semibold dark:text-warm-100">
{property.address || 'Unknown Address'}
</div>
<div className="text-sm text-warm-600 dark:text-warm-400">{property.postcode}</div>
{price !== undefined && (
<div className="mt-2 text-lg font-bold text-teal-700 dark:text-teal-400">
£{formatNumber(price)}
{pricePerSqm !== undefined && (
<span className="text-sm font-normal text-warm-600 dark:text-warm-400">
{' '}
(£{formatNumber(pricePerSqm)}/m²)
</span>
)}
</div>
)}
{estimatedPrice !== undefined && (
<div className="text-sm text-warm-600 dark:text-warm-400">
Est. value:{' '}
<span className="font-semibold text-teal-700 dark:text-teal-400">
£{formatNumber(estimatedPrice)}
</span>
</div>
)}
<div className="mt-2 grid grid-cols-2 gap-x-4 gap-y-1 text-sm dark:text-warm-300">
{property.property_type && (
<div>
<span className="text-warm-500 dark:text-warm-400">Type:</span> {property.property_type}
</div>
)}
{property.built_form && (
<div>
<span className="text-warm-500 dark:text-warm-400">Built form:</span>{' '}
{property.built_form}
</div>
)}
{property.duration && (
<div>
<span className="text-warm-500 dark:text-warm-400">Tenure:</span>{' '}
{formatDuration(property.duration)}
</div>
)}
{floorArea !== undefined && (
<div>
<span className="text-warm-500 dark:text-warm-400">Floor area:</span>{' '}
{formatNumber(floorArea)}m²
</div>
)}
{rooms !== undefined && (
<div>
<span className="text-warm-500 dark:text-warm-400">Rooms:</span> {formatNumber(rooms)}
</div>
)}
{age !== undefined && (
<div>
<span className="text-warm-500 dark:text-warm-400">Built:</span>{' '}
{formatAge(age, property.is_construction_date_approximate ?? true)}
</div>
)}
{property.current_energy_rating && (
<div>
<span className="text-warm-500 dark:text-warm-400">EPC rating:</span>{' '}
{property.current_energy_rating}
</div>
)}
{property.potential_energy_rating && (
<div>
<span className="text-warm-500 dark:text-warm-400">EPC potential:</span>{' '}
{property.potential_energy_rating}
</div>
)}
{councilTax !== undefined ? (
<div>
<span className="text-warm-500 dark:text-warm-400">Council tax:</span> £
{formatNumber(councilTax)}/yr
</div>
) : councilTaxD !== undefined ? (
<div>
<span className="text-warm-500 dark:text-warm-400">Council tax (D):</span> £
{formatNumber(councilTaxD)}/yr
</div>
) : null}
</div>
</div>
);
}

View file

@ -0,0 +1,82 @@
import { useMemo } from 'react';
import { SEGMENT_COLORS } from '../../lib/consts';
import { formatValue } from '../../lib/format';
interface Segment {
name: string;
value: number;
}
interface StackedBarChartProps {
segments: Segment[];
total: number;
/** Optional custom colors keyed by segment name. Falls back to SEGMENT_COLORS. */
colorMap?: Record<string, string>;
}
/** Strip common suffixes/prefixes to produce short legend labels */
function shortenLabel(name: string): string {
return name
.replace(' (avg/yr)', '')
.replace(/^% /, '')
.replace('and sexual offences', '')
.replace('and arson', '')
.replace('from the person', '')
.replace('Possession of weapons', 'Weapons')
.replace('Anti-social behaviour', 'Anti-social')
.replace('Criminal damage', 'Damage')
.trim();
}
export default function StackedBarChart({ segments, total, colorMap }: StackedBarChartProps) {
const sortedSegments = useMemo(() => [...segments].sort((a, b) => b.value - a.value), [segments]);
if (total === 0) {
return <div className="text-xs text-warm-400 dark:text-warm-500 italic">No data</div>;
}
return (
<div className="space-y-1.5">
{/* Stacked bar */}
<div className="flex h-4 rounded overflow-hidden bg-warm-200 dark:bg-warm-700">
{sortedSegments.map((segment, i) => {
const pct = (segment.value / total) * 100;
if (pct < 0.5) return null;
return (
<div
key={segment.name}
className="h-full"
style={{
width: `${pct}%`,
backgroundColor:
colorMap?.[segment.name] ?? SEGMENT_COLORS[i % SEGMENT_COLORS.length],
}}
title={`${shortenLabel(segment.name)}: ${formatValue(segment.value)} (${pct.toFixed(1)}%)`}
/>
);
})}
</div>
{/* Legend */}
<div className="flex flex-wrap gap-x-3 gap-y-0.5">
{sortedSegments.map((segment, i) => (
<div key={segment.name} className="flex items-center gap-1">
<span
className="w-2 h-2 rounded-sm shrink-0"
style={{
backgroundColor:
colorMap?.[segment.name] ?? SEGMENT_COLORS[i % SEGMENT_COLORS.length],
}}
/>
<span className="text-[10px] text-warm-600 dark:text-warm-400">
{shortenLabel(segment.name)}
</span>
<span className="text-[10px] text-warm-400 dark:text-warm-500">
{formatValue(segment.value)}
</span>
</div>
))}
</div>
</div>
);
}

View file

@ -0,0 +1,76 @@
import type { EnumFeatureStats } from '../../types';
interface StackedEnumChartProps {
components: { label: string; stats: EnumFeatureStats }[];
valueOrder: string[];
valueColors: string[];
}
/** Strip common suffixes to produce short row labels */
function shortenLabel(name: string): string {
return name.replace(/ risk$/, '');
}
export default function StackedEnumChart({
components,
valueOrder,
valueColors,
}: StackedEnumChartProps) {
const visibleRows = components.filter(({ stats }) => {
const total = Object.values(stats.counts).reduce((a, b) => a + b, 0);
if (total === 0) return false;
const lowCount = stats.counts[valueOrder[0]] ?? 0;
return total - lowCount > 0;
});
if (visibleRows.length === 0) {
return <div className="text-xs text-warm-400 dark:text-warm-500 italic mt-1">All low</div>;
}
return (
<div className="space-y-1.5">
{visibleRows.map(({ label, stats }) => {
const total = Object.values(stats.counts).reduce((a, b) => a + b, 0);
return (
<div key={label} className="flex items-center gap-2 text-xs">
<span className="w-24 truncate text-warm-500 dark:text-warm-400 text-right shrink-0">
{shortenLabel(label)}
</span>
<div className="flex-1 flex h-3.5 rounded overflow-hidden bg-warm-200 dark:bg-warm-700">
{valueOrder.map((value, i) => {
const count = stats.counts[value] ?? 0;
const pct = (count / total) * 100;
if (pct < 0.5) return null;
return (
<div
key={value}
className="h-full"
style={{
width: `${pct}%`,
backgroundColor: valueColors[i],
}}
title={`${value}: ${count} (${pct.toFixed(0)}%)`}
/>
);
})}
</div>
</div>
);
})}
{/* Legend */}
<div className="flex gap-x-3 gap-y-0.5 justify-center">
{valueOrder.map((value, i) => (
<div key={value} className="flex items-center gap-1">
<span
className="w-2 h-2 rounded-sm shrink-0"
style={{ backgroundColor: valueColors[i] }}
/>
<span className="text-[10px] text-warm-600 dark:text-warm-400">{value}</span>
</div>
))}
</div>
</div>
);
}

View file

@ -0,0 +1,26 @@
import type { HexagonLocation } from '../../lib/external-search';
interface StreetViewEmbedProps {
location: HexagonLocation;
}
export default function StreetViewEmbed({ location }: StreetViewEmbedProps) {
return (
<div>
<div className="px-3 py-1.5 text-xs font-bold text-warm-500 bg-warm-50 dark:bg-warm-900 dark:text-warm-400 sticky top-0">
Street View
</div>
<div className="px-3 py-2">
<div className="rounded overflow-hidden border border-warm-200 dark:border-warm-700">
<iframe
className="w-full"
style={{ height: 240, border: 0 }}
loading="lazy"
referrerPolicy="no-referrer-when-downgrade"
src={`https://maps.google.com/maps?layer=c&cbll=${location.lat},${location.lon}&cbp=11,0,0,0,0&output=svembed`}
/>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,69 @@
import { CheckIcon } from '../ui/icons/CheckIcon';
const FEATURES = [
'56 data layers across England',
'Every postcode scored and filterable',
'Unlimited map exploration and exports',
'Historical price data back to 1995',
'Crime, schools, transport, broadband & more',
'All future data updates included',
];
export default function PricingPage({
onOpenDashboard,
}: {
onOpenDashboard: () => void;
}) {
return (
<div className="flex-1 overflow-y-auto bg-warm-50 dark:bg-navy-950">
<div className="max-w-3xl mx-auto px-6 py-16">
<div className="text-center mb-12">
<h1 className="text-3xl md:text-4xl font-bold text-navy-950 dark:text-warm-100 mb-3">
One price. Yours forever.
</h1>
<p className="text-lg text-warm-500 dark:text-warm-400 max-w-lg mx-auto">
No subscriptions, no recurring fees. Pay once and get lifetime access to every feature.
</p>
</div>
<div className="max-w-md mx-auto bg-white dark:bg-warm-800 rounded-2xl border border-warm-200 dark:border-warm-700 shadow-lg overflow-hidden">
{/* Price header */}
<div className="bg-gradient-to-br from-navy-950 to-teal-900 px-8 py-10 text-center">
<div className="text-sm font-semibold text-teal-400 uppercase tracking-wide mb-2">
Lifetime License
</div>
<div className="flex items-baseline justify-center gap-1">
<span className="text-5xl font-extrabold text-white">£100</span>
<span className="text-warm-400 text-lg">/once</span>
</div>
<p className="text-warm-300 text-sm mt-2">
One-time payment, no subscription
</p>
</div>
{/* Features list */}
<div className="px-8 py-8">
<ul className="space-y-4">
{FEATURES.map((feature) => (
<li key={feature} className="flex items-start gap-3">
<CheckIcon className="w-5 h-5 text-teal-600 dark:text-teal-400 shrink-0 mt-0.5" />
<span className="text-warm-700 dark:text-warm-300">{feature}</span>
</li>
))}
</ul>
<button
onClick={onOpenDashboard}
className="w-full mt-8 px-6 py-4 bg-coral-500 text-white rounded-lg font-semibold hover:bg-coral-600 transition-colors text-lg shadow-lg shadow-coral-500/25"
>
Get started
</button>
<p className="text-center text-sm text-warm-400 dark:text-warm-500 mt-3">
30-day money-back guarantee
</p>
</div>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,170 @@
import { useState, useCallback } from 'react';
import type { SavedSearch } from '../../hooks/useSavedSearches';
import { BookmarkIcon } from '../ui/icons/BookmarkIcon';
import { TrashIcon } from '../ui/icons/TrashIcon';
import { SpinnerIcon } from '../ui/icons/SpinnerIcon';
import { CloseIcon } from '../ui/icons/CloseIcon';
import { formatRelativeTime } from '../../lib/format';
import { summarizeParams } from '../../lib/url-state';
export default function SavedSearchesPage({
searches,
loading,
onDelete,
onOpen,
}: {
searches: SavedSearch[];
loading: boolean;
onDelete: (id: string) => Promise<void>;
onOpen: (params: string) => void;
}) {
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
const [copiedId, setCopiedId] = useState<string | null>(null);
const handleDeleteConfirm = useCallback(async () => {
if (!deleteConfirmId) return;
await onDelete(deleteConfirmId);
setDeleteConfirmId(null);
}, [deleteConfirmId, onDelete]);
const handleShare = useCallback((params: string, id: string) => {
const url = `${window.location.origin}/?${params}`;
const onSuccess = () => {
setCopiedId(id);
setTimeout(() => setCopiedId(null), 2000);
};
if (navigator.clipboard?.writeText) {
navigator.clipboard.writeText(url).then(onSuccess);
} else {
const ta = document.createElement('textarea');
ta.value = url;
ta.style.position = 'fixed';
ta.style.opacity = '0';
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
document.body.removeChild(ta);
onSuccess();
}
}, []);
return (
<div className="flex-1 overflow-auto bg-warm-50 dark:bg-warm-900">
<div className="max-w-5xl mx-auto px-6 py-8">
<h1 className="text-2xl font-bold text-navy-950 dark:text-warm-100 mb-6">Saved Searches</h1>
{loading ? (
<div className="flex items-center justify-center py-20">
<SpinnerIcon className="w-8 h-8 text-teal-600 dark:text-teal-400 animate-spin" />
</div>
) : searches.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20 text-center">
<BookmarkIcon className="w-12 h-12 text-warm-300 dark:text-warm-600 mb-4" />
<p className="text-lg font-medium text-warm-600 dark:text-warm-400 mb-1">
No saved searches yet
</p>
<p className="text-sm text-warm-500 dark:text-warm-500">
Save your dashboard filters and view to quickly return to them later.
</p>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{searches.map((search) => (
<div
key={search.id}
className="bg-white dark:bg-warm-800 border border-warm-200 dark:border-warm-700 rounded-lg overflow-hidden"
>
{search.screenshotUrl ? (
<img
src={search.screenshotUrl}
alt={search.name}
className="w-full h-36 object-cover"
/>
) : (
<div className="w-full h-36 bg-gradient-to-br from-teal-600/20 to-navy-900/30 dark:from-teal-400/10 dark:to-navy-900/40 flex items-center justify-center">
<BookmarkIcon className="w-10 h-10 text-warm-300 dark:text-warm-600" />
</div>
)}
<div className="p-4">
<h3 className="font-medium text-navy-950 dark:text-warm-100 truncate mb-1">
{search.name}
</h3>
<p className="text-xs text-warm-500 dark:text-warm-400 mb-1">
{formatRelativeTime(search.created)}
</p>
<p className="text-xs text-warm-500 dark:text-warm-400 mb-3">
{summarizeParams(search.params)}
</p>
<div className="flex gap-2">
<button
onClick={() => onOpen(search.params)}
className="flex-1 px-3 py-1.5 text-sm font-medium rounded bg-teal-600 text-white hover:bg-teal-700"
>
Open
</button>
<button
onClick={() => handleShare(search.params, search.id)}
className="px-3 py-1.5 text-sm rounded border border-warm-200 dark:border-warm-700 text-warm-500 dark:text-warm-400 hover:text-warm-700 dark:hover:text-warm-300"
>
{copiedId === search.id ? 'Copied!' : 'Share'}
</button>
<button
onClick={() => setDeleteConfirmId(search.id)}
className="px-3 py-1.5 text-sm rounded border border-warm-200 dark:border-warm-700 text-warm-500 dark:text-warm-400 hover:text-warm-700 dark:hover:text-warm-300"
title="Delete"
>
<TrashIcon className="w-4 h-4" />
</button>
</div>
</div>
</div>
))}
</div>
)}
</div>
{/* Delete confirmation dialog */}
{deleteConfirmId && (
<div
className="fixed inset-0 z-50 flex items-center justify-center"
onClick={() => setDeleteConfirmId(null)}
>
<div className="absolute inset-0 bg-black/50 dark:bg-black/70" />
<div
className="relative w-full max-w-sm mx-4 bg-white dark:bg-warm-800 rounded-lg shadow-xl border border-warm-200 dark:border-warm-700"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between px-5 pt-5 pb-3">
<h2 className="text-lg font-semibold text-navy-950 dark:text-white">Delete search</h2>
<button
onClick={() => setDeleteConfirmId(null)}
className="text-warm-400 hover:text-warm-700 dark:text-warm-400 dark:hover:text-warm-200"
>
<CloseIcon className="w-5 h-5" />
</button>
</div>
<p className="px-5 pb-4 text-sm text-warm-700 dark:text-warm-300">
Are you sure you want to delete this saved search? This cannot be undone.
</p>
<div className="flex gap-3 justify-end px-5 pb-5">
<button
onClick={() => setDeleteConfirmId(null)}
className="px-4 py-2 text-sm rounded border border-warm-200 dark:border-warm-700 text-warm-700 dark:text-warm-300 hover:bg-warm-50 dark:hover:bg-warm-700"
>
Cancel
</button>
<button
onClick={handleDeleteConfirm}
className="px-4 py-2 text-sm rounded bg-red-600 text-white font-medium hover:bg-red-700"
>
Delete
</button>
</div>
</div>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,186 @@
import { useState, useCallback } from 'react';
import { CloseIcon } from './icons/CloseIcon';
type View = 'login' | 'register' | 'forgot';
export default function AuthModal({
onClose,
onLogin,
onRegister,
onForgotPassword,
loading,
error,
onClearError,
initialTab = 'login',
}: {
onClose: () => void;
onLogin: (email: string, password: string) => Promise<void>;
onRegister: (email: string, password: string) => Promise<void>;
onForgotPassword: (email: string) => Promise<void>;
loading: boolean;
error: string | null;
onClearError: () => void;
initialTab?: 'login' | 'register';
}) {
const [view, setView] = useState<View>(initialTab);
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [resetSent, setResetSent] = useState(false);
const switchView = useCallback(
(newView: View) => {
setView(newView);
setResetSent(false);
onClearError();
},
[onClearError]
);
const handleSubmit = useCallback(
async (e: React.FormEvent) => {
e.preventDefault();
try {
if (view === 'login') {
await onLogin(email, password);
onClose();
} else if (view === 'register') {
await onRegister(email, password);
onClose();
} else {
await onForgotPassword(email);
setResetSent(true);
}
} catch {
// Error is handled by the hook
}
},
[view, email, password, onLogin, onRegister, onForgotPassword, onClose]
);
const title =
view === 'login' ? 'Log in' : view === 'register' ? 'Create account' : 'Reset password';
return (
<div className="fixed inset-0 z-50 flex items-center justify-center" onClick={onClose}>
<div className="absolute inset-0 bg-black/50 dark:bg-black/70" />
<div
className="relative w-full max-w-sm mx-4 bg-white dark:bg-warm-900 rounded-lg shadow-xl border border-warm-200 dark:border-warm-700"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between px-5 pt-5 pb-3">
<h2 className="text-lg font-semibold text-navy-950 dark:text-white">{title}</h2>
<button
onClick={onClose}
className="text-warm-400 hover:text-warm-700 dark:text-warm-400 dark:hover:text-warm-200"
>
<CloseIcon className="w-5 h-5" />
</button>
</div>
{/* Tabs (hidden in forgot view) */}
{view !== 'forgot' && (
<div className="flex px-5 gap-4 border-b border-warm-200 dark:border-warm-700">
<button
className={`pb-2 text-sm font-medium border-b-2 transition-colors ${
view === 'login'
? 'border-teal-600 text-teal-600 dark:text-teal-400 dark:border-teal-400'
: 'border-transparent text-warm-500 dark:text-warm-400 hover:text-warm-700 dark:hover:text-warm-300'
}`}
onClick={() => switchView('login')}
>
Log in
</button>
<button
className={`pb-2 text-sm font-medium border-b-2 transition-colors ${
view === 'register'
? 'border-teal-600 text-teal-600 dark:text-teal-400 dark:border-teal-400'
: 'border-transparent text-warm-500 dark:text-warm-400 hover:text-warm-700 dark:hover:text-warm-300'
}`}
onClick={() => switchView('register')}
>
Register
</button>
</div>
)}
{/* Form */}
<form onSubmit={handleSubmit} className="p-5 space-y-4">
<div>
<label className="block text-sm font-medium text-warm-700 dark:text-warm-300 mb-1">
Email
</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="w-full px-3 py-2 text-sm rounded border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 text-navy-950 dark:text-white placeholder-warm-400 dark:placeholder-warm-500 outline-none focus:ring-2 ring-teal-400 dark:ring-teal-500"
placeholder="you@example.com"
/>
</div>
{view !== 'forgot' && (
<div>
<label className="block text-sm font-medium text-warm-700 dark:text-warm-300 mb-1">
Password
</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={8}
className="w-full px-3 py-2 text-sm rounded border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 text-navy-950 dark:text-white placeholder-warm-400 dark:placeholder-warm-500 outline-none focus:ring-2 ring-teal-400 dark:ring-teal-500"
placeholder={view === 'register' ? 'Min 8 characters' : 'Your password'}
/>
{view === 'login' && (
<button
type="button"
onClick={() => switchView('forgot')}
className="mt-1 text-xs text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300"
>
Forgot password?
</button>
)}
</div>
)}
{view === 'forgot' && resetSent && (
<p className="text-sm text-teal-700 dark:text-teal-400">
Check your email for a reset link.
</p>
)}
{error && <p className="text-sm text-red-600 dark:text-red-300">{error}</p>}
{!(view === 'forgot' && resetSent) && (
<button
type="submit"
disabled={loading}
className="w-full py-2 rounded bg-teal-600 text-white text-sm font-medium hover:bg-teal-700 dark:hover:bg-teal-600 disabled:opacity-50 disabled:cursor-wait transition-colors"
>
{loading
? 'Please wait...'
: view === 'login'
? 'Log in'
: view === 'register'
? 'Create account'
: 'Send reset link'}
</button>
)}
{view === 'forgot' && (
<button
type="button"
onClick={() => switchView('login')}
className="w-full text-center text-sm text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300"
>
Back to login
</button>
)}
</form>
</div>
</div>
);
}

View file

@ -0,0 +1,27 @@
import { ChevronIcon } from './icons/ChevronIcon';
interface CollapsibleGroupHeaderProps {
name: string;
expanded: boolean;
onToggle: () => void;
className?: string;
children?: React.ReactNode;
}
export function CollapsibleGroupHeader({
name,
expanded,
onToggle,
className = '',
children,
}: CollapsibleGroupHeaderProps) {
return (
<button onClick={onToggle} className={`w-full flex items-center justify-between ${className}`}>
<span>{name}</span>
<div className="flex items-center gap-1">
{children}
<ChevronIcon direction={expanded ? 'down' : 'right'} className="w-3.5 h-3.5" />
</div>
</button>
);
}

View file

@ -0,0 +1,27 @@
import type { ReactNode } from 'react';
interface EmptyStateProps {
icon: ReactNode;
title: string;
description: string;
centered?: boolean;
className?: string;
}
export function EmptyState({
icon,
title,
description,
centered = false,
className = '',
}: EmptyStateProps) {
return (
<div
className={`flex flex-col items-center justify-center text-center ${centered ? 'h-full px-4' : 'py-3 md:py-8'} ${className}`}
>
<div className="mb-2">{icon}</div>
<span className="text-sm font-medium text-warm-400 dark:text-warm-500">{title}</span>
<span className="text-xs text-warm-400 dark:text-warm-500 mt-1">{description}</span>
</div>
);
}

View file

@ -0,0 +1,49 @@
import type { FeatureMeta } from '../../types';
import { EyeIcon, InfoIcon, PlusIcon, CloseIcon } from './icons';
import { IconButton } from './IconButton';
interface FeatureActionsProps {
feature: FeatureMeta;
isPinned: boolean;
onTogglePin: (name: string) => void;
onShowInfo?: (feature: FeatureMeta) => void;
onRemove?: (name: string) => void;
onAdd?: (name: string) => void;
}
export function FeatureActions({
feature,
isPinned,
onTogglePin,
onShowInfo,
onRemove,
onAdd,
}: FeatureActionsProps) {
return (
<div className="flex items-center gap-0.5 shrink-0">
{feature.detail && onShowInfo && (
<IconButton onClick={() => onShowInfo(feature)} title="Feature info">
<InfoIcon />
</IconButton>
)}
<IconButton
onClick={() => onTogglePin(feature.name)}
title={isPinned ? 'Unpin color view' : 'Color map by this feature'}
active={isPinned}
size="md"
>
<EyeIcon filled={isPinned} />
</IconButton>
{onAdd && (
<IconButton onClick={() => onAdd(feature.name)} title="Add filter" size="md">
<PlusIcon />
</IconButton>
)}
{onRemove && (
<IconButton onClick={() => onRemove(feature.name)} title="Remove filter">
<CloseIcon className="w-3.5 h-3.5" />
</IconButton>
)}
</div>
);
}

View file

@ -0,0 +1,37 @@
import type { FeatureMeta } from '../../types';
import InfoPopup from './InfoPopup';
interface FeatureInfoPopupProps {
feature: FeatureMeta;
onClose: () => void;
onNavigateToSource?: (slug: string, featureName: string) => void;
}
export function FeatureInfoPopup({ feature, onClose, onNavigateToSource }: FeatureInfoPopupProps) {
return (
<InfoPopup
title={feature.name}
onClose={onClose}
sourceLink={
feature.source && onNavigateToSource
? {
label: 'View data source',
onClick: () => {
onNavigateToSource(feature.source!, feature.name);
onClose();
},
}
: undefined
}
>
{feature.description && (
<p className="text-xs text-warm-500 dark:text-warm-400 mb-2">{feature.description}</p>
)}
{feature.detail && (
<p className="text-sm text-warm-700 dark:text-warm-300 mb-4 leading-relaxed">
{feature.detail}
</p>
)}
</InfoPopup>
);
}

View file

@ -0,0 +1,39 @@
import type { FeatureMeta } from '../../types';
import { InfoIcon } from './icons';
interface FeatureLabelProps {
feature: FeatureMeta;
onShowInfo?: (feature: FeatureMeta) => void;
className?: string;
size?: 'xs' | 'sm';
}
export function FeatureLabel({
feature,
onShowInfo,
className = '',
size = 'xs',
}: FeatureLabelProps) {
const textClass = size === 'sm' ? 'text-sm' : 'text-xs';
return (
<div
className={`flex ${size === 'xs' ? 'items-center' : 'items-start'} gap-1 min-w-0 ${className}`}
>
<span
className={`${textClass} text-warm-700 dark:text-warm-300 ${size === 'xs' ? 'truncate' : ''}`}
>
{feature.name}
</span>
{feature.detail && onShowInfo && (
<button
onClick={() => onShowInfo(feature)}
className="p-1 -m-0.5 rounded text-warm-400 hover:text-warm-700 dark:hover:text-warm-300 hover:bg-warm-100 dark:hover:bg-warm-700 shrink-0"
title="Feature info"
>
<InfoIcon className="w-3.5 h-3.5" />
</button>
)}
</div>
);
}

View file

@ -0,0 +1,260 @@
import { useState, useCallback, useEffect } from 'react';
import type { AuthUser } from '../../hooks/useAuth';
import { DownloadIcon } from './icons/DownloadIcon';
import { BookmarkIcon } from './icons/BookmarkIcon';
import { LogoIcon } from './icons/LogoIcon';
import { CheckIcon } from './icons/CheckIcon';
import { ClipboardIcon } from './icons/ClipboardIcon';
import { MenuIcon } from './icons/MenuIcon';
import { SunIcon } from './icons/SunIcon';
import { MoonIcon } from './icons/MoonIcon';
import { SpinnerIcon } from './icons/SpinnerIcon';
import UserMenu from './UserMenu';
import MobileMenu from './MobileMenu';
export type Page = 'home' | 'dashboard' | 'data-sources' | 'faq' | 'saved-searches' | 'pricing';
export default function Header({
activePage,
onPageChange,
theme,
onToggleTheme,
onExport,
exporting,
onSaveSearch,
savingSearch,
user,
onLoginClick,
onRegisterClick,
onLogout,
isMobile,
}: {
activePage: Page;
onPageChange: (page: Page) => void;
theme: 'light' | 'dark';
onToggleTheme: () => void;
onExport: (() => void) | null;
exporting: boolean;
onSaveSearch: (() => void) | null;
savingSearch: boolean;
user: AuthUser | null;
onLoginClick: () => void;
onRegisterClick: () => void;
onLogout: () => void;
isMobile: boolean;
}) {
const [copied, setCopied] = useState(false);
const [menuOpen, setMenuOpen] = useState(false);
// Close menu on Escape
useEffect(() => {
if (!menuOpen) return;
const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape') setMenuOpen(false);
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, [menuOpen]);
// Close menu when switching away from mobile
useEffect(() => {
if (!isMobile) setMenuOpen(false);
}, [isMobile]);
const handleShare = useCallback(() => {
const url = window.location.href;
const onSuccess = () => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
if (navigator.clipboard?.writeText) {
navigator.clipboard.writeText(url).then(onSuccess);
} else {
const ta = document.createElement('textarea');
ta.value = url;
ta.style.position = 'fixed';
ta.style.opacity = '0';
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
document.body.removeChild(ta);
onSuccess();
}
}, []);
const tabClass = (page: Page) =>
`px-3 py-1.5 rounded text-sm font-medium transition-colors ${
activePage === page
? 'bg-navy-700 text-white'
: 'text-warm-300 hover:bg-navy-800 hover:text-white'
}`;
return (
<header className="h-12 bg-navy-900 text-white flex items-center px-4 shrink-0">
{/* Left: Logo + nav */}
<div className="flex items-center gap-4">
<button
className="flex items-center gap-2 hover:opacity-80 transition-opacity"
onClick={() => onPageChange('home')}
>
<LogoIcon className="w-5 h-5 text-teal-400" />
<span className="font-semibold text-lg">Perfect Postcodes</span>
</button>
{/* Desktop nav */}
{!isMobile && (
<nav className="flex items-center gap-2">
<button className={tabClass('dashboard')} onClick={() => onPageChange('dashboard')}>
Dashboard
</button>
{user && (
<button
className={tabClass('saved-searches')}
onClick={() => onPageChange('saved-searches')}
>
Saved
</button>
)}
<button
className={tabClass('data-sources')}
onClick={() => onPageChange('data-sources')}
>
Data Sources
</button>
<button className={tabClass('faq')} onClick={() => onPageChange('faq')}>
FAQ
</button>
<button className={tabClass('pricing')} onClick={() => onPageChange('pricing')}>
Pricing
</button>
</nav>
)}
</div>
{/* Right side */}
<div className="flex items-center gap-2 ml-auto">
{/* Desktop-only dashboard actions */}
{!isMobile && activePage === 'dashboard' && (
<>
{onSaveSearch && (
<button
onClick={onSaveSearch}
disabled={savingSearch}
className="flex items-center gap-1.5 px-3 py-1.5 rounded bg-navy-800 hover:bg-navy-700 transition-colors text-sm disabled:opacity-50 disabled:cursor-wait"
>
{savingSearch ? (
<SpinnerIcon className="w-4 h-4 animate-spin" />
) : (
<BookmarkIcon className="w-4 h-4" />
)}
Save
</button>
)}
<button
onClick={handleShare}
className="flex items-center gap-1.5 px-3 py-1.5 rounded bg-navy-800 hover:bg-navy-700 transition-colors text-sm"
>
{copied ? (
<>
<CheckIcon className="w-4 h-4" />
Copied!
</>
) : (
<>
<ClipboardIcon className="w-4 h-4" />
Share
</>
)}
</button>
<button
onClick={onExport ?? undefined}
disabled={!onExport || exporting}
className="flex items-center gap-1.5 px-3 py-1.5 rounded bg-navy-800 hover:bg-navy-700 transition-colors text-sm disabled:opacity-50 disabled:cursor-wait"
title="Export to Excel"
>
<DownloadIcon className="w-4 h-4" />
{exporting ? 'Exporting...' : 'Export'}
</button>
</>
)}
{/* Desktop-only auth */}
{!isMobile && (
<>
{user ? (
<UserMenu user={user} onLogout={onLogout} />
) : (
<>
<button
onClick={onLoginClick}
className="px-3 py-1.5 rounded bg-navy-800 hover:bg-navy-700 transition-colors text-sm"
>
Log in
</button>
<button
onClick={onRegisterClick}
className="px-3 py-1.5 rounded bg-teal-600 hover:bg-teal-700 transition-colors text-sm font-medium"
>
Register
</button>
</>
)}
</>
)}
{/* Mobile auth CTA (logged out only) */}
{isMobile && !user && (
<button
onClick={onRegisterClick}
className="px-4 py-1.5 rounded bg-teal-600 hover:bg-teal-700 transition-colors text-sm font-semibold"
>
Sign up
</button>
)}
{/* Theme toggle (desktop only) */}
{!isMobile && (
<button
onClick={onToggleTheme}
className="flex items-center justify-center w-8 h-8 rounded bg-navy-800 hover:bg-navy-700 transition-colors"
title={`Theme: ${theme}`}
>
{theme === 'light' ? <SunIcon className="w-4 h-4" /> : <MoonIcon className="w-4 h-4" />}
</button>
)}
{/* Mobile hamburger */}
{isMobile && (
<button
onClick={() => setMenuOpen(true)}
className="flex items-center justify-center w-10 h-10 -mr-2 rounded hover:bg-navy-800 transition-colors"
aria-label="Open menu"
>
<MenuIcon className="w-6 h-6" />
</button>
)}
</div>
{/* Mobile slide-in menu */}
{isMobile && menuOpen && (
<MobileMenu
activePage={activePage}
onPageChange={onPageChange}
theme={theme}
onToggleTheme={onToggleTheme}
onExport={onExport}
exporting={exporting}
onSaveSearch={onSaveSearch}
savingSearch={savingSearch}
user={user}
onLoginClick={onLoginClick}
onRegisterClick={onRegisterClick}
onLogout={onLogout}
onClose={() => setMenuOpen(false)}
onShare={handleShare}
copied={copied}
/>
)}
</header>
);
}

View file

@ -0,0 +1,34 @@
import type { ReactNode, MouseEvent } from 'react';
interface IconButtonProps {
onClick: (e: MouseEvent<HTMLButtonElement>) => void;
title?: string;
children: ReactNode;
active?: boolean;
className?: string;
size?: 'sm' | 'md';
}
export function IconButton({
onClick,
title,
children,
active,
className,
size = 'sm',
}: IconButtonProps) {
const padClasses = size === 'md' ? 'p-1' : 'p-0.5';
const colorClasses = active
? 'text-teal-600 dark:text-teal-400'
: 'text-warm-400 hover:text-warm-700 dark:hover:text-warm-300';
return (
<button
onClick={onClick}
title={title}
className={`${padClasses} rounded ${colorClasses} ${className || ''}`}
>
{children}
</button>
);
}

View file

@ -0,0 +1,46 @@
import { useRef, useCallback, type ReactNode } from 'react';
import { useClickOutside } from '../../hooks/useClickOutside';
import { CloseIcon } from './icons';
import { IconButton } from './IconButton';
interface InfoPopupProps {
title: string;
children: ReactNode;
onClose: () => void;
sourceLink?: { label: string; onClick: () => void };
}
export default function InfoPopup({ title, children, onClose, sourceLink }: InfoPopupProps) {
const popupRef = useRef<HTMLDivElement>(null);
const handleClose = useCallback(() => {
onClose();
}, [onClose]);
useClickOutside(popupRef, handleClose);
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30">
<div
ref={popupRef}
className="bg-white dark:bg-navy-800 border border-warm-200 dark:border-navy-700 rounded-lg shadow-xl max-w-md w-full mx-4 p-5"
>
<div className="flex items-start justify-between mb-3">
<h3 className="text-sm font-semibold text-warm-900 dark:text-warm-100 pr-4">{title}</h3>
<IconButton onClick={onClose} className="shrink-0">
<CloseIcon />
</IconButton>
</div>
{children}
{sourceLink && (
<button
onClick={sourceLink.onClick}
className="text-sm text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300 hover:underline"
>
{sourceLink.label}
</button>
)}
</div>
</div>
);
}

View file

@ -7,6 +7,8 @@ interface LabelProps {
export function Label({ children, className }: LabelProps) {
return (
<label className={`text-sm font-medium text-warm-700 dark:text-warm-300 ${className || ''}`}>{children}</label>
<label className={`text-sm font-medium text-warm-700 dark:text-warm-300 ${className || ''}`}>
{children}
</label>
);
}

View file

@ -0,0 +1,187 @@
import type { Page } from './Header';
import type { AuthUser } from '../../hooks/useAuth';
import { DownloadIcon } from './icons/DownloadIcon';
import { BookmarkIcon } from './icons/BookmarkIcon';
import { CheckIcon } from './icons/CheckIcon';
import { ClipboardIcon } from './icons/ClipboardIcon';
import { CloseIcon } from './icons/CloseIcon';
import { SunIcon } from './icons/SunIcon';
import { MoonIcon } from './icons/MoonIcon';
import { SpinnerIcon } from './icons/SpinnerIcon';
interface MobileMenuProps {
activePage: Page;
onPageChange: (page: Page) => void;
theme: 'light' | 'dark';
onToggleTheme: () => void;
onExport: (() => void) | null;
exporting: boolean;
onSaveSearch: (() => void) | null;
savingSearch: boolean;
user: AuthUser | null;
onLoginClick: () => void;
onRegisterClick: () => void;
onLogout: () => void;
onClose: () => void;
onShare: () => void;
copied: boolean;
}
export default function MobileMenu({
activePage,
onPageChange,
theme,
onToggleTheme,
onExport,
exporting,
onSaveSearch,
savingSearch,
user,
onLoginClick,
onRegisterClick,
onLogout,
onClose,
onShare,
copied,
}: MobileMenuProps) {
const mobileNavItem = (page: Page, label: string) => (
<button
key={page}
className={`w-full text-left px-4 py-3 text-base font-medium rounded ${
activePage === page
? 'bg-navy-700 text-white'
: 'text-warm-300 hover:bg-navy-800 hover:text-white'
}`}
onClick={() => {
onPageChange(page);
onClose();
}}
>
{label}
</button>
);
return (
<>
{/* Backdrop */}
<div className="fixed inset-0 bg-black/50 z-40" onClick={onClose} />
{/* Menu panel */}
<div className="fixed top-0 right-0 bottom-0 w-64 bg-navy-900 z-50 flex flex-col shadow-xl">
<div className="flex items-center justify-between px-4 h-12 border-b border-navy-700">
<span className="font-semibold">Menu</span>
<button
onClick={onClose}
className="flex items-center justify-center w-10 h-10 -mr-2 rounded hover:bg-navy-800 transition-colors"
aria-label="Close menu"
>
<CloseIcon className="w-5 h-5" />
</button>
</div>
<nav className="flex-1 flex flex-col gap-1 p-3 overflow-y-auto">
{mobileNavItem('dashboard', 'Dashboard')}
{user && mobileNavItem('saved-searches', 'Saved')}
{mobileNavItem('data-sources', 'Data Sources')}
{mobileNavItem('faq', 'FAQ')}
{mobileNavItem('pricing', 'Pricing')}
{/* Dashboard actions */}
{activePage === 'dashboard' && (
<div className="mt-3 pt-3 border-t border-navy-700 flex flex-col gap-1">
{onSaveSearch && (
<button
onClick={() => {
onSaveSearch();
onClose();
}}
disabled={savingSearch}
className="w-full flex items-center gap-2 px-4 py-3 text-base text-warm-300 hover:bg-navy-800 hover:text-white rounded disabled:opacity-50"
>
{savingSearch ? (
<SpinnerIcon className="w-5 h-5 animate-spin" />
) : (
<BookmarkIcon className="w-5 h-5" />
)}
Save
</button>
)}
<button
onClick={() => {
onShare();
onClose();
}}
className="w-full flex items-center gap-2 px-4 py-3 text-base text-warm-300 hover:bg-navy-800 hover:text-white rounded"
>
{copied ? <CheckIcon className="w-5 h-5" /> : <ClipboardIcon className="w-5 h-5" />}
{copied ? 'Copied!' : 'Share'}
</button>
<button
onClick={() => {
onExport?.();
onClose();
}}
disabled={!onExport || exporting}
className="w-full flex items-center gap-2 px-4 py-3 text-base text-warm-300 hover:bg-navy-800 hover:text-white rounded disabled:opacity-50"
>
<DownloadIcon className="w-5 h-5" />
{exporting ? 'Exporting...' : 'Export'}
</button>
</div>
)}
</nav>
{/* Theme toggle + Auth section at bottom */}
<div className="p-3 border-t border-navy-700 flex flex-col gap-3">
{/* Theme toggle */}
<button
onClick={() => {
onToggleTheme();
}}
className="w-full flex items-center gap-3 px-4 py-3 text-base text-warm-300 hover:bg-navy-800 hover:text-white rounded transition-colors"
>
{theme === 'light' ? <SunIcon className="w-5 h-5" /> : <MoonIcon className="w-5 h-5" />}
<span>Theme: {theme === 'light' ? 'Light' : 'Dark'}</span>
</button>
{/* Auth buttons */}
<div>
{user ? (
<div className="flex items-center justify-between px-4 py-2">
<span className="text-sm text-warm-300 truncate">{user.email}</span>
<button
onClick={() => {
onLogout();
onClose();
}}
className="text-sm text-warm-400 hover:text-white"
>
Log out
</button>
</div>
) : (
<div className="flex gap-2">
<button
onClick={() => {
onLoginClick();
onClose();
}}
className="flex-1 px-3 py-2.5 rounded bg-navy-800 hover:bg-navy-700 transition-colors text-sm text-center"
>
Log in
</button>
<button
onClick={() => {
onRegisterClick();
onClose();
}}
className="flex-1 px-3 py-2.5 rounded bg-teal-600 hover:bg-teal-700 transition-colors text-sm font-medium text-center"
>
Register
</button>
</div>
)}
</div>
</div>
</div>
</>
);
}

View file

@ -0,0 +1,95 @@
import { useState, useCallback, useEffect } from 'react';
import { CloseIcon } from './icons/CloseIcon';
import { SpinnerIcon } from './icons/SpinnerIcon';
export default function SaveSearchModal({
onClose,
onSave,
saving,
error,
}: {
onClose: () => void;
onSave: (name: string) => Promise<void>;
saving: boolean;
error: string | null;
}) {
const [name, setName] = useState('');
const handleSubmit = useCallback(
async (e: React.FormEvent) => {
e.preventDefault();
if (!name.trim() || saving) return;
try {
await onSave(name.trim());
onClose();
} catch {
// Error displayed in modal
}
},
[name, saving, onSave, onClose]
);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [onClose]);
return (
<div className="fixed inset-0 z-50 flex items-center justify-center" onClick={onClose}>
<div className="absolute inset-0 bg-black/50 dark:bg-black/70" />
<div
className="relative w-full max-w-sm mx-4 bg-white dark:bg-warm-900 rounded-lg shadow-xl border border-warm-200 dark:border-warm-700"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between px-5 pt-5 pb-3">
<h2 className="text-lg font-semibold text-navy-950 dark:text-white">Save Search</h2>
<button
onClick={onClose}
className="text-warm-400 hover:text-warm-700 dark:text-warm-400 dark:hover:text-warm-200"
>
<CloseIcon className="w-5 h-5" />
</button>
</div>
<form onSubmit={handleSubmit} className="p-5 pt-2 space-y-4">
<div>
<label className="block text-sm font-medium text-warm-700 dark:text-warm-300 mb-1">
Name
</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full px-3 py-2 text-sm rounded border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 text-navy-950 dark:text-white placeholder-warm-400 dark:placeholder-warm-500 outline-none focus:ring-2 ring-teal-400 dark:ring-teal-500"
placeholder="My search"
autoFocus
/>
</div>
{error && <p className="text-sm text-red-600 dark:text-red-300">{error}</p>}
<div className="flex gap-3 justify-end">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-sm rounded border border-warm-200 dark:border-warm-700 text-warm-700 dark:text-warm-300 hover:bg-warm-50 dark:hover:bg-warm-700"
>
Cancel
</button>
<button
type="submit"
disabled={!name.trim() || saving}
className="flex items-center gap-2 px-4 py-2 text-sm rounded bg-teal-600 text-white font-medium hover:bg-teal-700 disabled:opacity-50 disabled:cursor-wait"
>
{saving && <SpinnerIcon className="w-4 h-4 animate-spin" />}
{saving ? 'Saving...' : 'Save'}
</button>
</div>
</form>
</div>
</div>
);
}

View file

@ -0,0 +1,23 @@
interface SearchInputProps {
value: string;
onChange: (value: string) => void;
placeholder?: string;
className?: string;
}
export function SearchInput({
value,
onChange,
placeholder = 'Search...',
className = '',
}: SearchInputProps) {
return (
<input
type="text"
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
className={`w-full px-2 py-1 text-sm border rounded bg-white dark:bg-navy-800 dark:text-warm-200 border-warm-200 dark:border-navy-700 placeholder-warm-400 dark:placeholder-warm-500 focus:outline-none focus:ring-1 focus:ring-teal-400 ${className}`}
/>
);
}

View file

@ -1,5 +1,4 @@
import * as SliderPrimitive from '@radix-ui/react-slider';
import { cn } from '../../lib/utils';
interface SliderProps extends React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root> {
className?: string;
@ -8,7 +7,7 @@ interface SliderProps extends React.ComponentPropsWithoutRef<typeof SliderPrimit
export function Slider({ className, ...props }: SliderProps) {
return (
<SliderPrimitive.Root
className={cn('relative flex w-full touch-none select-none items-center', className)}
className={`relative flex w-full touch-none select-none items-center ${className || ''}`}
{...props}
>
<SliderPrimitive.Track className="relative h-2 w-full grow overflow-hidden rounded-full bg-warm-200 dark:bg-navy-700">

View file

@ -0,0 +1,20 @@
interface TabButtonProps {
label: string;
isActive: boolean;
onClick: () => void;
}
export function TabButton({ label, isActive, onClick }: TabButtonProps) {
return (
<button
className={`flex-1 p-3 ${
isActive
? 'border-b-2 border-teal-500 font-semibold dark:text-warm-100'
: 'text-warm-600 dark:text-warm-400'
}`}
onClick={onClick}
>
{label}
</button>
);
}

View file

@ -0,0 +1,39 @@
const DIGITS = '0123456789';
const H = 1.15; // digit slot height in em
function Digit({ char, delay, active }: { char: string; delay: number; active: boolean }) {
const idx = DIGITS.indexOf(char);
if (idx === -1) return <span>{char}</span>;
const offset = active ? -idx * H : 0;
return (
<span className="inline-block overflow-hidden" style={{ height: `${H}em` }}>
<span
className="block"
style={{
transform: `translateY(${offset}em)`,
transition: `transform 0.5s cubic-bezier(0.22, 1, 0.36, 1) ${delay}ms`,
}}
>
{DIGITS.split('').map((d) => (
<span key={d} className="block text-center" style={{ height: `${H}em`, lineHeight: `${H}em` }}>
{d}
</span>
))}
</span>
</span>
);
}
export function TickerValue({ text, active = true }: { text: string; active?: boolean }) {
const chars = text.split('');
const len = chars.length;
return (
<span className="inline-flex" style={{ fontVariantNumeric: 'tabular-nums' }}>
{chars.map((ch, i) => (
<Digit key={i} char={ch} delay={(len - 1 - i) * 30} active={active} />
))}
</span>
);
}

View file

@ -0,0 +1,54 @@
import { useState, useRef, useEffect } from 'react';
import type { AuthUser } from '../../hooks/useAuth';
export default function UserMenu({ user, onLogout }: { user: AuthUser; onLogout: () => void }) {
const [open, setOpen] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
// Close on outside click
useEffect(() => {
if (!open) return;
const handleClick = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
setOpen(false);
}
};
document.addEventListener('mousedown', handleClick);
return () => document.removeEventListener('mousedown', handleClick);
}, [open]);
const initial = user.email[0].toUpperCase();
return (
<div className="relative" ref={menuRef}>
<button
onClick={() => setOpen((prev) => !prev)}
className="flex items-center justify-center w-8 h-8 rounded-full bg-teal-600 text-white text-sm font-medium hover:bg-teal-700"
title={user.email}
>
{initial}
</button>
{open && (
<div className="absolute right-0 top-10 w-56 bg-white dark:bg-warm-800 border border-warm-200 dark:border-warm-700 rounded-lg shadow-lg z-50">
<div className="px-4 py-3 border-b border-warm-200 dark:border-warm-700">
<p className="text-sm font-medium text-navy-950 dark:text-warm-100 truncate">
{user.email}
</p>
</div>
<div className="p-1">
<button
onClick={() => {
setOpen(false);
onLogout();
}}
className="w-full text-left px-3 py-2 text-sm text-warm-700 dark:text-warm-300 hover:bg-warm-50 dark:hover:bg-warm-700 rounded"
>
Log out
</button>
</div>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,21 @@
interface IconProps {
className?: string;
}
export function BookmarkIcon({ className = 'w-3.5 h-3.5' }: IconProps) {
return (
<svg
className={className}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z"
/>
</svg>
);
}

View file

@ -0,0 +1,17 @@
interface IconProps {
className?: string;
}
export function CheckIcon({ 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="M5 13l4 4L19 7" />
</svg>
);
}

View file

@ -0,0 +1,26 @@
interface IconProps {
className?: string;
}
export function ChevronIcon({
direction,
className = 'w-4 h-4',
}: IconProps & { direction: 'left' | 'right' | 'up' | 'down' }) {
const paths: Record<string, string> = {
left: 'M15 19l-7-7 7-7',
right: 'M9 5l7 7-7 7',
up: 'M18 15l-6-6-6 6',
down: 'M6 9l6 6 6-6',
};
return (
<svg
className={className}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d={paths[direction]} />
</svg>
);
}

View file

@ -0,0 +1,21 @@
interface IconProps {
className?: string;
}
export function ClipboardIcon({ 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="M8 5H6a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2v-1M8 5a2 2 0 002 2h2a2 2 0 002-2M8 5a2 2 0 012-2h2a2 2 0 012 2m0 0h2a2 2 0 012 2v3m2 4H10m0 0l3-3m-3 3l3 3"
/>
</svg>
);
}

View file

@ -0,0 +1,17 @@
interface IconProps {
className?: string;
}
export function CloseIcon({ 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="M6 18L18 6M6 6l12 12" />
</svg>
);
}

View file

@ -0,0 +1,18 @@
interface IconProps {
className?: string;
}
export function DownloadIcon({ className = 'w-3.5 h-3.5' }: IconProps) {
return (
<svg
className={className}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 5v14m0 0l-6-6m6 6l6-6" />
<path strokeLinecap="round" strokeLinejoin="round" d="M5 21h14" />
</svg>
);
}

View file

@ -0,0 +1,27 @@
interface IconProps {
className?: string;
}
export function EyeIcon({ filled, className = 'w-7 h-7' }: IconProps & { filled: boolean }) {
return (
<svg
className={className}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
>
<path
d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"
fill={filled ? 'currentColor' : 'none'}
/>
<circle
cx="12"
cy="12"
r="3"
fill={filled ? 'currentColor' : 'none'}
stroke={filled ? 'white' : 'currentColor'}
/>
</svg>
);
}

View file

@ -0,0 +1,21 @@
interface IconProps {
className?: string;
}
export function FilterIcon({ className = 'w-8 h-8' }: IconProps) {
return (
<svg
className={className}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 3c2.755 0 5.455.232 8.083.678.533.09.917.556.917 1.096v1.044a2.25 2.25 0 01-.659 1.591l-5.432 5.432a2.25 2.25 0 00-.659 1.591v2.927a2.25 2.25 0 01-1.244 2.013L9.75 21v-6.568a2.25 2.25 0 00-.659-1.591L3.659 7.409A2.25 2.25 0 013 5.818V4.774c0-.54.384-1.006.917-1.096A48.32 48.32 0 0112 3z"
/>
</svg>
);
}

View file

@ -0,0 +1,18 @@
interface IconProps {
className?: string;
}
export function InfoIcon({ className = 'w-3.5 h-3.5' }: IconProps) {
return (
<svg
className={className}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
>
<circle cx="12" cy="12" r="10" />
<path strokeLinecap="round" d="M12 16v-4m0-4h.01" />
</svg>
);
}

View file

@ -0,0 +1,21 @@
interface IconProps {
className?: string;
}
export function LightbulbIcon({ 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="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"
/>
</svg>
);
}

View file

@ -0,0 +1,22 @@
interface IconProps {
className?: string;
}
export function LogoIcon({ 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="M12 2L20.7 7v10L12 22l-8.7-5V7z"
/>
<path strokeLinecap="round" strokeLinejoin="round" d="M8.5 12.5l2.5 2.5 4.5-5" />
</svg>
);
}

View file

@ -0,0 +1,22 @@
interface IconProps {
className?: string;
}
export function MapPinIcon({ 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="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"
/>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
);
}

View file

@ -0,0 +1,17 @@
interface IconProps {
className?: string;
}
export function MenuIcon({ 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="M4 6h16M4 12h16M4 18h16" />
</svg>
);
}

View file

@ -0,0 +1,21 @@
interface IconProps {
className?: string;
}
export function MoonIcon({ 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="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"
/>
</svg>
);
}

View file

@ -0,0 +1,17 @@
interface IconProps {
className?: string;
}
export function PlusIcon({ className = 'w-7 h-7' }: IconProps) {
return (
<svg
className={className}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 5v14m-7-7h14" />
</svg>
);
}

View file

@ -0,0 +1,16 @@
interface IconProps {
className?: string;
}
export function SpinnerIcon({ className = 'w-4 h-4' }: IconProps) {
return (
<svg className={className} fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
/>
</svg>
);
}

View file

@ -0,0 +1,21 @@
interface IconProps {
className?: string;
}
export function SunIcon({ 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="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"
/>
</svg>
);
}

View file

@ -0,0 +1,21 @@
interface IconProps {
className?: string;
}
export function TrashIcon({ className = 'w-3.5 h-3.5' }: IconProps) {
return (
<svg
className={className}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
);
}

View file

@ -0,0 +1,8 @@
export { CloseIcon } from './CloseIcon';
export { InfoIcon } from './InfoIcon';
export { EyeIcon } from './EyeIcon';
export { PlusIcon } from './PlusIcon';
export { ChevronIcon } from './ChevronIcon';
export { FilterIcon } from './FilterIcon';
export { LightbulbIcon } from './LightbulbIcon';
export { MenuIcon } from './MenuIcon';

View file

@ -0,0 +1,123 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import type { FeatureMeta, FeatureFilters, HexagonStatsResponse } from '../types';
import { apiUrl, authHeaders, logNonAbortError } from '../lib/api';
interface UseAreaSummaryOptions {
stats: HexagonStatsResponse | null;
hexagonId: string | null;
isPostcode: boolean;
filters: FeatureFilters;
features: FeatureMeta[];
}
interface UseAreaSummaryResult {
summary: string;
loading: boolean;
error: string | null;
}
const FORBIDDEN_FEATURES = [
'% White',
'% Black',
'% Asian',
'% Mixed',
'% Other',
'Environmental risk',
'Collapsible deposits risk',
'Compressible ground risk',
'Landslide risk',
'Running sand risk',
'Shrink-swell risk',
'Soluble rocks risk',
];
export function useAreaSummary({
stats,
hexagonId,
isPostcode,
filters,
features,
}: UseAreaSummaryOptions): UseAreaSummaryResult {
const [summary, setSummary] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const abortRef = useRef<AbortController | null>(null);
const fetchSummary = useCallback(async () => {
if (!stats || !hexagonId) return;
abortRef.current?.abort();
const controller = new AbortController();
abortRef.current = controller;
setSummary('');
setLoading(true);
setError(null);
try {
const filterDescriptions: string[] = [];
for (const [name, value] of Object.entries(filters)) {
const meta = features.find((f) => f.name === name);
if (meta?.type === 'enum') {
filterDescriptions.push(`${name}: ${(value as string[]).join(', ')}`);
} else {
const [min, max] = value as [number, number];
filterDescriptions.push(`${name}: ${min}${max}`);
}
}
const body = {
count: stats.count,
location: hexagonId,
is_postcode: isPostcode,
filters: filterDescriptions,
numeric_stats: stats.numeric_features
.filter((f) => !FORBIDDEN_FEATURES.includes(f.name))
.map((f) => ({
name: f.name,
mean: f.mean,
})),
enum_stats: stats.enum_features
.filter((f) => !FORBIDDEN_FEATURES.includes(f.name))
.map((f) => ({
name: f.name,
counts: f.counts,
})),
};
const url = apiUrl('area-summary');
const response = await fetch(
url,
authHeaders({
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
signal: controller.signal,
})
);
if (!response.ok) {
const text = await response.text();
throw new Error(text || `HTTP ${response.status}`);
}
const json = await response.json();
setSummary(json.summary || '');
setLoading(false);
} catch (err) {
if (controller.signal.aborted) return;
logNonAbortError('area-summary', err);
setError(err instanceof Error ? err.message : 'Failed to generate summary');
setLoading(false);
}
}, [stats, hexagonId, isPostcode, filters, features]);
useEffect(() => {
fetchSummary();
return () => {
abortRef.current?.abort();
};
}, [stats, hexagonId]); // eslint-disable-line react-hooks/exhaustive-deps
return { summary, loading, error };
}

View file

@ -0,0 +1,127 @@
import { useState, useEffect, useCallback } from 'react';
import pb from '../lib/pocketbase';
export interface AuthUser {
id: string;
email: string;
verified: boolean;
}
// PocketBase RecordModel stores user fields as dynamic properties
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function recordToUser(record: any): AuthUser {
return {
id: record.id || '',
email: record.email || '',
verified: record.verified || false,
};
}
export function useAuth() {
const [user, setUser] = useState<AuthUser | null>(() => {
if (pb.authStore.isValid && pb.authStore.record) {
return recordToUser(pb.authStore.record);
}
return null;
});
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// Sync with authStore changes (cross-tab, external updates)
useEffect(() => {
const unsubscribe = pb.authStore.onChange(() => {
if (pb.authStore.isValid && pb.authStore.record) {
setUser(recordToUser(pb.authStore.record));
} else {
setUser(null);
}
});
return unsubscribe;
}, []);
const login = useCallback(async (email: string, password: string) => {
setLoading(true);
setError(null);
try {
const result = await pb.collection('users').authWithPassword(email, password);
setUser(recordToUser(result.record));
} catch (err) {
const msg = err instanceof Error ? err.message : 'Login failed';
setError(msg);
throw err;
} finally {
setLoading(false);
}
}, []);
const register = useCallback(async (email: string, password: string) => {
setLoading(true);
setError(null);
try {
await pb.collection('users').create({
email,
password,
passwordConfirm: password,
});
// Auto-login after registration
const result = await pb.collection('users').authWithPassword(email, password);
setUser(recordToUser(result.record));
} catch (err) {
const msg = err instanceof Error ? err.message : 'Registration failed';
setError(msg);
throw err;
} finally {
setLoading(false);
}
}, []);
const loginWithOAuth = useCallback(async (provider: string) => {
setLoading(true);
setError(null);
try {
const result = await pb.collection('users').authWithOAuth2({ provider });
setUser(recordToUser(result.record));
} catch (err) {
const msg = err instanceof Error ? err.message : 'OAuth login failed';
setError(msg);
throw err;
} finally {
setLoading(false);
}
}, []);
const logout = useCallback(() => {
pb.authStore.clear();
setUser(null);
}, []);
const requestPasswordReset = useCallback(async (email: string) => {
setLoading(true);
setError(null);
try {
await pb.collection('users').requestPasswordReset(email);
} catch (err) {
const msg = err instanceof Error ? err.message : 'Password reset request failed';
setError(msg);
throw err;
} finally {
setLoading(false);
}
}, []);
const clearError = useCallback(() => {
setError(null);
}, []);
return {
user,
loading,
error,
login,
register,
loginWithOAuth,
logout,
requestPasswordReset,
clearError,
};
}

View file

@ -0,0 +1,13 @@
import { useEffect, type RefObject } from 'react';
export function useClickOutside(ref: RefObject<HTMLElement | null>, callback: () => void) {
useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) {
callback();
}
}
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [ref, callback]);
}

View file

@ -0,0 +1,16 @@
import { useState, useCallback } from 'react';
export function useCollapsibleGroups(): [Set<string>, (name: string) => void] {
const [collapsed, setCollapsed] = useState<Set<string>>(new Set());
const toggle = useCallback((name: string) => {
setCollapsed((prev) => {
const next = new Set(prev);
if (next.has(name)) next.delete(name);
else next.add(name);
return next;
});
}, []);
return [collapsed, toggle];
}

View file

@ -0,0 +1,495 @@
import { useCallback, useRef, useState, useMemo } from 'react';
import { H3HexagonLayer } from '@deck.gl/geo-layers';
import { GeoJsonLayer, IconLayer, TextLayer } from '@deck.gl/layers';
import type { PickingInfo } from '@deck.gl/core';
import type {
HexagonData,
PostcodeFeature,
PostcodeProperties,
POI,
FeatureMeta,
Bounds,
} from '../types';
import type { SearchedPostcode } from '../components/map/PostcodeSearch';
import {
emojiToTwemojiUrl,
DENSITY_GRADIENT,
DENSITY_GRADIENT_DARK,
getFeatureFillColor,
} from '../lib/map-utils';
/** Convert POI id (e.g. "n12345") to OpenStreetMap URL */
function osmIdToUrl(id: string): string | null {
const match = id.match(/^([nwr])(\d+)$/);
if (!match) return null;
const typeMap: Record<string, string> = { n: 'node', w: 'way', r: 'relation' };
return `https://www.openstreetmap.org/${typeMap[match[1]]}/${match[2]}`;
}
export { osmIdToUrl };
interface UseDeckLayersProps {
data: HexagonData[];
postcodeData: PostcodeFeature[];
usePostcodeView: boolean;
pois: POI[];
viewFeature: string | null;
colorRange: [number, number] | null;
filterRange: [number, number] | null;
features: FeatureMeta[];
selectedHexagonId: string | null;
hoveredHexagonId: string | null;
onHexagonClick: (id: string, isPostcode?: boolean) => void;
onHexagonHover: (h3: string | null, x?: number, y?: number) => void;
theme: 'light' | 'dark';
searchedPostcode?: SearchedPostcode | null;
bounds?: Bounds | null;
}
export interface PopupInfo {
x: number;
y: number;
name: string;
category: string;
id: string;
}
export function useDeckLayers({
data,
postcodeData,
usePostcodeView,
pois,
viewFeature,
colorRange,
filterRange,
features,
selectedHexagonId,
hoveredHexagonId,
onHexagonClick,
onHexagonHover,
theme,
searchedPostcode,
bounds: viewportBounds,
}: UseDeckLayersProps) {
const [popupInfo, setPopupInfo] = useState<PopupInfo | null>(null);
const [hoverPosition, setHoverPosition] = useState<{ x: number; y: number } | null>(null);
const [selectedPostcode, setSelectedPostcode] = useState<string | null>(null);
const [hoveredPostcode, setHoveredPostcode] = useState<string | null>(null);
const isDark = theme === 'dark';
const densityGradient = isDark ? DENSITY_GRADIENT_DARK : DENSITY_GRADIENT;
// --- Refs for deck.gl accessors (avoid re-creating layers on every change) ---
const viewFeatureRef = useRef(viewFeature);
viewFeatureRef.current = viewFeature;
const colorRangeRef = useRef(colorRange);
colorRangeRef.current = colorRange;
const filterRangeRef = useRef(filterRange);
filterRangeRef.current = filterRange;
const isDarkRef = useRef(isDark);
isDarkRef.current = isDark;
const densityGradientRef = useRef(densityGradient);
densityGradientRef.current = densityGradient;
const selectedHexagonIdRef = useRef(selectedHexagonId);
selectedHexagonIdRef.current = selectedHexagonId;
const hoveredHexagonIdRef = useRef(hoveredHexagonId);
hoveredHexagonIdRef.current = hoveredHexagonId;
const selectedPostcodeRef = useRef(selectedPostcode);
selectedPostcodeRef.current = selectedPostcode;
const hoveredPostcodeRef = useRef(hoveredPostcode);
hoveredPostcodeRef.current = hoveredPostcode;
const colorFeatureMeta = useMemo(
() => (viewFeature ? features.find((f) => f.name === viewFeature) || null : null),
[viewFeature, features]
);
const colorFeatureMetaRef = useRef(colorFeatureMeta);
colorFeatureMetaRef.current = colorFeatureMeta;
// --- Count ranges ---
const countRange = useMemo(() => {
if (data.length === 0) return { min: 0, max: 1 };
let min = Infinity;
let max = -Infinity;
for (const d of data) {
if (viewportBounds) {
if (
d.lat < viewportBounds.south ||
d.lat > viewportBounds.north ||
d.lon < viewportBounds.west ||
d.lon > viewportBounds.east
)
continue;
}
const c = d.count as number;
if (c < min) min = c;
if (c > max) max = c;
}
if (min === Infinity) return { min: 0, max: 1 };
if (min === max) return { min, max: min + 1 };
return { min, max };
}, [data, viewportBounds]);
const countRangeRef = useRef(countRange);
countRangeRef.current = countRange;
const postcodeCountRange = useMemo(() => {
if (postcodeData.length === 0) return { min: 0, max: 1 };
let min = Infinity;
let max = -Infinity;
for (const d of postcodeData) {
if (viewportBounds) {
const [lng, lat] = d.properties.centroid as [number, number];
if (
lat < viewportBounds.south ||
lat > viewportBounds.north ||
lng < viewportBounds.west ||
lng > viewportBounds.east
)
continue;
}
const c = d.properties.count;
if (c < min) min = c;
if (c > max) max = c;
}
if (min === Infinity) return { min: 0, max: 1 };
if (min === max) return { min, max: min + 1 };
return { min, max };
}, [postcodeData, viewportBounds]);
const postcodeCountRangeRef = useRef(postcodeCountRange);
postcodeCountRangeRef.current = postcodeCountRange;
// --- Click/hover handlers ---
const onHexagonClickRef = useRef(onHexagonClick);
onHexagonClickRef.current = onHexagonClick;
const handleHexagonClick = useCallback((info: PickingInfo<HexagonData>) => {
if (info.object && 'h3' in info.object) {
onHexagonClickRef.current(info.object.h3);
}
}, []);
const onHexagonHoverRef = useRef(onHexagonHover);
onHexagonHoverRef.current = onHexagonHover;
const handleHexagonHover = useCallback((info: PickingInfo<HexagonData>) => {
if (info.object && 'h3' in info.object && info.x !== undefined && info.y !== undefined) {
setHoverPosition({ x: info.x, y: info.y });
onHexagonHoverRef.current(info.object.h3, info.x, info.y);
} else {
setHoverPosition(null);
onHexagonHoverRef.current(null);
}
}, []);
const handlePoiHover = useCallback((info: PickingInfo<POI>) => {
if (info.object && info.x !== undefined && info.y !== undefined) {
setPopupInfo({
x: info.x,
y: info.y,
name: info.object.name,
category: info.object.category,
id: info.object.id,
});
} else {
setPopupInfo(null);
}
}, []);
const handlePoiHoverRef = useRef(handlePoiHover);
handlePoiHoverRef.current = handlePoiHover;
const stablePoiHover = useCallback((info: PickingInfo<POI>) => {
handlePoiHoverRef.current(info);
}, []);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const handlePostcodeClick = useCallback((info: PickingInfo<any>) => {
const pc = info.object?.properties?.postcode;
if (pc) {
setSelectedPostcode((prev) => (prev === pc ? null : pc));
onHexagonClickRef.current(pc, true);
}
}, []);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const handlePostcodeHoverCallback = useCallback((info: PickingInfo<any>) => {
const pc = info.object?.properties?.postcode;
if (pc && info.x !== undefined && info.y !== undefined) {
setHoveredPostcode(pc);
setHoverPosition({ x: info.x, y: info.y });
onHexagonHoverRef.current(pc, info.x, info.y);
} else {
setHoveredPostcode(null);
setHoverPosition(null);
onHexagonHoverRef.current(null);
}
}, []);
// --- Color triggers ---
const colorTrigger = `${viewFeature}|${colorRange?.[0]}|${colorRange?.[1]}|${filterRange?.[0]}|${filterRange?.[1]}|${countRange.min}|${countRange.max}|${selectedHexagonId}|${hoveredHexagonId}|${theme}`;
const postcodeColorTrigger = `${viewFeature}|${colorRange?.[0]}|${colorRange?.[1]}|${filterRange?.[0]}|${filterRange?.[1]}|${postcodeCountRange.min}|${postcodeCountRange.max}|${selectedPostcode}|${hoveredPostcode}|${theme}`;
// --- Layers ---
const hexLayer = useMemo(
() =>
new H3HexagonLayer<HexagonData>({
id: 'h3-hexagons',
data,
getHexagon: (d) => d.h3,
getFillColor: (d) => {
const vf = viewFeatureRef.current;
const clr = colorRangeRef.current;
const fr = filterRangeRef.current;
const cfm = colorFeatureMetaRef.current;
const dark = isDarkRef.current;
if (vf && clr && cfm) {
const val = d[`avg_${vf}`] ?? d[`min_${vf}`];
const minVal = d[`min_${vf}`] as number | undefined;
const maxVal = d[`max_${vf}`] as number | undefined;
return getFeatureFillColor(
val as number | null | undefined,
minVal,
maxVal,
clr,
fr,
0,
densityGradientRef.current,
dark,
255
);
}
const cr = countRangeRef.current;
const c = d.count as number;
const t = (c - cr.min) / (cr.max - cr.min);
return getFeatureFillColor(
null,
undefined,
undefined,
null,
null,
t,
densityGradientRef.current,
dark,
255
);
},
getLineColor: (d) => {
if (d.h3 === selectedHexagonIdRef.current)
return [255, 255, 255, 255] as [number, number, number, number];
if (d.h3 === hoveredHexagonIdRef.current)
return [29, 228, 195, 200] as [number, number, number, number];
return [0, 0, 0, 0] as [number, number, number, number];
},
getLineWidth: (d) => {
if (d.h3 === selectedHexagonIdRef.current) return 3;
if (d.h3 === hoveredHexagonIdRef.current) return 2;
return 0;
},
lineWidthUnits: 'pixels',
updateTriggers: {
getFillColor: [colorTrigger],
getLineColor: [colorTrigger],
getLineWidth: [colorTrigger],
},
extruded: false,
pickable: true,
opacity: 1,
highPrecision: true,
onClick: handleHexagonClick,
onHover: handleHexagonHover,
// @ts-expect-error beforeId is a MapboxOverlay interleave prop, not typed in LayerProps
beforeId: 'landuse_park',
}),
[data, colorTrigger, handleHexagonClick, handleHexagonHover]
);
const postcodeLayer = useMemo(
() =>
new GeoJsonLayer<PostcodeProperties>({
id: 'postcode-polygons',
data: postcodeData as PostcodeFeature[],
getFillColor: (f) => {
const d = f.properties;
const vf = viewFeatureRef.current;
const clr = colorRangeRef.current;
const fr = filterRangeRef.current;
const cfm = colorFeatureMetaRef.current;
const dark = isDarkRef.current;
if (vf && clr && cfm) {
const val = d[`avg_${vf}`] ?? d[`min_${vf}`];
const minVal = d[`min_${vf}`] as number | undefined;
const maxVal = d[`max_${vf}`] as number | undefined;
return getFeatureFillColor(
val as number | null | undefined,
minVal,
maxVal,
clr,
fr,
0,
densityGradientRef.current,
dark,
180
);
}
const cr = postcodeCountRangeRef.current;
const c = d.count;
const t = (c - cr.min) / (cr.max - cr.min);
return getFeatureFillColor(
null,
undefined,
undefined,
null,
null,
t,
densityGradientRef.current,
dark,
180
);
},
getLineColor: (f) => {
const pc = f.properties.postcode;
const dark = isDarkRef.current;
if (pc === selectedPostcodeRef.current)
return [255, 255, 255, 255] as [number, number, number, number];
if (pc === hoveredPostcodeRef.current)
return [29, 228, 195, 200] as [number, number, number, number];
return (dark ? [180, 170, 160, 100] : [100, 100, 100, 150]) as [
number,
number,
number,
number,
];
},
getLineWidth: (f) => {
const pc = f.properties.postcode;
if (pc === selectedPostcodeRef.current) return 3;
if (pc === hoveredPostcodeRef.current) return 2;
return 1;
},
lineWidthUnits: 'pixels',
updateTriggers: {
getFillColor: [postcodeColorTrigger],
getLineColor: [postcodeColorTrigger],
getLineWidth: [postcodeColorTrigger],
},
extruded: false,
pickable: true,
onClick: handlePostcodeClick,
onHover: handlePostcodeHoverCallback,
// @ts-expect-error beforeId is a MapboxOverlay interleave prop, not typed in LayerProps
beforeId: 'landuse_park',
}),
[postcodeData, postcodeColorTrigger, handlePostcodeClick, handlePostcodeHoverCallback]
);
const postcodeLabelsLayer = useMemo(
() =>
new TextLayer<PostcodeFeature>({
id: 'postcode-labels',
data: postcodeData,
getPosition: (f) => f.properties.centroid,
getText: (f) => f.properties.postcode,
getSize: 12,
getColor: theme === 'dark' ? [255, 255, 255, 240] : [40, 40, 40, 220],
getTextAnchor: 'middle',
getAlignmentBaseline: 'center',
fontFamily: 'Inter, system-ui, sans-serif',
fontWeight: 600,
outlineWidth: 2,
outlineColor: theme === 'dark' ? [30, 30, 30, 200] : [255, 255, 255, 200],
sizeUnits: 'pixels',
sizeMinPixels: 10,
sizeMaxPixels: 14,
billboard: false,
pickable: false,
}),
[postcodeData, theme]
);
const poiLayer = useMemo(
() =>
new IconLayer<POI>({
id: 'poi-icons',
data: pois,
getPosition: (d) => [d.lng, d.lat],
getIcon: (d) => ({
url: emojiToTwemojiUrl(d.emoji),
width: 72,
height: 72,
}),
getSize: 24,
sizeMinPixels: 20,
sizeMaxPixels: 40,
pickable: true,
onHover: stablePoiHover,
}),
[pois, stablePoiHover]
);
// Check if the searched postcode has data (passes current filters)
const searchedPostcodeHasData = useMemo(() => {
if (!searchedPostcode) return false;
return postcodeData.some((f) => f.properties.postcode === searchedPostcode.postcode);
}, [searchedPostcode, postcodeData]);
// Highlight layer for searched postcode
const searchedPostcodeHighlightLayer = useMemo(() => {
if (!searchedPostcode) return null;
const hasData = searchedPostcodeHasData;
const feature = {
type: 'Feature' as const,
geometry: searchedPostcode.geometry,
properties: {},
};
return new GeoJsonLayer({
id: 'searched-postcode-highlight',
data: [feature],
getFillColor: hasData
? [29, 228, 195, 40] // teal tint when has data
: [255, 180, 0, 30], // orange tint when filtered out
getLineColor: hasData
? [29, 228, 195, 255] // solid teal when has data
: [255, 180, 0, 200], // orange when filtered out (no matching properties)
getLineWidth: hasData ? 4 : 3,
lineWidthUnits: 'pixels',
stroked: true,
filled: true,
pickable: false,
});
}, [searchedPostcode, searchedPostcodeHasData]);
const layers = useMemo(() => {
const baseLayers = usePostcodeView
? [postcodeLayer, postcodeLabelsLayer, poiLayer]
: [hexLayer, poiLayer];
if (searchedPostcodeHighlightLayer) {
return [...baseLayers, searchedPostcodeHighlightLayer];
}
return baseLayers;
}, [
usePostcodeView,
hexLayer,
postcodeLayer,
postcodeLabelsLayer,
poiLayer,
searchedPostcodeHighlightLayer,
]);
const handleMouseLeave = useCallback(() => {
setHoverPosition(null);
setHoveredPostcode(null);
setPopupInfo(null);
onHexagonHoverRef.current(null);
}, []);
return {
layers,
popupInfo,
hoverPosition,
countRange,
postcodeCountRange,
colorFeatureMeta,
handleMouseLeave,
selectedPostcode,
hoveredPostcode,
};
}

View file

@ -0,0 +1,21 @@
import { useRef, useEffect } from 'react';
export function useFadeInRef() {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const el = ref.current;
if (!el) return;
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
el.classList.add('fade-in-visible');
observer.unobserve(el);
}
},
{ threshold: 0.15 }
);
observer.observe(el);
return () => observer.disconnect();
}, []);
return ref;
}

View file

@ -0,0 +1,154 @@
import { useState, useCallback, useRef, useMemo } from 'react';
import type { FeatureMeta, FeatureFilters, Bounds, HexagonData, ApiResponse } from '../types';
import { apiUrl, logNonAbortError } from '../lib/api';
interface UseFiltersOptions {
initialFilters: FeatureFilters;
features: FeatureMeta[];
}
export function useFilters({ initialFilters, features }: UseFiltersOptions) {
// Use refs for bounds/resolution so handleDragStart always has latest values
const boundsRef = useRef<Bounds | null>(null);
const resolutionRef = useRef<number>(8);
const [filters, setFilters] = useState<FeatureFilters>(initialFilters);
const [activeFeature, setActiveFeature] = useState<string | null>(null);
const [dragValue, setDragValue] = useState<[number, number] | null>(null);
const [pinnedFeature, setPinnedFeature] = useState<string | null>(null);
const [dragData, setDragData] = useState<HexagonData[] | null>(null);
const dragAbortRef = useRef<AbortController | null>(null);
const enabledFeatures = useMemo(() => new Set(Object.keys(filters)), [filters]);
const viewFeature = activeFeature || pinnedFeature;
const viewSource: 'drag' | 'eye' | null = activeFeature ? 'drag' : pinnedFeature ? 'eye' : null;
const filterRange = useMemo((): [number, number] | null => {
if (!viewFeature) return null;
if (activeFeature && dragValue) return dragValue;
const filterVal = filters[viewFeature];
if (filterVal && typeof filterVal[0] === 'number') return filterVal as [number, number];
return null;
}, [viewFeature, activeFeature, dragValue, filters]);
const handleAddFilter = useCallback(
(name: string) => {
const meta = features.find((f) => f.name === name);
if (!meta) return;
if (meta.type === 'enum' && meta.values) {
setFilters((prev) => ({ ...prev, [name]: [...meta.values!] }));
} else if (meta.min != null && meta.max != null) {
setFilters((prev) => ({ ...prev, [name]: [meta.min!, meta.max!] }));
}
},
[features]
);
const handleFilterChange = useCallback((name: string, value: [number, number] | string[]) => {
setFilters((prev) => ({ ...prev, [name]: value }));
}, []);
const handleRemoveFilter = useCallback((name: string) => {
setFilters((prev) => {
const next = { ...prev };
delete next[name];
return next;
});
setPinnedFeature((prev) => (prev === name ? null : prev));
}, []);
const handleDragStart = useCallback(
(name: string) => {
const meta = features.find((f) => f.name === name);
if (meta?.type === 'enum') return;
setActiveFeature(name);
const fval = filters[name];
setDragValue(fval && !Array.isArray(fval[0]) ? (fval as [number, number]) : null);
const currentBounds = boundsRef.current;
if (!currentBounds) return;
if (dragAbortRef.current) dragAbortRef.current.abort();
dragAbortRef.current = new AbortController();
const otherFilters = Object.entries(filters).filter(([k]) => k !== name);
let filtersStr = '';
if (otherFilters.length > 0) {
filtersStr = otherFilters
.map(([n, value]) => {
const m = features.find((f) => f.name === n);
if (m?.type === 'enum') return `${n}:${(value as string[]).join('|')}`;
const [min, max] = value as [number, number];
return `${n}:${min}:${max}`;
})
.join(',');
}
const boundsStr = `${currentBounds.south},${currentBounds.west},${currentBounds.north},${currentBounds.east}`;
const params = new URLSearchParams({
resolution: resolutionRef.current.toString(),
bounds: boundsStr,
});
if (filtersStr) params.set('filters', filtersStr);
params.set('fields', name);
fetch(apiUrl('hexagons', params), {
signal: dragAbortRef.current.signal,
})
.then((res) => res.json())
.then((json: ApiResponse) => setDragData(json.features || []))
.catch((err) => logNonAbortError('Failed to fetch drag data', err));
},
[filters, features]
);
const handleDragChange = useCallback((value: [number, number]) => {
setDragValue(value);
}, []);
const handleDragEnd = useCallback(() => {
if (activeFeature && dragValue) {
setFilters((prev) => ({ ...prev, [activeFeature]: dragValue }));
}
setActiveFeature(null);
setDragValue(null);
setDragData(null);
if (dragAbortRef.current) {
dragAbortRef.current.abort();
dragAbortRef.current = null;
}
}, [activeFeature, dragValue]);
const handleTogglePin = useCallback((name: string) => {
setPinnedFeature((prev) => (prev === name ? null : name));
}, []);
const handleCancelPin = useCallback(() => {
setPinnedFeature(null);
}, []);
const updateBoundsInfo = useCallback((newBounds: Bounds | null, newResolution: number) => {
boundsRef.current = newBounds;
resolutionRef.current = newResolution;
}, []);
return {
filters,
activeFeature,
dragValue,
dragData,
pinnedFeature,
enabledFeatures,
viewFeature,
viewSource,
filterRange,
handleAddFilter,
handleFilterChange,
handleRemoveFilter,
handleDragStart,
handleDragChange,
handleDragEnd,
handleTogglePin,
handleCancelPin,
updateBoundsInfo,
};
}

View file

@ -0,0 +1,176 @@
import { useState, useCallback } from 'react';
import type {
FeatureMeta,
FeatureFilters,
Property,
HexagonPropertiesResponse,
HexagonStatsResponse,
} from '../types';
import { buildFilterString, apiUrl, logNonAbortError, authHeaders } from '../lib/api';
interface SelectedHexagon {
id: string;
type: 'hexagon' | 'postcode';
resolution: number;
}
interface UseHexagonSelectionOptions {
filters: FeatureFilters;
features: FeatureMeta[];
resolution: number;
}
export function useHexagonSelection({ filters, features, resolution }: UseHexagonSelectionOptions) {
const [selectedHexagon, setSelectedHexagon] = useState<SelectedHexagon | null>(null);
const [properties, setProperties] = useState<Property[]>([]);
const [propertiesTotal, setPropertiesTotal] = useState(0);
const [propertiesOffset, setPropertiesOffset] = useState(0);
const [loadingProperties, setLoadingProperties] = useState(false);
const [areaStats, setAreaStats] = useState<HexagonStatsResponse | null>(null);
const [loadingAreaStats, setLoadingAreaStats] = useState(false);
const [hoveredHexagon, setHoveredHexagon] = useState<string | null>(null);
const [rightPaneTab, setRightPaneTab] = useState<'pois' | 'properties' | 'area'>('pois');
const fetchHexagonStats = useCallback(
async (h3: string, res: number, signal?: AbortSignal, fields?: string[]) => {
const params = new URLSearchParams({
h3,
resolution: res.toString(),
});
const filterStr = buildFilterString(filters, features);
if (filterStr) params.append('filters', filterStr);
if (fields) {
params.set('fields', fields.join(','));
}
const response = await fetch(apiUrl('hexagon-stats', params), authHeaders({ signal }));
return (await response.json()) as HexagonStatsResponse;
},
[filters, features]
);
const fetchPostcodeStats = useCallback(
async (postcode: string, signal?: AbortSignal) => {
const params = new URLSearchParams({ postcode });
const filterStr = buildFilterString(filters, features);
if (filterStr) params.append('filters', filterStr);
const response = await fetch(apiUrl('postcode-stats', params), authHeaders({ signal }));
return (await response.json()) as HexagonStatsResponse;
},
[filters, features]
);
const fetchHexagonProperties = useCallback(
async (h3: string, res: number, offset = 0) => {
setLoadingProperties(true);
try {
const params = new URLSearchParams({
h3,
resolution: res.toString(),
limit: '100',
offset: offset.toString(),
});
const filterStr = buildFilterString(filters, features);
if (filterStr) params.append('filters', filterStr);
const response = await fetch(apiUrl('hexagon-properties', params), authHeaders());
const data: HexagonPropertiesResponse = await response.json();
if (offset === 0) {
setProperties(data.properties);
} else {
setProperties((prev) => [...prev, ...data.properties]);
}
setPropertiesTotal(data.total);
setPropertiesOffset(offset + data.properties.length);
} catch (err) {
console.error('Failed to fetch properties:', err);
} finally {
setLoadingProperties(false);
}
},
[filters, features]
);
const handleHexagonClick = useCallback(
(id: string, isPostcode = false) => {
if (selectedHexagon?.id === id) {
setSelectedHexagon(null);
setProperties([]);
setAreaStats(null);
} else {
const type = isPostcode ? 'postcode' : 'hexagon';
setSelectedHexagon({ id, type, resolution });
setProperties([]);
setPropertiesTotal(0);
setPropertiesOffset(0);
setRightPaneTab('area');
if (isPostcode) {
setLoadingAreaStats(true);
fetchPostcodeStats(id)
.then((stats) => setAreaStats(stats))
.catch((error) => logNonAbortError('Failed to fetch postcode stats', error))
.finally(() => setLoadingAreaStats(false));
} else {
setLoadingAreaStats(true);
fetchHexagonStats(id, resolution)
.then((stats) => setAreaStats(stats))
.catch((error) => logNonAbortError('Failed to fetch area stats', error))
.finally(() => setLoadingAreaStats(false));
}
}
},
[selectedHexagon, resolution, fetchHexagonStats, fetchPostcodeStats]
);
const handleHexagonHover = useCallback((h3: string | null) => {
setHoveredHexagon(h3);
}, []);
const handleViewPropertiesFromArea = useCallback(() => {
if (selectedHexagon && selectedHexagon.type === 'hexagon') {
setRightPaneTab('properties');
setPropertiesOffset(0);
fetchHexagonProperties(selectedHexagon.id, selectedHexagon.resolution, 0);
}
}, [selectedHexagon, fetchHexagonProperties]);
const handlePropertiesTabClick = useCallback(() => {
setRightPaneTab('properties');
if (selectedHexagon?.type === 'hexagon' && properties.length === 0 && !loadingProperties) {
setPropertiesOffset(0);
fetchHexagonProperties(selectedHexagon.id, selectedHexagon.resolution, 0);
}
}, [selectedHexagon, properties.length, loadingProperties, fetchHexagonProperties]);
const handleLoadMoreProperties = useCallback(() => {
if (selectedHexagon && selectedHexagon.type === 'hexagon') {
fetchHexagonProperties(selectedHexagon.id, selectedHexagon.resolution, propertiesOffset);
}
}, [selectedHexagon, propertiesOffset, fetchHexagonProperties]);
const handleCloseSelection = useCallback(() => {
setSelectedHexagon(null);
setProperties([]);
setAreaStats(null);
}, []);
return {
selectedHexagon,
properties,
propertiesTotal,
loadingProperties,
areaStats,
loadingAreaStats,
hoveredHexagon,
rightPaneTab,
setRightPaneTab,
handleHexagonClick,
handleHexagonHover,
handleViewPropertiesFromArea,
handlePropertiesTabClick,
handleLoadMoreProperties,
handleCloseSelection,
};
}

View file

@ -0,0 +1,16 @@
import { useState, useEffect } from 'react';
const MOBILE_QUERY = '(max-width: 767px)';
export function useIsMobile(): boolean {
const [isMobile, setIsMobile] = useState(() => window.matchMedia(MOBILE_QUERY).matches);
useEffect(() => {
const mql = window.matchMedia(MOBILE_QUERY);
const handler = (e: MediaQueryListEvent) => setIsMobile(e.matches);
mql.addEventListener('change', handler);
return () => mql.removeEventListener('change', handler);
}, []);
return isMobile;
}

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