Compare commits
6 commits
4a0f00f2a4
...
fdd7a5c763
| Author | SHA1 | Date | |
|---|---|---|---|
| fdd7a5c763 | |||
| 36ec4003a3 | |||
| 1934a38677 | |||
| d34478e13c | |||
| 2924209898 | |||
| 18ace123dc |
21 changed files with 159 additions and 48 deletions
|
|
@ -12,13 +12,7 @@ import {
|
||||||
} from './lib/seoRoutes';
|
} from './lib/seoRoutes';
|
||||||
import Header, { type Page } from './components/ui/Header';
|
import Header, { type Page } from './components/ui/Header';
|
||||||
import type { FeatureMeta, FeatureGroup, POICategoriesResponse, POICategoryGroup } from './types';
|
import type { FeatureMeta, FeatureGroup, POICategoriesResponse, POICategoryGroup } from './types';
|
||||||
import {
|
import { fetchWithRetry, apiUrl, logNonAbortError, readDemoChoice, setDemoCenter } from './lib/api';
|
||||||
fetchWithRetry,
|
|
||||||
apiUrl,
|
|
||||||
logNonAbortError,
|
|
||||||
readDemoChoice,
|
|
||||||
setDemoCenter,
|
|
||||||
} from './lib/api';
|
|
||||||
import { trackEvent } from './lib/analytics';
|
import { trackEvent } from './lib/analytics';
|
||||||
import { parseUrlState } from './lib/url-state';
|
import { parseUrlState } from './lib/url-state';
|
||||||
import pb from './lib/pocketbase';
|
import pb from './lib/pocketbase';
|
||||||
|
|
@ -226,8 +220,7 @@ export default function App() {
|
||||||
const licensedAtMount = useMemo(
|
const licensedAtMount = useMemo(
|
||||||
() =>
|
() =>
|
||||||
pb.authStore.isValid &&
|
pb.authStore.isValid &&
|
||||||
(pb.authStore.record?.subscription === 'licensed' ||
|
(pb.authStore.record?.subscription === 'licensed' || pb.authStore.record?.is_admin === true),
|
||||||
pb.authStore.record?.is_admin === true),
|
|
||||||
[]
|
[]
|
||||||
);
|
);
|
||||||
const initialDemoChoice = useMemo(() => {
|
const initialDemoChoice = useMemo(() => {
|
||||||
|
|
@ -255,10 +248,7 @@ export default function App() {
|
||||||
);
|
);
|
||||||
const activePageRef = useRef<Page>('home');
|
const activePageRef = useRef<Page>('home');
|
||||||
const initialViewState = useMemo(
|
const initialViewState = useMemo(
|
||||||
() =>
|
() => (mapUrlState.hasExplicitView ? mapUrlState.viewState : (demoView ?? INITIAL_VIEW_STATE)),
|
||||||
mapUrlState.hasExplicitView
|
|
||||||
? mapUrlState.viewState
|
|
||||||
: (demoView ?? INITIAL_VIEW_STATE),
|
|
||||||
[mapUrlState.hasExplicitView, mapUrlState.viewState, demoView]
|
[mapUrlState.hasExplicitView, mapUrlState.viewState, demoView]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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(
|
vi.stubGlobal(
|
||||||
'fetch',
|
'fetch',
|
||||||
vi.fn((input: string | URL | Request) => {
|
vi.fn((input: string | URL | Request) => {
|
||||||
|
|
@ -416,8 +416,22 @@ describe('LocationSearch', () => {
|
||||||
|
|
||||||
render(<LocationSearch onFlyTo={vi.fn()} onLocationSearched={vi.fn()} />);
|
render(<LocationSearch onFlyTo={vi.fn()} onLocationSearched={vi.fn()} />);
|
||||||
|
|
||||||
|
// 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');
|
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.change(input, { target: { value: postcode } });
|
||||||
fireEvent.keyDown(input, { key: 'Enter' });
|
fireEvent.keyDown(input, { key: 'Enter' });
|
||||||
|
|
||||||
|
|
@ -432,13 +446,13 @@ describe('LocationSearch', () => {
|
||||||
const stored = JSON.parse(window.localStorage.getItem(RECENT_SEARCHES_STORAGE_KEY) ?? '[]') as {
|
const stored = JSON.parse(window.localStorage.getItem(RECENT_SEARCHES_STORAGE_KEY) ?? '[]') as {
|
||||||
label?: string;
|
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);
|
fireEvent.focus(input);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByRole('button', { name: 'EC1A 1BB' })).toBeTruthy();
|
expect(screen.getByRole('button', { name: 'G1 1AA' })).toBeTruthy();
|
||||||
expect(screen.getByRole('button', { name: 'W1A 1AA' })).toBeTruthy();
|
|
||||||
expect(screen.getByRole('button', { name: 'E14 2DG' })).toBeTruthy();
|
expect(screen.getByRole('button', { name: 'E14 2DG' })).toBeTruthy();
|
||||||
});
|
});
|
||||||
expect(screen.queryByText('SW1A 1AA')).toBeNull();
|
expect(screen.queryByText('SW1A 1AA')).toBeNull();
|
||||||
|
|
|
||||||
|
|
@ -522,7 +522,12 @@ export default function MapPage({
|
||||||
setDemoPromptOpen(false);
|
setDemoPromptOpen(false);
|
||||||
// handleFlyTo uses map.jumpTo (instant) — no flashing 403s through
|
// handleFlyTo uses map.jumpTo (instant) — no flashing 403s through
|
||||||
// intermediate viewports; the move triggers a refetch carrying demoLat/Lon.
|
// 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) {
|
if (typeof navigator === 'undefined' || !navigator.geolocation) {
|
||||||
setDemoLocationError(t('locationSearch.geolocationUnsupported'));
|
setDemoLocationError(t('locationSearch.geolocationUnsupported'));
|
||||||
|
|
|
||||||
|
|
@ -32,8 +32,8 @@ describe('buildTimelineEvents', () => {
|
||||||
{ year: 2019, status: 'Rented (private)' },
|
{ year: 2019, status: 'Rented (private)' },
|
||||||
{ year: 2023, status: 'Owner-occupied' },
|
{ year: 2023, status: 'Owner-occupied' },
|
||||||
],
|
],
|
||||||
}),
|
})
|
||||||
),
|
)
|
||||||
).toEqual(['tenure:Owner-occupied:2023', 'tenure:Rented (private):2019']);
|
).toEqual(['tenure:Owner-occupied:2023', 'tenure:Rented (private):2019']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -47,8 +47,8 @@ describe('buildTimelineEvents', () => {
|
||||||
{ year: 2024, month: 1, price: 300_000 },
|
{ year: 2024, month: 1, price: 300_000 },
|
||||||
],
|
],
|
||||||
tenure_history: [{ year: 2020, status: 'Rented (private)' }],
|
tenure_history: [{ year: 2020, status: 'Rented (private)' }],
|
||||||
}),
|
})
|
||||||
),
|
)
|
||||||
).toEqual(['tenure:Rented (private):2020', 'sale:2015']);
|
).toEqual(['tenure:Rented (private):2020', 'sale:2015']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import type { AddressResult, PlaceResult } from '../types';
|
||||||
import { authHeaders, logNonAbortError } from '../lib/api';
|
import { authHeaders, logNonAbortError } from '../lib/api';
|
||||||
|
|
||||||
const RECENT_SEARCHES_STORAGE_KEY = 'perfect-postcode.locationSearch.recent';
|
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").
|
/** 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. */
|
* Outcodes like "E14" or "SW1A" intentionally do NOT match — they go through /api/places instead. */
|
||||||
|
|
|
||||||
|
|
@ -917,7 +917,7 @@ const en = {
|
||||||
body: 'Want to see property data around you? Use your location, or start the demo at Canary Wharf.',
|
body: 'Want to see property data around you? Use your location, or start the demo at Canary Wharf.',
|
||||||
useLocation: 'Use my location',
|
useLocation: 'Use my location',
|
||||||
locating: 'Locating…',
|
locating: 'Locating…',
|
||||||
useDefault: 'Show Canary Wharf',
|
useDefault: 'Show London',
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── Mobile Drawer ──────────────────────────────────
|
// ── Mobile Drawer ──────────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -29,8 +29,9 @@ describe('map utilities', () => {
|
||||||
expect(zoomToResolution(6.9)).toBe(5);
|
expect(zoomToResolution(6.9)).toBe(5);
|
||||||
expect(zoomToResolution(7)).toBe(6);
|
expect(zoomToResolution(7)).toBe(6);
|
||||||
expect(zoomToResolution(10.6)).toBe(8);
|
expect(zoomToResolution(10.6)).toBe(8);
|
||||||
expect(zoomToResolution(14)).toBe(8);
|
expect(zoomToResolution(12)).toBe(9);
|
||||||
expect(SMALLEST_VISIBLE_HEXAGON_RESOLUTION).toBe(8);
|
expect(zoomToResolution(14)).toBe(9);
|
||||||
|
expect(SMALLEST_VISIBLE_HEXAGON_RESOLUTION).toBe(9);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('computes exact viewport bounds by default', () => {
|
it('computes exact viewport bounds by default', () => {
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,8 @@ export interface OverlayDefinition {
|
||||||
label: string;
|
label: string;
|
||||||
description: string;
|
description: string;
|
||||||
detail: string;
|
detail: string;
|
||||||
|
/** Enabled on a fresh visit (no `overlay` URL params). */
|
||||||
|
defaultEnabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const OVERLAYS: OverlayDefinition[] = [
|
export const OVERLAYS: OverlayDefinition[] = [
|
||||||
|
|
@ -28,6 +30,7 @@ export const OVERLAYS: OverlayDefinition[] = [
|
||||||
description: 'Approximate police.uk street-crime heatmap',
|
description: 'Approximate police.uk street-crime heatmap',
|
||||||
detail:
|
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.',
|
'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',
|
id: 'trees-outside-woodlands',
|
||||||
|
|
@ -35,6 +38,7 @@ export const OVERLAYS: OverlayDefinition[] = [
|
||||||
description: 'Tree canopy and woodland polygons',
|
description: 'Tree canopy and woodland polygons',
|
||||||
detail:
|
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.',
|
'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',
|
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<string>(OVERLAY_IDS);
|
const OVERLAY_ID_SET = new Set<string>(OVERLAY_IDS);
|
||||||
|
|
||||||
export function isOverlayId(value: string): value is OverlayId {
|
export function isOverlayId(value: string): value is OverlayId {
|
||||||
|
|
|
||||||
|
|
@ -196,6 +196,32 @@ describe('url-state', () => {
|
||||||
expect(state.overlays).toEqual(new Set(['noise', 'crime-hotspots']));
|
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', () => {
|
it('round-trips satellite basemap selection', () => {
|
||||||
const params = stateToParams(
|
const params = stateToParams(
|
||||||
null,
|
null,
|
||||||
|
|
|
||||||
|
|
@ -48,7 +48,7 @@ import {
|
||||||
type PoiFilterName,
|
type PoiFilterName,
|
||||||
} from './poi-distance-filter';
|
} from './poi-distance-filter';
|
||||||
import { dedupeTravelTimeEntries } from './travel-params';
|
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 { CRIME_TYPES, isCrimeTypeValue } from './crime-types';
|
||||||
import { isBasemapId, type BasemapId } from './basemaps';
|
import { isBasemapId, type BasemapId } from './basemaps';
|
||||||
import {
|
import {
|
||||||
|
|
@ -59,6 +59,7 @@ import {
|
||||||
|
|
||||||
const POI_NONE_PARAM = '__none';
|
const POI_NONE_PARAM = '__none';
|
||||||
const CRIME_TYPES_NONE_PARAM = '__none';
|
const CRIME_TYPES_NONE_PARAM = '__none';
|
||||||
|
const OVERLAY_NONE_PARAM = '__none';
|
||||||
const CRIME_OVERLAY_ID: OverlayId = 'crime-hotspots';
|
const CRIME_OVERLAY_ID: OverlayId = 'crime-hotspots';
|
||||||
|
|
||||||
export interface UrlState {
|
export interface UrlState {
|
||||||
|
|
@ -227,7 +228,7 @@ export function parseUrlState(): UrlState {
|
||||||
hasExplicitView: false,
|
hasExplicitView: false,
|
||||||
filters: parseFilters(params),
|
filters: parseFilters(params),
|
||||||
poiCategories: new Set(),
|
poiCategories: new Set(),
|
||||||
overlays: new Set(),
|
overlays: new Set(DEFAULT_OVERLAY_IDS),
|
||||||
basemap: 'standard',
|
basemap: 'standard',
|
||||||
colorOpacity: DEFAULT_COLOR_OPACITY,
|
colorOpacity: DEFAULT_COLOR_OPACITY,
|
||||||
tab: 'area',
|
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');
|
const overlayParams = params.getAll('overlay');
|
||||||
if (overlayParams.length > 0) {
|
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.
|
// Crime-type filter: repeated `crimeType` params, or the `__none` sentinel.
|
||||||
|
|
@ -451,10 +457,16 @@ export function stateToParams(
|
||||||
}
|
}
|
||||||
|
|
||||||
if (selectedOverlays) {
|
if (selectedOverlays) {
|
||||||
|
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) {
|
for (const overlay of selectedOverlays) {
|
||||||
params.append('overlay', overlay);
|
params.append('overlay', overlay);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Crime-type selection only matters while the crime overlay is on. Emit it
|
// Crime-type selection only matters while the crime overlay is on. Emit it
|
||||||
// only when it deviates from the default (all types selected) to keep URLs
|
// only when it deviates from the default (all types selected) to keep URLs
|
||||||
|
|
|
||||||
|
|
@ -320,6 +320,7 @@ pub(super) fn build_address_prefix_index(
|
||||||
for tokens in prefix_index.values_mut() {
|
for tokens in prefix_index.values_mut() {
|
||||||
tokens.sort_unstable();
|
tokens.sort_unstable();
|
||||||
tokens.dedup();
|
tokens.dedup();
|
||||||
|
tokens.shrink_to_fit();
|
||||||
}
|
}
|
||||||
|
|
||||||
prefix_index
|
prefix_index
|
||||||
|
|
|
||||||
|
|
@ -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
|
// Keep every distinctive token: common road words ("high", "church", "station") are
|
||||||
// exactly what people search, and dropping them made those roads unsearchable while a
|
// 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
|
// prefix fallback surfaced the wrong street ("Highbury" for "High"). The candidate scan
|
||||||
|
|
@ -838,7 +850,10 @@ impl PropertyData {
|
||||||
.max()
|
.max()
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
let address_prefix_index = build_address_prefix_index(&address_token_index);
|
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();
|
let address_postings_count: usize = address_token_index.values().map(Vec::len).sum();
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
tokens = address_token_index.len(),
|
tokens = address_token_index.len(),
|
||||||
|
|
@ -861,6 +876,9 @@ impl PropertyData {
|
||||||
.or_default()
|
.or_default()
|
||||||
.push(new_row as u32);
|
.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 postcode_interner = postcode_rodeo.into_reader();
|
||||||
|
|
||||||
let row_to_poi_metric_idx: Vec<u32> = if poi_metrics.is_empty() {
|
let row_to_poi_metric_idx: Vec<u32> = if poi_metrics.is_empty() {
|
||||||
|
|
@ -914,8 +932,7 @@ impl PropertyData {
|
||||||
|
|
||||||
// Re-key tenure_history by permuted row index
|
// Re-key tenure_history by permuted row index
|
||||||
let tenure_history: FxHashMap<u32, Vec<TenureEvent>> = {
|
let tenure_history: FxHashMap<u32, Vec<TenureEvent>> = {
|
||||||
let mut map =
|
let mut map = FxHashMap::with_capacity_and_hasher(tenure_raw.len(), Default::default());
|
||||||
FxHashMap::with_capacity_and_hasher(tenure_raw.len(), Default::default());
|
|
||||||
for (new_row, &old_row) in perm.iter().enumerate() {
|
for (new_row, &old_row) in perm.iter().enumerate() {
|
||||||
if let Some(events) = tenure_raw.remove(&old_row) {
|
if let Some(events) = tenure_raw.remove(&old_row) {
|
||||||
map.insert(new_row as u32, events);
|
map.insert(new_row as u32, events);
|
||||||
|
|
|
||||||
|
|
@ -88,7 +88,8 @@ pub struct PropertyData {
|
||||||
/// Prefix lookup from typed address-token prefix to indexed full address tokens.
|
/// Prefix lookup from typed address-token prefix to indexed full address tokens.
|
||||||
address_prefix_index: FxHashMap<String, Vec<String>>,
|
address_prefix_index: FxHashMap<String, Vec<String>>,
|
||||||
/// Interned normalized address-search tokens used for per-row scoring.
|
/// 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.
|
/// Flat per-row normalized address-search token keys.
|
||||||
address_search_token_keys: Vec<lasso::Spur>,
|
address_search_token_keys: Vec<lasso::Spur>,
|
||||||
/// Offset into `address_search_token_keys` for each row.
|
/// Offset into `address_search_token_keys` for each row.
|
||||||
|
|
@ -243,7 +244,7 @@ impl PropertyData {
|
||||||
postcode_row_index: FxHashMap::default(),
|
postcode_row_index: FxHashMap::default(),
|
||||||
address_token_index: FxHashMap::default(),
|
address_token_index: FxHashMap::default(),
|
||||||
address_prefix_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_keys: Vec::new(),
|
||||||
address_search_token_offsets: Vec::new(),
|
address_search_token_offsets: Vec::new(),
|
||||||
address_search_token_lengths: Vec::new(),
|
address_search_token_lengths: Vec::new(),
|
||||||
|
|
|
||||||
|
|
@ -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.
|
/// Resolve a request's demo zone from its query params, falling back to Canary Wharf.
|
||||||
pub fn resolve_demo_zone(query: Option<&str>) -> DemoZone {
|
pub fn resolve_demo_zone(query: Option<&str>) -> DemoZone {
|
||||||
let (lat, lon) = demo_center_from_query(query)
|
let (lat, lon) =
|
||||||
.unwrap_or((FREE_ZONE_FALLBACK_LAT, FREE_ZONE_FALLBACK_LON));
|
demo_center_from_query(query).unwrap_or((FREE_ZONE_FALLBACK_LAT, FREE_ZONE_FALLBACK_LON));
|
||||||
DemoZone {
|
DemoZone {
|
||||||
free_zone: free_zone_around(lat, lon),
|
free_zone: free_zone_around(lat, lon),
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,8 +10,8 @@ use url::form_urlencoded;
|
||||||
|
|
||||||
use crate::auth::PocketBaseUser;
|
use crate::auth::PocketBaseUser;
|
||||||
use crate::consts::{
|
use crate::consts::{
|
||||||
MAX_SHARE_LAT_SPAN, MAX_SHARE_LON_SPAN, MAX_SHARE_ZOOM, MIN_SHARE_ZOOM, SHARE_CACHE_MAX_ENTRIES,
|
MAX_SHARE_LAT_SPAN, MAX_SHARE_LON_SPAN, MAX_SHARE_ZOOM, MIN_SHARE_ZOOM,
|
||||||
SHARE_CACHE_TTL_SECS,
|
SHARE_CACHE_MAX_ENTRIES, SHARE_CACHE_TTL_SECS,
|
||||||
};
|
};
|
||||||
use crate::demo_zone::FreeZone;
|
use crate::demo_zone::FreeZone;
|
||||||
use crate::pocketbase::get_superuser_token;
|
use crate::pocketbase::get_superuser_token;
|
||||||
|
|
|
||||||
|
|
@ -127,7 +127,10 @@ fn is_internal_request(headers: &HeaderMap, peer: Option<IpAddr>) -> bool {
|
||||||
fn filter_count(query: &str) -> usize {
|
fn filter_count(query: &str) -> usize {
|
||||||
for (key, value) in form_urlencoded::parse(query.as_bytes()) {
|
for (key, value) in form_urlencoded::parse(query.as_bytes()) {
|
||||||
if key == "filters" {
|
if key == "filters" {
|
||||||
return value.split(";;").filter(|entry| !entry.trim().is_empty()).count();
|
return value
|
||||||
|
.split(";;")
|
||||||
|
.filter(|entry| !entry.trim().is_empty())
|
||||||
|
.count();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
0
|
0
|
||||||
|
|
@ -157,7 +160,10 @@ pub async fn demo_guard_middleware(req: Request, next: Next) -> Response {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Licensed users and admins bypass demo guards entirely.
|
// Licensed users and admins bypass demo guards entirely.
|
||||||
let user = req.extensions().get::<OptionalUser>().and_then(|u| u.0.clone());
|
let user = req
|
||||||
|
.extensions()
|
||||||
|
.get::<OptionalUser>()
|
||||||
|
.and_then(|u| u.0.clone());
|
||||||
if let Some(u) = &user {
|
if let Some(u) = &user {
|
||||||
if u.is_admin || u.subscription == "licensed" {
|
if u.is_admin || u.subscription == "licensed" {
|
||||||
return next.run(req).await;
|
return next.run(req).await;
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,10 @@ const KEEP_UNKNOWN_LISTING_FILTER_FEATURES: &[&str] = &[
|
||||||
"Leasehold/Freehold",
|
"Leasehold/Freehold",
|
||||||
"Number of bedrooms & living rooms",
|
"Number of bedrooms & living rooms",
|
||||||
"Property type",
|
"Property type",
|
||||||
|
"Construction year",
|
||||||
|
"Interior height (m)",
|
||||||
|
"Current energy rating",
|
||||||
|
"Potential energy rating"
|
||||||
];
|
];
|
||||||
const LISTING_BOUNDS_EPSILON_DEGREES: f64 = 0.00001;
|
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)?;
|
require_bounds(params.bounds).map_err(IntoResponse::into_response)?;
|
||||||
|
|
||||||
let share_bounds = resolve_share_code(&state, params.share.as_deref()).await;
|
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 quant = state.data.quant_ref();
|
||||||
let poi_quant = state.data.poi_metrics.quant_ref();
|
let poi_quant = state.data.poi_metrics.quant_ref();
|
||||||
|
|
|
||||||
|
|
@ -502,7 +502,12 @@ pub async fn get_export(
|
||||||
}
|
}
|
||||||
|
|
||||||
let share_bounds = resolve_share_code(&state, params.share.as_deref()).await;
|
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 quant = state.data.quant_ref();
|
||||||
let poi_quant = state.data.poi_metrics.quant_ref();
|
let poi_quant = state.data.poi_metrics.quant_ref();
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,12 @@ pub async fn get_filter_counts(
|
||||||
let (south, west, north, east) =
|
let (south, west, north, east) =
|
||||||
require_bounds(params.bounds).map_err(IntoResponse::into_response)?;
|
require_bounds(params.bounds).map_err(IntoResponse::into_response)?;
|
||||||
let share_bounds = resolve_share_code(&state, params.share.as_deref()).await;
|
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 quant = state.data.quant_ref();
|
||||||
let poi_quant = state.data.poi_metrics.quant_ref();
|
let poi_quant = state.data.poi_metrics.quant_ref();
|
||||||
|
|
|
||||||
|
|
@ -263,7 +263,12 @@ pub async fn get_hexagons(
|
||||||
require_bounds(params.bounds).map_err(IntoResponse::into_response)?;
|
require_bounds(params.bounds).map_err(IntoResponse::into_response)?;
|
||||||
|
|
||||||
let share_bounds = resolve_share_code(&state, params.share.as_deref()).await;
|
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 quant = state.data.quant_ref();
|
||||||
let poi_quant = state.data.poi_metrics.quant_ref();
|
let poi_quant = state.data.poi_metrics.quant_ref();
|
||||||
|
|
|
||||||
|
|
@ -62,7 +62,12 @@ pub async fn get_postcodes(
|
||||||
require_bounds(params.bounds).map_err(IntoResponse::into_response)?;
|
require_bounds(params.bounds).map_err(IntoResponse::into_response)?;
|
||||||
|
|
||||||
let share_bounds = resolve_share_code(&state, params.share.as_deref()).await;
|
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 quant = state.data.quant_ref();
|
||||||
let poi_quant = state.data.poi_metrics.quant_ref();
|
let poi_quant = state.data.poi_metrics.quant_ref();
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue