diff --git a/CLAUDE.md b/CLAUDE.md index 592ee92..7ec17af 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -15,7 +15,7 @@ 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 :3000 (proxies /api to :8001) # Data pipeline task prepare # Build wide.parquet from all pre-downloaded sources @@ -92,7 +92,9 @@ Rust + Axum. Loads parquet into memory at startup. **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 @@ -109,14 +111,61 @@ Serves `frontend/dist/` as static fallback in production. 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 :3000 proxies `/api` to :8001; also handles VS Code `/proxy/PORT` patterns + +**Shared UI Components (`frontend/src/components/ui/`):** +- `Icons.tsx` — Central icon library (CloseIcon, InfoIcon, EyeIcon, PlusIcon, ChevronIcon, FilterIcon, LightbulbIcon). All icons accept `className` prop for sizing. +- `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) @@ -221,6 +270,7 @@ Every UI element must use the correct token from this table. Do not invent new p - **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) @@ -259,5 +309,32 @@ Follow these conventions in all Rust code: - **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 `` placeholder in HTML, replaced at runtime by middleware + +## Rust Performance Patterns (server-rs) + +**Lookup optimization:** +- `AppState.feature_name_to_index: FxHashMap` 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` (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` + +**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 for enum values — complexity not worth modest benefit