diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 7dcf4b0..1274f4e 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -12,13 +12,7 @@ import { } from './lib/seoRoutes'; import Header, { type Page } from './components/ui/Header'; import type { FeatureMeta, FeatureGroup, POICategoriesResponse, POICategoryGroup } from './types'; -import { - fetchWithRetry, - apiUrl, - logNonAbortError, - readDemoChoice, - setDemoCenter, -} from './lib/api'; +import { fetchWithRetry, apiUrl, logNonAbortError, readDemoChoice, setDemoCenter } from './lib/api'; import { trackEvent } from './lib/analytics'; import { parseUrlState } from './lib/url-state'; import pb from './lib/pocketbase'; @@ -226,8 +220,7 @@ export default function App() { const licensedAtMount = useMemo( () => pb.authStore.isValid && - (pb.authStore.record?.subscription === 'licensed' || - pb.authStore.record?.is_admin === true), + (pb.authStore.record?.subscription === 'licensed' || pb.authStore.record?.is_admin === true), [] ); const initialDemoChoice = useMemo(() => { @@ -255,10 +248,7 @@ export default function App() { ); const activePageRef = useRef('home'); const initialViewState = useMemo( - () => - mapUrlState.hasExplicitView - ? mapUrlState.viewState - : (demoView ?? INITIAL_VIEW_STATE), + () => (mapUrlState.hasExplicitView ? mapUrlState.viewState : (demoView ?? INITIAL_VIEW_STATE)), [mapUrlState.hasExplicitView, mapUrlState.viewState, demoView] ); diff --git a/frontend/src/components/map/LocationSearch.test.tsx b/frontend/src/components/map/LocationSearch.test.tsx index 5a66b7b..7bea049 100644 --- a/frontend/src/components/map/LocationSearch.test.tsx +++ b/frontend/src/components/map/LocationSearch.test.tsx @@ -397,7 +397,7 @@ describe('LocationSearch', () => { }); }); - it('keeps only the three most recent local searches', async () => { + it('keeps only the ten most recent local searches', async () => { vi.stubGlobal( 'fetch', vi.fn((input: string | URL | Request) => { @@ -416,8 +416,22 @@ describe('LocationSearch', () => { render(); + // Search eleven distinct postcodes; only the ten most recent should be kept. + const postcodes = [ + 'SW1A 1AA', + 'E14 2DG', + 'W1A 1AA', + 'EC1A 1BB', + 'N1 1AA', + 'M1 1AE', + 'B33 8TH', + 'CR2 6XH', + 'DN55 1PT', + 'L1 8JQ', + 'G1 1AA', + ]; const input = screen.getByRole('textbox'); - for (const postcode of ['SW1A 1AA', 'E14 2DG', 'W1A 1AA', 'EC1A 1BB']) { + for (const postcode of postcodes) { fireEvent.change(input, { target: { value: postcode } }); fireEvent.keyDown(input, { key: 'Enter' }); @@ -432,13 +446,13 @@ describe('LocationSearch', () => { const stored = JSON.parse(window.localStorage.getItem(RECENT_SEARCHES_STORAGE_KEY) ?? '[]') as { label?: string; }[]; - expect(stored.map((search) => search.label)).toEqual(['EC1A 1BB', 'W1A 1AA', 'E14 2DG']); + // Most recent first, oldest ('SW1A 1AA') dropped past the limit of ten. + expect(stored.map((search) => search.label)).toEqual([...postcodes].reverse().slice(0, 10)); fireEvent.focus(input); await waitFor(() => { - expect(screen.getByRole('button', { name: 'EC1A 1BB' })).toBeTruthy(); - expect(screen.getByRole('button', { name: 'W1A 1AA' })).toBeTruthy(); + expect(screen.getByRole('button', { name: 'G1 1AA' })).toBeTruthy(); expect(screen.getByRole('button', { name: 'E14 2DG' })).toBeTruthy(); }); expect(screen.queryByText('SW1A 1AA')).toBeNull(); diff --git a/frontend/src/components/map/MapPage.tsx b/frontend/src/components/map/MapPage.tsx index beaecb3..a7a17bc 100644 --- a/frontend/src/components/map/MapPage.tsx +++ b/frontend/src/components/map/MapPage.tsx @@ -522,7 +522,12 @@ export default function MapPage({ setDemoPromptOpen(false); // handleFlyTo uses map.jumpTo (instant) — no flashing 403s through // intermediate viewports; the move triggers a refetch carrying demoLat/Lon. - mapFlyToRef.current?.(center.lat, center.lon, INITIAL_VIEW_STATE.zoom, getMobileMapFlyToOptions()); + mapFlyToRef.current?.( + center.lat, + center.lon, + INITIAL_VIEW_STATE.zoom, + getMobileMapFlyToOptions() + ); }; if (typeof navigator === 'undefined' || !navigator.geolocation) { setDemoLocationError(t('locationSearch.geolocationUnsupported')); diff --git a/frontend/src/components/map/PropertiesPane.test.tsx b/frontend/src/components/map/PropertiesPane.test.tsx index 93993bc..63272ae 100644 --- a/frontend/src/components/map/PropertiesPane.test.tsx +++ b/frontend/src/components/map/PropertiesPane.test.tsx @@ -32,8 +32,8 @@ describe('buildTimelineEvents', () => { { year: 2019, status: 'Rented (private)' }, { year: 2023, status: 'Owner-occupied' }, ], - }), - ), + }) + ) ).toEqual(['tenure:Owner-occupied:2023', 'tenure:Rented (private):2019']); }); @@ -47,8 +47,8 @@ describe('buildTimelineEvents', () => { { year: 2024, month: 1, price: 300_000 }, ], tenure_history: [{ year: 2020, status: 'Rented (private)' }], - }), - ), + }) + ) ).toEqual(['tenure:Rented (private):2020', 'sale:2015']); }); diff --git a/frontend/src/hooks/useLocationSearch.ts b/frontend/src/hooks/useLocationSearch.ts index 05def85..ddb4e26 100644 --- a/frontend/src/hooks/useLocationSearch.ts +++ b/frontend/src/hooks/useLocationSearch.ts @@ -3,7 +3,7 @@ import type { AddressResult, PlaceResult } from '../types'; import { authHeaders, logNonAbortError } from '../lib/api'; const RECENT_SEARCHES_STORAGE_KEY = 'perfect-postcode.locationSearch.recent'; -const RECENT_SEARCH_LIMIT = 3; +const RECENT_SEARCH_LIMIT = 10; /** Matches a full UK postcode with complete inward code (e.g. "E14 2DG", "SW1A1AA"). * Outcodes like "E14" or "SW1A" intentionally do NOT match — they go through /api/places instead. */ diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 9886757..8280b60 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -917,7 +917,7 @@ const en = { body: 'Want to see property data around you? Use your location, or start the demo at Canary Wharf.', useLocation: 'Use my location', locating: 'Locating…', - useDefault: 'Show Canary Wharf', + useDefault: 'Show London', }, // ── Mobile Drawer ────────────────────────────────── diff --git a/frontend/src/lib/map-utils.test.ts b/frontend/src/lib/map-utils.test.ts index 8f74019..6f67f50 100644 --- a/frontend/src/lib/map-utils.test.ts +++ b/frontend/src/lib/map-utils.test.ts @@ -29,8 +29,9 @@ describe('map utilities', () => { expect(zoomToResolution(6.9)).toBe(5); expect(zoomToResolution(7)).toBe(6); expect(zoomToResolution(10.6)).toBe(8); - expect(zoomToResolution(14)).toBe(8); - expect(SMALLEST_VISIBLE_HEXAGON_RESOLUTION).toBe(8); + expect(zoomToResolution(12)).toBe(9); + expect(zoomToResolution(14)).toBe(9); + expect(SMALLEST_VISIBLE_HEXAGON_RESOLUTION).toBe(9); }); it('computes exact viewport bounds by default', () => { diff --git a/frontend/src/lib/overlays.ts b/frontend/src/lib/overlays.ts index 72d4d44..fb192cc 100644 --- a/frontend/src/lib/overlays.ts +++ b/frontend/src/lib/overlays.ts @@ -12,6 +12,8 @@ export interface OverlayDefinition { label: string; description: string; detail: string; + /** Enabled on a fresh visit (no `overlay` URL params). */ + defaultEnabled?: boolean; } export const OVERLAYS: OverlayDefinition[] = [ @@ -28,6 +30,7 @@ export const OVERLAYS: OverlayDefinition[] = [ description: 'Approximate police.uk street-crime heatmap', detail: 'Client-side heatmap of street-level crimes published by police.uk over the most recent months. Police.uk coordinates are anonymised snap-to-grid points, not exact offence locations, so the heatmap should be read as an approximation of relative density rather than a precise map of incidents.', + defaultEnabled: true, }, { id: 'trees-outside-woodlands', @@ -35,6 +38,7 @@ export const OVERLAYS: OverlayDefinition[] = [ description: 'Tree canopy and woodland polygons', detail: 'Forest Research Trees Outside Woodland (TOW) v1 canopy polygons — lone trees and groups of trees — unioned with National Forest Inventory (NFI) woodland blocks (≥0.5 ha) that TOW deliberately excludes. Together they cover both street trees and actual woods. Polygon opacity scales with canopy area.', + defaultEnabled: true, }, { id: 'property-borders', @@ -45,6 +49,11 @@ export const OVERLAYS: OverlayDefinition[] = [ }, ]; +/** Overlays shown on a fresh visit, before any `overlay` URL params apply. */ +export const DEFAULT_OVERLAY_IDS: OverlayId[] = OVERLAYS.filter((o) => o.defaultEnabled).map( + (o) => o.id +); + const OVERLAY_ID_SET = new Set(OVERLAY_IDS); export function isOverlayId(value: string): value is OverlayId { diff --git a/frontend/src/lib/url-state.test.ts b/frontend/src/lib/url-state.test.ts index 5c48d55..4808ab0 100644 --- a/frontend/src/lib/url-state.test.ts +++ b/frontend/src/lib/url-state.test.ts @@ -196,6 +196,32 @@ describe('url-state', () => { expect(state.overlays).toEqual(new Set(['noise', 'crime-hotspots'])); }); + it('enables crime + trees overlays by default on a fresh visit', () => { + const state = parseUrlState(); + + expect(state.overlays).toEqual(new Set(['crime-hotspots', 'trees-outside-woodlands'])); + }); + + it('round-trips an explicit "all overlays off" via the __none sentinel', () => { + const params = stateToParams( + null, + {}, + [], + new Set(), + 'area', + undefined, + undefined, + new Set() + ); + + expect(params.getAll('overlay')).toEqual(['__none']); + + window.history.replaceState({}, '', `/?${params.toString()}`); + const state = parseUrlState(); + + expect(state.overlays).toEqual(new Set()); + }); + it('round-trips satellite basemap selection', () => { const params = stateToParams( null, diff --git a/frontend/src/lib/url-state.ts b/frontend/src/lib/url-state.ts index c8d18f4..20bfe22 100644 --- a/frontend/src/lib/url-state.ts +++ b/frontend/src/lib/url-state.ts @@ -48,7 +48,7 @@ import { type PoiFilterName, } from './poi-distance-filter'; import { dedupeTravelTimeEntries } from './travel-params'; -import { isOverlayId, type OverlayId } from './overlays'; +import { isOverlayId, DEFAULT_OVERLAY_IDS, type OverlayId } from './overlays'; import { CRIME_TYPES, isCrimeTypeValue } from './crime-types'; import { isBasemapId, type BasemapId } from './basemaps'; import { @@ -59,6 +59,7 @@ import { const POI_NONE_PARAM = '__none'; const CRIME_TYPES_NONE_PARAM = '__none'; +const OVERLAY_NONE_PARAM = '__none'; const CRIME_OVERLAY_ID: OverlayId = 'crime-hotspots'; export interface UrlState { @@ -227,7 +228,7 @@ export function parseUrlState(): UrlState { hasExplicitView: false, filters: parseFilters(params), poiCategories: new Set(), - overlays: new Set(), + overlays: new Set(DEFAULT_OVERLAY_IDS), basemap: 'standard', colorOpacity: DEFAULT_COLOR_OPACITY, tab: 'area', @@ -266,9 +267,14 @@ export function parseUrlState(): UrlState { } } + // Overlays default to DEFAULT_OVERLAY_IDS on a fresh visit; explicit `overlay` + // params override that, and the `__none` sentinel records "all off" so it + // survives a reload instead of silently restoring the defaults. const overlayParams = params.getAll('overlay'); if (overlayParams.length > 0) { - result.overlays = new Set(overlayParams.filter(isOverlayId)); + result.overlays = overlayParams.includes(OVERLAY_NONE_PARAM) + ? new Set() + : new Set(overlayParams.filter(isOverlayId)); } // Crime-type filter: repeated `crimeType` params, or the `__none` sentinel. @@ -451,8 +457,14 @@ export function stateToParams( } if (selectedOverlays) { - for (const overlay of selectedOverlays) { - params.append('overlay', overlay); + if (selectedOverlays.size === 0) { + // Distinguish a deliberate "all overlays off" from a fresh visit, which + // would otherwise re-apply the defaults on reload. + params.append('overlay', OVERLAY_NONE_PARAM); + } else { + for (const overlay of selectedOverlays) { + params.append('overlay', overlay); + } } } diff --git a/server-rs/src/data/property/address_search.rs b/server-rs/src/data/property/address_search.rs index 30fb105..a763300 100644 --- a/server-rs/src/data/property/address_search.rs +++ b/server-rs/src/data/property/address_search.rs @@ -320,6 +320,7 @@ pub(super) fn build_address_prefix_index( for tokens in prefix_index.values_mut() { tokens.sort_unstable(); tokens.dedup(); + tokens.shrink_to_fit(); } prefix_index diff --git a/server-rs/src/data/property/loading.rs b/server-rs/src/data/property/loading.rs index 46060a4..29d224d 100644 --- a/server-rs/src/data/property/loading.rs +++ b/server-rs/src/data/property/loading.rs @@ -827,6 +827,18 @@ impl PropertyData { } } } + + // Reclaim geometric-growth slack. `address_search_token_keys` and the + // posting lists are built by repeated `push`, so their capacity rounds up + // to the next doubling step; at full UK scale (88M row-token keys, 39M + // postings) that slack is ~250MB of permanently-resident waste. These + // tables live for the whole process, so shrinking once at build time is + // a pure win with no behaviour change. + address_search_token_keys.shrink_to_fit(); + for postings in address_token_index.values_mut() { + postings.shrink_to_fit(); + } + // Keep every distinctive token: common road words ("high", "church", "station") are // exactly what people search, and dropping them made those roads unsearchable while a // prefix fallback surfaced the wrong street ("Highbury" for "High"). The candidate scan @@ -838,7 +850,10 @@ impl PropertyData { .max() .unwrap_or(0); let address_prefix_index = build_address_prefix_index(&address_token_index); - let address_search_interner = address_search_rodeo.into_reader(); + // Resolve-only view: scoring only ever calls `.resolve(spur)`, never + // `.get(str)`, so we drop the string->key reverse map that a RodeoReader + // would keep (~tens of MB of hashmap we never touch). + let address_search_interner = address_search_rodeo.into_resolver(); let address_postings_count: usize = address_token_index.values().map(Vec::len).sum(); tracing::info!( tokens = address_token_index.len(), @@ -861,6 +876,9 @@ impl PropertyData { .or_default() .push(new_row as u32); } + for rows in postcode_row_index.values_mut() { + rows.shrink_to_fit(); + } let postcode_interner = postcode_rodeo.into_reader(); let row_to_poi_metric_idx: Vec = if poi_metrics.is_empty() { @@ -914,8 +932,7 @@ impl PropertyData { // Re-key tenure_history by permuted row index let tenure_history: FxHashMap> = { - let mut map = - FxHashMap::with_capacity_and_hasher(tenure_raw.len(), Default::default()); + let mut map = FxHashMap::with_capacity_and_hasher(tenure_raw.len(), Default::default()); for (new_row, &old_row) in perm.iter().enumerate() { if let Some(events) = tenure_raw.remove(&old_row) { map.insert(new_row as u32, events); diff --git a/server-rs/src/data/property/mod.rs b/server-rs/src/data/property/mod.rs index adcb8ef..72a3b4d 100644 --- a/server-rs/src/data/property/mod.rs +++ b/server-rs/src/data/property/mod.rs @@ -88,7 +88,8 @@ pub struct PropertyData { /// Prefix lookup from typed address-token prefix to indexed full address tokens. address_prefix_index: FxHashMap>, /// Interned normalized address-search tokens used for per-row scoring. - address_search_interner: lasso::RodeoReader, + /// Resolve-only (no string->key reverse map): scoring only resolves keys. + address_search_interner: lasso::RodeoResolver, /// Flat per-row normalized address-search token keys. address_search_token_keys: Vec, /// Offset into `address_search_token_keys` for each row. @@ -243,7 +244,7 @@ impl PropertyData { postcode_row_index: FxHashMap::default(), address_token_index: FxHashMap::default(), address_prefix_index: FxHashMap::default(), - address_search_interner: lasso::Rodeo::default().into_reader(), + address_search_interner: lasso::Rodeo::default().into_resolver(), address_search_token_keys: Vec::new(), address_search_token_offsets: Vec::new(), address_search_token_lengths: Vec::new(), diff --git a/server-rs/src/demo_zone.rs b/server-rs/src/demo_zone.rs index 08cf81b..5f45975 100644 --- a/server-rs/src/demo_zone.rs +++ b/server-rs/src/demo_zone.rs @@ -73,8 +73,8 @@ fn demo_center_from_query(query: Option<&str>) -> Option<(f64, f64)> { /// Resolve a request's demo zone from its query params, falling back to Canary Wharf. pub fn resolve_demo_zone(query: Option<&str>) -> DemoZone { - let (lat, lon) = demo_center_from_query(query) - .unwrap_or((FREE_ZONE_FALLBACK_LAT, FREE_ZONE_FALLBACK_LON)); + let (lat, lon) = + demo_center_from_query(query).unwrap_or((FREE_ZONE_FALLBACK_LAT, FREE_ZONE_FALLBACK_LON)); DemoZone { free_zone: free_zone_around(lat, lon), } diff --git a/server-rs/src/licensing.rs b/server-rs/src/licensing.rs index 618a7e0..a10f5f9 100644 --- a/server-rs/src/licensing.rs +++ b/server-rs/src/licensing.rs @@ -10,8 +10,8 @@ use url::form_urlencoded; use crate::auth::PocketBaseUser; use crate::consts::{ - MAX_SHARE_LAT_SPAN, MAX_SHARE_LON_SPAN, MAX_SHARE_ZOOM, MIN_SHARE_ZOOM, SHARE_CACHE_MAX_ENTRIES, - SHARE_CACHE_TTL_SECS, + MAX_SHARE_LAT_SPAN, MAX_SHARE_LON_SPAN, MAX_SHARE_ZOOM, MIN_SHARE_ZOOM, + SHARE_CACHE_MAX_ENTRIES, SHARE_CACHE_TTL_SECS, }; use crate::demo_zone::FreeZone; use crate::pocketbase::get_superuser_token; diff --git a/server-rs/src/ratelimit.rs b/server-rs/src/ratelimit.rs index 0579890..bdb99ee 100644 --- a/server-rs/src/ratelimit.rs +++ b/server-rs/src/ratelimit.rs @@ -127,7 +127,10 @@ fn is_internal_request(headers: &HeaderMap, peer: Option) -> bool { fn filter_count(query: &str) -> usize { for (key, value) in form_urlencoded::parse(query.as_bytes()) { if key == "filters" { - return value.split(";;").filter(|entry| !entry.trim().is_empty()).count(); + return value + .split(";;") + .filter(|entry| !entry.trim().is_empty()) + .count(); } } 0 @@ -157,7 +160,10 @@ pub async fn demo_guard_middleware(req: Request, next: Next) -> Response { } // Licensed users and admins bypass demo guards entirely. - let user = req.extensions().get::().and_then(|u| u.0.clone()); + let user = req + .extensions() + .get::() + .and_then(|u| u.0.clone()); if let Some(u) = &user { if u.is_admin || u.subscription == "licensed" { return next.run(req).await; diff --git a/server-rs/src/routes/actual_listings.rs b/server-rs/src/routes/actual_listings.rs index 6c6e39d..645abcc 100644 --- a/server-rs/src/routes/actual_listings.rs +++ b/server-rs/src/routes/actual_listings.rs @@ -45,6 +45,10 @@ const KEEP_UNKNOWN_LISTING_FILTER_FEATURES: &[&str] = &[ "Leasehold/Freehold", "Number of bedrooms & living rooms", "Property type", + "Construction year", + "Interior height (m)", + "Current energy rating", + "Potential energy rating" ]; const LISTING_BOUNDS_EPSILON_DEGREES: f64 = 0.00001; @@ -71,7 +75,12 @@ pub async fn get_actual_listings( require_bounds(params.bounds).map_err(IntoResponse::into_response)?; let share_bounds = resolve_share_code(&state, params.share.as_deref()).await; - check_license_bounds(&user.0, (south, west, north, east), geo.free_zone, share_bounds)?; + check_license_bounds( + &user.0, + (south, west, north, east), + geo.free_zone, + share_bounds, + )?; let quant = state.data.quant_ref(); let poi_quant = state.data.poi_metrics.quant_ref(); diff --git a/server-rs/src/routes/export.rs b/server-rs/src/routes/export.rs index 347cdea..1d8491f 100644 --- a/server-rs/src/routes/export.rs +++ b/server-rs/src/routes/export.rs @@ -502,7 +502,12 @@ pub async fn get_export( } let share_bounds = resolve_share_code(&state, params.share.as_deref()).await; - check_license_bounds(&user.0, (south, west, north, east), geo.free_zone, share_bounds)?; + check_license_bounds( + &user.0, + (south, west, north, east), + geo.free_zone, + share_bounds, + )?; let quant = state.data.quant_ref(); let poi_quant = state.data.poi_metrics.quant_ref(); diff --git a/server-rs/src/routes/filter_counts.rs b/server-rs/src/routes/filter_counts.rs index 0061bd1..364fab4 100644 --- a/server-rs/src/routes/filter_counts.rs +++ b/server-rs/src/routes/filter_counts.rs @@ -41,7 +41,12 @@ pub async fn get_filter_counts( let (south, west, north, east) = require_bounds(params.bounds).map_err(IntoResponse::into_response)?; let share_bounds = resolve_share_code(&state, params.share.as_deref()).await; - check_license_bounds(&user.0, (south, west, north, east), geo.free_zone, share_bounds)?; + check_license_bounds( + &user.0, + (south, west, north, east), + geo.free_zone, + share_bounds, + )?; let quant = state.data.quant_ref(); let poi_quant = state.data.poi_metrics.quant_ref(); diff --git a/server-rs/src/routes/hexagons.rs b/server-rs/src/routes/hexagons.rs index d6077e0..0824d72 100644 --- a/server-rs/src/routes/hexagons.rs +++ b/server-rs/src/routes/hexagons.rs @@ -263,7 +263,12 @@ pub async fn get_hexagons( require_bounds(params.bounds).map_err(IntoResponse::into_response)?; let share_bounds = resolve_share_code(&state, params.share.as_deref()).await; - check_license_bounds(&user.0, (south, west, north, east), geo.free_zone, share_bounds)?; + check_license_bounds( + &user.0, + (south, west, north, east), + geo.free_zone, + share_bounds, + )?; let quant = state.data.quant_ref(); let poi_quant = state.data.poi_metrics.quant_ref(); diff --git a/server-rs/src/routes/postcodes.rs b/server-rs/src/routes/postcodes.rs index 18a3219..5203453 100644 --- a/server-rs/src/routes/postcodes.rs +++ b/server-rs/src/routes/postcodes.rs @@ -62,7 +62,12 @@ pub async fn get_postcodes( require_bounds(params.bounds).map_err(IntoResponse::into_response)?; let share_bounds = resolve_share_code(&state, params.share.as_deref()).await; - check_license_bounds(&user.0, (south, west, north, east), geo.free_zone, share_bounds)?; + check_license_bounds( + &user.0, + (south, west, north, east), + geo.free_zone, + share_bounds, + )?; let quant = state.data.quant_ref(); let poi_quant = state.data.poi_metrics.quant_ref();