Merge branch 'main' of https://github.com/rubyhrzhang/property-map
This commit is contained in:
commit
1397b6afd5
217 changed files with 24403 additions and 5858 deletions
|
|
@ -1,5 +1,3 @@
|
|||
data/
|
||||
data_sources/
|
||||
.venv
|
||||
**/node_modules
|
||||
**/dist
|
||||
|
|
@ -8,6 +6,5 @@ server-rs/target
|
|||
.task
|
||||
.claude
|
||||
__pycache__
|
||||
*.parquet
|
||||
analyses/
|
||||
*.log
|
||||
|
|
|
|||
46
.github/workflows/ci.yml
vendored
46
.github/workflows/ci.yml
vendored
|
|
@ -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
1
.gitignore
vendored
|
|
@ -8,3 +8,4 @@ tfl_journey_client
|
|||
server-rs/target
|
||||
.task
|
||||
data
|
||||
frontend/public/assets
|
||||
|
|
|
|||
10
.vscode/settings.json
vendored
10
.vscode/settings.json
vendored
|
|
@ -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
160
CLAUDE.md
|
|
@ -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 4–12)
|
||||
- `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 4–12 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
|
||||
|
|
|
|||
|
|
@ -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
250
Makefile.data
Normal 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
126
README.md
|
|
@ -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)
|
||||

|
||||
- [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 there’s a property listed there right now. The best things come to those who’re patient. We will justify your patience. But we will also show you if your expectations are impossible to meet. I’d much rather be told upfront then spend months of my life looking for something that can’t 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
|
||||
|
|
|
|||
|
|
@ -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}}
|
||||
44
Taskfile.yml
44
Taskfile.yml
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
95
docker-compose.yml
Normal 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:
|
||||
3487
frontend/package-lock.json
generated
3487
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -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
BIN
frontend/public/cereal.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.3 MiB |
1
frontend/public/favicon.svg
Normal file
1
frontend/public/favicon.svg
Normal 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
BIN
frontend/public/house.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.4 MiB |
140
frontend/scripts/prerender.mjs
Normal file
140
frontend/scripts/prerender.mjs
Normal 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);
|
||||
});
|
||||
1260
frontend/src/App.tsx
1260
frontend/src/App.tsx
File diff suppressed because it is too large
Load diff
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
});
|
||||
|
|
@ -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'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 & Wales.
|
||||
<br />
|
||||
<span className="text-teal-600">One map. Your 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'll
|
||||
accept. Narrowit shows you every area that qualifies — 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 · Free · 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'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 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' },
|
||||
];
|
||||
|
|
@ -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>
|
||||
);
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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">
|
||||
22
frontend/src/components/home/BottomIllustration.tsx
Normal file
22
frontend/src/components/home/BottomIllustration.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
118
frontend/src/components/home/CategoryArt.tsx
Normal file
118
frontend/src/components/home/CategoryArt.tsx
Normal 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;
|
||||
}
|
||||
}
|
||||
131
frontend/src/components/home/HexCanvas.tsx
Normal file
131
frontend/src/components/home/HexCanvas.tsx
Normal 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 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
243
frontend/src/components/home/HomeDemo.tsx
Normal file
243
frontend/src/components/home/HomeDemo.tsx
Normal 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)} – {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>
|
||||
);
|
||||
}
|
||||
326
frontend/src/components/home/HomePage.tsx
Normal file
326
frontend/src/components/home/HomePage.tsx
Normal 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 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's just three. We've built 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'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's the problem with property search: listings only show you what's on
|
||||
the market{' '}
|
||||
<strong className="font-semibold text-navy-950 dark:text-warm-100">right now</strong>{' '}
|
||||
— 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'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 — years of historical transactions and public records,
|
||||
extended with proprietary algorithms so the map doesn'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've set — 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 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',
|
||||
|
||||
},
|
||||
];
|
||||
58
frontend/src/components/map/AISummaryCard.tsx
Normal file
58
frontend/src/components/map/AISummaryCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
412
frontend/src/components/map/AreaPane.tsx
Normal file
412
frontend/src/components/map/AreaPane.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
173
frontend/src/components/map/DualHistogram.tsx
Normal file
173
frontend/src/components/map/DualHistogram.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
23
frontend/src/components/map/EnumBarChart.tsx
Normal file
23
frontend/src/components/map/EnumBarChart.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
53
frontend/src/components/map/ExternalSearchLinks.tsx
Normal file
53
frontend/src/components/map/ExternalSearchLinks.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
129
frontend/src/components/map/FeatureBrowser.tsx
Normal file
129
frontend/src/components/map/FeatureBrowser.tsx
Normal 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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
273
frontend/src/components/map/Filters.tsx
Normal file
273
frontend/src/components/map/Filters.tsx
Normal 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't a box of cereal you grab because it's on sale.
|
||||
Don'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'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't always have properties listed right now. We help
|
||||
you identify where you should be looking, so when something does come up,
|
||||
you're ready.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="font-semibold text-navy-950 dark:text-warm-100 mb-1">
|
||||
Know what's possible
|
||||
</h4>
|
||||
<p className="text-warm-600 dark:text-warm-300">
|
||||
We'd rather tell you upfront if your expectations are unrealistic than have you
|
||||
spend months searching for something that doesn't exist.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</InfoPopup>
|
||||
)}
|
||||
|
||||
{activeInfoFeature && (
|
||||
<FeatureInfoPopup
|
||||
feature={activeInfoFeature}
|
||||
onClose={() => setActiveInfoFeature(null)}
|
||||
onNavigateToSource={onNavigateToSource}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
29
frontend/src/components/map/HistogramLegend.tsx
Normal file
29
frontend/src/components/map/HistogramLegend.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
99
frontend/src/components/map/HoverCard.tsx
Normal file
99
frontend/src/components/map/HoverCard.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
286
frontend/src/components/map/Map.tsx
Normal file
286
frontend/src/components/map/Map.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
71
frontend/src/components/map/MapLegend.tsx
Normal file
71
frontend/src/components/map/MapLegend.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
569
frontend/src/components/map/MapPage.tsx
Normal file
569
frontend/src/components/map/MapPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
63
frontend/src/components/map/MobileDrawer.tsx
Normal file
63
frontend/src/components/map/MobileDrawer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
204
frontend/src/components/map/POIPane.tsx
Normal file
204
frontend/src/components/map/POIPane.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
81
frontend/src/components/map/PostcodeSearch.tsx
Normal file
81
frontend/src/components/map/PostcodeSearch.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
183
frontend/src/components/map/PriceHistoryChart.tsx
Normal file
183
frontend/src/components/map/PriceHistoryChart.tsx
Normal 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;
|
||||
}
|
||||
246
frontend/src/components/map/PropertiesPane.tsx
Normal file
246
frontend/src/components/map/PropertiesPane.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
82
frontend/src/components/map/StackedBarChart.tsx
Normal file
82
frontend/src/components/map/StackedBarChart.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
76
frontend/src/components/map/StackedEnumChart.tsx
Normal file
76
frontend/src/components/map/StackedEnumChart.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
26
frontend/src/components/map/StreetViewEmbed.tsx
Normal file
26
frontend/src/components/map/StreetViewEmbed.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
69
frontend/src/components/pricing/PricingPage.tsx
Normal file
69
frontend/src/components/pricing/PricingPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
170
frontend/src/components/saved-searches/SavedSearchesPage.tsx
Normal file
170
frontend/src/components/saved-searches/SavedSearchesPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
186
frontend/src/components/ui/AuthModal.tsx
Normal file
186
frontend/src/components/ui/AuthModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
27
frontend/src/components/ui/CollapsibleGroupHeader.tsx
Normal file
27
frontend/src/components/ui/CollapsibleGroupHeader.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
27
frontend/src/components/ui/EmptyState.tsx
Normal file
27
frontend/src/components/ui/EmptyState.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
49
frontend/src/components/ui/FeatureIcons.tsx
Normal file
49
frontend/src/components/ui/FeatureIcons.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
37
frontend/src/components/ui/FeatureInfoPopup.tsx
Normal file
37
frontend/src/components/ui/FeatureInfoPopup.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
39
frontend/src/components/ui/FeatureLabel.tsx
Normal file
39
frontend/src/components/ui/FeatureLabel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
260
frontend/src/components/ui/Header.tsx
Normal file
260
frontend/src/components/ui/Header.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
34
frontend/src/components/ui/IconButton.tsx
Normal file
34
frontend/src/components/ui/IconButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
46
frontend/src/components/ui/InfoPopup.tsx
Normal file
46
frontend/src/components/ui/InfoPopup.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
187
frontend/src/components/ui/MobileMenu.tsx
Normal file
187
frontend/src/components/ui/MobileMenu.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
95
frontend/src/components/ui/SaveSearchModal.tsx
Normal file
95
frontend/src/components/ui/SaveSearchModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
23
frontend/src/components/ui/SearchInput.tsx
Normal file
23
frontend/src/components/ui/SearchInput.tsx
Normal 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}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -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">
|
||||
20
frontend/src/components/ui/TabButton.tsx
Normal file
20
frontend/src/components/ui/TabButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
39
frontend/src/components/ui/TickerValue.tsx
Normal file
39
frontend/src/components/ui/TickerValue.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
54
frontend/src/components/ui/UserMenu.tsx
Normal file
54
frontend/src/components/ui/UserMenu.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
21
frontend/src/components/ui/icons/BookmarkIcon.tsx
Normal file
21
frontend/src/components/ui/icons/BookmarkIcon.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
17
frontend/src/components/ui/icons/CheckIcon.tsx
Normal file
17
frontend/src/components/ui/icons/CheckIcon.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
26
frontend/src/components/ui/icons/ChevronIcon.tsx
Normal file
26
frontend/src/components/ui/icons/ChevronIcon.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
21
frontend/src/components/ui/icons/ClipboardIcon.tsx
Normal file
21
frontend/src/components/ui/icons/ClipboardIcon.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
17
frontend/src/components/ui/icons/CloseIcon.tsx
Normal file
17
frontend/src/components/ui/icons/CloseIcon.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
18
frontend/src/components/ui/icons/DownloadIcon.tsx
Normal file
18
frontend/src/components/ui/icons/DownloadIcon.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
27
frontend/src/components/ui/icons/EyeIcon.tsx
Normal file
27
frontend/src/components/ui/icons/EyeIcon.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
21
frontend/src/components/ui/icons/FilterIcon.tsx
Normal file
21
frontend/src/components/ui/icons/FilterIcon.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
18
frontend/src/components/ui/icons/InfoIcon.tsx
Normal file
18
frontend/src/components/ui/icons/InfoIcon.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
21
frontend/src/components/ui/icons/LightbulbIcon.tsx
Normal file
21
frontend/src/components/ui/icons/LightbulbIcon.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
22
frontend/src/components/ui/icons/LogoIcon.tsx
Normal file
22
frontend/src/components/ui/icons/LogoIcon.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
22
frontend/src/components/ui/icons/MapPinIcon.tsx
Normal file
22
frontend/src/components/ui/icons/MapPinIcon.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
17
frontend/src/components/ui/icons/MenuIcon.tsx
Normal file
17
frontend/src/components/ui/icons/MenuIcon.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
21
frontend/src/components/ui/icons/MoonIcon.tsx
Normal file
21
frontend/src/components/ui/icons/MoonIcon.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
17
frontend/src/components/ui/icons/PlusIcon.tsx
Normal file
17
frontend/src/components/ui/icons/PlusIcon.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
16
frontend/src/components/ui/icons/SpinnerIcon.tsx
Normal file
16
frontend/src/components/ui/icons/SpinnerIcon.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
21
frontend/src/components/ui/icons/SunIcon.tsx
Normal file
21
frontend/src/components/ui/icons/SunIcon.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
21
frontend/src/components/ui/icons/TrashIcon.tsx
Normal file
21
frontend/src/components/ui/icons/TrashIcon.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
8
frontend/src/components/ui/icons/index.ts
Normal file
8
frontend/src/components/ui/icons/index.ts
Normal 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';
|
||||
123
frontend/src/hooks/useAreaSummary.ts
Normal file
123
frontend/src/hooks/useAreaSummary.ts
Normal 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 };
|
||||
}
|
||||
127
frontend/src/hooks/useAuth.ts
Normal file
127
frontend/src/hooks/useAuth.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
13
frontend/src/hooks/useClickOutside.ts
Normal file
13
frontend/src/hooks/useClickOutside.ts
Normal 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]);
|
||||
}
|
||||
16
frontend/src/hooks/useCollapsibleGroups.ts
Normal file
16
frontend/src/hooks/useCollapsibleGroups.ts
Normal 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];
|
||||
}
|
||||
495
frontend/src/hooks/useDeckLayers.ts
Normal file
495
frontend/src/hooks/useDeckLayers.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
21
frontend/src/hooks/useFadeIn.ts
Normal file
21
frontend/src/hooks/useFadeIn.ts
Normal 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;
|
||||
}
|
||||
154
frontend/src/hooks/useFilters.ts
Normal file
154
frontend/src/hooks/useFilters.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
176
frontend/src/hooks/useHexagonSelection.ts
Normal file
176
frontend/src/hooks/useHexagonSelection.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
16
frontend/src/hooks/useIsMobile.ts
Normal file
16
frontend/src/hooks/useIsMobile.ts
Normal 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
Loading…
Add table
Add a link
Reference in a new issue