From 1588c01b1950163c95e28ffbc5312973d61ba4cd Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Wed, 11 Feb 2026 21:32:33 +0000 Subject: [PATCH] These work --- CLAUDE.md | 11 ++- Dockerfile | 2 +- frontend/src/App.tsx | 14 ++-- frontend/src/components/home/HomeDemo.tsx | 34 +++++++- frontend/src/components/map/Filters.tsx | 46 ++++++++--- .../src/components/map/PropertiesPane.tsx | 16 ++-- frontend/src/components/ui/Header.tsx | 5 +- frontend/src/components/ui/MobileMenu.tsx | 1 + frontend/src/hooks/useFilters.ts | 2 +- frontend/src/hooks/useMapData.ts | 6 +- frontend/src/hooks/useSavedSearches.ts | 20 ++--- frontend/src/lib/format.ts | 75 ++++++++++++++++++ frontend/src/lib/property-fields.ts | 9 +-- frontend/src/lib/url-state.ts | 74 +----------------- server-rs/src/main.rs | 25 +++--- server-rs/src/parsing/filters.rs | 77 +++++++++++-------- server-rs/src/routes/hexagon_stats.rs | 3 +- server-rs/src/routes/postcode_stats.rs | 3 +- server-rs/src/routes/properties.rs | 38 ++++----- 19 files changed, 260 insertions(+), 201 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 1b20795..e4a91af 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -72,7 +72,7 @@ Python + Polars. Two phases: - `poi_proximity.py` — Counts POIs within 2km per postcode using 0.05° spatial grid - `crime.py` — Aggregates crime CSVs into yearly averages by LSOA -**Critical: column renaming in `merge.py`** — The pipeline renames columns from snake_case to human-readable names before writing `wide.parquet`. The Rust server auto-discovers features from whatever column names exist in the parquet. Key renames: +**Critical: column renaming in `merge.py`** — The pipeline renames columns from snake_case to human-readable names before writing `wide.parquet`. The Rust server and frontend use **only** these human-readable names — there are no fallbacks to snake_case. Key renames: - `pp_address` → `Address per Property Register` - `postcode` → `Postcode` - `latest_price` → `Last known price` @@ -80,7 +80,7 @@ Python + Polars. Two phases: - `total_floor_area` → `Total floor area (sqm)` - `current_energy_rating` → `Current energy rating` -The server and frontend must handle these human-readable names. See the full rename map in `merge.py`. +The server requires these exact column names at startup (will error if missing). See the full rename map in `merge.py`. ### Backend (`server-rs/`) @@ -127,7 +127,7 @@ React 18 + TypeScript. deck.gl `H3HexagonLayer` over MapLibre GL. TailwindCSS. N - `useUrlSync` — URL state synchronization **Key patterns:** -- URL encodes view/filters/POI categories/active tab as query params for shareable links +- URL encodes view/filters/POI categories/active tab as query params for shareable links. Only the current format is supported — no legacy parameter parsing (old `v=`, `f=`, or tab abbreviations are not handled). - 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 @@ -153,7 +153,7 @@ React 18 + TypeScript. deck.gl `H3HexagonLayer` over MapLibre GL. TailwindCSS. N - `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. +- `property-fields.ts` — `getNum(property, key)` for getting a single numeric property value. Takes exactly one key — no fallback names. When adding new UI, prefer using these shared components over inline implementations to maintain consistency. @@ -271,6 +271,7 @@ Every UI element must use the correct token from this table. Do not invent new p ## Coding Preferences +- **No backwards compatibility, no silent fallbacks**: Never add fallback codepaths for old data formats, legacy URL parameters, or alternate field names. Never silently swallow errors — always error loudly (return an error, panic, or at minimum log). If something is wrong, the code should fail visibly. One canonical name per field, one format per API, one way to do things. - **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 @@ -313,6 +314,8 @@ Follow these conventions in all Rust code: - **Startup precomputation**: Static responses (like `/api/features`) are computed once at startup and cached in `AppState` - **POI transform validation**: Fails if any OSM category is unmapped — guarantees exhaustive coverage - **Fuzzy join**: Groups by postcode, uses `thefuzz.token_sort_ratio` with numeric token compatibility, greedy assignment from highest score +- **Filter parsing is strict**: `parse_filters()` returns `Result` — malformed entries, unknown feature names, and unparseable numbers all return 400 Bad Request. No silent skipping of invalid filters. +- **Data loading is strict**: `extract_string_col` and `lookup_enum_value` take a single column name (no fallback names). H3 precomputation panics on invalid coordinates. Required parquet columns must exist at startup. - **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. diff --git a/Dockerfile b/Dockerfile index 71839f5..7197f0b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -18,7 +18,7 @@ FROM debian:bookworm-slim RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates && rm -rf /var/lib/apt/lists/* WORKDIR /app COPY --from=server /app/server-rs/target/release/property-map-server ./ -COPY --from=frontend /app/frontend/dist ./dist/ +COPY --from=frontend /app/frontend/dist ./frontend/dist/ COPY property-data/wide.parquet ./data/ COPY property-data/filtered_uk_pois.parquet ./data/ diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 4564ee9..b7c3ade 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -3,6 +3,7 @@ import MapPage, { type ExportState } from './components/map/MapPage'; import PricingPage from './components/pricing/PricingPage'; import HomePage from './components/home/HomePage'; import SavedSearchesPage from './components/saved-searches/SavedSearchesPage'; +import LearnPage from './components/learn/LearnPage'; import Header, { type Page } from './components/ui/Header'; import AuthModal from './components/ui/AuthModal'; import SaveSearchModal from './components/ui/SaveSearchModal'; @@ -27,6 +28,8 @@ function pageToPath(page: Page): string { return '/dashboard'; case 'saved-searches': return '/saved'; + case 'learn': + return '/learn'; case 'pricing': return '/pricing'; default: @@ -37,6 +40,7 @@ case 'saved-searches': function pathToPage(pathname: string): Page | null { if (pathname === '/dashboard') return 'dashboard'; if (pathname === '/saved') return 'saved-searches'; + if (pathname === '/learn') return 'learn'; if (pathname === '/pricing') return 'pricing'; if (pathname === '/') return 'home'; return null; @@ -75,14 +79,6 @@ export default function App() { // Restore from history state (e.g. popstate) if (window.history.state?.page) return window.history.state.page; - // Backward compat: dashboard params on unknown path - const params = new URLSearchParams(window.location.search); - if (params.has('lat') || params.has('filter') || params.has('poi') || params.has('tab') || params.has('v') || params.has('f') || params.has('dest')) { - // Rewrite URL to /dashboard keeping query params - window.history.replaceState({ page: 'dashboard' }, '', `/dashboard${window.location.search}`); - return 'dashboard'; - } - return 'home'; }); @@ -235,6 +231,8 @@ export default function App() { navigateTo('dashboard')} onOpenPricing={() => navigateTo('pricing')} theme={theme} features={features} /> ) : activePage === 'pricing' ? ( navigateTo('dashboard')} /> + ) : activePage === 'learn' ? ( + ) : activePage === 'saved-searches' ? ( ([]); + const [loading, setLoading] = useState(true); + const [fetching, setFetching] = useState(false); const [sliderValues, setSliderValues] = useState>({}); const [activeFeature, setActiveFeature] = useState(null); const [dragValue, setDragValue] = useState<[number, number] | null>(null); @@ -83,10 +86,18 @@ export default function HomeDemo({ features, theme }: HomeDemoProps) { } abortRef.current?.abort(); abortRef.current = new AbortController(); + setFetching(true); fetch(apiUrl('hexagons', params), authHeaders({ signal: abortRef.current.signal })) .then((res) => res.json()) - .then((data: { features: HexagonData[] }) => setHexData(data.features)) - .catch(() => {}); + .then((data: { features: HexagonData[] }) => { + setHexData(data.features); + setLoading(false); + setFetching(false); + }) + .catch((err) => { + logNonAbortError('Failed to fetch demo hexagons', err); + setFetching(false); + }); }, [features, sliderValues]); useEffect(() => { @@ -133,7 +144,7 @@ export default function HomeDemo({ features, theme }: HomeDemoProps) { fetch(apiUrl('hexagons', params), authHeaders({ signal: dragAbortRef.current.signal })) .then((res) => res.json()) .then((data: { features: HexagonData[] }) => setDragHexData(data.features)) - .catch(() => {}); + .catch((err) => logNonAbortError('Failed to fetch demo drag data', err)); }, [features, sliderValues] ); @@ -182,6 +193,21 @@ export default function HomeDemo({ features, theme }: HomeDemoProps) { hideLegend={true} /> + {loading && ( +
+
+ +

+ Connecting to server... +

+
+
+ )} + {!loading && fetching && ( +
+ Loading... +
+ )} {/* Colour spectrum legend */}
diff --git a/frontend/src/components/map/Filters.tsx b/frontend/src/components/map/Filters.tsx index ec7280e..3e7a1ef 100644 --- a/frontend/src/components/map/Filters.tsx +++ b/frontend/src/components/map/Filters.tsx @@ -6,7 +6,8 @@ import { CollapsibleGroupHeader } from '../ui/CollapsibleGroupHeader'; import { PillToggle } from '../ui/PillToggle'; import { PillGroup } from '../ui/PillGroup'; import type { FeatureMeta, FeatureFilters } from '../../types'; -import { formatFilterValue } from '../../lib/format'; +import { formatFilterValue, buildPercentileScale } from '../../lib/format'; +import type { PercentileScale } from '../../lib/format'; import { groupFeaturesByCategory } from '../../lib/features'; import { useCollapsibleGroups } from '../../hooks/useCollapsibleGroups'; import InfoPopup from '../ui/InfoPopup'; @@ -21,27 +22,30 @@ function SliderLabels({ min, max, value, + displayValues, }: { min: number; max: number; value: [number, number]; + displayValues?: [number, number]; }) { const range = max - min || 1; const leftPct = ((value[0] - min) / range) * 100; const rightPct = ((value[1] - min) / range) * 100; + const labels = displayValues || value; return (
- {formatFilterValue(value[0])} + {formatFilterValue(labels[0])} - {formatFilterValue(value[1])} + {formatFilterValue(labels[1])}
); @@ -119,6 +123,16 @@ export default memo(function Filters({ [enabledFeatureList] ); + const percentileScales = useMemo(() => { + const scales = new Map(); + for (const f of features) { + if (f.type === 'numeric' && f.histogram) { + scales.set(f.name, buildPercentileScale(f.histogram)); + } + } + return scales; + }, [features]); + return (
@@ -230,7 +244,10 @@ export default memo(function Filters({ isActive && dragValue ? dragValue : (filters[feature.name] as [number, number]) || [feature.min!, feature.max!]; - const step = feature.step ?? (feature.max! - feature.min!) / 100; + const scale = percentileScales.get(feature.name); + const sliderValue: [number, number] = scale + ? [Math.round(scale.toPercentile(displayValue[0])), Math.round(scale.toPercentile(displayValue[1]))] + : displayValue; return (
onDragChange([min, max])} + min={scale ? 0 : feature.min!} + max={scale ? 100 : feature.max!} + step={scale ? 1 : (feature.step ?? (feature.max! - feature.min!) / 100)} + value={sliderValue} + onValueChange={ + scale + ? ([pMin, pMax]) => onDragChange([scale.toValue(pMin), scale.toValue(pMax)]) + : ([min, max]) => onDragChange([min, max]) + } onPointerDown={() => onDragStart(feature.name)} onPointerUp={() => onDragEnd()} /> - +
); diff --git a/frontend/src/components/map/PropertiesPane.tsx b/frontend/src/components/map/PropertiesPane.tsx index 7368672..6b898aa 100644 --- a/frontend/src/components/map/PropertiesPane.tsx +++ b/frontend/src/components/map/PropertiesPane.tsx @@ -142,18 +142,14 @@ function PropertyLoadingSkeleton() { } function PropertyCard({ property }: { property: Property }) { - const price = getNum(property, 'Last known price', 'latest_price'); + const price = getNum(property, 'Last known price'); const estimatedPrice = getNum(property, 'Estimated current price'); - const pricePerSqm = getNum(property, 'Price per sqm', 'price_per_sqm'); + const pricePerSqm = getNum(property, 'Price per sqm'); const estPricePerSqm = getNum(property, 'Est. 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 transactionDate = getNum(property, 'Date of last transaction', 'date_of_transfer'); + const floorArea = getNum(property, 'Total floor area (sqm)'); + const rooms = getNum(property, 'Rooms (including bedrooms & bathrooms)'); + const age = getNum(property, 'Approximate construction age'); + const transactionDate = getNum(property, 'Date of last transaction'); const councilTax = getNum(property, 'Council tax (£/yr)'); const councilTaxD = getNum(property, 'Council tax Band D (£/yr)'); diff --git a/frontend/src/components/ui/Header.tsx b/frontend/src/components/ui/Header.tsx index 3cba4c5..8becc3b 100644 --- a/frontend/src/components/ui/Header.tsx +++ b/frontend/src/components/ui/Header.tsx @@ -13,7 +13,7 @@ import { SpinnerIcon } from './icons/SpinnerIcon'; import UserMenu from './UserMenu'; import MobileMenu from './MobileMenu'; -export type Page = 'home' | 'dashboard' | 'saved-searches' | 'pricing'; +export type Page = 'home' | 'dashboard' | 'saved-searches' | 'learn' | 'pricing'; export default function Header({ activePage, @@ -133,6 +133,9 @@ export default function Header({ Saved )} + diff --git a/frontend/src/components/ui/MobileMenu.tsx b/frontend/src/components/ui/MobileMenu.tsx index 9b51001..5f9a30a 100644 --- a/frontend/src/components/ui/MobileMenu.tsx +++ b/frontend/src/components/ui/MobileMenu.tsx @@ -81,6 +81,7 @@ export default function MobileMenu({ {mobileNavItem('home', 'Home')} {mobileNavItem('dashboard', 'Dashboard')} {user && mobileNavItem('saved-searches', 'Saved')} + {mobileNavItem('learn', 'Learn')} {mobileNavItem('pricing', 'Pricing')} {/* Dashboard actions */} diff --git a/frontend/src/hooks/useFilters.ts b/frontend/src/hooks/useFilters.ts index a9c7b6f..7aa8b18 100644 --- a/frontend/src/hooks/useFilters.ts +++ b/frontend/src/hooks/useFilters.ts @@ -95,7 +95,7 @@ export function useFilters({ initialFilters, features }: UseFiltersOptions) { signal: dragAbortRef.current.signal, }) .then((res) => res.json()) - .then((json: ApiResponse) => setDragData(json.features || [])) + .then((json: ApiResponse) => setDragData(json.features)) .catch((err) => logNonAbortError('Failed to fetch drag data', err)); }, [filters, features] diff --git a/frontend/src/hooks/useMapData.ts b/frontend/src/hooks/useMapData.ts index dfba0d0..14b983c 100644 --- a/frontend/src/hooks/useMapData.ts +++ b/frontend/src/hooks/useMapData.ts @@ -9,7 +9,7 @@ import type { ApiResponse, } from '../types'; import { buildFilterString, apiUrl, logNonAbortError, authHeaders } from '../lib/api'; -import { POSTCODE_ZOOM_THRESHOLD } from '../lib/map-utils'; +import { POSTCODE_ZOOM_THRESHOLD } from '../lib/consts'; import { COLOR_RANGE_LOW_PERCENTILE, COLOR_RANGE_HIGH_PERCENTILE } from '../lib/consts'; /** Return the p-th percentile (0–100) from a sorted array via linear interpolation. */ @@ -101,7 +101,7 @@ export function useMapData({ }) ); const json: { features: PostcodeFeature[] } = await res.json(); - setPostcodeData(json.features || []); + setPostcodeData(json.features); setRawData([]); } else { const params = new URLSearchParams({ @@ -121,7 +121,7 @@ export function useMapData({ }) ); const json: ApiResponse = await res.json(); - setRawData(json.features || []); + setRawData(json.features); setPostcodeData([]); } } catch (err) { diff --git a/frontend/src/hooks/useSavedSearches.ts b/frontend/src/hooks/useSavedSearches.ts index af77335..8c9d567 100644 --- a/frontend/src/hooks/useSavedSearches.ts +++ b/frontend/src/hooks/useSavedSearches.ts @@ -51,25 +51,19 @@ export function useSavedSearches(userId: string | null) { try { const params = window.location.search.replace(/^\?/, ''); - // Try to capture a screenshot via the screenshot endpoint - let screenshotBlob: Blob | null = null; - try { - const screenshotUrl = apiUrl('screenshot', new URLSearchParams(params)); - const res = await fetch(screenshotUrl); - if (res.ok) { - screenshotBlob = await res.blob(); - } - } catch { - // Screenshot is optional — save without it + // Capture a screenshot via the screenshot endpoint + const screenshotUrl = apiUrl('screenshot', new URLSearchParams(params)); + const screenshotRes = await fetch(screenshotUrl); + if (!screenshotRes.ok) { + throw new Error(`Screenshot failed: ${screenshotRes.status} ${screenshotRes.statusText}`); } + const screenshotBlob = await screenshotRes.blob(); const formData = new FormData(); formData.append('user', userId); formData.append('name', name); formData.append('params', params); - if (screenshotBlob) { - formData.append('screenshot', screenshotBlob, 'screenshot.png'); - } + formData.append('screenshot', screenshotBlob, 'screenshot.png'); await pb.collection('saved_searches').create(formData); await fetchSearches(); diff --git a/frontend/src/lib/format.ts b/frontend/src/lib/format.ts index c92fa14..69f32fb 100644 --- a/frontend/src/lib/format.ts +++ b/frontend/src/lib/format.ts @@ -65,6 +65,81 @@ export function formatRelativeTime(isoDate: string): string { return new Date(isoDate).toLocaleDateString(); } +// Percentile-based scale: maps between percentile space (0–100) and absolute values +// using the histogram's CDF. Each percentile step = 1% of data. +export interface PercentileScale { + toValue: (percentile: number) => number; + toPercentile: (value: number) => number; +} + +export function buildPercentileScale(hist: { + min: number; + max: number; + p1: number; + p99: number; + counts: number[]; +}): PercentileScale { + const n = hist.counts.length; + const total = hist.counts.reduce((a, b) => a + b, 0); + + if (n === 0 || total === 0) { + const range = hist.max - hist.min || 1; + return { + toValue: (p) => hist.min + (p / 100) * range, + toPercentile: (v) => ((v - hist.min) / range) * 100, + }; + } + + // Bin boundaries: [min, p1, ..middle edges.., p99, max] + const boundaries: number[] = []; + if (n === 1) { + boundaries.push(hist.min, hist.max); + } else { + boundaries.push(hist.min, hist.p1); + if (n > 2) { + const middleWidth = (hist.p99 - hist.p1) / (n - 2); + for (let i = 1; i < n - 1; i++) { + boundaries.push(hist.p1 + i * middleWidth); + } + } + boundaries.push(hist.max); + } + + // Cumulative fraction: cumFrac[0]=0, cumFrac[n]=1 + const cumFrac: number[] = [0]; + for (let i = 0; i < n; i++) { + cumFrac.push(cumFrac[i] + hist.counts[i] / total); + } + cumFrac[n] = 1; // ensure exact 1.0 + + return { + toValue(percentile: number): number { + const target = Math.max(0, Math.min(1, percentile / 100)); + if (target <= 0) return boundaries[0]; + if (target >= 1) return boundaries[n]; + let i = 0; + for (; i < n - 1; i++) { + if (cumFrac[i + 1] > target) break; + } + const binFrac = cumFrac[i + 1] - cumFrac[i]; + const t = binFrac > 0 ? (target - cumFrac[i]) / binFrac : 0; + return boundaries[i] + t * (boundaries[i + 1] - boundaries[i]); + }, + + toPercentile(value: number): number { + if (value <= boundaries[0]) return 0; + if (value >= boundaries[n]) return 100; + let i = 0; + for (; i < n - 1; i++) { + if (boundaries[i + 1] > value) break; + } + const binWidth = boundaries[i + 1] - boundaries[i]; + const t = binWidth > 0 ? (value - boundaries[i]) / binWidth : 0; + return (cumFrac[i] + t * (cumFrac[i + 1] - cumFrac[i])) * 100; + }, + }; +} + // Calculate weighted mean from histogram with outlier bins. // Bin 0 = [min, p1), bins 1..n-2 = [p1, p99) evenly, bin n-1 = [p99, max]. export function calculateHistogramMean(histogram: { diff --git a/frontend/src/lib/property-fields.ts b/frontend/src/lib/property-fields.ts index fe866f2..3fb7391 100644 --- a/frontend/src/lib/property-fields.ts +++ b/frontend/src/lib/property-fields.ts @@ -1,10 +1,7 @@ import type { Property } from '../types'; -// Generic getter for any field names (for dynamic lookups) -export 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; - } +export function getNum(property: Property, key: string): number | undefined { + const v = property[key]; + if (v !== undefined && v !== null && typeof v === 'number') return v; return undefined; } diff --git a/frontend/src/lib/url-state.ts b/frontend/src/lib/url-state.ts index 218e802..7908d78 100644 --- a/frontend/src/lib/url-state.ts +++ b/frontend/src/lib/url-state.ts @@ -27,30 +27,6 @@ function parseFilters(params: URLSearchParams): FeatureFilters | undefined { return Object.keys(filters).length > 0 ? filters : undefined; } -/** Backward compat: parse old comma-packed `f` param */ -function parseLegacyFilters(f: string): FeatureFilters | undefined { - const filters: FeatureFilters = {}; - for (const segment of f.split(',')) { - const colonIdx = segment.indexOf(':'); - if (colonIdx === -1) continue; - const name = segment.substring(0, colonIdx); - const rest = segment.substring(colonIdx + 1); - if (rest.includes(':')) { - const [minStr, maxStr] = rest.split(':'); - const min = Number(minStr); - const max = Number(maxStr); - if (!isNaN(min) && !isNaN(max)) { - filters[name] = [min, max]; - } - } else if (rest.includes('|')) { - filters[name] = rest.split('|'); - } else { - filters[name] = [rest]; - } - } - return Object.keys(filters).length > 0 ? filters : undefined; -} - export function parseUrlState(): { viewState?: ViewState; filters?: FeatureFilters; @@ -72,45 +48,21 @@ export function parseUrlState(): { if (!isNaN(latN) && !isNaN(lonN) && !isNaN(zoomN)) { result.viewState = { latitude: latN, longitude: lonN, zoom: zoomN, pitch: 0 }; } - } else { - // Backward compat: old packed `v=lat,lon,zoom` - const v = params.get('v'); - if (v) { - const parts = v.split(',').map(Number); - if (parts.length === 3 && parts.every((n) => !isNaN(n))) { - result.viewState = { latitude: parts[0], longitude: parts[1], zoom: parts[2], pitch: 0 }; - } - } } // Filters: repeated `filter` params result.filters = parseFilters(params); - if (!result.filters) { - // Backward compat: old packed `f` param - const f = params.get('f'); - if (f) result.filters = parseLegacyFilters(f); - } // POI categories: repeated `poi` params const poiParams = params.getAll('poi'); if (poiParams.length > 0) { - // Handle both new (repeated params) and old (comma-separated) formats - const categories = poiParams.flatMap((p) => p.split(',')).filter(Boolean); - if (categories.length > 0) { - result.poiCategories = new Set(categories); - } + result.poiCategories = new Set(poiParams.filter(Boolean)); } // Tab: full name const tab = params.get('tab'); if (tab === 'properties' || tab === 'pois' || tab === 'area') { result.tab = tab; - } else if (tab === 'p') { - result.tab = 'properties'; // backward compat - } else if (tab === 'o') { - result.tab = 'pois'; - } else if (tab === 'a') { - result.tab = 'area'; } // Travel time @@ -121,7 +73,7 @@ export function parseUrlState(): { const tt: TravelTimeInitial = { destination: [parts[0], parts[1]], destinationLabel: params.get('destLabel') || '', - mode: (params.get('tmode') as TransportMode) || 'transit', + mode: (params.get('tmode') as TransportMode) || 'car', }; const ttRange = params.get('tt'); if (ttRange) { @@ -178,7 +130,7 @@ export function stateToParams( if (travelTime.destinationLabel) { params.set('destLabel', travelTime.destinationLabel); } - if (travelTime.mode !== 'transit') { + if (travelTime.mode !== 'car') { params.set('tmode', travelTime.mode); } if (travelTime.timeRange) { @@ -193,7 +145,6 @@ export function summarizeParams(queryString: string): string { const params = new URLSearchParams(queryString); const parts: string[] = []; - // New format: repeated `filter` params const filterParams = params.getAll('filter'); if (filterParams.length > 0) { const filterNames = filterParams @@ -207,28 +158,11 @@ export function summarizeParams(queryString: string): string { filterNames.length <= 2 ? filterNames.join(', ') : `${filterNames.length} filters` ); } - } else { - // Backward compat: old packed `f` param - const f = params.get('f'); - if (f) { - const filterNames = f - .split(',') - .map((seg) => { - const colonIdx = seg.indexOf(':'); - return colonIdx > 0 ? seg.substring(0, colonIdx) : seg; - }) - .filter(Boolean); - if (filterNames.length > 0) { - parts.push( - filterNames.length <= 2 ? filterNames.join(', ') : `${filterNames.length} filters` - ); - } - } } const poiParams = params.getAll('poi'); if (poiParams.length > 0) { - const count = poiParams.flatMap((p) => p.split(',')).filter(Boolean).length; + const count = poiParams.filter(Boolean).length; if (count > 0) { parts.push(`${count} POI ${count === 1 ? 'category' : 'categories'}`); } diff --git a/server-rs/src/main.rs b/server-rs/src/main.rs index 3e8a13e..1d6e563 100644 --- a/server-rs/src/main.rs +++ b/server-rs/src/main.rs @@ -191,20 +191,11 @@ async fn main() -> anyhow::Result<()> { let poi_category_groups = poi_data.category_groups()?; // Read index.html at startup for crawler OG injection - let frontend_dist = cli.dist.unwrap_or_else(|| { - if let Ok(executable) = std::env::current_exe() { - let executable_dir = executable - .parent() - .unwrap_or_else(|| std::path::Path::new(".")); - let dist_next_to_binary = executable_dir.join("dist"); - if dist_next_to_binary.exists() { - return dist_next_to_binary; - } - } - PathBuf::from("frontend/dist") - }); + let frontend_dist = cli + .dist + .unwrap_or_else(|| PathBuf::from("frontend/dist")); - let index_html = if frontend_dist.exists() { + let index_html = { let index_path = frontend_dist.join("index.html"); match std::fs::read_to_string(&index_path) { Ok(html) => { @@ -212,12 +203,14 @@ async fn main() -> anyhow::Result<()> { Some(html) } Err(err) => { - warn!("Could not read index.html: {}", err); + warn!( + "Could not read {}: {} (OG injection disabled)", + index_path.display(), + err + ); None } } - } else { - None }; let http_client = reqwest::Client::new(); diff --git a/server-rs/src/parsing/filters.rs b/server-rs/src/parsing/filters.rs index 87a0322..3d4e23b 100644 --- a/server-rs/src/parsing/filters.rs +++ b/server-rs/src/parsing/filters.rs @@ -1,6 +1,7 @@ use rustc_hash::{FxHashMap, FxHashSet}; /// Filter for numeric features: value must be in [min, max] range. +#[derive(Debug)] pub struct ParsedFilter { pub feat_idx: usize, pub min: f32, @@ -9,6 +10,7 @@ pub struct ParsedFilter { /// Filter for enum features: value must be one of the allowed indices. /// Uses FxHashSet (f32 bits) for O(1) lookups instead of O(n) Vec::contains. +#[derive(Debug)] pub struct ParsedEnumFilter { pub feat_idx: usize, /// Allowed enum indices stored as f32 bits for exact comparison @@ -18,31 +20,33 @@ pub struct ParsedEnumFilter { /// Parse comma-separated filter string into numeric and enum filters. /// Numeric format: `name:min:max` /// Enum format: `name:val1|val2|val3` (pipe-separated string values) +/// +/// Returns an error if any filter entry is malformed or references an unknown feature. pub fn parse_filters( filter_str: Option<&str>, feature_name_to_index: &FxHashMap, enum_values: &FxHashMap>, -) -> (Vec, Vec) { +) -> Result<(Vec, Vec), String> { let mut numeric = Vec::new(); let mut enums = Vec::new(); let input = match filter_str.filter(|text| !text.is_empty()) { Some(text) => text, - None => return (numeric, enums), + None => return Ok((numeric, enums)), }; for entry in input.split(',') { let parts: Vec<&str> = entry.splitn(2, ':').collect(); if parts.len() != 2 { - continue; + return Err(format!("Malformed filter entry (missing ':'): '{entry}'")); } let name = parts[0].trim(); let rest = parts[1].trim(); // Find feature index by name (O(1) lookup) - let Some(&feat_idx) = feature_name_to_index.get(name) else { - continue; - }; + let &feat_idx = feature_name_to_index + .get(name) + .ok_or_else(|| format!("Unknown feature in filter: '{name}'"))?; // Check if this is an enum feature if let Some(values) = enum_values.get(&feat_idx) { @@ -62,21 +66,23 @@ pub fn parse_filters( // Numeric filter: parse min:max let num_parts: Vec<&str> = rest.splitn(2, ':').collect(); if num_parts.len() != 2 { - continue; + return Err(format!( + "Numeric filter '{name}' must have format 'name:min:max', got '{entry}'" + )); } - let min = match num_parts[0].trim().parse::() { - Ok(value) => value, - Err(_) => continue, - }; - let max = match num_parts[1].trim().parse::() { - Ok(value) => value, - Err(_) => continue, - }; + let min = num_parts[0] + .trim() + .parse::() + .map_err(|err| format!("Invalid min value in filter '{name}': {err}"))?; + let max = num_parts[1] + .trim() + .parse::() + .map_err(|err| format!("Invalid max value in filter '{name}': {err}"))?; numeric.push(ParsedFilter { feat_idx, min, max }); } } - (numeric, enums) + Ok((numeric, enums)) } /// Check if a row passes all filters. @@ -155,7 +161,8 @@ mod tests { Some("price:100:500"), &feature_name_to_index(), &enum_values(), - ); + ) + .unwrap(); assert_eq!(numeric.len(), 1); assert_eq!(numeric[0].feat_idx, 0); assert_eq!(numeric[0].min, 100.0); @@ -166,7 +173,7 @@ mod tests { #[test] fn parse_filters_enum() { let (numeric, enums) = - parse_filters(Some("rating:A|C"), &feature_name_to_index(), &enum_values()); + parse_filters(Some("rating:A|C"), &feature_name_to_index(), &enum_values()).unwrap(); assert!(numeric.is_empty()); assert_eq!(enums.len(), 1); assert_eq!(enums[0].feat_idx, 2); @@ -176,19 +183,23 @@ mod tests { } #[test] - fn parse_filters_empty_and_invalid() { - let (n, e) = parse_filters(None, &feature_name_to_index(), &enum_values()); + fn parse_filters_empty() { + let (n, e) = parse_filters(None, &feature_name_to_index(), &enum_values()).unwrap(); assert!(n.is_empty() && e.is_empty()); - let (n, e) = parse_filters(Some(""), &feature_name_to_index(), &enum_values()); + let (n, e) = parse_filters(Some(""), &feature_name_to_index(), &enum_values()).unwrap(); assert!(n.is_empty() && e.is_empty()); + } - let (n, e) = parse_filters( + #[test] + fn parse_filters_unknown_feature_errors() { + let result = parse_filters( Some("unknown:1:2"), &feature_name_to_index(), &enum_values(), ); - assert!(n.is_empty() && e.is_empty()); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("Unknown feature")); } #[test] @@ -226,7 +237,8 @@ mod tests { Some("Price:100000:500000,Area:50:200"), &extended_feature_map(), &extended_enum_values(), - ); + ) + .unwrap(); assert_eq!(numeric.len(), 2); assert_eq!(numeric[0].feat_idx, 0); @@ -239,22 +251,23 @@ mod tests { Some("Price:100000:500000,Type:Semi|Terraced"), &extended_feature_map(), &extended_enum_values(), - ); + ) + .unwrap(); assert_eq!(numeric.len(), 1); assert_eq!(enums.len(), 1); } #[test] - fn parse_invalid_numeric_format_ignored() { - let (numeric, enums) = parse_filters( + fn parse_invalid_numeric_format_errors() { + let result = parse_filters( Some("Price:not_a_number:500000"), &extended_feature_map(), &extended_enum_values(), ); - assert!(numeric.is_empty()); - assert!(enums.is_empty()); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("Invalid min value")); } #[test] @@ -263,7 +276,8 @@ mod tests { Some("Type:Detached|Unknown|Flat"), &extended_feature_map(), &extended_enum_values(), - ); + ) + .unwrap(); assert_eq!(enums.len(), 1); assert!(enums[0].allowed.contains(&(0.0_f32).to_bits())); // Detached @@ -277,7 +291,8 @@ mod tests { Some("Price : 100000 : 500000 , Type : Detached | Flat"), &extended_feature_map(), &extended_enum_values(), - ); + ) + .unwrap(); assert_eq!(numeric.len(), 1); assert_eq!(enums.len(), 1); diff --git a/server-rs/src/routes/hexagon_stats.rs b/server-rs/src/routes/hexagon_stats.rs index 488cd3d..8f21899 100644 --- a/server-rs/src/routes/hexagon_stats.rs +++ b/server-rs/src/routes/hexagon_stats.rs @@ -90,7 +90,8 @@ pub async fn get_hexagon_stats( params.filters.as_deref(), &state.feature_name_to_index, &state.data.enum_values, - ); + ) + .map_err(|err| (StatusCode::BAD_REQUEST, err))?; let num_filters = parsed_filters.len() + parsed_enum_filters.len(); let (fields_specified, field_set) = parse_field_set(params.fields.as_deref()); diff --git a/server-rs/src/routes/postcode_stats.rs b/server-rs/src/routes/postcode_stats.rs index e439c3d..670b46c 100644 --- a/server-rs/src/routes/postcode_stats.rs +++ b/server-rs/src/routes/postcode_stats.rs @@ -52,7 +52,8 @@ pub async fn get_postcode_stats( params.filters.as_deref(), &state.feature_name_to_index, &state.data.enum_values, - ); + ) + .map_err(|err| (StatusCode::BAD_REQUEST, err))?; let num_filters = parsed_filters.len() + parsed_enum_filters.len(); let (fields_specified, field_set) = parse_field_set(params.fields.as_deref()); diff --git a/server-rs/src/routes/properties.rs b/server-rs/src/routes/properties.rs index cc97a4b..52a7a89 100644 --- a/server-rs/src/routes/properties.rs +++ b/server-rs/src/routes/properties.rs @@ -63,7 +63,7 @@ fn non_empty_string(text: &str) -> Option { } } -/// Look up an enum feature value by trying multiple possible column names. +/// Look up an enum feature value by column name. /// Uses the unified feature model: enum values stored as f32 indices in feature_data. fn lookup_enum_value( feature_name_to_index: &FxHashMap, @@ -71,22 +71,17 @@ fn lookup_enum_value( num_features: usize, enum_values: &FxHashMap>, row: usize, - names: &[&str], + name: &str, ) -> Option { - for name in names { - if let Some(&feat_idx) = feature_name_to_index.get(*name) { - if let Some(values) = enum_values.get(&feat_idx) { - let value = feature_data[row * num_features + feat_idx]; - if value.is_finite() { - let idx = value as usize; - if let Some(str_value) = values.get(idx) { - return Some(str_value.clone()); - } - } - } - } + let &feat_idx = feature_name_to_index.get(name)?; + let values = enum_values.get(&feat_idx)?; + let value = feature_data[row * num_features + feat_idx]; + if value.is_finite() { + let idx = value as usize; + values.get(idx).cloned() + } else { + None } - None } pub async fn get_hexagon_properties( @@ -111,7 +106,8 @@ pub async fn get_hexagon_properties( params.filters.as_deref(), &state.feature_name_to_index, &state.data.enum_values, - ); + ) + .map_err(|err| (StatusCode::BAD_REQUEST, err))?; let num_filters = parsed_filters.len() + parsed_enum_filters.len(); let result = tokio::task::spawn_blocking(move || { @@ -182,7 +178,7 @@ pub async fn get_hexagon_properties( num_features, enum_values, row, - &["Property type", "epc_property_type", "pp_property_type"], + "Property type", ), built_form: lookup_enum_value( feature_name_to_index, @@ -190,7 +186,7 @@ pub async fn get_hexagon_properties( num_features, enum_values, row, - &["Property type/built form", "built_form"], + "Property type/built form", ), duration: lookup_enum_value( feature_name_to_index, @@ -198,7 +194,7 @@ pub async fn get_hexagon_properties( num_features, enum_values, row, - &["Leashold/Freehold", "duration"], + "Leashold/Freehold", ), current_energy_rating: lookup_enum_value( feature_name_to_index, @@ -206,7 +202,7 @@ pub async fn get_hexagon_properties( num_features, enum_values, row, - &["Current energy rating", "current_energy_rating"], + "Current energy rating", ), potential_energy_rating: lookup_enum_value( feature_name_to_index, @@ -214,7 +210,7 @@ pub async fn get_hexagon_properties( num_features, enum_values, row, - &["Potential energy rating", "potential_energy_rating"], + "Potential energy rating", ), lat: state.data.lat[row], lon: state.data.lon[row],