From 72653a4ddf60372a410c51370e3c84481277e7dc Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Sun, 8 Mar 2026 21:09:30 +0000 Subject: [PATCH] More improvements --- CLAUDE.md | 6 +- frontend/src/components/map/Filters.tsx | 28 +++-- frontend/src/components/map/MobileDrawer.tsx | 13 ++- frontend/src/hooks/useCollapsibleGroups.ts | 13 ++- frontend/src/hooks/useHexagonSelection.ts | 112 +++++++++++++++++-- frontend/src/index.html | 1 + server-rs/src/main.rs | 6 + 7 files changed, 146 insertions(+), 33 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 5600b9d..622c023 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -89,7 +89,7 @@ Rust + Axum. Loads parquet into memory at startup. **Structure** (uses Rust 2018 module style — `foo.rs` + `foo/` directory, not `foo/mod.rs`): - `data.rs` + `data/` — Property and POI data loading - `parsing.rs` + `parsing/` — Filter parsing and bounds parsing -- `routes.rs` + `routes/` — One file per endpoint +- `routes.rs` + `routes/` — One file per endpoint. `properties.rs` exports shared `build_property()` used by both hexagon and postcode property endpoints - `utils.rs` + `utils/` — GridIndex, hashing, interned columns - `consts.rs` — Key constants (histogram bins, H3 range, max enum cardinality, excluded columns) @@ -99,6 +99,7 @@ Rust + Axum. Loads parquet into memory at startup. - `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/postcode-properties?postcode=&filters=&limit=&offset=` — Paginated properties within a postcode - `GET /api/pois?bounds=&categories=` — POIs by bounds (max 5000) - `GET /api/poi-categories` — Available POI category names @@ -120,7 +121,7 @@ React 18 + TypeScript. deck.gl `H3HexagonLayer` over MapLibre GL. TailwindCSS. N - 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 + - `useHexagonSelection` — Selection state, area stats, properties fetching (supports both hexagons and postcodes) - `usePOIData` — POI fetching with debounce - `usePaneResize` — Reusable pane resize handlers - `useTheme` — Theme state with localStorage persistence @@ -324,6 +325,7 @@ Follow these conventions in all Rust code: - **Travel time is strict**: `mode` param is required (400) when `destination` is set — no silent default to "car". R5 failures return 502 Bad Gateway, not silent omission. `r5_url` is `Option` — returns 503 if travel time requested without R5 configured. - **Filter bounds format**: `south,west,north,east` (not standard bbox order) - **Server-side AABB filtering**: Both `/api/hexagons` and `/api/postcodes` filter results by bounding-box intersection with query bounds. Hexagons use `h3_cell_bounds()` (h3o returns degrees, not radians). Postcodes compute polygon AABB from vertices. See `bounds_intersect()` in `parsing/bounds.rs`. +- **Postcode row matching**: Both `postcode-stats` and `postcode-properties` use the same pattern: look up centroid from `postcode_data`, search `GridIndex` within `POSTCODE_SEARCH_OFFSET` (0.02°) of centroid, then exact string match on `state.data.postcode(row)`. Simpler than hexagon matching (no H3 cell computation needed). - **GridIndex returns slightly more than requested**: The 0.01° grid cells mean properties up to ~1km outside the viewport may be returned. The AABB filter in the route handlers catches these extras. - **POI proximity**: Uses 0.05° grid (~5km cells) to reduce candidates before haversine distance check - **OG tag injection**: Uses `` placeholder in HTML, replaced at runtime by middleware diff --git a/frontend/src/components/map/Filters.tsx b/frontend/src/components/map/Filters.tsx index f83fc91..05f23f1 100644 --- a/frontend/src/components/map/Filters.tsx +++ b/frontend/src/components/map/Filters.tsx @@ -1,4 +1,4 @@ -import { memo, useState, useMemo, useRef, useCallback } from 'react'; +import { memo, useState, useMemo, useRef, useCallback, useEffect } from 'react'; import { Slider } from '../ui/Slider'; import { LightbulbIcon } from '../ui/icons'; @@ -212,23 +212,27 @@ export default memo(function Filters({ const activeEntryCount = travelTimeEntries.length; + const pendingScrollRef = useRef(null); + const handleAddAndScroll = useCallback( (name: string) => { const feature = features.find((f) => f.name === name); if (feature?.group) expandGroup(feature.group); + pendingScrollRef.current = name; onAddFilter(name); - // Double rAF: first lets React commit the DOM update, second lets layout settle - requestAnimationFrame(() => { - requestAnimationFrame(() => { - const el = scrollRef.current?.querySelector(`[data-filter-name="${CSS.escape(name)}"]`); - if (el) { - el.scrollIntoView({ behavior: 'smooth', block: 'start' }); - } - }); - }); }, [onAddFilter, features, expandGroup] ); + + useEffect(() => { + const name = pendingScrollRef.current; + if (!name) return; + pendingScrollRef.current = null; + const el = scrollRef.current?.querySelector(`[data-filter-name="${CSS.escape(name)}"]`); + if (el) { + el.scrollIntoView({ behavior: 'smooth', block: 'start' }); + } + }, [enabledFeatureList]); const enabledGroups = useMemo( () => groupFeaturesByCategory(enabledFeatureList), [enabledFeatureList] @@ -357,7 +361,7 @@ export default memo(function Filters({
@@ -414,7 +418,7 @@ export default memo(function Filters({
diff --git a/frontend/src/components/map/MobileDrawer.tsx b/frontend/src/components/map/MobileDrawer.tsx index 680b67d..fa5507a 100644 --- a/frontend/src/components/map/MobileDrawer.tsx +++ b/frontend/src/components/map/MobileDrawer.tsx @@ -1,21 +1,22 @@ -import { useState, useEffect } from 'react'; +import { useEffect } from 'react'; import { CloseIcon } from '../ui/icons/CloseIcon'; import { TabButton } from '../ui/TabButton'; -type DrawerTab = 'area' | 'properties'; - interface MobileDrawerProps { onClose: () => void; renderArea: () => React.ReactNode; renderProperties: () => React.ReactNode; + tab: 'area' | 'properties'; + onTabChange: (tab: 'area' | 'properties') => void; } export default function MobileDrawer({ onClose, renderArea, renderProperties, + tab, + onTabChange, }: MobileDrawerProps) { - const [tab, setTab] = useState('area'); // Close on Escape useEffect(() => { @@ -35,11 +36,11 @@ export default function MobileDrawer({
{/* Tab bar + close */}
- setTab('area')} /> + onTabChange('area')} /> setTab('properties')} + onClick={() => onTabChange('properties')} />