new demo mode & tenure
Some checks failed
Build and publish Docker image / build-and-push (push) Successful in 8m43s
CI / Check (push) Failing after 8m49s

This commit is contained in:
Andras Schmelczer 2026-06-17 07:54:30 +01:00
parent 7656f24544
commit 4a0f00f2a4
64 changed files with 2875 additions and 338 deletions

View file

@ -12,9 +12,9 @@ const ROUTES = [
{ {
path: '/', path: '/',
output: 'index.html', output: 'index.html',
title: 'Stop searching the wrong places | Perfect Postcode', title: 'Find the best-value postcode in England | Perfect Postcode',
description: description:
'Filter every postcode in England by budget, commute, schools, crime, noise, broadband, property prices and amenities before you start chasing viewings.', 'Rank every postcode in England by value: £/sqm, sold prices, schools, commute, crime, noise and broadband. Find the underpriced postcode the market overlooked.',
}, },
{ {
path: '/learn', path: '/learn',

View file

@ -12,7 +12,13 @@ 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 { fetchWithRetry, apiUrl, logNonAbortError } from './lib/api'; import {
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';
@ -209,11 +215,35 @@ export default function App() {
}, []); }, []);
const initialRoute = useMemo(() => pathToPage(window.location.pathname), []); const initialRoute = useMemo(() => pathToPage(window.location.pathname), []);
const [mapUrlState, setMapUrlState] = useState(urlState); const [mapUrlState, setMapUrlState] = useState(urlState);
// IP-derived initial map centre (fetched from /api/geo on a fresh visit). Null // Demo location: a fresh visitor (no explicit URL view) is offered the "check
// until resolved; `geoChecked` flips once the lookup settles (or times out) so // your area" prompt (in MapPage). A choice made earlier this session is reused —
// the dashboard map mounts with the right centre instead of a London flash. // GPS coords re-centre the map and are sent to the server (via lib/api), while
const [ipViewState, setIpViewState] = useState<typeof INITIAL_VIEW_STATE | null>(null); // 'declined' keeps Canary Wharf. Resolve it once, before children fetch, so the
const [geoChecked, setGeoChecked] = useState(() => urlState.hasExplicitView); // very first data request already carries the centre.
// Read license synchronously from the persisted session so we never apply the
// demo location for a returning licensed user (they bypass the free zone, and we
// don't want their location in request URLs).
const licensedAtMount = useMemo(
() =>
pb.authStore.isValid &&
(pb.authStore.record?.subscription === 'licensed' ||
pb.authStore.record?.is_admin === true),
[]
);
const initialDemoChoice = useMemo(() => {
const choice = readDemoChoice();
if (choice && choice !== 'declined' && !licensedAtMount) setDemoCenter(choice);
return choice;
}, [licensedAtMount]);
const [demoView] = useState<typeof INITIAL_VIEW_STATE | null>(() =>
initialDemoChoice && initialDemoChoice !== 'declined' && !licensedAtMount
? {
...INITIAL_VIEW_STATE,
latitude: initialDemoChoice.lat,
longitude: initialDemoChoice.lon,
}
: null
);
const [dashboardRouteKey, setDashboardRouteKey] = useState(() => const [dashboardRouteKey, setDashboardRouteKey] = useState(() =>
window.location.pathname === '/dashboard' ? window.location.search : '' window.location.pathname === '/dashboard' ? window.location.search : ''
); );
@ -228,8 +258,8 @@ export default function App() {
() => () =>
mapUrlState.hasExplicitView mapUrlState.hasExplicitView
? mapUrlState.viewState ? mapUrlState.viewState
: (ipViewState ?? INITIAL_VIEW_STATE), : (demoView ?? INITIAL_VIEW_STATE),
[mapUrlState.hasExplicitView, mapUrlState.viewState, ipViewState] [mapUrlState.hasExplicitView, mapUrlState.viewState, demoView]
); );
const isScreenshotMode = useMemo(() => { const isScreenshotMode = useMemo(() => {
@ -282,6 +312,16 @@ export default function App() {
refreshAuth, refreshAuth,
clearError, clearError,
} = useAuth(); } = useAuth();
// Only offer the "check your area" demo prompt to non-licensed visitors on a
// fresh visit. `user` is hydrated synchronously from the persisted PocketBase
// session, so a returning premium user is recognised at mount (no flash).
const offerDemoLocation =
!urlState.hasExplicitView && initialDemoChoice == null && !hasFullAccess(user);
// Stop transmitting the demo location once the user is licensed (in-session
// upgrade, cross-tab sign-in, or a stale session that refreshed to licensed).
useEffect(() => {
if (hasFullAccess(user)) setDemoCenter(null);
}, [user]);
const { startCheckout: startPostAuthCheckout } = useLicense(); const { startCheckout: startPostAuthCheckout } = useLicense();
const [showAuthModal, setShowAuthModal] = useState(false); const [showAuthModal, setShowAuthModal] = useState(false);
const [authModalTab, setAuthModalTab] = useState<'login' | 'register'>('login'); const [authModalTab, setAuthModalTab] = useState<'login' | 'register'>('login');
@ -424,44 +464,6 @@ export default function App() {
return () => controller.abort(); return () => controller.abort();
}, []); }, []);
// On a fresh visit (no view in the URL), centre the initial map on the visitor's
// approximate location from their IP. Falls back to INITIAL_VIEW_STATE (inner
// London) when geolocation is unavailable. Skipped when the URL already carries a
// view (shared/dashboard links) so we never override it. `geoChecked` gates the
// dashboard map render so it mounts once with the resolved centre.
useEffect(() => {
if (urlState.hasExplicitView) return; // geoChecked already initialised true
let cancelled = false;
const controller = new AbortController();
const finish = () => {
if (!cancelled) setGeoChecked(true);
};
// Never block the map for more than this if the lookup is slow/hangs.
const timeout = window.setTimeout(finish, 1500);
fetch(apiUrl('geo'), { signal: controller.signal })
.then((res) => (res.ok ? res.json() : null))
.then((data: { center?: { lat: number; lon: number } } | null) => {
if (cancelled || !data?.center) return;
setIpViewState({
...INITIAL_VIEW_STATE,
latitude: data.center.lat,
longitude: data.center.lon,
});
})
.catch(() => {})
.finally(() => {
window.clearTimeout(timeout);
finish();
});
return () => {
cancelled = true;
window.clearTimeout(timeout);
controller.abort();
};
// Mount-only: resolve the IP-based initial view exactly once.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const navigateTo = useCallback( const navigateTo = useCallback(
(page: Page, hash?: string, infoFeature?: string) => { (page: Page, hash?: string, infoFeature?: string) => {
const targetHash = normalizeHash(hash); const targetHash = normalizeHash(hash);
@ -769,8 +771,6 @@ export default function App() {
refreshAuth(); refreshAuth();
}} }}
/> />
) : !geoChecked ? (
<PageFallback />
) : ( ) : (
<MapPage <MapPage
key={dashboardRouteKey} key={dashboardRouteKey}
@ -778,6 +778,7 @@ export default function App() {
poiCategoryGroups={poiCategoryGroups} poiCategoryGroups={poiCategoryGroups}
initialFilters={mapUrlState.filters} initialFilters={mapUrlState.filters}
initialViewState={initialViewState} initialViewState={initialViewState}
offerDemoLocation={offerDemoLocation}
initialPOICategories={mapUrlState.poiCategories} initialPOICategories={mapUrlState.poiCategories}
initialOverlays={mapUrlState.overlays} initialOverlays={mapUrlState.overlays}
initialCrimeTypes={mapUrlState.crimeTypes} initialCrimeTypes={mapUrlState.crimeTypes}

View file

@ -0,0 +1,71 @@
import { useTranslation } from 'react-i18next';
import { useModalA11y } from '../../hooks/useModalA11y';
import { SpinnerIcon } from '../ui/icons/SpinnerIcon';
interface DemoLocationPromptProps {
/** True while a geolocation request is in flight. */
locating: boolean;
/** Error message to show (e.g. permission denied), or null. */
error: string | null;
onUseLocation: () => void;
onUseDefault: () => void;
}
/**
* First-visit demo prompt: offers to centre the map on the visitor's GPS location
* (with consent) or start at the Canary Wharf default. Rendered over the map (which
* is already showing Canary Wharf), so declining is just a dismiss.
*/
export default function DemoLocationPrompt({
locating,
error,
onUseLocation,
onUseDefault,
}: DemoLocationPromptProps) {
const { t } = useTranslation();
const dialogRef = useModalA11y();
return (
<div
className="fixed inset-0 z-[10000] flex items-center justify-center bg-black/50"
role="presentation"
>
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-labelledby="demo-location-title"
tabIndex={-1}
className="relative w-full max-w-sm mx-4 bg-white dark:bg-warm-800 rounded-2xl shadow-2xl border border-warm-200 dark:border-warm-700 overflow-hidden outline-none"
>
<div className="bg-gradient-to-br from-navy-950 to-teal-900 px-6 py-7 text-center">
<h2 id="demo-location-title" className="text-xl font-bold text-white mb-2">
{t('demoLocation.title')}
</h2>
<p className="text-warm-300 text-sm">{t('demoLocation.body')}</p>
</div>
<div className="px-6 py-6 flex flex-col gap-3">
<button
type="button"
onClick={onUseLocation}
disabled={locating}
className="w-full px-6 py-3 border border-[#d27a11] bg-[#f09a22] text-navy-950 rounded-lg font-semibold hover:bg-[#df8614] transition-colors shadow-lg shadow-[#7a3905]/25 disabled:opacity-50 disabled:cursor-wait flex items-center justify-center gap-2"
>
{locating && <SpinnerIcon className="w-5 h-5 animate-spin" />}
{locating ? t('demoLocation.locating') : t('demoLocation.useLocation')}
</button>
<button
type="button"
onClick={onUseDefault}
className="w-full px-4 py-2 text-sm text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300"
>
{t('demoLocation.useDefault')}
</button>
{error && <p className="text-center text-sm text-red-600 dark:text-red-400">{error}</p>}
</div>
</div>
</div>
);
}

View file

@ -9,7 +9,19 @@ describe('compactHistogramLabel', () => {
compactHistogramLabel(index, 5, 0, 5.95, center, fmt, true) compactHistogramLabel(index, 5, 0, 5.95, center, fmt, true)
); );
expect(labels).toEqual(['0', '1', '3', '5', '6+']); expect(labels).toEqual(['', '1', '3', '5', '6+']);
});
it('does not show 0 twice when the low-outlier bin starts at zero', () => {
const fmt = (value: number) => value.toFixed(2);
// Integer-binned count feature (e.g. school catchments): p1=0, p99=3,
// one-unit-wide middle bins. Bin 0 (< 0) is empty, bin 1 holds the value 0.
const labels = [0, 0.5, 1.5, 2.5, 3].map((center, index) =>
compactHistogramLabel(index, 5, 0, 3, center, fmt, true)
);
expect(labels).toEqual(['', '0', '1', '2', '3+']);
expect(labels.filter((label) => label === '0')).toHaveLength(1);
}); });
it('labels the first integer count bucket as zero when it means below one', () => { it('labels the first integer count bucket as zero when it means below one', () => {

View file

@ -52,7 +52,11 @@ export function compactHistogramLabel(
if (index === 0) { if (index === 0) {
if (!integerLabels) return `<${formatLabel(p1)}`; if (!integerLabels) return `<${formatLabel(p1)}`;
const firstBoundary = Math.ceil(p1); const firstBoundary = Math.ceil(p1);
return firstBoundary <= 1 ? '0' : `<${firstBoundary.toLocaleString()}`; // This outlier bin holds values strictly below p1. When p1 <= 0 there are no
// (non-negative) integers below it, so the value 0 lives in the first middle
// bin instead — labelling this empty bin "0" too would show "0" twice.
if (firstBoundary <= 0) return '';
return firstBoundary === 1 ? '0' : `<${firstBoundary.toLocaleString()}`;
} }
if (index === barCount - 1) { if (index === barCount - 1) {
if (!integerLabels) return `${formatLabel(p99)}+`; if (!integerLabels) return `${formatLabel(p99)}+`;

View file

@ -2,7 +2,7 @@ import { useState, useCallback, useRef, useEffect } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import type { MapFlyToOptions, PostcodeGeometry } from '../../types'; import type { MapFlyToOptions, PostcodeGeometry } from '../../types';
import { authHeaders, isAbortError } from '../../lib/api'; import { authHeaders, isAbortError } from '../../lib/api';
import { POSTCODE_SEARCH_ZOOM } from '../../lib/consts'; import { DEV_LOCATION, POSTCODE_SEARCH_ZOOM } from '../../lib/consts';
import { useIsMobile } from '../../hooks/useIsMobile'; import { useIsMobile } from '../../hooks/useIsMobile';
import { import {
useLocationSearch, useLocationSearch,
@ -56,11 +56,6 @@ const ZOOM_FOR_TYPE: Record<string, number> = {
retail: 15, retail: 15,
}; };
const DEV_CURRENT_LOCATION = {
latitude: 51.5033635,
longitude: -0.1276248,
};
export default function LocationSearch({ export default function LocationSearch({
onFlyTo, onFlyTo,
onLocationSearched, onLocationSearched,
@ -288,7 +283,7 @@ export default function LocationSearch({
search.close(); search.close();
try { try {
const { latitude, longitude } = __DEV__ const { latitude, longitude } = __DEV__
? DEV_CURRENT_LOCATION ? DEV_LOCATION
: await new Promise<GeolocationCoordinates>((resolve, reject) => { : await new Promise<GeolocationCoordinates>((resolve, reject) => {
if (!navigator.geolocation) { if (!navigator.geolocation) {
reject(new Error('Geolocation unsupported')); reject(new Error('Geolocation unsupported'));

View file

@ -82,6 +82,9 @@ interface MapProps {
hideLegend?: boolean; hideLegend?: boolean;
hideLocationSearch?: boolean; hideLocationSearch?: boolean;
hideTopCardsWhenNarrow?: boolean; hideTopCardsWhenNarrow?: boolean;
/** Space (px) to keep clear on the right of the top-card overlay so the
* search bar doesn't run under controls anchored there (mobile buttons). */
topCardsRightInset?: number;
travelTimeEntries?: TravelTimeEntry[]; travelTimeEntries?: TravelTimeEntry[];
densityLabel?: string; densityLabel?: string;
totalCount?: number; totalCount?: number;
@ -227,6 +230,7 @@ export default memo(function Map({
hideLegend = false, hideLegend = false,
hideLocationSearch = false, hideLocationSearch = false,
hideTopCardsWhenNarrow = false, hideTopCardsWhenNarrow = false,
topCardsRightInset = 0,
travelTimeEntries = EMPTY_TRAVEL_ENTRIES, travelTimeEntries = EMPTY_TRAVEL_ENTRIES,
densityLabel: densityLabelProp, densityLabel: densityLabelProp,
totalCount: totalCountProp, totalCount: totalCountProp,
@ -337,6 +341,31 @@ export default memo(function Map({
setMapReady(true); setMapReady(true);
}, []); }, []);
// deck.gl runs interleaved, sharing this canvas's WebGL context. If the browser
// drops and restores that context, MapLibre rebuilds its own GL resources, but
// the deck instance keeps buffers/textures from the dead context — the source of
// the "bufferSubData: no buffer" / "bindTexture: deleted object" errors. Remount
// the overlay on restore so deck.gl rebuilds against the live context.
const [deckOverlayKey, setDeckOverlayKey] = useState(0);
useEffect(() => {
if (!mapReady) return;
const canvas = mapRef.current?.getCanvas();
if (!canvas) return;
const handleContextLost = (event: Event) => {
// Let the browser restore the context instead of giving up on it.
event.preventDefault();
};
const handleContextRestored = () => setDeckOverlayKey((key) => key + 1);
canvas.addEventListener('webglcontextlost', handleContextLost);
canvas.addEventListener('webglcontextrestored', handleContextRestored);
return () => {
canvas.removeEventListener('webglcontextlost', handleContextLost);
canvas.removeEventListener('webglcontextrestored', handleContextRestored);
};
}, [mapReady]);
const handleFlyTo = useCallback( const handleFlyTo = useCallback(
(lat: number, lng: number, zoom: number, options?: MapFlyToOptions) => { (lat: number, lng: number, zoom: number, options?: MapFlyToOptions) => {
const targetPoint = const targetPoint =
@ -486,7 +515,7 @@ export default memo(function Map({
activeCrimeTypes={activeCrimeTypes} activeCrimeTypes={activeCrimeTypes}
zoom={viewState.zoom} zoom={viewState.zoom}
/> />
<DeckOverlay layers={layers} getTooltip={null} /> <DeckOverlay key={deckOverlayKey} layers={layers} getTooltip={null} />
{!screenshotMode && <ScaleControl position="bottom-left" maxWidth={100} unit="metric" />} {!screenshotMode && <ScaleControl position="bottom-left" maxWidth={100} unit="metric" />}
</MapGL> </MapGL>
{basemap === 'satellite' && ( {basemap === 'satellite' && (
@ -549,6 +578,7 @@ export default memo(function Map({
layoutClass={topCardsLayoutClass} layoutClass={topCardsLayoutClass}
showLocationSearch={showLocationSearch} showLocationSearch={showLocationSearch}
showLegend={showLegend} showLegend={showLegend}
rightInset={topCardsRightInset}
onFlyTo={handleFlyTo} onFlyTo={handleFlyTo}
onLocationSearched={onLocationSearched} onLocationSearched={onLocationSearched}
onCurrentLocationFound={onCurrentLocationFound} onCurrentLocationFound={onCurrentLocationFound}

View file

@ -0,0 +1,95 @@
// @vitest-environment jsdom
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { MapErrorBoundary } from './MapErrorBoundary';
const captureException = vi.fn();
vi.mock('@sentry/react', () => ({
captureException: (...args: unknown[]) => captureException(...args),
}));
// Controls whether the child throws on its next render. The boundary remounts
// the child on recovery, so flipping this lets us simulate a transient crash.
let shouldThrow = true;
function MaybeBoom() {
if (shouldThrow) {
throw new Error('webgl boom');
}
return <div>map content</div>;
}
let consoleErrorSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
shouldThrow = true;
captureException.mockClear();
// React logs caught render errors to console.error; keep test output clean.
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
vi.useFakeTimers();
});
afterEach(() => {
vi.runOnlyPendingTimers();
vi.useRealTimers();
consoleErrorSpy.mockRestore();
cleanup();
});
describe('MapErrorBoundary', () => {
it('renders children when they do not throw', () => {
shouldThrow = false;
render(
<MapErrorBoundary>
<MaybeBoom />
</MapErrorBoundary>
);
expect(screen.getByText('map content')).toBeTruthy();
expect(captureException).not.toHaveBeenCalled();
});
it('reports the error and auto-recovers by remounting the map', () => {
render(
<MapErrorBoundary>
<MaybeBoom />
</MapErrorBoundary>
);
// The throw is caught: children are not shown, and the error is reported.
expect(screen.queryByText('map content')).toBeNull();
expect(captureException).toHaveBeenCalledTimes(1);
// The next mount succeeds; advancing past the recovery delay remounts it.
shouldThrow = false;
act(() => {
vi.advanceTimersByTime(1000);
});
expect(screen.getByText('map content')).toBeTruthy();
});
it('falls back to a manual retry after repeated crashes, then recovers on retry', () => {
render(
<MapErrorBoundary>
<MaybeBoom />
</MapErrorBoundary>
);
// Keep crashing through every auto-recovery attempt.
act(() => {
vi.advanceTimersByTime(1000);
});
act(() => {
vi.advanceTimersByTime(1000);
});
const retryButton = screen.getByRole('button', { name: /reload the map/i });
expect(retryButton).toBeTruthy();
expect(captureException.mock.calls.length).toBeGreaterThanOrEqual(3);
// Manual retry remounts the (now healthy) map.
shouldThrow = false;
fireEvent.click(retryButton);
expect(screen.getByText('map content')).toBeTruthy();
});
});

View file

@ -0,0 +1,141 @@
import { Component, Fragment, type ReactNode } from 'react';
import * as Sentry from '@sentry/react';
import { MapFallback } from './map-page/Fallbacks';
/**
* Scoped error boundary for the WebGL map.
*
* deck.gl runs in interleaved mode, sharing MapLibre's WebGL context. When that
* shared context gets into a bad state (lost context, a buffer/texture finalized
* while still referenced the "bufferSubData: no buffer" / "bindTexture: deleted
* object" storm), the next interleaved draw can throw. deck.gl's own animation
* loop swallows render errors via its `onError` prop, but interleaved draws fire
* from MapLibre's synchronous `render` event, which can run inside a React commit
* so the throw bubbles to the nearest React error boundary.
*
* Without this, that throw reaches the single top-level boundary and blanks the
* entire app ("Something went wrong / Refresh the page"). This boundary contains
* the blast radius to the map and auto-recovers by remounting it, which builds a
* fresh MapLibre + deck.gl context. Filters, selection and viewport live above
* this boundary (in MapPage / the URL), so a remount preserves them.
*/
const MAX_AUTO_RECOVERIES = 2;
// Remount after a short delay so a transient GL state can settle first.
const RECOVERY_DELAY_MS = 400;
// Crashes spaced further apart than this are treated as independent, so the
// auto-recovery budget resets — only a tight crash loop escalates to manual retry.
const STABILITY_WINDOW_MS = 30_000;
interface MapErrorBoundaryProps {
children: ReactNode;
}
interface MapErrorBoundaryState {
errored: boolean;
failedPermanently: boolean;
renderKey: number;
recoveries: number;
}
export class MapErrorBoundary extends Component<MapErrorBoundaryProps, MapErrorBoundaryState> {
state: MapErrorBoundaryState = {
errored: false,
failedPermanently: false,
renderKey: 0,
recoveries: 0,
};
private recoveryTimer: ReturnType<typeof setTimeout> | null = null;
private lastErrorAt = 0;
static getDerivedStateFromError(): Partial<MapErrorBoundaryState> {
return { errored: true };
}
componentDidCatch(error: Error, info: { componentStack?: string | null }) {
const now = Date.now();
const withinWindow = now - this.lastErrorAt < STABILITY_WINDOW_MS;
this.lastErrorAt = now;
const recoveries = withinWindow ? this.state.recoveries + 1 : 1;
Sentry.captureException(error, {
tags: { scope: 'map' },
extra: { componentStack: info.componentStack, recoveries },
});
if (recoveries <= MAX_AUTO_RECOVERIES) {
this.scheduleRecovery(recoveries);
} else {
this.setState({ failedPermanently: true, recoveries });
}
}
componentWillUnmount() {
this.clearRecoveryTimer();
}
private clearRecoveryTimer() {
if (this.recoveryTimer) {
clearTimeout(this.recoveryTimer);
this.recoveryTimer = null;
}
}
private scheduleRecovery(recoveries: number) {
this.clearRecoveryTimer();
this.recoveryTimer = setTimeout(() => {
this.recoveryTimer = null;
this.setState((prev) => ({
errored: false,
failedPermanently: false,
renderKey: prev.renderKey + 1,
recoveries,
}));
}, RECOVERY_DELAY_MS);
}
private handleManualRetry = () => {
this.clearRecoveryTimer();
this.lastErrorAt = 0;
this.setState((prev) => ({
errored: false,
failedPermanently: false,
renderKey: prev.renderKey + 1,
recoveries: 0,
}));
};
render() {
if (this.state.failedPermanently) {
return (
<div className="flex h-full w-full items-center justify-center bg-warm-100 px-6 text-center dark:bg-navy-950">
<div>
<h2 className="text-lg font-semibold text-warm-900 dark:text-warm-100">
The map ran into a problem
</h2>
<p className="mt-2 text-sm text-warm-600 dark:text-warm-300">
This can happen when your browser&apos;s graphics context is interrupted.
</p>
<button
type="button"
onClick={this.handleManualRetry}
className="mt-4 rounded-lg bg-teal-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-teal-700 dark:bg-teal-500 dark:hover:bg-teal-400"
>
Reload the map
</button>
</div>
</div>
);
}
// While auto-recovering, show the same spinner used for the lazy-load
// fallback so the remount reads as a brief reload rather than a crash.
if (this.state.errored) {
return <MapFallback />;
}
return <Fragment key={this.state.renderKey}>{this.props.children}</Fragment>;
}
}

View file

@ -22,10 +22,23 @@ import {
travelFieldKey, travelFieldKey,
useTravelTime, useTravelTime,
} from '../../hooks/useTravelTime'; } from '../../hooks/useTravelTime';
import { apiUrl, authHeaders, buildFilterString } from '../../lib/api'; import {
apiUrl,
authHeaders,
buildFilterString,
persistDemoChoice,
readDemoChoice,
setDemoCenter,
} from '../../lib/api';
import DemoLocationPrompt from './DemoLocationPrompt';
import { useFilterCounts } from '../../hooks/useFilterCounts'; import { useFilterCounts } from '../../hooks/useFilterCounts';
import { trackEvent } from '../../lib/analytics'; import { trackEvent } from '../../lib/analytics';
import { INITIAL_VIEW_STATE, POSTCODE_ZOOM_THRESHOLD } from '../../lib/consts'; import {
DEMO_MAX_FILTERS,
DEV_LOCATION,
INITIAL_VIEW_STATE,
POSTCODE_ZOOM_THRESHOLD,
} from '../../lib/consts';
import { boundsToCenterZoom } from '../../lib/fit-bounds'; import { boundsToCenterZoom } from '../../lib/fit-bounds';
import type { OverlayId } from '../../lib/overlays'; import type { OverlayId } from '../../lib/overlays';
import { CRIME_TYPE_VALUES } from '../../lib/crime-types'; import { CRIME_TYPE_VALUES } from '../../lib/crime-types';
@ -72,6 +85,8 @@ import type { MapFlyTo, MapPageProps } from './map-page/types';
export type { ExportState } from './map-page/types'; export type { ExportState } from './map-page/types';
declare const __DEV__: boolean;
const EMPTY_ACTUAL_LISTINGS: ActualListing[] = []; const EMPTY_ACTUAL_LISTINGS: ActualListing[] = [];
export default function MapPage({ export default function MapPage({
@ -79,6 +94,7 @@ export default function MapPage({
poiCategoryGroups, poiCategoryGroups,
initialFilters, initialFilters,
initialViewState, initialViewState,
offerDemoLocation,
initialPOICategories, initialPOICategories,
initialOverlays, initialOverlays,
initialCrimeTypes, initialCrimeTypes,
@ -138,6 +154,12 @@ export default function MapPage({
initialPostcode ?? null initialPostcode ?? null
); );
// Demo (unlicensed) users are limited to DEMO_MAX_FILTERS filters; hitting the
// cap opens the upgrade modal. Licensed users and admins are unlimited.
const filtersUnlimited = user?.subscription === 'licensed' || user?.isAdmin === true;
const [filterLimitHit, setFilterLimitHit] = useState(false);
const handleFilterLimitReached = useCallback(() => setFilterLimitHit(true), []);
const { const {
filters, filters,
activeFeature, activeFeature,
@ -160,6 +182,8 @@ export default function MapPage({
} = useFilters({ } = useFilters({
initialFilters, initialFilters,
features, features,
filterLimit: filtersUnlimited ? null : DEMO_MAX_FILTERS,
onFilterLimitReached: handleFilterLimitReached,
}); });
const { const {
@ -461,10 +485,78 @@ export default function MapPage({
const [upgradeModalDismissed, setUpgradeModalDismissed] = useState(false); const [upgradeModalDismissed, setUpgradeModalDismissed] = useState(false);
const handleZoomToFreeZone = useCallback(() => { const handleZoomToFreeZone = useCallback(() => {
setUpgradeModalDismissed(true); setUpgradeModalDismissed(true);
const target = shareReturnViewRef.current ?? INITIAL_VIEW_STATE; setFilterLimitHit(false);
// Fly back to the visitor's own free zone. Prefer the server-reported zone
// (reflects their chosen demo centre — including a GPS choice made this
// session), then a share-return view, then the initial centre.
const fz = mapData.freeZone;
const target =
shareReturnViewRef.current ??
(fz
? {
latitude: (fz.south + fz.north) / 2,
longitude: (fz.west + fz.east) / 2,
zoom: initialViewState.zoom,
}
: initialViewState);
mapFlyToRef.current?.(target.latitude, target.longitude, target.zoom); mapFlyToRef.current?.(target.latitude, target.longitude, target.zoom);
}, [initialViewState, mapData.freeZone]);
// "Check your area" demo prompt: GPS recenters the map AND moves the free zone
// (the chosen centre is sent to the server via lib/api on each request);
// declining keeps Canary Wharf. Persisted per session so we don't re-ask.
const [demoPromptOpen, setDemoPromptOpen] = useState(
// Also check the persisted choice directly: MapPage remounts on navigation
// (route key change) while App's `offerDemoLocation` is frozen at mount, so a
// choice made earlier this session must still suppress a re-prompt.
() => !!offerDemoLocation && !screenshotMode && !ogMode && readDemoChoice() == null
);
const [demoLocating, setDemoLocating] = useState(false);
const [demoLocationError, setDemoLocationError] = useState<string | null>(null);
const handleUseMyLocation = useCallback(() => {
const applyCenter = (center: { lat: number; lon: number }) => {
setDemoCenter(center);
persistDemoChoice(center);
setDemoLocating(false);
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());
};
if (typeof navigator === 'undefined' || !navigator.geolocation) {
setDemoLocationError(t('locationSearch.geolocationUnsupported'));
return;
}
setDemoLocating(true);
setDemoLocationError(null);
navigator.geolocation.getCurrentPosition(
(position) => applyCenter({ lat: position.coords.latitude, lon: position.coords.longitude }),
() => {
// In dev, geolocation is usually blocked (e.g. served over http), so fall
// back to 10 Downing Street to keep the "data follows you" flow testable.
if (__DEV__) {
applyCenter({ lat: DEV_LOCATION.latitude, lon: DEV_LOCATION.longitude });
return;
}
setDemoLocating(false);
setDemoLocationError(t('locationSearch.geolocationFailed'));
},
{ enableHighAccuracy: false, timeout: 10000, maximumAge: 600000 }
);
}, [t, getMobileMapFlyToOptions]);
const handleUseDefaultLocation = useCallback(() => {
persistDemoChoice('declined');
setDemoPromptOpen(false);
}, []); }, []);
// Never show the demo prompt to licensed/admin users — e.g. if the session
// hydrated as free then refreshed to licensed, or they signed in in another tab.
useEffect(() => {
if (filtersUnlimited) setDemoPromptOpen(false);
}, [filtersUnlimited]);
const pois = usePOIData(mapData.bounds, selectedPOICategories); const pois = usePOIData(mapData.bounds, selectedPOICategories);
const overlaysZoomedIn = (mapData.currentView?.zoom ?? 0) >= POSTCODE_ZOOM_THRESHOLD; const overlaysZoomedIn = (mapData.currentView?.zoom ?? 0) >= POSTCODE_ZOOM_THRESHOLD;
const actualListingsFilterParam = useMemo( const actualListingsFilterParam = useMemo(
@ -648,6 +740,15 @@ export default function MapPage({
} }
}, [mapData.licenseRequired]); }, [mapData.licenseRequired]);
// Clear the filter-cap upsell once it's no longer relevant: the user became
// licensed/admin, or dropped back under the cap by removing filters. Prevents a
// stale flag from resurfacing the modal spuriously.
useEffect(() => {
if (filtersUnlimited || Object.keys(filters).length < DEMO_MAX_FILTERS) {
setFilterLimitHit(false);
}
}, [filtersUnlimited, filters]);
const handleUpgradeClick = useCallback(() => { const handleUpgradeClick = useCallback(() => {
onNavigateTo('pricing'); onNavigateTo('pricing');
}, [onNavigateTo]); }, [onNavigateTo]);
@ -968,8 +1069,14 @@ export default function MapPage({
</div> </div>
) : null; ) : null;
// The upgrade modal serves two cases: panning outside the free zone
// (licenseRequired) and hitting the demo filter cap (filterLimitHit). The zone
// case takes precedence; for the filter case, dismissing just closes the modal
// (no camera move).
const showZoneUpgradeModal = mapData.licenseRequired && !upgradeModalDismissed;
const showFilterUpgradeModal = !showZoneUpgradeModal && filterLimitHit && !filtersUnlimited;
const upgradeModal = const upgradeModal =
mapData.licenseRequired && !upgradeModalDismissed ? ( showZoneUpgradeModal || showFilterUpgradeModal ? (
<Suspense fallback={null}> <Suspense fallback={null}>
<UpgradeModal <UpgradeModal
isLoggedIn={!!user} isLoggedIn={!!user}
@ -982,12 +1089,31 @@ export default function MapPage({
: onRegisterClick() : onRegisterClick()
} }
onStartCheckout={() => license.startCheckout(checkoutReturnPath)} onStartCheckout={() => license.startCheckout(checkoutReturnPath)}
onZoomToFreeZone={handleZoomToFreeZone} onZoomToFreeZone={
isShareReturn={!!shareReturnViewRef.current} showFilterUpgradeModal ? () => setFilterLimitHit(false) : handleZoomToFreeZone
}
isShareReturn={showZoneUpgradeModal && !!shareReturnViewRef.current}
/> />
</Suspense> </Suspense>
) : null; ) : null;
const demoLocationPrompt = demoPromptOpen ? (
<DemoLocationPrompt
locating={demoLocating}
error={demoLocationError}
onUseLocation={handleUseMyLocation}
onUseDefault={handleUseDefaultLocation}
/>
) : null;
// Both overlays render where the page components place {upgradeModal}.
const overlayModals = (
<>
{upgradeModal}
{demoLocationPrompt}
</>
);
if (isMobile) { if (isMobile) {
return ( return (
<MobileMapPage <MobileMapPage
@ -1039,7 +1165,7 @@ export default function MapPage({
renderAreaPane={renderAreaPane} renderAreaPane={renderAreaPane}
renderPropertiesPane={renderPropertiesPane} renderPropertiesPane={renderPropertiesPane}
toasts={toasts} toasts={toasts}
upgradeModal={upgradeModal} upgradeModal={overlayModals}
editingBar={editingBar} editingBar={editingBar}
/> />
); );
@ -1099,7 +1225,7 @@ export default function MapPage({
renderAreaPane={renderAreaPane} renderAreaPane={renderAreaPane}
renderPropertiesPane={renderPropertiesPane} renderPropertiesPane={renderPropertiesPane}
toasts={toasts} toasts={toasts}
upgradeModal={upgradeModal} upgradeModal={overlayModals}
/> />
); );
} }

View file

@ -15,6 +15,12 @@ interface MapTopCardsProps {
layoutClass: string; layoutClass: string;
showLocationSearch: boolean; showLocationSearch: boolean;
showLegend: boolean; showLegend: boolean;
/**
* Space (px) to keep clear on the right of the overlay so the cards don't
* slide under sibling controls anchored there (e.g. the mobile action
* buttons). The expanded location search shrinks to fit the remaining width.
*/
rightInset?: number;
onFlyTo: (lat: number, lng: number, zoom: number, options?: MapFlyToOptions) => void; onFlyTo: (lat: number, lng: number, zoom: number, options?: MapFlyToOptions) => void;
onLocationSearched?: (location: SearchedLocation | null) => void; onLocationSearched?: (location: SearchedLocation | null) => void;
onCurrentLocationFound?: (lat: number, lng: number) => void; onCurrentLocationFound?: (lat: number, lng: number) => void;
@ -40,6 +46,7 @@ export const MapTopCards = memo(function MapTopCards({
layoutClass, layoutClass,
showLocationSearch, showLocationSearch,
showLegend, showLegend,
rightInset = 0,
onFlyTo, onFlyTo,
onLocationSearched, onLocationSearched,
onCurrentLocationFound, onCurrentLocationFound,
@ -65,6 +72,7 @@ export const MapTopCards = memo(function MapTopCards({
return ( return (
<div <div
className={`absolute top-3 left-3 right-3 z-20 flex gap-2 pointer-events-none ${layoutClass}`} className={`absolute top-3 left-3 right-3 z-20 flex gap-2 pointer-events-none ${layoutClass}`}
style={rightInset ? { paddingRight: rightInset } : undefined}
> >
{showLocationSearch && ( {showLocationSearch && (
<LocationSearch <LocationSearch

View file

@ -154,4 +154,40 @@ describe('MobileBottomSheet keyboard avoidance', () => {
expect(coveredHeights[coveredHeights.length - 1]).toBe(452); expect(coveredHeights[coveredHeights.length - 1]).toBe(452);
}); });
it('caps the height so the drag handle never slides under a top header', async () => {
installViewport({ innerHeight: 800, visualHeight: 800 });
const { coveredHeights, sheet, container } = renderSheet();
const handle = sheet.firstElementChild?.firstElementChild;
if (!(handle instanceof HTMLElement)) throw new Error('Expected bottom sheet drag handle');
// Pretend a 48px app header sits above the sheet's layout parent, so the
// sheet must reserve that strip (plus a small gap) at the top of the window.
container.getBoundingClientRect = () =>
({
top: 48,
bottom: 800,
left: 0,
right: 390,
width: 390,
height: 752,
x: 0,
y: 48,
toJSON: () => ({}),
}) as DOMRect;
await act(async () => {
window.dispatchEvent(new Event('resize'));
});
// Drag the handle far above the top of the window.
await act(async () => {
fireEvent.pointerDown(handle, { pointerId: 1, clientY: 700 });
fireEvent.pointerMove(handle, { pointerId: 1, clientY: -400 });
});
// 800 (viewport) - 48 (header) - 16 (gap) = 736 px ceiling; never taller.
expect(coveredHeights[coveredHeights.length - 1]).toBe(736);
});
}); });

View file

@ -1,6 +1,10 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
// Small gap kept between the sheet's top edge (where the drag handle lives) and
// whatever sits above it, so the handle always stays fully on-screen.
const SHEET_TOP_GAP_PX = 16;
interface VisualViewportState { interface VisualViewportState {
height: number; height: number;
bottomInset: number; bottomInset: number;
@ -117,15 +121,39 @@ export default function MobileBottomSheet({
const scrollIntoViewTimerRef = useRef<number | null>(null); const scrollIntoViewTimerRef = useRef<number | null>(null);
const [height, setHeight] = useState<number | null>(null); const [height, setHeight] = useState<number | null>(null);
const [isDragging, setIsDragging] = useState(false); const [isDragging, setIsDragging] = useState(false);
// The sheet is fixed to the window, but it logically lives below the app
// header. Measure the top of our layout parent (the map area, which starts at
// the header's bottom edge) so the sheet can never grow tall enough to slide
// its drag handle up underneath the header, where it'd become unreachable and
// leave the sheet stuck open.
const [topInset, setTopInset] = useState(0);
useLayoutEffect(() => {
const measure = () => {
const parent = sheetRef.current?.parentElement;
setTopInset(parent ? Math.max(0, parent.getBoundingClientRect().top) : 0);
};
measure();
window.addEventListener('resize', measure);
window.addEventListener('orientationchange', measure);
return () => {
window.removeEventListener('resize', measure);
window.removeEventListener('orientationchange', measure);
};
}, []);
const heightBounds = useMemo(() => { const heightBounds = useMemo(() => {
const available = viewport.height; const available = viewport.height;
const min = Math.min(132, Math.max(104, available * 0.22));
// Reserve the header strip (topInset) plus a small gap at the top so the
// drag handle always stays on-screen and grabbable.
const ceiling = available - topInset - SHEET_TOP_GAP_PX;
return { return {
min: Math.min(132, Math.max(104, available * 0.22)), min,
initial: Math.min(available * 0.56, Math.max(330, available * 0.44)), initial: Math.min(available * 0.56, Math.max(330, available * 0.44)),
max: Math.max(300, available - 12), max: Math.max(min, ceiling),
}; };
}, [viewport.height]); }, [viewport.height, topInset]);
const currentHeight = clamp(height ?? heightBounds.initial, heightBounds.min, heightBounds.max); const currentHeight = clamp(height ?? heightBounds.initial, heightBounds.min, heightBounds.max);

View file

@ -0,0 +1,58 @@
import { describe, expect, it } from 'vitest';
import { buildTimelineEvents } from './PropertiesPane';
import type { Property } from '../../types';
function makeProperty(overrides: Partial<Property>): Property {
return { lat: 51, lon: -0.1, ...overrides } as Property;
}
// Compact, discriminated summary of each event so assertions stay type-safe.
function summarize(property: Property): string[] {
return buildTimelineEvents(property).map((event) => {
switch (event.kind) {
case 'tenure':
return `tenure:${event.status}:${event.year}`;
case 'sale':
return `sale:${event.year}`;
case 'reno':
return `reno:${event.event}:${event.year}`;
case 'built':
return `built:${event.year}`;
}
});
}
describe('buildTimelineEvents', () => {
it('renders tenure changes newest-first with their status', () => {
expect(
summarize(
makeProperty({
tenure_history: [
{ year: 2019, status: 'Rented (private)' },
{ year: 2023, status: 'Owner-occupied' },
],
}),
),
).toEqual(['tenure:Owner-occupied:2023', 'tenure:Rented (private):2019']);
});
it('interleaves tenure changes with sales by year', () => {
expect(
summarize(
makeProperty({
historical_prices: [
{ year: 2015, month: 6, price: 200_000 },
// Most recent sale is the card headline, so it is dropped here.
{ year: 2024, month: 1, price: 300_000 },
],
tenure_history: [{ year: 2020, status: 'Rented (private)' }],
}),
),
).toEqual(['tenure:Rented (private):2020', 'sale:2015']);
});
it('emits nothing when there is no tenure history', () => {
expect(summarize(makeProperty({}))).toEqual([]);
});
});

View file

@ -9,6 +9,7 @@ import {
formatYearMonth, formatYearMonth,
} from '../../lib/format'; } from '../../lib/format';
import { getNum } from '../../lib/property-fields'; import { getNum } from '../../lib/property-fields';
import { buildEpcCertificateUrl } from '../../lib/external-search';
import { useRetainedScrollTop } from '../../hooks/useRetainedScrollTop'; import { useRetainedScrollTop } from '../../hooks/useRetainedScrollTop';
import InfoPopup from '../ui/InfoPopup'; import InfoPopup from '../ui/InfoPopup';
import { SearchInput } from '../ui/SearchInput'; import { SearchInput } from '../ui/SearchInput';
@ -292,6 +293,29 @@ function PropertyCard({ property }: { property: Property }) {
)} )}
</div> </div>
{property.postcode && property.current_energy_rating && (
<a
href={buildEpcCertificateUrl(property.postcode)}
target="_blank"
rel="noopener noreferrer"
className="mt-2 inline-flex items-center gap-1 text-sm text-teal-600 dark:text-teal-400 hover:underline"
>
{t('propertyCard.viewEpcCertificate')}
<svg
aria-hidden
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="w-3.5 h-3.5"
>
<path d="M7 17 17 7M9 7h8v8" />
</svg>
</a>
)}
<PropertyTimeline property={property} /> <PropertyTimeline property={property} />
</div> </div>
); );
@ -300,9 +324,10 @@ function PropertyCard({ property }: { property: Property }) {
type TimelineEvent = type TimelineEvent =
| { kind: 'sale'; year: number; month: number; price: number; sortKey: number } | { kind: 'sale'; year: number; month: number; price: number; sortKey: number }
| { kind: 'reno'; year: number; event: string; sortKey: number } | { kind: 'reno'; year: number; event: string; sortKey: number }
| { kind: 'tenure'; year: number; status: string; sortKey: number }
| { kind: 'built'; year: number; approximate: boolean; sortKey: number }; | { kind: 'built'; year: number; approximate: boolean; sortKey: number };
function buildTimelineEvents(property: Property): TimelineEvent[] { export function buildTimelineEvents(property: Property): TimelineEvent[] {
const events: TimelineEvent[] = []; const events: TimelineEvent[] = [];
// Skip the most recent sale: it's already shown in the card headline. // Skip the most recent sale: it's already shown in the card headline.
@ -329,6 +354,16 @@ function buildTimelineEvents(property: Property): TimelineEvent[] {
}); });
} }
for (const tenure of property.tenure_history ?? []) {
events.push({
kind: 'tenure',
year: tenure.year,
status: tenure.status,
// Mid-year, like renos: a tenure change is dated only to the EPC year.
sortKey: tenure.year + 0.5,
});
}
const builtYear = getNum(property, 'Construction year'); const builtYear = getNum(property, 'Construction year');
const approximate = property.is_construction_date_approximate ?? true; const approximate = property.is_construction_date_approximate ?? true;
if (builtYear !== undefined && Number.isFinite(builtYear) && builtYear > 0) { if (builtYear !== undefined && Number.isFinite(builtYear) && builtYear > 0) {
@ -355,6 +390,22 @@ function PropertyTimeline({ property }: { property: Property }) {
const events = useMemo(() => buildTimelineEvents(property), [property]); const events = useMemo(() => buildTimelineEvents(property), [property]);
if (events.length === 0) return null; if (events.length === 0) return null;
// Translate the pipeline's canonical tenure status. Literal keys keep the
// typed `t` happy; an unexpected status falls back to its raw value (the same
// contract the renovation labels use) rather than vanishing.
const tenureLabel = (status: string): string => {
switch (status) {
case 'Owner-occupied':
return t('propertyCard.tenureOwnerOccupied');
case 'Rented (private)':
return t('propertyCard.tenureRentedPrivate');
case 'Rented (social)':
return t('propertyCard.tenureRentedSocial');
default:
return status;
}
};
return ( return (
<div className="mt-3"> <div className="mt-3">
<div className="text-xs text-warm-500 dark:text-warm-400 mb-1.5"> <div className="text-xs text-warm-500 dark:text-warm-400 mb-1.5">
@ -383,6 +434,16 @@ function PropertyTimeline({ property }: { property: Property }) {
</span> </span>
</> </>
)} )}
{event.kind === 'tenure' && (
<>
<span className="text-warm-700 dark:text-warm-200">
{tenureLabel(event.status)}
</span>
<span className="ml-1.5 text-xs text-warm-500 dark:text-warm-400">
{event.year}
</span>
</>
)}
{event.kind === 'built' && ( {event.kind === 'built' && (
<> <>
<span className="text-warm-700 dark:text-warm-200"> <span className="text-warm-700 dark:text-warm-200">
@ -419,6 +480,14 @@ function TimelineMarker({ kind }: { kind: TimelineEvent['kind'] }) {
/> />
); );
} }
if (kind === 'tenure') {
return (
<span
aria-hidden
className={`${base} bg-indigo-400 dark:bg-indigo-400 ring-warm-50 dark:ring-warm-900`}
/>
);
}
return ( return (
<span <span
aria-hidden aria-hidden

View file

@ -23,6 +23,7 @@ import { SpinnerIcon } from '../../ui/icons/SpinnerIcon';
import { IndeterminateProgressBar } from '../../ui/IndeterminateProgressBar'; import { IndeterminateProgressBar } from '../../ui/IndeterminateProgressBar';
import type { MapFlyTo, PaneResizeHandlers } from './types'; import type { MapFlyTo, PaneResizeHandlers } from './types';
import { MapFallback, PaneFallback } from './Fallbacks'; import { MapFallback, PaneFallback } from './Fallbacks';
import { MapErrorBoundary } from '../MapErrorBoundary';
import { LoadingOverlay } from './LoadingOverlay'; import { LoadingOverlay } from './LoadingOverlay';
import { Joyride, Map, MapPageSelectionPane } from './lazyComponents'; import { Joyride, Map, MapPageSelectionPane } from './lazyComponents';
@ -187,6 +188,7 @@ export function DesktopMapPage({
<div data-tutorial="map" className="flex-1 relative"> <div data-tutorial="map" className="flex-1 relative">
<IndeterminateProgressBar show={mapData.loading && !initialLoading} /> <IndeterminateProgressBar show={mapData.loading && !initialLoading} />
<MapErrorBoundary>
<Suspense fallback={<MapFallback />}> <Suspense fallback={<MapFallback />}>
<Map <Map
data={mapData.data} data={mapData.data}
@ -226,6 +228,7 @@ export function DesktopMapPage({
totalCount={totalCount} totalCount={totalCount}
/> />
</Suspense> </Suspense>
</MapErrorBoundary>
<div className="absolute bottom-4 right-4 z-10 flex max-w-[calc(100%_-_2rem)] flex-row flex-wrap justify-end gap-2"> <div className="absolute bottom-4 right-4 z-10 flex max-w-[calc(100%_-_2rem)] flex-row flex-wrap justify-end gap-2">
{onToggleActualListings && ( {onToggleActualListings && (
<button <button

View file

@ -21,6 +21,7 @@ import { SpinnerIcon } from '../../ui/icons/SpinnerIcon';
import { IndeterminateProgressBar } from '../../ui/IndeterminateProgressBar'; import { IndeterminateProgressBar } from '../../ui/IndeterminateProgressBar';
import type { MapFlyTo } from './types'; import type { MapFlyTo } from './types';
import { MapFallback, PaneFallback } from './Fallbacks'; import { MapFallback, PaneFallback } from './Fallbacks';
import { MapErrorBoundary } from '../MapErrorBoundary';
import { LoadingOverlay } from './LoadingOverlay'; import { LoadingOverlay } from './LoadingOverlay';
import { Map, MobileDrawer } from './lazyComponents'; import { Map, MobileDrawer } from './lazyComponents';
@ -136,12 +137,23 @@ export function MobileMapPage({
bottomScreenInset bottomScreenInset
)}px - 7rem))`; )}px - 7rem))`;
// The action buttons (listing/overlays/POI) are pinned to the top-right over
// the map. Reserve their width on the right of the top-card overlay so the
// expanded search bar shrinks to fit instead of sliding underneath them.
// Each button is p-2 + a 1.25rem icon (36px); they sit in a `gap-2` (8px) row.
const ACTION_BUTTON_WIDTH = 36;
const ACTION_BUTTON_GAP = 8;
const actionButtonCount = (onToggleActualListings ? 1 : 0) + 2;
const topCardsRightInset =
actionButtonCount * ACTION_BUTTON_WIDTH + actionButtonCount * ACTION_BUTTON_GAP; // inter-button gaps + breathing room before the search
return ( return (
<div className="flex-1 overflow-hidden relative"> <div className="flex-1 overflow-hidden relative">
<LoadingOverlay show={initialLoading} /> <LoadingOverlay show={initialLoading} />
<div className="absolute inset-0"> <div className="absolute inset-0">
<IndeterminateProgressBar show={mapData.loading && !initialLoading} /> <IndeterminateProgressBar show={mapData.loading && !initialLoading} />
<MapErrorBoundary>
<Suspense fallback={<MapFallback />}> <Suspense fallback={<MapFallback />}>
<Map <Map
data={mapData.data} data={mapData.data}
@ -176,10 +188,12 @@ export function MobileMapPage({
actualListings={actualListings} actualListings={actualListings}
bounds={mapData.bounds} bounds={mapData.bounds}
hideLegend hideLegend
topCardsRightInset={topCardsRightInset}
travelTimeEntries={travelTimeEntries} travelTimeEntries={travelTimeEntries}
bottomScreenInset={bottomScreenInset} bottomScreenInset={bottomScreenInset}
/> />
</Suspense> </Suspense>
</MapErrorBoundary>
</div> </div>
<div className="absolute right-3 top-3 z-20 flex max-w-[calc(100%_-_1.5rem)] flex-row flex-wrap justify-end gap-2"> <div className="absolute right-3 top-3 z-20 flex max-w-[calc(100%_-_1.5rem)] flex-row flex-wrap justify-end gap-2">

View file

@ -26,6 +26,8 @@ export interface MapPageProps {
poiCategoryGroups: POICategoryGroup[]; poiCategoryGroups: POICategoryGroup[];
initialFilters: FeatureFilters; initialFilters: FeatureFilters;
initialViewState: ViewState; initialViewState: ViewState;
/** Offer the "check your area" GPS prompt (fresh demo visit, no URL view). */
offerDemoLocation?: boolean;
initialPOICategories: Set<string>; initialPOICategories: Set<string>;
initialOverlays?: Set<OverlayId>; initialOverlays?: Set<OverlayId>;
initialCrimeTypes?: Set<string>; initialCrimeTypes?: Set<string>;

View file

@ -156,6 +156,12 @@ export function useExportController({
// drive which rows appear. // drive which rows appear.
const filterStr = buildFilterString(filters, features); const filterStr = buildFilterString(filters, features);
if (filterStr) params.set('filters', filterStr); if (filterStr) params.set('filters', filterStr);
// Travel times are per-postcode, so include them as columns here too.
// The server ignores the time-range filter in list mode (the supplied
// postcodes drive the rows), but still shows each one's travel time.
const travelParam = buildTravelParam(travelTimeEntries);
if (travelParam) params.set('travel', travelParam);
appendTravelStateParams(params, travelTimeEntries);
if (shareCode) params.set('share', shareCode); if (shareCode) params.set('share', shareCode);
} else { } else {
const { south, west, north, east } = bounds!; const { south, west, north, east } = bounds!;

View file

@ -59,6 +59,101 @@ describe('useFilters', () => {
expect(result.current.filters.price).toEqual([10, 90]); expect(result.current.filters.price).toEqual([10, 90]);
}); });
it('caps the number of filters for demo users and reports when the limit is hit', () => {
const sixFeatures: FeatureMeta[] = ['a', 'b', 'c', 'd', 'e', 'f'].map((name) => ({
name,
type: 'numeric',
min: 0,
max: 100,
}));
const onFilterLimitReached = vi.fn();
const { result } = renderHook(() =>
useFilters({
initialFilters: {},
features: sixFeatures,
filterLimit: 5,
onFilterLimitReached,
})
);
act(() => {
['a', 'b', 'c', 'd', 'e'].forEach((name) => result.current.handleAddFilter(name));
});
expect(Object.keys(result.current.filters)).toHaveLength(5);
expect(onFilterLimitReached).not.toHaveBeenCalled();
// The 6th distinct filter is blocked and reported.
act(() => {
result.current.handleAddFilter('f');
});
expect(Object.keys(result.current.filters)).toHaveLength(5);
expect(result.current.filters.f).toBeUndefined();
expect(onFilterLimitReached).toHaveBeenCalledTimes(1);
// Re-adding an already-present feature is allowed (it replaces, no new key).
act(() => {
result.current.handleAddFilter('a');
});
expect(Object.keys(result.current.filters)).toHaveLength(5);
expect(onFilterLimitReached).toHaveBeenCalledTimes(1);
});
it('caps bulk handleSetFilters (e.g. AI results) to the demo limit and reports it', () => {
const sixFeatures: FeatureMeta[] = ['a', 'b', 'c', 'd', 'e', 'f'].map((name) => ({
name,
type: 'numeric',
min: 0,
max: 100,
}));
const onFilterLimitReached = vi.fn();
const { result } = renderHook(() =>
useFilters({
initialFilters: {},
features: sixFeatures,
filterLimit: 5,
onFilterLimitReached,
})
);
act(() => {
result.current.handleSetFilters({
a: [0, 1],
b: [0, 1],
c: [0, 1],
d: [0, 1],
e: [0, 1],
f: [0, 1],
});
});
expect(Object.keys(result.current.filters)).toHaveLength(5);
expect(onFilterLimitReached).toHaveBeenCalledTimes(1);
});
it('does not cap filters when filterLimit is null (licensed users)', () => {
const sixFeatures: FeatureMeta[] = ['a', 'b', 'c', 'd', 'e', 'f'].map((name) => ({
name,
type: 'numeric',
min: 0,
max: 100,
}));
const { result } = renderHook(() =>
useFilters({
initialFilters: {},
features: sixFeatures,
filterLimit: null,
})
);
act(() => {
['a', 'b', 'c', 'd', 'e', 'f'].forEach((name) => result.current.handleAddFilter(name));
});
expect(Object.keys(result.current.filters)).toHaveLength(6);
});
it('uses the provided initial range for drag-only feature keys', () => { it('uses the provided initial range for drag-only feature keys', () => {
const { result } = renderHook(() => const { result } = renderHook(() =>
useFilters({ useFilters({

View file

@ -47,6 +47,10 @@ import {
interface UseFiltersOptions { interface UseFiltersOptions {
initialFilters: FeatureFilters; initialFilters: FeatureFilters;
features: FeatureMeta[]; features: FeatureMeta[];
/** Max simultaneous filters for the current user; `null`/`undefined` = unlimited. */
filterLimit?: number | null;
/** Called when an add is blocked because `filterLimit` is already reached. */
onFilterLimitReached?: () => void;
} }
function normalizeFilters(filters: FeatureFilters): FeatureFilters { function normalizeFilters(filters: FeatureFilters): FeatureFilters {
@ -104,13 +108,25 @@ function getNextNumericKeyId(
return max + 1; return max + 1;
} }
export function useFilters({ initialFilters, features }: UseFiltersOptions) { export function useFilters({
initialFilters,
features,
filterLimit,
onFilterLimitReached,
}: UseFiltersOptions) {
const initialFiltersRef = useRef<FeatureFilters | null>(null); const initialFiltersRef = useRef<FeatureFilters | null>(null);
if (!initialFiltersRef.current) { if (!initialFiltersRef.current) {
initialFiltersRef.current = normalizeFilters(initialFilters); initialFiltersRef.current = normalizeFilters(initialFilters);
} }
const [filters, setFilters] = useState<FeatureFilters>(() => initialFiltersRef.current!); const [filters, setFilters] = useState<FeatureFilters>(() => initialFiltersRef.current!);
// Mirrors used by handleAddFilter's limit check so its identity stays stable.
const filtersRef = useRef(filters);
filtersRef.current = filters;
const filterLimitRef = useRef(filterLimit);
filterLimitRef.current = filterLimit;
const onFilterLimitReachedRef = useRef(onFilterLimitReached);
onFilterLimitReachedRef.current = onFilterLimitReached;
const [activeFeature, setActiveFeature] = useState<string | null>(null); const [activeFeature, setActiveFeature] = useState<string | null>(null);
const [dragValue, setDragValue] = useState<[number, number] | null>(null); const [dragValue, setDragValue] = useState<[number, number] | null>(null);
const [pinnedFeature, setPinnedFeature] = useState<string | null>(null); const [pinnedFeature, setPinnedFeature] = useState<string | null>(null);
@ -175,6 +191,26 @@ export function useFilters({ initialFilters, features }: UseFiltersOptions) {
) { ) {
return; return;
} }
// Demo (unlicensed) users are capped at `filterLimit` simultaneous filters.
// Re-applying an existing regular feature replaces it (no new key), so it's
// always allowed; the special/generated filters always add a new key.
const limit = filterLimitRef.current;
if (limit != null) {
const current = filtersRef.current;
const addsNewKey =
name === SCHOOL_FILTER_NAME ||
name === SPECIFIC_CRIMES_FILTER_NAME ||
name === ELECTION_VOTE_SHARE_FILTER_NAME ||
name === ETHNICITIES_FILTER_NAME ||
POI_FILTER_NAMES.includes(name as PoiFilterName) ||
!(name in current);
if (addsNewKey && Object.keys(current).length >= limit) {
onFilterLimitReachedRef.current?.();
return;
}
}
trackEvent('Filter Add', { feature: name }); trackEvent('Filter Add', { feature: name });
setFilters((prev) => { setFilters((prev) => {
undoStackRef.current.push(prev); undoStackRef.current.push(prev);
@ -470,7 +506,16 @@ export function useFilters({ initialFilters, features }: UseFiltersOptions) {
}, []); }, []);
const handleSetFilters = useCallback((newFilters: FeatureFilters) => { const handleSetFilters = useCallback((newFilters: FeatureFilters) => {
setFilters(normalizeFilters(newFilters)); // Enforce the demo cap on bulk sets too (e.g. AI filters, which bypass the
// per-add path). Without this, an unlicensed user could end up with >5 filters,
// which the server then rejects (400), breaking the map.
let next = newFilters;
const limit = filterLimitRef.current;
if (limit != null && Object.keys(next).length > limit) {
next = Object.fromEntries(Object.entries(next).slice(0, limit));
onFilterLimitReachedRef.current?.();
}
setFilters(normalizeFilters(next));
setActiveFeature(null); setActiveFeature(null);
setDragValue(null); setDragValue(null);
setPinnedFeature(null); setPinnedFeature(null);

View file

@ -80,6 +80,18 @@ describe('useHexagonSelection', () => {
return Promise.resolve(jsonResponse(stats(12))); return Promise.resolve(jsonResponse(stats(12)));
} }
if (url.pathname.startsWith('/api/postcode/')) {
const postcode = decodeURIComponent(url.pathname.slice('/api/postcode/'.length));
return Promise.resolve(
jsonResponse({
postcode,
latitude: 51.5,
longitude: -0.115,
geometry: postcodeGeometry,
})
);
}
if (url.pathname === '/api/postcode-properties') { if (url.pathname === '/api/postcode-properties') {
return Promise.resolve( return Promise.resolve(
jsonResponse({ properties: [], total: 0, offset: 0, truncated: false }) jsonResponse({ properties: [], total: 0, offset: 0, truncated: false })
@ -428,4 +440,60 @@ describe('useHexagonSelection', () => {
expect(allPropertiesParams.has('filters')).toBe(false); expect(allPropertiesParams.has('filters')).toBe(false);
expect(allPropertiesParams.has('travel')).toBe(false); expect(allPropertiesParams.has('travel')).toBe(false);
}); });
it('restores the original clicked postcode after zooming out to a hexagon and back', async () => {
// Stable references: the sync effect synchronously resets properties when it
// runs, so unstable filters/hexagonData/travel props (new identity each
// render) would re-trigger it forever. In the app these come from memoised
// hooks; mirror that here.
const stableFilters = {};
const stableHexagonData: never[] = [];
const stableTravel: TravelTimeEntry[] = [];
const { result, rerender } = renderHook(
({ usePostcodeView }: { usePostcodeView: boolean }) =>
useHexagonSelection({
filters: stableFilters,
features,
hexagonData: stableHexagonData,
resolution: 9,
usePostcodeView,
travelTimeEntries: stableTravel,
}),
{ initialProps: { usePostcodeView: true } }
);
// Click a postcode whose neighbouring hexagon reports a *different*
// central_postcode ('SW1A 1AA', see stats()).
act(() => {
result.current.handleHexagonClick('AB1 2CD', true, postcodeGeometry);
});
await waitFor(() => {
expect(result.current.selectedHexagon?.id).toBe('AB1 2CD');
expect(result.current.selectedHexagon?.type).toBe('postcode');
});
expect(result.current.selectedHexagon?.anchorPostcode?.id).toBe('AB1 2CD');
// Zoom out past the postcode threshold: the selection follows down to a hexagon.
act(() => {
rerender({ usePostcodeView: false });
});
await waitFor(() => {
expect(result.current.selectedHexagon?.type).toBe('hexagon');
});
expect(result.current.selectedHexagon?.anchorPostcode?.id).toBe('AB1 2CD');
// Zoom back in: the original postcode must be restored, not the hexagon's
// central_postcode ('SW1A 1AA').
act(() => {
rerender({ usePostcodeView: true });
});
await waitFor(() => {
expect(result.current.selectedHexagon?.type).toBe('postcode');
});
expect(result.current.selectedHexagon?.id).toBe('AB1 2CD');
expect(result.current.selectedPostcodeGeometry).toBe(postcodeGeometry);
});
}); });

View file

@ -21,6 +21,14 @@ interface SelectedHexagon {
type: 'hexagon' | 'postcode'; type: 'hexagon' | 'postcode';
resolution: number; resolution: number;
lockedResolution?: boolean; lockedResolution?: boolean;
/**
* The postcode the user is really tracking. Set when a postcode is selected
* (or first derived from a hexagon) and carried through zoom-driven hexagon
* conversions, so that zooming out and back in restores the same postcode
* instead of drifting to a neighbour whose centre happens to fall in the same
* cell. Cleared implicitly on every explicit (re)selection.
*/
anchorPostcode?: { id: string; geometry: PostcodeGeometry | null };
} }
interface JourneyDest { interface JourneyDest {
@ -331,7 +339,12 @@ export function useHexagonSelection({
setSelectedPostcodeGeometry(null); setSelectedPostcodeGeometry(null);
} else { } else {
const type: SelectedHexagon['type'] = isPostcode ? 'postcode' : 'hexagon'; const type: SelectedHexagon['type'] = isPostcode ? 'postcode' : 'hexagon';
const selection = { id, type, resolution }; const selection: SelectedHexagon = {
id,
type,
resolution,
...(isPostcode ? { anchorPostcode: { id, geometry: geometry ?? null } } : {}),
};
const requestId = invalidateAreaRequests(); const requestId = invalidateAreaRequests();
invalidatePropertyRequests(); invalidatePropertyRequests();
trackEvent('Hexagon Click', { type }); trackEvent('Hexagon Click', { type });
@ -445,7 +458,7 @@ export function useHexagonSelection({
(usePostcodeView && (usePostcodeView &&
selection.type === 'hexagon' && selection.type === 'hexagon' &&
!selection.lockedResolution && !selection.lockedResolution &&
areaStats?.central_postcode != null) || (selection.anchorPostcode != null || areaStats?.central_postcode != null)) ||
(!usePostcodeView && selection.type === 'postcode' && !selection.lockedResolution) || (!usePostcodeView && selection.type === 'postcode' && !selection.lockedResolution) ||
(!usePostcodeView && (!usePostcodeView &&
selection.type === 'hexagon' && selection.type === 'hexagon' &&
@ -483,19 +496,29 @@ export function useHexagonSelection({
let nextStats: HexagonStatsResponse | null = null; let nextStats: HexagonStatsResponse | null = null;
if (usePostcodeView && selection.type === 'hexagon' && !selection.lockedResolution) { if (usePostcodeView && selection.type === 'hexagon' && !selection.lockedResolution) {
if (!areaStats?.central_postcode) return; // Prefer the anchored postcode (the one the user originally tracked) so
const lookup = await fetchPostcodeLookup(areaStats.central_postcode, controller.signal); // a zoom-out/zoom-in round-trip restores it exactly, rather than the
nextSelection = { id: lookup.postcode, type: 'postcode', resolution }; // hexagon's current central_postcode, which may be a neighbour.
nextGeometry = lookup.geometry; const anchor = selection.anchorPostcode;
nextStats = await fetchPostcodeStats( const postcode = anchor?.id ?? areaStats?.central_postcode;
lookup.postcode, if (!postcode) return;
controller.signal, let geometry = anchor?.geometry ?? null;
areaStatsUseFilters if (!geometry) {
); geometry = (await fetchPostcodeLookup(postcode, controller.signal)).geometry;
}
nextSelection = {
id: postcode,
type: 'postcode',
resolution,
anchorPostcode: { id: postcode, geometry },
};
nextGeometry = geometry;
nextStats = await fetchPostcodeStats(postcode, controller.signal, areaStatsUseFilters);
} else if (!usePostcodeView && selection.type === 'postcode') { } else if (!usePostcodeView && selection.type === 'postcode') {
const lookup = await fetchPostcodeLookup(selection.id, controller.signal); const lookup = await fetchPostcodeLookup(selection.id, controller.signal);
const nextId = latLngToCell(lookup.latitude, lookup.longitude, resolution); const nextId = latLngToCell(lookup.latitude, lookup.longitude, resolution);
nextSelection = { id: nextId, type: 'hexagon', resolution }; const anchor = selection.anchorPostcode ?? { id: selection.id, geometry: lookup.geometry };
nextSelection = { id: nextId, type: 'hexagon', resolution, anchorPostcode: anchor };
nextStats = await fetchHexagonStats( nextStats = await fetchHexagonStats(
nextId, nextId,
resolution, resolution,
@ -514,7 +537,12 @@ export function useHexagonSelection({
? cellToParent(selection.id, resolution) ? cellToParent(selection.id, resolution)
: overlappingHexagon?.h3; : overlappingHexagon?.h3;
if (!nextId) return; if (!nextId) return;
nextSelection = { id: nextId, type: 'hexagon', resolution }; nextSelection = {
id: nextId,
type: 'hexagon',
resolution,
anchorPostcode: selection.anchorPostcode,
};
nextStats = await fetchHexagonStats( nextStats = await fetchHexagonStats(
nextId, nextId,
resolution, resolution,

View file

@ -457,6 +457,10 @@ export function useMapData({
if (requestKey === latestDataRequestKeyRef.current && !isAbortError(err)) { if (requestKey === latestDataRequestKeyRef.current && !isAbortError(err)) {
logNonAbortError('Failed to fetch data', err); logNonAbortError('Failed to fetch data', err);
setLoading(false); setLoading(false);
// Mark this request settled so a terminal error (e.g. 400 filter_limit or
// 429 rate_limited) clears the loading spinner instead of hanging it; the
// last successful data stays visible.
setLoadedDataKey(requestKey);
} }
} }
}, DEBOUNCE_MS); }, DEBOUNCE_MS);

View file

@ -834,11 +834,15 @@ const de: Translations = {
listedBuildingBadge: 'Wahrscheinlich Listed Building', listedBuildingBadge: 'Wahrscheinlich Listed Building',
epcRating: 'EPC-Bewertung:', epcRating: 'EPC-Bewertung:',
epcPotential: 'EPC-Potenzial:', epcPotential: 'EPC-Potenzial:',
viewEpcCertificate: 'EPC-Zertifikat ansehen',
renovations: 'Renovierungen', renovations: 'Renovierungen',
perSqm: '/m²', perSqm: '/m²',
historyTitle: 'Historie', historyTitle: 'Historie',
historySale: 'Verkauf', historySale: 'Verkauf',
historyBuilt: 'Baujahr', historyBuilt: 'Baujahr',
tenureOwnerOccupied: 'Eigennutzung',
tenureRentedPrivate: 'Privat vermietet',
tenureRentedSocial: 'Sozialwohnung',
searchPlaceholder: 'Nach Adresse oder Postcode suchen...', searchPlaceholder: 'Nach Adresse oder Postcode suchen...',
propertyData: 'Immobiliendaten', propertyData: 'Immobiliendaten',
propertyDataDesc: propertyDataDesc:
@ -923,6 +927,14 @@ const de: Translations = {
geolocationFailed: 'Standort konnte nicht ermittelt werden', geolocationFailed: 'Standort konnte nicht ermittelt werden',
}, },
demoLocation: {
title: 'Ihre Umgebung erkunden',
body: 'Möchten Sie Immobiliendaten in Ihrer Nähe sehen? Verwenden Sie Ihren Standort oder starten Sie die Demo in Canary Wharf.',
useLocation: 'Meinen Standort verwenden',
locating: 'Standort wird ermittelt…',
useDefault: 'Canary Wharf anzeigen',
},
// ── Mobile Drawer ────────────────────────────────── // ── Mobile Drawer ──────────────────────────────────
mobileDrawer: { mobileDrawer: {
closeDrawer: 'Drawer schließen', closeDrawer: 'Drawer schließen',

View file

@ -820,11 +820,15 @@ const en = {
listedBuildingBadge: 'Likely listed', listedBuildingBadge: 'Likely listed',
epcRating: 'EPC rating:', epcRating: 'EPC rating:',
epcPotential: 'EPC potential:', epcPotential: 'EPC potential:',
viewEpcCertificate: 'View EPC certificate',
renovations: 'Renovations', renovations: 'Renovations',
perSqm: '/m²', perSqm: '/m²',
historyTitle: 'History', historyTitle: 'History',
historySale: 'Sale', historySale: 'Sale',
historyBuilt: 'Built', historyBuilt: 'Built',
tenureOwnerOccupied: 'Owner-occupied',
tenureRentedPrivate: 'Privately rented',
tenureRentedSocial: 'Social housing',
searchPlaceholder: 'Search by address or postcode...', searchPlaceholder: 'Search by address or postcode...',
propertyData: 'Property Data', propertyData: 'Property Data',
propertyDataDesc: propertyDataDesc:
@ -907,6 +911,15 @@ const en = {
geolocationFailed: 'Couldnt determine your location', geolocationFailed: 'Couldnt determine your location',
}, },
// ── Demo location prompt ───────────────────────────
demoLocation: {
title: 'Explore your area',
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',
},
// ── Mobile Drawer ────────────────────────────────── // ── Mobile Drawer ──────────────────────────────────
mobileDrawer: { mobileDrawer: {
closeDrawer: 'Close drawer', closeDrawer: 'Close drawer',

View file

@ -848,11 +848,15 @@ const fr: Translations = {
listedBuildingBadge: 'Bâtiment probablement classé', listedBuildingBadge: 'Bâtiment probablement classé',
epcRating: 'Note EPC :', epcRating: 'Note EPC :',
epcPotential: 'Potentiel EPC :', epcPotential: 'Potentiel EPC :',
viewEpcCertificate: 'Voir le certificat EPC',
renovations: 'Rénovations', renovations: 'Rénovations',
perSqm: '/m²', perSqm: '/m²',
historyTitle: 'Historique', historyTitle: 'Historique',
historySale: 'Vente', historySale: 'Vente',
historyBuilt: 'Construit', historyBuilt: 'Construit',
tenureOwnerOccupied: 'Propriétaire occupant',
tenureRentedPrivate: 'Loué (privé)',
tenureRentedSocial: 'Logement social',
searchPlaceholder: 'Rechercher par adresse ou code postal...', searchPlaceholder: 'Rechercher par adresse ou code postal...',
propertyData: 'Données immobilières', propertyData: 'Données immobilières',
propertyDataDesc: propertyDataDesc:
@ -936,6 +940,14 @@ const fr: Translations = {
geolocationFailed: 'Impossible de déterminer votre position', geolocationFailed: 'Impossible de déterminer votre position',
}, },
demoLocation: {
title: 'Explorez votre quartier',
body: 'Envie de voir les données immobilières autour de vous ? Utilisez votre position, ou commencez la démo à Canary Wharf.',
useLocation: 'Utiliser ma position',
locating: 'Localisation…',
useDefault: 'Afficher Canary Wharf',
},
// ── Mobile Drawer ────────────────────────────────── // ── Mobile Drawer ──────────────────────────────────
mobileDrawer: { mobileDrawer: {
closeDrawer: 'Fermer le tiroir', closeDrawer: 'Fermer le tiroir',

View file

@ -808,11 +808,15 @@ const hi: Translations = {
listedBuildingBadge: 'शायद सूचीबद्ध', listedBuildingBadge: 'शायद सूचीबद्ध',
epcRating: 'EPC रेटिंग:', epcRating: 'EPC रेटिंग:',
epcPotential: 'संभावित EPC:', epcPotential: 'संभावित EPC:',
viewEpcCertificate: 'EPC प्रमाणपत्र देखें',
renovations: 'नवीनीकरण', renovations: 'नवीनीकरण',
perSqm: '/वर्ग मी', perSqm: '/वर्ग मी',
historyTitle: 'इतिहास', historyTitle: 'इतिहास',
historySale: 'बिक्री', historySale: 'बिक्री',
historyBuilt: 'निर्मित', historyBuilt: 'निर्मित',
tenureOwnerOccupied: 'मालिक का निवास',
tenureRentedPrivate: 'निजी किराये पर',
tenureRentedSocial: 'सामाजिक किराया आवास',
searchPlaceholder: 'पते या पोस्टकोड से खोजें...', searchPlaceholder: 'पते या पोस्टकोड से खोजें...',
propertyData: 'संपत्ति डेटा', propertyData: 'संपत्ति डेटा',
propertyDataDesc: propertyDataDesc:
@ -891,6 +895,14 @@ const hi: Translations = {
geolocationFailed: 'आपका स्थान निर्धारित नहीं किया जा सका', geolocationFailed: 'आपका स्थान निर्धारित नहीं किया जा सका',
}, },
demoLocation: {
title: 'अपना क्षेत्र देखें',
body: 'क्या आप अपने आसपास की संपत्ति डेटा देखना चाहते हैं? अपना स्थान उपयोग करें, या Canary Wharf से डेमो शुरू करें।',
useLocation: 'मेरा स्थान उपयोग करें',
locating: 'स्थान खोजा जा रहा है…',
useDefault: 'Canary Wharf दिखाएं',
},
mobileDrawer: { mobileDrawer: {
closeDrawer: 'ड्रॉअर बंद करें', closeDrawer: 'ड्रॉअर बंद करें',
}, },

View file

@ -836,11 +836,15 @@ const hu: Translations = {
listedBuildingBadge: 'Talán műemlék', listedBuildingBadge: 'Talán műemlék',
epcRating: 'EPC minősítés:', epcRating: 'EPC minősítés:',
epcPotential: 'Lehetséges EPC-minősítés:', epcPotential: 'Lehetséges EPC-minősítés:',
viewEpcCertificate: 'EPC-tanúsítvány megtekintése',
renovations: 'Felújítások', renovations: 'Felújítások',
perSqm: '/m²', perSqm: '/m²',
historyTitle: 'Előzmények', historyTitle: 'Előzmények',
historySale: 'Eladás', historySale: 'Eladás',
historyBuilt: 'Építés', historyBuilt: 'Építés',
tenureOwnerOccupied: 'Tulajdonos lakja',
tenureRentedPrivate: 'Magánbérlemény',
tenureRentedSocial: 'Szociális bérlakás',
searchPlaceholder: 'Keresés cím vagy irányítószám alapján...', searchPlaceholder: 'Keresés cím vagy irányítószám alapján...',
propertyData: 'Ingatlanadatok', propertyData: 'Ingatlanadatok',
propertyDataDesc: propertyDataDesc:
@ -924,6 +928,14 @@ const hu: Translations = {
geolocationFailed: 'Nem sikerült meghatározni a tartózkodási helyed', geolocationFailed: 'Nem sikerült meghatározni a tartózkodási helyed',
}, },
demoLocation: {
title: 'Fedezd fel a környékedet',
body: 'Szeretnél ingatlanadatokat látni a környékeden? Használd a helyzeted, vagy indítsd a demót a Canary Wharfnál.',
useLocation: 'Saját helyzet használata',
locating: 'Helymeghatározás…',
useDefault: 'Canary Wharf megjelenítése',
},
// ── Mobile Drawer ────────────────────────────────── // ── Mobile Drawer ──────────────────────────────────
mobileDrawer: { mobileDrawer: {
closeDrawer: 'Panel bezárása', closeDrawer: 'Panel bezárása',

View file

@ -781,11 +781,15 @@ const zh: Translations = {
listedBuildingBadge: '可能为受保护建筑', listedBuildingBadge: '可能为受保护建筑',
epcRating: 'EPC 评级:', epcRating: 'EPC 评级:',
epcPotential: '潜在 EPC 评级:', epcPotential: '潜在 EPC 评级:',
viewEpcCertificate: '查看 EPC 证书',
renovations: '改造记录', renovations: '改造记录',
perSqm: '/m²', perSqm: '/m²',
historyTitle: '历史', historyTitle: '历史',
historySale: '成交', historySale: '成交',
historyBuilt: '建成', historyBuilt: '建成',
tenureOwnerOccupied: '自住',
tenureRentedPrivate: '私人出租',
tenureRentedSocial: '社会租赁',
searchPlaceholder: '按地址或邮编搜索...', searchPlaceholder: '按地址或邮编搜索...',
propertyData: '房产数据', propertyData: '房产数据',
propertyDataDesc: propertyDataDesc:
@ -866,6 +870,14 @@ const zh: Translations = {
geolocationFailed: '无法确定您的位置', geolocationFailed: '无法确定您的位置',
}, },
demoLocation: {
title: '探索您的区域',
body: '想查看您周围的房产数据吗?使用您的位置,或从金丝雀码头开始演示。',
useLocation: '使用我的位置',
locating: '正在定位…',
useDefault: '显示金丝雀码头',
},
// ── Mobile Drawer ────────────────────────────────── // ── Mobile Drawer ──────────────────────────────────
mobileDrawer: { mobileDrawer: {
closeDrawer: '关闭侧栏', closeDrawer: '关闭侧栏',

View file

@ -6,8 +6,8 @@
<meta name="theme-color" content="#fafaf9" media="(prefers-color-scheme: light)" /> <meta name="theme-color" content="#fafaf9" media="(prefers-color-scheme: light)" />
<meta name="theme-color" content="#0a0e1a" media="(prefers-color-scheme: dark)" /> <meta name="theme-color" content="#0a0e1a" media="(prefers-color-scheme: dark)" />
<meta name="referrer" content="no-referrer" /> <meta name="referrer" content="no-referrer" />
<title>Perfect Postcode - Find where to buy before browsing listings</title> <title>Perfect Postcode - Find the best-value postcode in England</title>
<meta name="description" content="Filter every postcode in England by budget, commute, schools, crime, noise, broadband, property prices and amenities before you start chasing viewings." /> <meta name="description" content="Rank every postcode in England by value: £/sqm, sold prices, schools, commute, crime, noise and broadband. Find the underpriced postcode the market overlooked." />
<meta name="x-og-placeholder" content="__PERFECT_POSTCODE_OG_TAGS__" /> <meta name="x-og-placeholder" content="__PERFECT_POSTCODE_OG_TAGS__" />
<script id="perfect-postcode-bugsink-config" type="application/json">__PERFECT_POSTCODE_BUGSINK_CONFIG__</script> <script id="perfect-postcode-bugsink-config" type="application/json">__PERFECT_POSTCODE_BUGSINK_CONFIG__</script>
<script> <script>

View file

@ -38,9 +38,77 @@ export function authHeaders(init?: RequestInit): RequestInit {
return { ...init, headers: { ...existing, ...headers } }; return { ...init, headers: { ...existing, ...headers } };
} }
// --- Demo location -----------------------------------------------------------
// The visitor's chosen demo centre (their GPS location, with consent) is sent to
// the server on every API request as demoLat/demoLon so the server can build the
// free-zone box around it. Held module-side and injected by apiUrl(), so every
// gated data call carries it without threading it through each hook. Unset when
// the visitor declines (the server then defaults to Canary Wharf).
const DEMO_CENTER_KEY = 'demoCenter';
export type DemoCenter = { lat: number; lon: number };
let demoCenter: DemoCenter | null = null;
/** Endpoints whose access is gated by the demo free zone only these need the
* visitor's demo centre, so we keep the coarse location out of every other URL. */
const DEMO_GATED_ENDPOINTS = new Set([
'hexagons',
'postcodes',
'filter-counts',
'hexagon-stats',
'postcode-stats',
'postcode-properties',
'hexagon-properties',
'journey',
'actual-listings',
'export',
]);
/** Set (or clear) the demo centre sent on subsequent gated API requests. */
export function setDemoCenter(center: DemoCenter | null): void {
demoCenter = center;
}
/** Persist the visitor's demo-location choice for this browser session. */
export function persistDemoChoice(value: DemoCenter | 'declined'): void {
try {
sessionStorage.setItem(
DEMO_CENTER_KEY,
value === 'declined' ? 'declined' : JSON.stringify(value)
);
} catch {
// sessionStorage unavailable (private mode / SSR) — re-prompting is fine.
}
}
/** Read this session's demo-location choice: coords, 'declined', or null (unset). */
export function readDemoChoice(): DemoCenter | 'declined' | null {
try {
const raw = sessionStorage.getItem(DEMO_CENTER_KEY);
if (!raw) return null;
if (raw === 'declined') return 'declined';
const parsed = JSON.parse(raw) as Partial<DemoCenter>;
if (typeof parsed?.lat === 'number' && typeof parsed?.lon === 'number') {
return { lat: parsed.lat, lon: parsed.lon };
}
} catch {
// Ignore malformed/unavailable storage.
}
return null;
}
export function apiUrl(endpoint: string, params?: URLSearchParams): string { export function apiUrl(endpoint: string, params?: URLSearchParams): string {
const path = endpoint.startsWith('/') ? endpoint : `/api/${endpoint}`; const path = endpoint.startsWith('/') ? endpoint : `/api/${endpoint}`;
const query = params?.toString(); const merged = new URLSearchParams(params);
if (demoCenter && DEMO_GATED_ENDPOINTS.has(endpoint)) {
// Coarsen to ~1 km (the demo free-zone box is ~25 km wide) so a precise location
// never reaches the server or its access logs.
merged.set('demoLat', demoCenter.lat.toFixed(2));
merged.set('demoLon', demoCenter.lon.toFixed(2));
}
const query = merged.toString();
return query ? `${path}?${query}` : path; return query ? `${path}?${query}` : path;
} }

View file

@ -13,16 +13,28 @@ export const MAP_MIN_ZOOM = 5.5;
export const BUFFER_MULTIPLIER = 1; export const BUFFER_MULTIPLIER = 1;
/** Demo free zone bounds (south, west, north, east) — must match server FREE_ZONE_BOUNDS */ /**
export const FREE_ZONE_BOUNDS = { south: 51.44, west: -0.31, north: 51.59, east: 0.05 }; * Default demo centre (Canary Wharf). Used as the initial map view and the demo
* free-zone centre when the visitor declines the "check your area" prompt. When
* they consent, their GPS location is used instead (see lib/api `setDemoCenter`).
* Keep in sync with FREE_ZONE_FALLBACK_LAT/LON in the Rust server.
*/
export const CANARY_WHARF = { latitude: 51.5054, longitude: -0.0235 };
/** 10 Downing Street geolocation fallback in dev so the GPS flows stay testable
* without a real fix (e.g. over http, where browsers deny geolocation). */
export const DEV_LOCATION = { latitude: 51.5033635, longitude: -0.1276248 };
export const INITIAL_VIEW_STATE: ViewState = { export const INITIAL_VIEW_STATE: ViewState = {
longitude: (FREE_ZONE_BOUNDS.west + FREE_ZONE_BOUNDS.east) / 2, longitude: CANARY_WHARF.longitude,
latitude: (FREE_ZONE_BOUNDS.south + FREE_ZONE_BOUNDS.north) / 2, latitude: CANARY_WHARF.latitude,
zoom: 14, zoom: 14,
pitch: 0, pitch: 0,
}; };
/** Demo (unlicensed) users can apply at most this many filters simultaneously. */
export const DEMO_MAX_FILTERS = 5;
/** /**
* Zoom to H3 resolution mapping thresholds. * Zoom to H3 resolution mapping thresholds.
* Returns the H3 resolution to use for a given zoom level. * Returns the H3 resolution to use for a given zoom level.
@ -32,6 +44,7 @@ export const ZOOM_TO_RESOLUTION_THRESHOLDS = [
{ maxZoom: 9, resolution: 6 }, { maxZoom: 9, resolution: 6 },
{ maxZoom: 10.5, resolution: 7 }, { maxZoom: 10.5, resolution: 7 },
{ maxZoom: 11.5, resolution: 8 }, { maxZoom: 11.5, resolution: 8 },
{ maxZoom: 14, resolution: 9 },
] as const; ] as const;
export const SMALLEST_VISIBLE_HEXAGON_RESOLUTION = Math.max( export const SMALLEST_VISIBLE_HEXAGON_RESOLUTION = Math.max(
@ -43,7 +56,7 @@ export const SMALLEST_VISIBLE_HEXAGON_RESOLUTION = Math.max(
// past the finest hexagon level so detail appears while still relatively // past the finest hexagon level so detail appears while still relatively
// zoomed out. (Each overlay additionally can't render below its own tile-data // zoomed out. (Each overlay additionally can't render below its own tile-data
// floor, OVERLAY_MIN_ZOOM, regardless of this limit.) // floor, OVERLAY_MIN_ZOOM, regardless of this limit.)
export const POSTCODE_ZOOM_THRESHOLD = 12.5; export const POSTCODE_ZOOM_THRESHOLD = 14;
export const POSTCODE_SEARCH_ZOOM = 16; export const POSTCODE_SEARCH_ZOOM = 16;
export const FEATURE_GRADIENT: { t: number; color: [number, number, number] }[] = [ export const FEATURE_GRADIENT: { t: number; color: [number, number, number] }[] = [

View file

@ -92,6 +92,14 @@ interface SearchUrlOptions {
rightmoveLocationId?: string; rightmoveLocationId?: string;
} }
/** UK government "Find an energy certificate" domestic search, scoped to the
* property's postcode. We don't store the per-certificate LMK key, so this lands
* the user on the postcode result list where they pick the matching address. */
export function buildEpcCertificateUrl(postcode: string): string {
const params = new URLSearchParams({ postcode });
return `https://find-energy-certificate.service.gov.uk/find-a-certificate/search-by-postcode?${params.toString()}`;
}
export function buildRightmoveExactPostcodeRedirectUrl( export function buildRightmoveExactPostcodeRedirectUrl(
postcode: string, postcode: string,
targetUrl: string targetUrl: string

View file

@ -124,7 +124,7 @@ export const SEO_LANDING_PAGES: Record<SeoLandingKey, SeoLandingContent> = {
{ {
question: 'Is this a replacement for Rightmove or Zoopla?', question: 'Is this a replacement for Rightmove or Zoopla?',
answer: answer:
'No. Use it before and alongside listing portals. Perfect Postcode helps decide where to look; listing portals show whats currently for sale.', 'No, it does a different job. Listing portals show whats for sale today; Perfect Postcode shows where the value is across Englands postcodes.',
}, },
{ {
question: 'Can I compare price with schools or commute time?', question: 'Can I compare price with schools or commute time?',

View file

@ -63,6 +63,9 @@ const CRIME_OVERLAY_ID: OverlayId = 'crime-hotspots';
export interface UrlState { export interface UrlState {
viewState: ViewState; viewState: ViewState;
/** True only when the URL carried explicit lat/lon/zoom (shared/dashboard link).
* False on a fresh visit, so the app may centre on the visitor's IP instead. */
hasExplicitView: boolean;
filters: FeatureFilters; filters: FeatureFilters;
poiCategories: Set<string>; poiCategories: Set<string>;
overlays: Set<OverlayId>; overlays: Set<OverlayId>;
@ -221,6 +224,7 @@ export function parseUrlState(): UrlState {
const params = new URLSearchParams(window.location.search); const params = new URLSearchParams(window.location.search);
const result: UrlState = { const result: UrlState = {
viewState: INITIAL_VIEW_STATE, viewState: INITIAL_VIEW_STATE,
hasExplicitView: false,
filters: parseFilters(params), filters: parseFilters(params),
poiCategories: new Set(), poiCategories: new Set(),
overlays: new Set(), overlays: new Set(),
@ -246,6 +250,7 @@ export function parseUrlState(): UrlState {
const zoomN = Number(zoom); const zoomN = Number(zoom);
if (!isNaN(latN) && !isNaN(lonN) && !isNaN(zoomN)) { if (!isNaN(latN) && !isNaN(lonN) && !isNaN(zoomN)) {
result.viewState = { latitude: latN, longitude: lonN, zoom: zoomN, pitch: 0 }; result.viewState = { latitude: latN, longitude: lonN, zoom: zoomN, pitch: 0 };
result.hasExplicitView = true;
} }
} }

View file

@ -202,6 +202,14 @@ export interface RenovationEvent {
event: string; event: string;
} }
/** A point on the tenure timeline: the year an EPC certificate first recorded a
* new occupancy `status` ("Owner-occupied" / "Rented (private)" /
* "Rented (social)"). Surfaces when a home was let out vs lived in. */
export interface TenureEvent {
year: number;
status: string;
}
export interface HistoricalPrice { export interface HistoricalPrice {
year: number; year: number;
month: number; month: number;
@ -229,6 +237,7 @@ export interface Property {
is_construction_date_approximate?: boolean; is_construction_date_approximate?: boolean;
renovation_history?: RenovationEvent[]; renovation_history?: RenovationEvent[];
historical_prices?: HistoricalPrice[]; historical_prices?: HistoricalPrice[];
tenure_history?: TenureEvent[];
// All other numeric features (dynamic, including construction_age_band) // All other numeric features (dynamic, including construction_age_band)
[key: string]: [key: string]:
@ -237,6 +246,7 @@ export interface Property {
| boolean | boolean
| RenovationEvent[] | RenovationEvent[]
| HistoricalPrice[] | HistoricalPrice[]
| TenureEvent[]
| string[] | string[]
| undefined; | undefined;
} }

View file

@ -105,6 +105,35 @@ def epc_band_to_year(band: pl.Expr) -> pl.Expr:
) )
# Coarse occupancy statuses derived from the raw EPC TENURE field, in the order
# the timeline reads them. EPC lodges tenure as one of "Owner-occupied",
# "Rented (private)", "Rented (social)" (plus blanks / "unknown" /
# "Not defined - ..." for new dwellings). Anything unrecognised maps to null
# (unknown) so it neither appears on the timeline nor breaks the change chain.
TENURE_OWNER_OCCUPIED = "Owner-occupied"
TENURE_RENTED_PRIVATE = "Rented (private)"
TENURE_RENTED_SOCIAL = "Rented (social)"
def tenure_status(tenure: pl.Expr) -> pl.Expr:
"""Normalise the raw EPC TENURE field to a coarse occupancy status.
Matching is case-insensitive and order-sensitive: "social" is tested before
the generic "rent" so "Rented (social)" lands on the social bucket rather
than the private one. Null/blank/unrecognised tenures yield null.
"""
lowered = tenure.str.to_lowercase()
return (
pl.when(lowered.str.contains("owner"))
.then(pl.lit(TENURE_OWNER_OCCUPIED))
.when(lowered.str.contains("social"))
.then(pl.lit(TENURE_RENTED_SOCIAL))
.when(lowered.str.contains("rent"))
.then(pl.lit(TENURE_RENTED_PRIVATE))
.otherwise(pl.lit(None, dtype=pl.String))
)
EPC_SOURCE_COLUMNS = [ EPC_SOURCE_COLUMNS = [
"address", "address",
# The individual lines behind `address` (= address1+2+3): address2/3 # The individual lines behind `address` (= address1+2+3): address2/3
@ -484,6 +513,53 @@ def _run(epc_path: Path, price_paid_path: Path, output_path: Path, temp_dir: Pat
print(f"Renovation events: {events.height} properties with events") print(f"Renovation events: {events.height} properties with events")
print(event_counts) print(event_counts)
# Tenure-history fork: a chronological timeline of owner-occupied <-> rented
# transitions, derived from the per-certificate EPC TENURE field. Each
# certificate is one tenure observation; we keep only the change points so
# the property history can show *when* a home was let out vs lived in.
#
# Emission rule (walking certificates oldest-first, ignoring unknown-tenure
# ones so they neither appear nor break the chain):
# - the first known status is emitted only when it is a rental — an
# owner-occupied baseline is the unremarkable default for a property
# that has changed hands and would only clutter the timeline;
# - every later certificate is emitted when its status differs from the
# previous known one (this is what surfaces the return to owner-occupied
# that closes a rental period).
# Eager like the events/social forks: shift().over() does not run under the
# streaming sink used by fuzzy_join_on_postcode.
tenure_events = (
epc_base.with_columns(tenure_status(pl.col("tenure")).alias("_tenure_status"))
.filter(
pl.col("inspection_date").is_not_null()
& pl.col("_tenure_status").is_not_null()
)
.sort("inspection_date")
.with_columns(
pl.col("_tenure_status")
.shift(1)
.over("_epc_match_address", "_epc_match_postcode")
.alias("_prev_tenure_status"),
)
.filter(
pl.when(pl.col("_prev_tenure_status").is_null())
.then(pl.col("_tenure_status") != pl.lit(TENURE_OWNER_OCCUPIED))
.otherwise(pl.col("_tenure_status") != pl.col("_prev_tenure_status"))
)
.with_columns(
pl.col("inspection_date").dt.year().cast(pl.Int32).alias("_event_year"),
)
.group_by("_epc_match_address", "_epc_match_postcode")
.agg(
pl.struct(
pl.col("_event_year").alias("year"),
pl.col("_tenure_status").alias("status"),
).alias("tenure_history"),
)
.collect()
)
print(f"Tenure timelines: {tenure_events.height} properties with tenure changes")
# Social tenure fork: flag properties that were ever social housing # Social tenure fork: flag properties that were ever social housing
social_tenure = ( social_tenure = (
epc_base.filter(pl.col("tenure").str.to_lowercase().str.contains("social")) epc_base.filter(pl.col("tenure").str.to_lowercase().str.contains("social"))
@ -494,13 +570,18 @@ def _run(epc_path: Path, price_paid_path: Path, output_path: Path, temp_dir: Pat
) )
print(f"Former council houses (EPC social tenure): {social_tenure.height}") print(f"Former council houses (EPC social tenure): {social_tenure.height}")
# Left-join events and social tenure back onto dedup EPC # Left-join events, tenure history and social tenure back onto dedup EPC
epc = ( epc = (
epc.join( epc.join(
events.lazy(), events.lazy(),
on=["_epc_match_address", "_epc_match_postcode"], on=["_epc_match_address", "_epc_match_postcode"],
how="left", how="left",
) )
.join(
tenure_events.lazy(),
on=["_epc_match_address", "_epc_match_postcode"],
how="left",
)
.join( .join(
social_tenure.lazy(), social_tenure.lazy(),
on=["_epc_match_address", "_epc_match_postcode"], on=["_epc_match_address", "_epc_match_postcode"],
@ -600,9 +681,7 @@ def _run(epc_path: Path, price_paid_path: Path, output_path: Path, temp_dir: Pat
# flags are computed here and joined back into the lazy stream. # flags are computed here and joined back into the lazy stream.
outliers = flag_price_outliers( outliers = flag_price_outliers(
price_paid_base.filter(value_ok) price_paid_base.filter(value_ok)
.select( .select("_pp_group_address", "_pp_group_postcode", "date_of_transfer", "price")
"_pp_group_address", "_pp_group_postcode", "date_of_transfer", "price"
)
.collect(engine="streaming") .collect(engine="streaming")
) )
print(f"Implausible consecutive-sale price jumps flagged: {outliers.height}") print(f"Implausible consecutive-sale price jumps flagged: {outliers.height}")
@ -665,7 +744,15 @@ def _run(epc_path: Path, price_paid_path: Path, output_path: Path, temp_dir: Pat
right_postcode_col="epc_postcode", right_postcode_col="epc_postcode",
left_variant_cols=["pp_address_loc"], left_variant_cols=["pp_address_loc"],
right_variant_cols=["epc_address_a1", "epc_address_a12"], right_variant_cols=["epc_address_a1", "epc_address_a12"],
# Include EPC-only dwellings (an energy certificate but no Land
# Registry sale) so the property universe is "any dwelling we hold a
# record for", not just ones that have sold since 1995.
keep_unmatched_right=True,
) )
# EPC-only rows have no price-paid postcode; fall back to the EPC postcode
# so every row carries one (the active-English-postcode filter and the
# postcode->coordinates join downstream both key on it).
.with_columns(pl.coalesce("postcode", "epc_postcode").alias("postcode"))
.drop("epc_postcode") .drop("epc_postcode")
# Audit trail: keep the fuzzy-match confidence (100 = exact address # Audit trail: keep the fuzzy-match confidence (100 = exact address
# match) in the published output; null means no EPC match. # match) in the published output; null means no EPC match.
@ -676,10 +763,17 @@ def _run(epc_path: Path, price_paid_path: Path, output_path: Path, temp_dir: Pat
matched = joined.filter( matched = joined.filter(
pl.col("epc_address").is_not_null() & pl.col("pp_address").is_not_null() pl.col("epc_address").is_not_null() & pl.col("pp_address").is_not_null()
) )
pp_only = joined.filter(
pl.col("pp_address").is_not_null() & pl.col("epc_address").is_null()
)
epc_only = joined.filter(pl.col("pp_address").is_null())
total = joined.height total = joined.height
print(f"Unique properties: {total}") print(f"Unique properties: {total}")
print(f"Matched: {matched.height} ({100 * matched.height / total:.1f}%)") print(
print(f"Unmatched: {total - matched.height}") f"Matched (sale + EPC): {matched.height} ({100 * matched.height / total:.1f}%)"
)
print(f"Sale only (no EPC): {pp_only.height}")
print(f"EPC only (never sold): {epc_only.height}")
# For new-builds (old_new == "Y"), use the first transaction date year as # For new-builds (old_new == "Y"), use the first transaction date year as
# the exact construction date; otherwise fall back to the EPC age band. # the exact construction date; otherwise fall back to the EPC age band.
@ -689,10 +783,23 @@ def _run(epc_path: Path, price_paid_path: Path, output_path: Path, temp_dir: Pat
) )
is_new_build = pl.col("old_new") == "Y" is_new_build = pl.col("old_new") == "Y"
# A dwelling cannot have been built after it was first sold, yet the EPC age
# band (a coarse range midpoint) sometimes lands later than the earliest
# Land Registry transfer. Cap the band-derived year at the first transfer
# year so the published build year is never after the first known sale. The
# cap only fires when both years exist: the transfer year alone is an upper
# bound, not an estimate, so we never fabricate a build year from it for a
# non-new-build that lacks an EPC band.
capped_band_year = (
pl.when(transfer_year.is_not_null() & (transfer_year < epc_band_year))
.then(transfer_year)
.otherwise(epc_band_year)
)
joined = joined.with_columns( joined = joined.with_columns(
pl.when(is_new_build & transfer_year.is_not_null()) pl.when(is_new_build & transfer_year.is_not_null())
.then(transfer_year) .then(transfer_year)
.otherwise(epc_band_year) .otherwise(capped_band_year)
.alias("construction_age_band"), .alias("construction_age_band"),
pl.when(is_new_build & transfer_year.is_not_null()) pl.when(is_new_build & transfer_year.is_not_null())
.then(pl.lit(0, dtype=pl.UInt8)) .then(pl.lit(0, dtype=pl.UInt8))

View file

@ -733,14 +733,14 @@ def _validate_lsoa_source_coverage(iod_path: Path, ethnicity_path: Path) -> None
postcode resolves into, so a missing LSOA would silently null the ethnicity postcode resolves into, so a missing LSOA would silently null the ethnicity
columns for those postcodes; require full coverage instead. columns for those postcodes; require full coverage instead.
""" """
iod_lsoas = pl.read_parquet( iod_lsoas = pl.read_parquet(iod_path, columns=["LSOA code (2021)"]).rename(
iod_path, columns=["LSOA code (2021)"] {"LSOA code (2021)": "lsoa21"}
).rename({"LSOA code (2021)": "lsoa21"}) )
ethnicity_lsoas = pl.read_parquet(ethnicity_path, columns=["lsoa21"]) ethnicity_lsoas = pl.read_parquet(ethnicity_path, columns=["lsoa21"])
missing_ethnicity = iod_lsoas.join( missing_ethnicity = iod_lsoas.join(ethnicity_lsoas, on="lsoa21", how="anti").sort(
ethnicity_lsoas, on="lsoa21", how="anti" "lsoa21"
).sort("lsoa21") )
if missing_ethnicity.height > 0: if missing_ethnicity.height > 0:
raise ValueError( raise ValueError(
"Ethnicity data is missing LSOA coverage: " "Ethnicity data is missing LSOA coverage: "
@ -749,9 +749,7 @@ def _validate_lsoa_source_coverage(iod_path: Path, ethnicity_path: Path) -> None
) )
def _validate_lad_source_coverage( def _validate_lad_source_coverage(iod_path: Path, rental_prices_path: Path) -> None:
iod_path: Path, rental_prices_path: Path
) -> None:
iod_lads = ( iod_lads = (
pl.read_parquet( pl.read_parquet(
iod_path, iod_path,
@ -845,18 +843,32 @@ def _remap_terminated_postcodes(
def _dedupe_collapsed_properties(wide: pl.LazyFrame) -> pl.LazyFrame: def _dedupe_collapsed_properties(wide: pl.LazyFrame) -> pl.LazyFrame:
"""Keep one row per (postcode, pp_address) — the most-recent transaction. """Keep one row per (postcode, address) — the most-recent transaction.
The terminated-postcode remap can map two distinct postcodes onto one active The terminated-postcode remap can map two distinct postcodes onto one active
successor, collapsing the same physical address onto a single successor, collapsing the same physical address onto a single
(postcode, pp_address) key with conflicting sale records. Keep the row with (postcode, address) key with conflicting sale records. Keep the row with the
the latest date_of_transfer so the headline price/date reflect the most latest date_of_transfer so the headline price/date reflect the most recent
recent transaction; genuinely distinct addresses (a different pp_address) are transaction; genuinely distinct addresses are untouched.
untouched. pp_address is non-null here (join_epc_pp filters it), so the key
never merges unrelated rows. The dedup key coalesces the price-paid address with the EPC address: EPC-only
dwellings (never sold) have a null pp_address, so keying on pp_address alone
would collapse EVERY EPC-only dwelling in a postcode onto one
(postcode, null) key and silently drop all but one. Each dwelling's coalesced
address is unique within its postcode (the EPC frame is deduped on
address+postcode upstream), so the coalesced key keeps them distinct while
leaving sold-property dedup unchanged pp_address wins the coalesce whenever
a sale exists.
""" """
return wide.sort("date_of_transfer", descending=True, nulls_last=True).unique( return (
subset=["postcode", "pp_address"], keep="first", maintain_order=True wide.with_columns(
pl.coalesce("pp_address", "epc_address").alias("_dedupe_address")
)
.sort("date_of_transfer", descending=True, nulls_last=True)
.unique(
subset=["postcode", "_dedupe_address"], keep="first", maintain_order=True
)
.drop("_dedupe_address")
) )

View file

@ -266,11 +266,79 @@ def test_run_joins_domestic_zip_with_price_paid(tmp_path: Path):
] ]
assert df.get_column("renovation_history").list.len().to_list() == [1] assert df.get_column("renovation_history").list.len().to_list() == [1]
assert df.get_column("historical_prices").list.len().to_list() == [2] assert df.get_column("historical_prices").list.len().to_list() == [2]
# Tenure timeline: the social baseline (a rental, so emitted) followed by the
# switch to owner-occupied that closes the rented period.
assert df.get_column("tenure_history").to_list() == [
[
{"year": 2023, "status": "Rented (social)"},
{"year": 2024, "status": "Owner-occupied"},
]
]
# Audit trail: the accepted fuzzy match's score is published (100 = exact # Audit trail: the accepted fuzzy match's score is published (100 = exact
# post-normalisation address match). # post-normalisation address match).
assert df.get_column("epc_match_score").to_list() == [100] assert df.get_column("epc_match_score").to_list() == [100]
def test_run_tenure_history_tracks_rent_owner_transitions(tmp_path: Path):
# owner-occupied (2016) -> privately rented (2019) -> owner-occupied (2023).
# The owner-occupied baseline is suppressed (it is the unremarkable default);
# the let-out and the return to owner-occupation are both surfaced so the
# rented period [2019, 2023) is visible on the timeline.
zip_path = tmp_path / "domestic-csv.zip"
_write_epc_zip(
zip_path,
[
_row(inspection_date="2016-04-01", tenure="owner-occupied"),
_row(inspection_date="2019-04-01", tenure="Rented (private)"),
_row(inspection_date="2023-04-01", tenure="owner-occupied"),
],
)
price_paid_path = tmp_path / "price-paid.parquet"
_price_paid_frame(prices=[250_000], dates=[date(2024, 2, 3)]).write_parquet(
price_paid_path
)
output_path = tmp_path / "epc-pp.parquet"
_run(zip_path, price_paid_path, output_path, tmp_path)
df = pl.read_parquet(output_path)
assert df.height == 1
assert df.get_column("tenure_history").to_list() == [
[
{"year": 2019, "status": "Rented (private)"},
{"year": 2023, "status": "Owner-occupied"},
]
]
def test_run_tenure_history_empty_when_always_owner_occupied(tmp_path: Path):
# A property only ever observed as owner-occupied has no tenure change worth
# surfacing — the timeline column is null (no events), not a noisy baseline.
zip_path = tmp_path / "domestic-csv.zip"
_write_epc_zip(
zip_path,
[
_row(inspection_date="2016-04-01", tenure="owner-occupied"),
_row(inspection_date="2023-04-01", tenure="owner-occupied"),
],
)
price_paid_path = tmp_path / "price-paid.parquet"
_price_paid_frame(prices=[250_000], dates=[date(2024, 2, 3)]).write_parquet(
price_paid_path
)
output_path = tmp_path / "epc-pp.parquet"
_run(zip_path, price_paid_path, output_path, tmp_path)
df = pl.read_parquet(output_path)
assert df.height == 1
assert df.get_column("tenure_history").to_list() == [None]
def test_run_dedup_prefers_valid_dated_cert_over_garbled_date(tmp_path: Path): def test_run_dedup_prefers_valid_dated_cert_over_garbled_date(tmp_path: Path):
# Two certificates for the same property. The cert with the garbled, # Two certificates for the same property. The cert with the garbled,
# unparseable inspection_date must NOT be chosen as "latest": a string sort # unparseable inspection_date must NOT be chosen as "latest": a string sort
@ -397,8 +465,9 @@ def test_run_does_not_attach_epc_facts_to_low_score_address_match(tmp_path: Path
df = pl.read_parquet(output_path) df = pl.read_parquet(output_path)
assert df.height == 1 # The low-score EPC must NOT attach its facts to the price-paid property.
assert df.select( sold = df.filter(pl.col("pp_address").is_not_null())
assert sold.select(
"pp_address", "pp_address",
"epc_address", "epc_address",
"total_floor_area", "total_floor_area",
@ -415,6 +484,29 @@ def test_run_does_not_attach_epc_facts_to_low_score_address_match(tmp_path: Path
} }
] ]
# Instead the unmatched EPC enters the universe as its own EPC-only row: a
# dwelling we hold a certificate for but that has no Land Registry sale, so
# its EPC facts are present while the price columns stay null.
epc_only = df.filter(pl.col("pp_address").is_null())
assert epc_only.select(
"epc_address",
"postcode",
"total_floor_area",
"current_energy_rating",
"latest_price",
"epc_match_score",
).to_dicts() == [
{
"epc_address": "1 Totally Different Road",
"postcode": "AA1 1AA",
"total_floor_area": 84.5,
"current_energy_rating": "C",
"latest_price": None,
"epc_match_score": None,
}
]
assert df.height == 2
def test_run_excludes_category_b_sales_from_price_aggregations(tmp_path: Path): def test_run_excludes_category_b_sales_from_price_aggregations(tmp_path: Path):
# Category B entries (repossessions, bulk/portfolio, power-of-sale) must not # Category B entries (repossessions, bulk/portfolio, power-of-sale) must not
@ -506,6 +598,54 @@ def test_run_new_build_keeps_early_first_transfer_when_sub_min_price(tmp_path: P
assert df.get_column("historical_prices").list.len().to_list() == [1] assert df.get_column("historical_prices").list.len().to_list() == [1]
def test_run_caps_band_year_at_first_transfer_year(tmp_path: Path):
# A non-new-build (old_new "N") whose EPC age band ("2003 onwards" -> 2003)
# lands AFTER its first Land Registry sale (1998). A dwelling cannot have
# been built after it was first sold, so the published build year must be
# capped at the first transfer year (1998), not the later band estimate.
# It stays flagged as an estimate (approximate=1) — it is still EPC-derived.
zip_path = tmp_path / "domestic-csv.zip"
_write_epc_zip(
zip_path, [_row(construction_age_band="England and Wales: 2003 onwards")]
)
price_paid_path = tmp_path / "price-paid.parquet"
_price_paid_frame(prices=[250_000], dates=[date(1998, 5, 1)]).write_parquet(
price_paid_path
)
output_path = tmp_path / "epc-pp.parquet"
_run(zip_path, price_paid_path, output_path, tmp_path)
df = pl.read_parquet(output_path)
assert df.height == 1
# Capped at the 1998 first sale, not the 2003 band midpoint.
assert df.get_column("construction_age_band").to_list() == [1998]
assert df.get_column("is_construction_date_approximate").to_list() == [1]
def test_run_keeps_band_year_when_earlier_than_first_transfer(tmp_path: Path):
# The common case: the EPC band (1950-1966 -> 1958) predates the first
# recorded sale (2020). The cap must NOT fire — the band estimate stands.
zip_path = tmp_path / "domestic-csv.zip"
_write_epc_zip(zip_path)
price_paid_path = tmp_path / "price-paid.parquet"
_price_paid_frame(prices=[250_000], dates=[date(2020, 5, 1)]).write_parquet(
price_paid_path
)
output_path = tmp_path / "epc-pp.parquet"
_run(zip_path, price_paid_path, output_path, tmp_path)
df = pl.read_parquet(output_path)
assert df.height == 1
assert df.get_column("construction_age_band").to_list() == [1958]
assert df.get_column("is_construction_date_approximate").to_list() == [1]
def test_run_keeps_sale_above_lowered_min_price(tmp_path: Path): def test_run_keeps_sale_above_lowered_min_price(tmp_path: Path):
# A genuine cheap sale of 30_000 sits between the OLD floor (50k) and the # A genuine cheap sale of 30_000 sits between the OLD floor (50k) and the
# NEW floor (10k): it must now be RETAINED in the price aggregations. This # NEW floor (10k): it must now be RETAINED in the price aggregations. This
@ -548,13 +688,16 @@ def test_run_keeps_sale_above_lowered_min_price(tmp_path: Path):
assert df.get_column("latest_price").to_list() == [30_000] assert df.get_column("latest_price").to_list() == [30_000]
def _write_epc_zip(zip_path: Path) -> None: def _write_epc_zip(zip_path: Path, rows: list[dict[str, str]] | None = None) -> None:
"""Write a minimal domestic zip with the default certificate row.""" """Write a minimal domestic zip with the given certificate rows.
Defaults to a single default certificate row when ``rows`` is omitted.
"""
with zipfile.ZipFile(zip_path, "w", compression=zipfile.ZIP_DEFLATED) as archive: with zipfile.ZipFile(zip_path, "w", compression=zipfile.ZIP_DEFLATED) as archive:
csv_buffer = io.StringIO() csv_buffer = io.StringIO()
writer = csv.DictWriter(csv_buffer, fieldnames=EPC_SOURCE_COLUMNS) writer = csv.DictWriter(csv_buffer, fieldnames=EPC_SOURCE_COLUMNS)
writer.writeheader() writer.writeheader()
writer.writerow(_row()) writer.writerows(rows if rows is not None else [_row()])
archive.writestr("certificates-2024.csv", csv_buffer.getvalue()) archive.writestr("certificates-2024.csv", csv_buffer.getvalue())

View file

@ -582,9 +582,9 @@ def test_validate_lsoa_source_coverage_allows_full_ethnicity_coverage(
) )
# Ethnicity may carry extra LSOAs (e.g. property-less ones); only the IoD # Ethnicity may carry extra LSOAs (e.g. property-less ones); only the IoD
# LSOAs are required to all be present. # LSOAs are required to all be present.
pl.DataFrame( pl.DataFrame({"lsoa21": ["E01000001", "E01000002", "E01000003"]}).write_parquet(
{"lsoa21": ["E01000001", "E01000002", "E01000003"]} ethnicity_path
).write_parquet(ethnicity_path) )
_validate_lsoa_source_coverage(iod_path, ethnicity_path) _validate_lsoa_source_coverage(iod_path, ethnicity_path)
@ -1318,6 +1318,7 @@ def test_dedupe_collapsed_properties_keeps_most_recent_per_address() -> None:
{ {
"postcode": ["SW3 3JY", "SW3 3JY", "SW3 3JY"], "postcode": ["SW3 3JY", "SW3 3JY", "SW3 3JY"],
"pp_address": ["45 ELYSTAN PLACE", "45 ELYSTAN PLACE", "9 OTHER ROAD"], "pp_address": ["45 ELYSTAN PLACE", "45 ELYSTAN PLACE", "9 OTHER ROAD"],
"epc_address": ["45 ELYSTAN PLACE", "45 ELYSTAN PLACE", "9 OTHER ROAD"],
"date_of_transfer": [ "date_of_transfer": [
datetime(1990, 1, 1), datetime(1990, 1, 1),
datetime(2015, 6, 1), datetime(2015, 6, 1),
@ -1339,6 +1340,39 @@ def test_dedupe_collapsed_properties_keeps_most_recent_per_address() -> None:
assert by_addr["45 ELYSTAN PLACE"]["latest_price"] == 4_500_000 assert by_addr["45 ELYSTAN PLACE"]["latest_price"] == 4_500_000
# A genuinely distinct address in the same postcode is untouched. # A genuinely distinct address in the same postcode is untouched.
assert by_addr["9 OTHER ROAD"]["latest_price"] == 250_000 assert by_addr["9 OTHER ROAD"]["latest_price"] == 250_000
# The helper dedup-key column is internal and must not leak into the output.
assert "_dedupe_address" not in out.columns
def test_dedupe_collapsed_properties_keeps_distinct_epc_only_dwellings() -> None:
# EPC-only dwellings (an energy certificate but no Land Registry sale) have a
# null pp_address. Keying the dedup on pp_address alone would collapse every
# such dwelling in a postcode onto a single (postcode, null) key and drop all
# but one; the coalesced (pp_address, epc_address) key keeps them distinct.
from datetime import datetime
wide = pl.LazyFrame(
{
"postcode": ["AB1 2CD", "AB1 2CD", "AB1 2CD"],
"pp_address": [None, None, "5 SOLD STREET"],
"epc_address": ["1 NEVER SOLD LANE", "2 NEVER SOLD LANE", None],
"date_of_transfer": [None, None, datetime(2010, 1, 1)],
"latest_price": [None, None, 300_000],
}
)
out = _dedupe_collapsed_properties(wide).collect()
# All three survive: two distinct EPC-only dwellings + one sold property.
assert out.height == 3
pairs = [
(row["pp_address"], row["epc_address"]) for row in out.iter_rows(named=True)
]
assert sorted(pairs, key=lambda p: (p[0] or "", p[1] or "")) == [
(None, "1 NEVER SOLD LANE"),
(None, "2 NEVER SOLD LANE"),
("5 SOLD STREET", None),
]
def _property_candidates(rows: list[dict]) -> pl.DataFrame: def _property_candidates(rows: list[dict]) -> pl.DataFrame:

View file

@ -81,6 +81,7 @@ def fuzzy_join_on_postcode(
min_score_without_numbers: int = MIN_FUZZY_SCORE_WITHOUT_NUMBERS, min_score_without_numbers: int = MIN_FUZZY_SCORE_WITHOUT_NUMBERS,
left_variant_cols: Sequence[str] = (), left_variant_cols: Sequence[str] = (),
right_variant_cols: Sequence[str] = (), right_variant_cols: Sequence[str] = (),
keep_unmatched_right: bool = False,
) -> pl.LazyFrame: ) -> pl.LazyFrame:
"""Fuzzy join two LazyFrames by matching addresses within postcode buckets. """Fuzzy join two LazyFrames by matching addresses within postcode buckets.
@ -106,6 +107,14 @@ def fuzzy_join_on_postcode(
``_match_score`` (UInt8) audit column holding the token_sort_ratio of ``_match_score`` (UInt8) audit column holding the token_sort_ratio of
the accepted match (exact matches score 100). Unmatched rows have null the accepted match (exact matches score 100). Unmatched rows have null
right columns and a null score. right columns and a null score.
When ``keep_unmatched_right`` is set the join becomes a full outer join:
right rows that matched no left row are appended with all left columns (and
``_match_score``) null. This turns "price-paid LEFT JOIN epc" into a union
that also surfaces EPC-only dwellings (an energy certificate but no Land
Registry sale). It assumes ``right`` is already unique on its match key
(address + postcode) as the deduped EPC frame is so each unmatched right
row appears once.
""" """
tmpdir = tempfile.mkdtemp(prefix="fuzzy_join_", dir=local_tmp_dir()) tmpdir = tempfile.mkdtemp(prefix="fuzzy_join_", dir=local_tmp_dir())
@ -153,7 +162,9 @@ def fuzzy_join_on_postcode(
.collect(engine="streaming") .collect(engine="streaming")
) )
left_variant_names = [f"_left_variant_{i}" for i in range(len(left_variant_cols))] left_variant_names = [
f"_left_variant_{i}" for i in range(len(left_variant_cols))
]
right_variant_names = [ right_variant_names = [
f"_right_variant_{i}" for i in range(len(right_variant_cols)) f"_right_variant_{i}" for i in range(len(right_variant_cols))
] ]
@ -263,6 +274,21 @@ def fuzzy_join_on_postcode(
.drop("_left_idx", "_right_idx") .drop("_left_idx", "_right_idx")
.collect(engine="streaming") .collect(engine="streaming")
) )
if keep_unmatched_right:
# Right rows that matched no left row. Anti-join (not is_in) so this
# scales to millions of matched ids without a giant in-memory list.
matched_ids = pl.LazyFrame(
{"_right_idx": pl.Series([m[1] for m in matches], dtype=pl.UInt32)}
)
unmatched_right = (
right_cached.join(matched_ids, on="_right_idx", how="anti")
.drop("_right_idx")
.collect(engine="streaming")
)
# Diagonal concat fills the absent left columns (and _match_score)
# with null for these EPC-only rows.
result = pl.concat([result, unmatched_right], how="diagonal")
finally: finally:
shutil.rmtree(tmpdir, ignore_errors=True) shutil.rmtree(tmpdir, ignore_errors=True)

View file

@ -220,6 +220,77 @@ def test_fuzzy_join_matches_high_score_number_less_pair():
assert result["right_address"].to_list() == ["THE OLD RECTORY"] assert result["right_address"].to_list() == ["THE OLD RECTORY"]
def test_fuzzy_join_keep_unmatched_right_appends_epc_only_rows():
# Models "price-paid LEFT JOIN epc" with keep_unmatched_right: matched pairs
# keep both sides; an unmatched LEFT (sold-but-no-EPC) row keeps null right
# columns; an unmatched RIGHT (EPC-only, never sold) row is appended with
# null left columns so the dwelling still enters the property universe.
left = pl.LazyFrame(
{
"left_id": ["sold_with_epc", "sold_no_epc"],
"left_address": ["10 High Street", "12 High Street"],
"left_postcode": ["AB1 2CD", "AB1 2CD"],
}
)
right = pl.LazyFrame(
{
"right_id": ["epc_for_10", "epc_only"],
"right_address": ["10 HIGH STREET", "14 High Street"],
"right_postcode": ["AB1 2CD", "AB1 2CD"],
}
)
result = (
fuzzy_join_on_postcode(
left=left,
right=right,
left_address_col="left_address",
right_address_col="right_address",
left_postcode_col="left_postcode",
right_postcode_col="right_postcode",
keep_unmatched_right=True,
)
.sort("left_id", "right_id", nulls_last=True)
.collect()
)
assert result.select("left_id", "right_id").to_dicts() == [
# Sold but no EPC: left present, right null (ordinary left-join row).
{"left_id": "sold_no_epc", "right_id": None},
# Matched pair: both sides present.
{"left_id": "sold_with_epc", "right_id": "epc_for_10"},
# EPC-only dwelling: appended with null left columns.
{"left_id": None, "right_id": "epc_only"},
]
# The appended EPC-only row carries its own address/postcode and a null score.
epc_only = result.filter(pl.col("right_id") == "epc_only")
assert epc_only["right_address"].to_list() == ["14 High Street"]
assert epc_only["left_address"].to_list() == [None]
assert epc_only["_match_score"].to_list() == [None]
def test_fuzzy_join_keep_unmatched_right_off_by_default():
# Without the flag the join stays left-only: an EPC-only right row is dropped.
left = pl.LazyFrame(
{"left_address": ["10 High Street"], "left_postcode": ["AB1 2CD"]}
)
right = pl.LazyFrame(
{"right_address": ["14 High Street"], "right_postcode": ["AB1 2CD"]}
)
result = fuzzy_join_on_postcode(
left=left,
right=right,
left_address_col="left_address",
right_address_col="right_address",
left_postcode_col="left_postcode",
right_postcode_col="right_postcode",
).collect()
assert result.height == 1
assert result["right_address"].to_list() == [None]
def test_numbers_compatible_treats_letter_suffix_as_part_of_the_number(): def test_numbers_compatible_treats_letter_suffix_as_part_of_the_number():
# 8A, 8B and plain 8 are three different properties on the same street; # 8A, 8B and plain 8 are three different properties on the same street;
# digit-only extraction collapsed all three to {8} and let them match. # digit-only extraction collapsed all three to {8} and let them match.
@ -252,9 +323,7 @@ def test_numbers_compatible_gates_single_letter_flats():
assert not _numbers_compatible( assert not _numbers_compatible(
"FLAT D 39 GERTRUDE STREET", "FLAT F 39 GERTRUDE STREET" "FLAT D 39 GERTRUDE STREET", "FLAT F 39 GERTRUDE STREET"
) )
assert _numbers_compatible( assert _numbers_compatible("FLAT D 39 GERTRUDE STREET", "39 GERTRUDE STREET FLAT D")
"FLAT D 39 GERTRUDE STREET", "39 GERTRUDE STREET FLAT D"
)
assert not _numbers_compatible("FLAT B ROSE COURT", "ROSE COURT") assert not _numbers_compatible("FLAT B ROSE COURT", "ROSE COURT")
# A letter glued to a number ("A3") is a unit name, not a flat letter. # A letter glued to a number ("A3") is a unit name, not a flat letter.
assert _numbers_compatible("FLAT A3 CHESHAM HEIGHTS", "FLAT A3 CHESHAM HEIGHTS") assert _numbers_compatible("FLAT A3 CHESHAM HEIGHTS", "FLAT A3 CHESHAM HEIGHTS")
@ -263,9 +332,9 @@ def test_numbers_compatible_gates_single_letter_flats():
def test_admissible_variants_allows_locality_suffix_only(): def test_admissible_variants_allows_locality_suffix_only():
# Locality words may differ between a variant and its primary; digits and # Locality words may differ between a variant and its primary; digits and
# flat designators may not (the gate ran on the primary only). # flat designators may not (the gate ran on the primary only).
assert _admissible_variants( assert _admissible_variants("12 OAK ROAD", ["12 OAK ROAD HALE", "12 OAK ROAD"]) == (
"12 OAK ROAD", ["12 OAK ROAD HALE", "12 OAK ROAD"] "12 OAK ROAD HALE",
) == ("12 OAK ROAD HALE",) )
# Dropping "FLAT 1" (digit) or "FLAT B" (flat designator) is inadmissible: # Dropping "FLAT 1" (digit) or "FLAT B" (flat designator) is inadmissible:
# the variant would score a single flat as the whole building. # the variant would score a single flat as the whole building.
assert ( assert (
@ -278,9 +347,7 @@ def test_admissible_variants_allows_locality_suffix_only():
# disagrees with the combined address must not smuggle in a different # disagrees with the combined address must not smuggle in a different
# street for scoring. # street for scoring.
assert _admissible_variants("12 OAK ROAD", ["12 ELM ROAD"]) == () assert _admissible_variants("12 OAK ROAD", ["12 ELM ROAD"]) == ()
assert ( assert _admissible_variants("1 TOTALLY DIFFERENT ROAD", ["1 EXAMPLE STREET"]) == ()
_admissible_variants("1 TOTALLY DIFFERENT ROAD", ["1 EXAMPLE STREET"]) == ()
)
def test_fuzzy_join_variant_recovers_locality_suffix_mismatch(): def test_fuzzy_join_variant_recovers_locality_suffix_mismatch():

View file

@ -3,9 +3,9 @@ pub const QUANT_SCALE: f32 = 65534.0;
pub const HISTOGRAM_BINS: usize = 100; pub const HISTOGRAM_BINS: usize = 100;
pub const H3_PRECOMPUTE_MAX: u8 = 12; pub const H3_PRECOMPUTE_MAX: u8 = 9;
pub const H3_REQUEST_MIN: u8 = 4; pub const H3_REQUEST_MIN: u8 = 4;
pub const H3_REQUEST_MAX: u8 = 12; pub const H3_REQUEST_MAX: u8 = 9;
pub const SERVER_ADDRESS: &str = "0.0.0.0:8001"; pub const SERVER_ADDRESS: &str = "0.0.0.0:8001";
@ -15,6 +15,12 @@ pub const MAX_POIS_PER_REQUEST: usize = 3000;
pub const PROPERTIES_LIMIT: usize = 100; pub const PROPERTIES_LIMIT: usize = 100;
pub const PLACES_LIMIT: usize = 20; pub const PLACES_LIMIT: usize = 20;
/// Enum feature whose count is an attribute of the postcode/area as a whole
/// rather than of the filter-matching subset: it is always computed over every
/// property in the area so applying filters never changes it. Must match the
/// feature name in `features.rs`.
pub const FORMER_COUNCIL_HOUSE_FEATURE: &str = "Former council house";
pub const PRICE_HISTORY_POINTS_LIMIT: usize = 5000; pub const PRICE_HISTORY_POINTS_LIMIT: usize = 5000;
pub const POSTCODE_SEARCH_OFFSET: f64 = 0.02; pub const POSTCODE_SEARCH_OFFSET: f64 = 0.02;
@ -26,9 +32,29 @@ pub const AI_FILTERS_WEEKLY_TOKEN_LIMIT: u64 = 10_000_000;
/// Timeout for outbound HTTP service calls (seconds). /// Timeout for outbound HTTP service calls (seconds).
pub const SERVICE_CALL_TIMEOUT: u64 = 120; pub const SERVICE_CALL_TIMEOUT: u64 = 120;
/// Demo free zone bounds (south, west, north, east) — inner London, roughly zone 1. /// Half-spans of the demo free zone box, in degrees. The box is built this wide
/// Users without a license can only query data within these bounds. /// around the visitor's approximate (IP-derived) location — or the fallback
pub const FREE_ZONE_BOUNDS: (f64, f64, f64, f64) = (51.44, -0.31, 51.59, 0.05); /// centre below when the IP can't be geolocated. Sized to match the original
/// inner-London "zone 1" box (≈0.15° lat × 0.36° lon).
pub const FREE_ZONE_HALF_LAT: f64 = 0.075;
pub const FREE_ZONE_HALF_LON: f64 = 0.18;
/// Fallback demo free-zone centre (Canary Wharf), used when the visitor hasn't
/// supplied a demo centre — i.e. they declined the "check your area" prompt or no
/// GPS fix was available. When they consent, the browser sends their own centre.
pub const FREE_ZONE_FALLBACK_LAT: f64 = 51.5054;
pub const FREE_ZONE_FALLBACK_LON: f64 = -0.0235;
/// Demo (unlicensed) users may apply at most this many filters. Enforced both
/// client-side (UX) and server-side (anti-DoS); must match the frontend constant.
pub const DEMO_MAX_FILTERS: usize = 5;
/// Sliding-window rate limit for unlicensed traffic to `/api/` endpoints, keyed by
/// account id (preferred) or client IP. Generous enough for active demo use, tight
/// enough to blunt tile-by-tile scraping. Licensed users/admins are exempt.
pub const DEMO_RATE_LIMIT_MAX: usize = 600;
pub const DEMO_RATE_LIMIT_WINDOW_SECS: u64 = 60;
/// Soft cap on tracked rate-limit keys before stale ones are pruned.
pub const DEMO_RATE_LIMIT_MAX_KEYS: usize = 50_000;
pub const SHARE_CACHE_TTL_SECS: u64 = 300; pub const SHARE_CACHE_TTL_SECS: u64 = 300;
pub const SHARE_CACHE_MAX_ENTRIES: usize = 1024; pub const SHARE_CACHE_MAX_ENTRIES: usize = 1024;

View file

@ -67,6 +67,6 @@ pub use poi::{resolve_poi_category_filter, POICategoryGroup, POIData, SchoolMeta
pub use postcodes::{OutcodeData, PostcodeData}; pub use postcodes::{OutcodeData, PostcodeData};
pub use property::{ pub use property::{
precompute_h3, FeatureStats, Histogram, HistoricalPrice, PostcodePoiMetrics, PropertyData, precompute_h3, FeatureStats, Histogram, HistoricalPrice, PostcodePoiMetrics, PropertyData,
QuantRef, RenovationEvent, QuantRef, RenovationEvent, TenureEvent,
}; };
pub use travel_time::{slugify, TravelTimeStore}; pub use travel_time::{slugify, TravelTimeStore};

View file

@ -251,6 +251,26 @@ pub(super) fn address_search_tokens(text: &str) -> Vec<String> {
tokens tokens
} }
/// Search tokens for a property row, covering its display address plus an
/// alternative spelling (`alt`, the EPC form) when distinct — so the property is
/// findable by either form. The result is deduped: each token appears at most
/// once, which the inverted-index posting lists rely on (a token must post a
/// given row exactly once to keep those lists strictly ascending and unique).
pub(super) fn merged_address_search_tokens(display: &str, alt: Option<&str>) -> Vec<String> {
let mut tokens = address_search_tokens(display);
if let Some(alt) = alt {
let alt = alt.trim();
if !alt.is_empty() && alt != display.trim() {
for token in address_search_tokens(alt) {
if !tokens.contains(&token) {
tokens.push(token);
}
}
}
}
tokens
}
fn is_address_search_token(token: &str) -> bool { fn is_address_search_token(token: &str) -> bool {
if looks_like_postcode_fragment(token) { if looks_like_postcode_fragment(token) {
return false; return false;
@ -929,6 +949,60 @@ mod tests {
assert_eq!(parsed.text_groups[1].alternatives, vec!["cour".to_string()]); assert_eq!(parsed.text_groups[1].alternatives, vec!["cour".to_string()]);
} }
#[test]
fn merged_tokens_index_both_address_forms() {
// A property whose price-paid form is "10 Park Road" but whose EPC form
// names the building ("Kingswood House 10 Park Road") must be findable by
// the building name too: the distinctive "kingswood" token comes only
// from the EPC form.
let display_only = address_search_tokens("10 Park Road");
assert!(!display_only.contains(&"kingswood".to_string()));
let merged =
merged_address_search_tokens("10 Park Road", Some("Kingswood House 10 Park Road"));
assert!(merged.contains(&"kingswood".to_string()));
// Tokens shared by both forms are still present and not duplicated.
assert!(merged.contains(&"park".to_string()));
assert!(merged.contains(&"road".to_string()));
assert!(merged.contains(&"10".to_string()));
// No token repeats — posting-list uniqueness depends on this.
let mut deduped = merged.clone();
deduped.sort_unstable();
deduped.dedup();
assert_eq!(deduped.len(), merged.len());
}
#[test]
fn merged_tokens_ignore_redundant_or_missing_alt_form() {
let baseline = address_search_tokens("12 Baker Street");
// An EPC form identical to the display adds nothing.
assert_eq!(
merged_address_search_tokens("12 Baker Street", Some("12 Baker Street")),
baseline
);
// A blank EPC form (whitespace) and an absent one are both no-ops.
assert_eq!(
merged_address_search_tokens("12 Baker Street", Some(" ")),
baseline
);
assert_eq!(
merged_address_search_tokens("12 Baker Street", None),
baseline
);
}
#[test]
fn merged_tokens_handle_epc_only_display() {
// EPC-only dwellings have no price-paid form, so the EPC address IS the
// display; passing it as both arguments must not duplicate its tokens.
let epc = "Flat 4 Rosewood Court";
assert_eq!(
merged_address_search_tokens(epc, Some(epc)),
address_search_tokens(epc)
);
}
#[test] #[test]
fn address_search_tokens_keep_actual_address_terms_for_scoring() { fn address_search_tokens_keep_actual_address_terms_for_scoring() {
let tokens = address_search_tokens("Flat 2, 10 Downing Cour"); let tokens = address_search_tokens("Flat 2, 10 Downing Cour");

View file

@ -14,11 +14,11 @@ use crate::consts::{NAN_U16, QUANT_SCALE};
use crate::features::{self, Bounds}; use crate::features::{self, Bounds};
use super::address_search::{ use super::address_search::{
address_search_tokens, build_address_prefix_index, is_address_candidate_token, build_address_prefix_index, is_address_candidate_token, merged_address_search_tokens,
}; };
use super::poi_metrics::{PostcodePoiMetrics, NO_POI_METRIC_ROW}; use super::poi_metrics::{PostcodePoiMetrics, NO_POI_METRIC_ROW};
use super::stats::{column_to_f32_vec, compute_feature_stats, FeatureStats, Histogram}; use super::stats::{column_to_f32_vec, compute_feature_stats, FeatureStats, Histogram};
use super::{HistoricalPrice, PropertyData, RenovationEvent}; use super::{HistoricalPrice, PropertyData, RenovationEvent, TenureEvent};
const MISSING_COORDINATE_SAMPLE_LIMIT: usize = 10; const MISSING_COORDINATE_SAMPLE_LIMIT: usize = 10;
const COUNTRY_COLUMN_CANDIDATES: &[&str] = &[ const COUNTRY_COLUMN_CANDIDATES: &[&str] = &[
@ -330,6 +330,10 @@ impl PropertyData {
if has_historical_prices { if has_historical_prices {
select_exprs.push(col("historical_prices")); select_exprs.push(col("historical_prices"));
} }
let has_tenure_history = schema.get("tenure_history").is_some();
if has_tenure_history {
select_exprs.push(col("tenure_history"));
}
let df = combined_lf let df = combined_lf
.filter(col("lat").is_not_null().and(col("lon").is_not_null())) .filter(col("lat").is_not_null().and(col("lon").is_not_null()))
.select(select_exprs) .select(select_exprs)
@ -439,7 +443,6 @@ impl PropertyData {
.collect() .collect()
}; };
let address_raw = extract_string_col(&df, "Address per Property Register")?;
let postcode_raw = extract_string_col(&df, "Postcode")?; let postcode_raw = extract_string_col(&df, "Postcode")?;
// Extract optional string columns // Extract optional string columns
@ -468,6 +471,13 @@ impl PropertyData {
}; };
let property_sub_type_raw = extract_optional_string_col(&df, "Property sub-type")?; let property_sub_type_raw = extract_optional_string_col(&df, "Property sub-type")?;
let price_qualifier_raw = extract_optional_string_col(&df, "Price qualifier")?; let price_qualifier_raw = extract_optional_string_col(&df, "Price qualifier")?;
// Display + search addresses. The price-paid form ("Address per Property
// Register") is the primary display address; the EPC form ("Address per
// EPC") is an alternative spelling indexed alongside it so a property is
// findable by either. For EPC-only dwellings (no Land Registry sale) the
// price-paid form is null, so the EPC form becomes the display address.
let pp_address_raw = extract_optional_string_col(&df, "Address per Property Register")?;
let epc_address_raw = extract_optional_string_col(&df, "Address per EPC")?;
tracing::info!("Building enum features"); tracing::info!("Building enum features");
// enum_col_major: Vec<(values_list, encoded_as_f32)> // enum_col_major: Vec<(values_list, encoded_as_f32)>
@ -675,6 +685,57 @@ impl PropertyData {
FxHashMap::default() FxHashMap::default()
}; };
// Extract tenure_history: List<Struct{year: i32, status: str}>
let mut tenure_raw: FxHashMap<u32, Vec<TenureEvent>> = if has_tenure_history {
tracing::info!("Extracting tenure history");
let tenure_col = df
.column("tenure_history")
.context("Missing tenure_history column")?;
let list_ca = tenure_col
.list()
.context("tenure_history is not a list column")?;
let mut history: FxHashMap<u32, Vec<TenureEvent>> = FxHashMap::default();
for old_row in 0..row_count {
if let Some(inner) = list_ca.get_as_series(old_row) {
if inner.is_empty() {
continue;
}
let structs = inner
.struct_()
.context("tenure_history inner is not a struct")?;
let years = structs
.field_by_name("year")
.context("Missing 'year' field in tenure_history struct")?;
let statuses = structs
.field_by_name("status")
.context("Missing 'status' field in tenure_history struct")?;
let mut row_events = Vec::new();
for idx in 0..inner.len() {
let year = years.get(idx).context("Failed to get year value")?;
let status = statuses.get(idx).context("Failed to get status value")?;
if let (AnyValue::Int32(yr), AnyValue::String(st)) = (&year, &status) {
row_events.push(TenureEvent {
year: *yr,
status: st.to_string(),
});
}
}
if !row_events.is_empty() {
history.insert(old_row as u32, row_events);
}
}
}
tracing::info!(
properties_with_tenure_changes = history.len(),
"Tenure history extracted"
);
history
} else {
FxHashMap::default()
};
// Free the projected joined frame before building the row-major matrix. // Free the projected joined frame before building the row-major matrix.
drop(df); drop(df);
@ -712,9 +773,21 @@ impl PropertyData {
}) })
.context("Required numeric column 'Last known price' not configured")?; .context("Required numeric column 'Last known price' not configured")?;
// Build contiguous address buffer and address search index (permuted) // Build contiguous address buffer and address search index (permuted).
// Each row's posting lists cover BOTH address spellings we hold — the
// price-paid form and the EPC form — so a property is findable by either;
// the display address prefers the price-paid form, falling back to the EPC
// form for never-sold (EPC-only) dwellings whose price-paid form is null.
tracing::info!("Building interned strings"); tracing::info!("Building interned strings");
let total_addr_bytes: usize = address_raw.iter().map(|text| text.len()).sum(); // Display address for a row, preferring the price-paid form and falling
// back to the EPC form (so EPC-only dwellings still have a display string).
let coalesced_display = |old_row: usize| -> &str {
match pp_address_raw[old_row].as_deref() {
Some(pp) if !pp.is_empty() => pp,
_ => epc_address_raw[old_row].as_deref().unwrap_or(""),
}
};
let total_addr_bytes: usize = (0..row_count).map(|row| coalesced_display(row).len()).sum();
let mut address_buffer = String::with_capacity(total_addr_bytes); let mut address_buffer = String::with_capacity(total_addr_bytes);
let mut address_offsets = Vec::with_capacity(row_count); let mut address_offsets = Vec::with_capacity(row_count);
let mut address_lengths = Vec::with_capacity(row_count); let mut address_lengths = Vec::with_capacity(row_count);
@ -724,14 +797,19 @@ impl PropertyData {
let mut address_search_token_offsets = Vec::with_capacity(row_count); let mut address_search_token_offsets = Vec::with_capacity(row_count);
let mut address_search_token_lengths = Vec::with_capacity(row_count); let mut address_search_token_lengths = Vec::with_capacity(row_count);
for (new_row, &perm_index) in perm.iter().enumerate() { for (new_row, &perm_index) in perm.iter().enumerate() {
let addr = &address_raw[perm_index as usize]; let old_row = perm_index as usize;
let display = coalesced_display(old_row);
let offset = address_buffer.len() as u32; let offset = address_buffer.len() as u32;
let length = addr.len().min(u16::MAX as usize) as u16; let length = display.len().min(u16::MAX as usize) as u16;
address_offsets.push(offset); address_offsets.push(offset);
address_lengths.push(length); address_lengths.push(length);
address_buffer.push_str(&addr[..length as usize]); address_buffer.push_str(&display[..length as usize]);
let search_tokens = address_search_tokens(addr); // Tokens cover the display address plus the EPC-form spelling when
// distinct, so the property is findable by either form (deduped so
// each token posts this row exactly once).
let search_tokens =
merged_address_search_tokens(display, epc_address_raw[old_row].as_deref());
let token_offset = address_search_token_keys.len() as u32; let token_offset = address_search_token_keys.len() as u32;
let token_length = search_tokens.len().min(u16::MAX as usize) as u16; let token_length = search_tokens.len().min(u16::MAX as usize) as u16;
address_search_token_offsets.push(token_offset); address_search_token_offsets.push(token_offset);
@ -834,6 +912,18 @@ impl PropertyData {
map map
}; };
// Re-key tenure_history by permuted row index
let tenure_history: FxHashMap<u32, Vec<TenureEvent>> = {
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);
}
}
map
};
// Permute optional string columns into sparse HashMaps // Permute optional string columns into sparse HashMaps
let property_sub_type: FxHashMap<u32, String> = { let property_sub_type: FxHashMap<u32, String> = {
let mut map = FxHashMap::default(); let mut map = FxHashMap::default();
@ -968,6 +1058,7 @@ impl PropertyData {
approx_build_date_bits, approx_build_date_bits,
renovation_history, renovation_history,
historical_prices, historical_prices,
tenure_history,
property_sub_type, property_sub_type,
price_qualifier, price_qualifier,
}) })

View file

@ -40,6 +40,16 @@ pub struct HistoricalPrice {
pub price: i64, pub price: i64,
} }
/// A point on the property's tenure timeline: the year a certificate first
/// recorded a new occupancy `status` ("Owner-occupied" / "Rented (private)" /
/// "Rented (social)"). Derived per-EPC and reduced to change points by the
/// pipeline, so this surfaces *when* a home was let out vs lived in.
#[derive(Serialize, Clone)]
pub struct TenureEvent {
pub year: i32,
pub status: String,
}
pub struct PropertyData { pub struct PropertyData {
pub lat: Vec<f32>, pub lat: Vec<f32>,
pub lon: Vec<f32>, pub lon: Vec<f32>,
@ -100,6 +110,9 @@ pub struct PropertyData {
/// Per-row historical sale transactions (Land Registry price-paid). /// Per-row historical sale transactions (Land Registry price-paid).
/// Keyed by (permuted) row index. Only rows with prices are present. /// Keyed by (permuted) row index. Only rows with prices are present.
historical_prices: FxHashMap<u32, Vec<HistoricalPrice>>, historical_prices: FxHashMap<u32, Vec<HistoricalPrice>>,
/// Per-row tenure timeline (owner-occupied <-> rented change points from
/// EPC). Keyed by (permuted) row index. Only rows with changes are present.
tenure_history: FxHashMap<u32, Vec<TenureEvent>>,
property_sub_type: FxHashMap<u32, String>, property_sub_type: FxHashMap<u32, String>,
price_qualifier: FxHashMap<u32, String>, price_qualifier: FxHashMap<u32, String>,
} }
@ -153,6 +166,14 @@ impl PropertyData {
.unwrap_or(&[]) .unwrap_or(&[])
} }
/// Get tenure timeline events for a given row (empty slice if none).
pub fn tenure_history(&self, row: usize) -> &[TenureEvent] {
self.tenure_history
.get(&(row as u32))
.map(|v| v.as_slice())
.unwrap_or(&[])
}
/// Get property sub-type for a given row. /// Get property sub-type for a given row.
pub fn property_sub_type(&self, row: usize) -> Option<&str> { pub fn property_sub_type(&self, row: usize) -> Option<&str> {
self.property_sub_type self.property_sub_type
@ -231,6 +252,7 @@ impl PropertyData {
approx_build_date_bits: Vec::new(), approx_build_date_bits: Vec::new(),
renovation_history: FxHashMap::default(), renovation_history: FxHashMap::default(),
historical_prices: FxHashMap::default(), historical_prices: FxHashMap::default(),
tenure_history: FxHashMap::default(),
property_sub_type: FxHashMap::default(), property_sub_type: FxHashMap::default(),
price_qualifier: FxHashMap::default(), price_qualifier: FxHashMap::default(),
} }

127
server-rs/src/demo_zone.rs Normal file
View file

@ -0,0 +1,127 @@
//! Per-visitor demo "free zone": the small area an unlicensed visitor may explore.
//!
//! The visitor's browser chooses the centre — their GPS location (with consent) or
//! a Canary Wharf default — and sends it on each request as `demoLat`/`demoLon`
//! query params. We build a fixed-size box around that centre for the licensing
//! check. Absent/invalid params fall back to Canary Wharf.
//!
//! The centre is client-supplied and therefore spoofable: this is a soft demo gate
//! backed by the rate-limit / filter-cap guards in `ratelimit.rs`, not a hard
//! security boundary. (We deliberately removed IP-based geolocation in favour of
//! this explicit, consent-based flow.)
use axum::extract::Request;
use axum::middleware::Next;
use axum::response::Response;
use serde::Serialize;
use url::form_urlencoded;
use crate::consts::{
FREE_ZONE_FALLBACK_LAT, FREE_ZONE_FALLBACK_LON, FREE_ZONE_HALF_LAT, FREE_ZONE_HALF_LON,
};
/// A demo free-zone bounding box (degrees).
#[derive(Clone, Copy, Debug, Serialize)]
pub struct FreeZone {
pub south: f64,
pub west: f64,
pub north: f64,
pub east: f64,
}
/// A visitor's resolved demo zone, inserted into request extensions by
/// [`demo_zone_middleware`] for every request.
#[derive(Clone, Copy, Debug)]
pub struct DemoZone {
pub free_zone: FreeZone,
}
/// Build the demo free-zone box centred on `(lat, lon)`, keeping a fixed size and
/// clamping to valid lat/lon ranges.
pub fn free_zone_around(lat: f64, lon: f64) -> FreeZone {
FreeZone {
south: (lat - FREE_ZONE_HALF_LAT).max(-90.0),
north: (lat + FREE_ZONE_HALF_LAT).min(90.0),
west: (lon - FREE_ZONE_HALF_LON).max(-180.0),
east: (lon + FREE_ZONE_HALF_LON).min(180.0),
}
}
/// Read the visitor's chosen demo centre from the `demoLat`/`demoLon` query params.
fn demo_center_from_query(query: Option<&str>) -> Option<(f64, f64)> {
let query = query?;
let mut lat: Option<f64> = None;
let mut lon: Option<f64> = None;
for (key, value) in form_urlencoded::parse(query.as_bytes()) {
match key.as_ref() {
"demoLat" => lat = value.parse().ok(),
"demoLon" => lon = value.parse().ok(),
_ => {}
}
}
let (lat, lon) = (lat?, lon?);
if lat.is_finite()
&& lon.is_finite()
&& (-90.0..=90.0).contains(&lat)
&& (-180.0..=180.0).contains(&lon)
{
Some((lat, lon))
} else {
None
}
}
/// 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));
DemoZone {
free_zone: free_zone_around(lat, lon),
}
}
/// Middleware that resolves the visitor's [`DemoZone`] and stashes it in request
/// extensions, mirroring how `auth_middleware` provides `OptionalUser`.
pub async fn demo_zone_middleware(req: Request, next: Next) -> Response {
let zone = resolve_demo_zone(req.uri().query());
let (mut parts, body) = req.into_parts();
parts.extensions.insert(zone);
next.run(Request::from_parts(parts, body)).await
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn fallback_box_is_canary_wharf_sized_and_centred() {
let fz = resolve_demo_zone(None).free_zone;
assert!((((fz.south + fz.north) / 2.0) - FREE_ZONE_FALLBACK_LAT).abs() < 1e-9);
assert!((((fz.west + fz.east) / 2.0) - FREE_ZONE_FALLBACK_LON).abs() < 1e-9);
}
#[test]
fn reads_client_supplied_centre() {
let fz = resolve_demo_zone(Some("demoLat=53.4808&demoLon=-2.2426&foo=bar")).free_zone;
assert!((((fz.south + fz.north) / 2.0) - 53.4808).abs() < 1e-9);
assert!((((fz.west + fz.east) / 2.0) - (-2.2426)).abs() < 1e-9);
}
#[test]
fn rejects_out_of_range_or_missing_coords() {
// Out of range → fallback.
let fz = resolve_demo_zone(Some("demoLat=999&demoLon=0")).free_zone;
assert!((((fz.south + fz.north) / 2.0) - FREE_ZONE_FALLBACK_LAT).abs() < 1e-9);
// Only one coord → fallback.
let fz = resolve_demo_zone(Some("demoLat=53.0")).free_zone;
assert!((((fz.south + fz.north) / 2.0) - FREE_ZONE_FALLBACK_LAT).abs() < 1e-9);
}
#[test]
fn box_keeps_a_constant_size_when_relocated() {
let a = free_zone_around(51.5, -0.1);
let b = free_zone_around(55.0, -3.0);
assert!(((a.north - a.south) - (b.north - b.south)).abs() < 1e-9);
assert!(((a.east - a.west) - (b.east - b.west)).abs() < 1e-9);
}
}

View file

@ -10,9 +10,10 @@ use url::form_urlencoded;
use crate::auth::PocketBaseUser; use crate::auth::PocketBaseUser;
use crate::consts::{ use crate::consts::{
FREE_ZONE_BOUNDS, MAX_SHARE_LAT_SPAN, MAX_SHARE_LON_SPAN, MAX_SHARE_ZOOM, MIN_SHARE_ZOOM, MAX_SHARE_LAT_SPAN, MAX_SHARE_LON_SPAN, MAX_SHARE_ZOOM, MIN_SHARE_ZOOM, SHARE_CACHE_MAX_ENTRIES,
SHARE_CACHE_MAX_ENTRIES, SHARE_CACHE_TTL_SECS, SHARE_CACHE_TTL_SECS,
}; };
use crate::demo_zone::FreeZone;
use crate::pocketbase::get_superuser_token; use crate::pocketbase::get_superuser_token;
use crate::state::AppState; use crate::state::AppState;
@ -225,12 +226,14 @@ pub fn is_valid_share_bounds(bounds: ShareBounds) -> bool {
/// Check whether the user is allowed to query data at the given bounds. /// Check whether the user is allowed to query data at the given bounds.
/// Licensed users and admins bypass the check entirely. /// Licensed users and admins bypass the check entirely.
/// Free/anonymous users get 403 unless the bounds fall inside the free zone /// Free/anonymous users get 403 unless the bounds fall inside their free zone
/// or inside the bbox granted by a valid share code. /// (a box centred on their IP-approximate location) or inside the bbox granted
/// by a valid share code.
#[allow(clippy::result_large_err)] #[allow(clippy::result_large_err)]
pub fn check_license_bounds( pub fn check_license_bounds(
user: &Option<PocketBaseUser>, user: &Option<PocketBaseUser>,
bounds: (f64, f64, f64, f64), bounds: (f64, f64, f64, f64),
free_zone: FreeZone,
share_bounds: Option<ShareBounds>, share_bounds: Option<ShareBounds>,
) -> Result<(), axum::response::Response> { ) -> Result<(), axum::response::Response> {
if let Some(u) = user { if let Some(u) = user {
@ -240,9 +243,12 @@ pub fn check_license_bounds(
} }
let (south, west, north, east) = bounds; let (south, west, north, east) = bounds;
let (fz_south, fz_west, fz_north, fz_east) = FREE_ZONE_BOUNDS;
if south >= fz_south && west >= fz_west && north <= fz_north && east <= fz_east { if south >= free_zone.south
&& west >= free_zone.west
&& north <= free_zone.north
&& east <= free_zone.east
{
return Ok(()); return Ok(());
} }
@ -256,10 +262,10 @@ pub fn check_license_bounds(
"error": "license_required", "error": "license_required",
"message": "A license is required to view data outside the demo area", "message": "A license is required to view data outside the demo area",
"free_zone": { "free_zone": {
"south": fz_south, "south": free_zone.south,
"west": fz_west, "west": free_zone.west,
"north": fz_north, "north": free_zone.north,
"east": fz_east, "east": free_zone.east,
} }
}); });
@ -272,9 +278,10 @@ pub fn check_license_point(
user: &Option<PocketBaseUser>, user: &Option<PocketBaseUser>,
lat: f64, lat: f64,
lon: f64, lon: f64,
free_zone: FreeZone,
share_bounds: Option<ShareBounds>, share_bounds: Option<ShareBounds>,
) -> Result<(), axum::response::Response> { ) -> Result<(), axum::response::Response> {
check_license_bounds(user, (lat, lon, lat, lon), share_bounds) check_license_bounds(user, (lat, lon, lat, lon), free_zone, share_bounds)
} }
#[cfg(test)] #[cfg(test)]

View file

@ -7,6 +7,7 @@ mod bugsink;
mod checkout_sessions; mod checkout_sessions;
mod consts; mod consts;
mod data; mod data;
mod demo_zone;
mod features; mod features;
mod language; mod language;
mod licensing; mod licensing;
@ -15,6 +16,7 @@ mod og_middleware;
pub mod parsing; pub mod parsing;
mod pocketbase; mod pocketbase;
mod pocketbase_locks; mod pocketbase_locks;
mod ratelimit;
mod routes; mod routes;
mod state; mod state;
pub mod utils; pub mod utils;
@ -720,6 +722,7 @@ async fn main() -> anyhow::Result<()> {
stripe_webhook_secret: cli.stripe_webhook_secret, stripe_webhook_secret: cli.stripe_webhook_secret,
stripe_referral_coupon_id: cli.stripe_referral_coupon_id, stripe_referral_coupon_id: cli.stripe_referral_coupon_id,
bugsink_frontend_config, bugsink_frontend_config,
demo_rate_limiter: Arc::new(ratelimit::DemoRateLimiter::new()),
}; };
let shared = Arc::new(SharedState::new(app_state)); let shared = Arc::new(SharedState::new(app_state));
@ -999,6 +1002,8 @@ async fn main() -> anyhow::Result<()> {
api api
} }
.layer(middleware::from_fn(metrics::track_metrics)) .layer(middleware::from_fn(metrics::track_metrics))
.layer(middleware::from_fn(ratelimit::demo_guard_middleware))
.layer(middleware::from_fn(demo_zone::demo_zone_middleware))
.layer(middleware::from_fn(auth::auth_middleware)) .layer(middleware::from_fn(auth::auth_middleware))
.layer(middleware::from_fn( .layer(middleware::from_fn(
move |req: axum::extract::Request, next: middleware::Next| { move |req: axum::extract::Request, next: middleware::Next| {

256
server-rs/src/ratelimit.rs Normal file
View file

@ -0,0 +1,256 @@
//! Defense-in-depth guards for unlicensed ("demo") API traffic: a per-key rate
//! limit and a server-side filter-count cap. These complement the client-side UX
//! limits so a scripted client can't trivially bypass them. Licensed users and
//! admins are exempt.
//!
//! This is a soft anti-abuse measure, not a hard security boundary: the rate-limit
//! key falls back to the (spoofable) client IP for anonymous users, and the demo
//! gate is best-effort by design (see demo_zone.rs). Logged-in free accounts are keyed
//! by their stable account id, which spoofing the IP header does not evade.
use std::net::{IpAddr, SocketAddr};
use std::sync::Arc;
use std::time::{Duration, Instant};
use axum::http::HeaderMap;
use axum::extract::{ConnectInfo, Request};
use axum::http::StatusCode;
use axum::middleware::Next;
use axum::response::{IntoResponse, Response};
use parking_lot::Mutex;
use rustc_hash::FxHashMap;
use serde_json::json;
use url::form_urlencoded;
use crate::auth::OptionalUser;
use crate::consts::{
DEMO_MAX_FILTERS, DEMO_RATE_LIMIT_MAX, DEMO_RATE_LIMIT_MAX_KEYS, DEMO_RATE_LIMIT_WINDOW_SECS,
};
use crate::state::AppState;
/// Extract the client IP, preferring reverse-proxy/CDN headers (we sit behind one
/// in production) and falling back to the socket peer. Client-supplied and
/// spoofable — only used as a coarse rate-limit key for anonymous visitors.
fn client_ip(headers: &HeaderMap, peer: Option<IpAddr>) -> Option<IpAddr> {
let from_header = |name: &str, take_first: bool| -> Option<IpAddr> {
let raw = headers.get(name)?.to_str().ok()?;
let candidate = if take_first {
raw.split(',').next().unwrap_or(raw)
} else {
raw
};
candidate.trim().parse().ok()
};
from_header("cf-connecting-ip", false)
.or_else(|| from_header("x-forwarded-for", true))
.or_else(|| from_header("x-real-ip", false))
.or(peer)
}
/// Sliding-window request counter keyed by account id or client IP.
pub struct DemoRateLimiter {
windows: Mutex<FxHashMap<String, Vec<Instant>>>,
}
impl DemoRateLimiter {
pub fn new() -> Self {
Self {
windows: Mutex::new(FxHashMap::default()),
}
}
/// Record a hit for `key`; returns `false` when the per-window limit is
/// exceeded (caller should reject with 429).
pub fn check(&self, key: &str) -> bool {
let now = Instant::now();
let window = Duration::from_secs(DEMO_RATE_LIMIT_WINDOW_SECS);
let mut map = self.windows.lock();
// Bound memory: when the table grows large, drop keys with no recent hits.
// If even that can't get us back under the cap (e.g. a spoofed-IP flood
// minting a fresh key per request), clear the table outright. That resets
// everyone's window — acceptable under attack — and keeps both the size and
// the cost of this scan bounded (it then won't re-run for ~MAX_KEYS inserts,
// instead of scanning O(n) on every request once full).
if map.len() > DEMO_RATE_LIMIT_MAX_KEYS {
map.retain(|_, hits| hits.iter().any(|t| now.duration_since(*t) < window));
if map.len() > DEMO_RATE_LIMIT_MAX_KEYS {
map.clear();
}
}
let hits = map.entry(key.to_string()).or_default();
hits.retain(|t| now.duration_since(*t) < window);
if hits.len() >= DEMO_RATE_LIMIT_MAX {
return false;
}
hits.push(now);
true
}
}
impl Default for DemoRateLimiter {
fn default() -> Self {
Self::new()
}
}
/// Whether a request is internal/direct rather than from a public visitor via the
/// edge proxy. Internal callers (the screenshot/OG service, health checks, local
/// dev) reach the server container directly: a private/loopback socket peer and no
/// edge-proxy forwarding headers. We exempt them so demo guards never throttle our
/// own services. (Behind the public proxy, real visitors carry forwarding headers.)
fn is_internal_request(headers: &HeaderMap, peer: Option<IpAddr>) -> bool {
let has_forwarding = headers.contains_key("cf-connecting-ip")
|| headers.contains_key("x-forwarded-for")
|| headers.contains_key("x-real-ip");
if has_forwarding {
return false;
}
match peer {
Some(IpAddr::V4(v4)) => {
v4.is_private() || v4.is_loopback() || v4.is_link_local() || v4.is_unspecified()
}
Some(IpAddr::V6(v6)) => {
v6.is_loopback()
|| v6.is_unspecified()
|| (v6.segments()[0] & 0xfe00) == 0xfc00 // fc00::/7 unique-local
|| (v6.segments()[0] & 0xffc0) == 0xfe80 // fe80::/10 link-local
}
None => true,
}
}
/// Count non-empty `;;`-separated entries in the `filters` query parameter.
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();
}
}
0
}
/// Whether the request carries a non-empty `share` code. Such requests view a shared
/// dashboard whose saved filter set may legitimately exceed the demo cap, so they're
/// exempt from the filter cap — the actual share-bounds authorization still runs in
/// the handler.
fn has_share_code(query: &str) -> bool {
form_urlencoded::parse(query.as_bytes())
.any(|(key, value)| key == "share" && !value.trim().is_empty())
}
/// Middleware applying the demo filter cap and rate limit to unlicensed `/api/`
/// traffic. Must run after `auth_middleware` (for `OptionalUser`) and the state
/// injection layer (for `AppState`).
pub async fn demo_guard_middleware(req: Request, next: Next) -> Response {
let path = req.uri().path();
// Map/overlay tiles aren't property-data pulls, and the basemap visibly breaks if
// they're throttled, so the demo guards only cover the data endpoints.
if !path.starts_with("/api/")
|| path.starts_with("/api/tiles/")
|| path.starts_with("/api/overlays/")
{
return next.run(req).await;
}
// Licensed users and admins bypass demo guards entirely.
let user = req.extensions().get::<OptionalUser>().and_then(|u| u.0.clone());
if let Some(u) = &user {
if u.is_admin || u.subscription == "licensed" {
return next.run(req).await;
}
}
let peer = req
.extensions()
.get::<ConnectInfo<SocketAddr>>()
.map(|info| info.0.ip());
// Exempt our own internal services (screenshots/OG, health checks, dev).
if is_internal_request(req.headers(), peer) {
return next.run(req).await;
}
// Server-side filter cap (mirrors the client demo limit; blunts oversized
// aggregation requests). Share recipients are exempt — their saved view may
// legitimately carry >5 filters and the share grant is checked in the handler.
if let Some(query) = req.uri().query() {
if !has_share_code(query) && filter_count(query) > DEMO_MAX_FILTERS {
return (
StatusCode::BAD_REQUEST,
axum::Json(json!({
"error": "filter_limit",
"message": format!("The demo is limited to {DEMO_MAX_FILTERS} filters"),
})),
)
.into_response();
}
}
// Rate limit per account (stable, not IP-spoofable) or, for anonymous
// visitors, per client IP.
let key = match &user {
Some(u) => format!("acct:{}", u.id),
None => match client_ip(req.headers(), peer) {
Some(ip) => format!("ip:{ip}"),
None => "anon".to_string(),
},
};
if let Some(state) = req.extensions().get::<Arc<AppState>>() {
if !state.demo_rate_limiter.check(&key) {
return (
StatusCode::TOO_MANY_REQUESTS,
axum::Json(json!({
"error": "rate_limited",
"message": "Too many requests — slow down, or sign in for full access",
})),
)
.into_response();
}
}
next.run(req).await
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn counts_filters_in_query() {
assert_eq!(filter_count(""), 0);
assert_eq!(filter_count("resolution=9"), 0);
assert_eq!(filter_count("filters=price%3A1%3A2"), 1);
assert_eq!(
filter_count("filters=price%3A1%3A2%3B%3Bbeds%3A2%3A4&resolution=9"),
2
);
// Trailing/empty entries are ignored.
assert_eq!(filter_count("filters=price%3A1%3A2%3B%3B"), 1);
}
#[test]
fn detects_share_code() {
assert!(!has_share_code(""));
assert!(!has_share_code("filters=a%3B%3Bb"));
assert!(!has_share_code("share=")); // empty value doesn't count
assert!(has_share_code("share=abc123"));
assert!(has_share_code("bounds=1,2,3,4&share=xyz&filters=a"));
}
#[test]
fn rate_limiter_blocks_past_the_window_limit() {
let limiter = DemoRateLimiter::new();
// The first DEMO_RATE_LIMIT_MAX hits for a key pass; the next is blocked.
for _ in 0..DEMO_RATE_LIMIT_MAX {
assert!(limiter.check("acct:abc"));
}
assert!(!limiter.check("acct:abc"));
// A different key has its own independent budget.
assert!(limiter.check("acct:xyz"));
}
}

View file

@ -51,6 +51,7 @@ const LISTING_BOUNDS_EPSILON_DEGREES: f64 = 0.00001;
pub async fn get_actual_listings( pub async fn get_actual_listings(
State(shared): State<Arc<SharedState>>, State(shared): State<Arc<SharedState>>,
Extension(user): Extension<OptionalUser>, Extension(user): Extension<OptionalUser>,
Extension(geo): Extension<crate::demo_zone::DemoZone>,
Query(params): Query<ActualListingsParams>, Query(params): Query<ActualListingsParams>,
) -> Result<Json<ActualListingsResponse>, Response> { ) -> Result<Json<ActualListingsResponse>, Response> {
let state = shared.load_state(); let state = shared.load_state();
@ -70,7 +71,7 @@ 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), 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();

View file

@ -14,6 +14,7 @@ use tracing::{info, warn};
use crate::auth::OptionalUser; use crate::auth::OptionalUser;
use crate::consts::NAN_U16; use crate::consts::NAN_U16;
use crate::data::travel_time::TravelData;
use crate::data::{PostcodePoiMetrics, QuantRef}; use crate::data::{PostcodePoiMetrics, QuantRef};
use crate::features; use crate::features;
use crate::licensing::{check_license_bounds, resolve_share_code}; use crate::licensing::{check_license_bounds, resolve_share_code};
@ -369,15 +370,98 @@ fn bounds_for_postcode_indices(
(south, west, north, east) (south, west, north, east)
} }
/// A travel-time column derived from the active `travel` / `tt` query params.
/// Each active travel destination becomes one column showing the per-postcode
/// median (or best-case) travel time in minutes.
struct TravelColumn {
/// Human-readable destination name for the header (e.g. "Bank tube station").
header: String,
/// Description-row text (e.g. "Travel time by public transport (minutes)").
description: String,
/// Postcode → travel time data for this destination.
data: TravelData,
/// Whether to report the best-case (5th percentile) time instead of median.
use_best: bool,
}
/// Parse the repeated `tt` frontend-state params into a (mode, slug) → label map.
/// Each value looks like `mode:slug:EncodedLabel[:b][:min:max]`, where the label
/// was percent-encoded with the frontend's `encodeURIComponent`.
fn parse_travel_labels(tt_params: &[String]) -> FxHashMap<(String, String), String> {
let mut map = FxHashMap::default();
for raw in tt_params {
// splitn(4) keeps the label as a single piece; it never contains a raw
// ':' because encodeURIComponent escapes it to %3A.
let mut parts = raw.splitn(4, ':');
let (Some(mode), Some(slug), Some(encoded_label)) =
(parts.next(), parts.next(), parts.next())
else {
continue;
};
let label = urlencoding::decode(encoded_label)
.map(|cow| cow.into_owned())
.unwrap_or_else(|_| encoded_label.to_string());
if !label.is_empty() {
map.insert((mode.to_string(), slug.to_string()), label);
}
}
map
}
/// Turn a destination slug into a display name when no label is supplied.
/// "bank-tube-station" → "Bank tube station".
fn humanize_slug(slug: &str) -> String {
let mut s = slug.replace('-', " ");
if let Some(first) = s.get_mut(0..1) {
first.make_ascii_uppercase();
}
s
}
/// Friendly name for a travel mode (incl. transit variants) for the desc row.
fn pretty_travel_mode(mode: &str) -> &'static str {
match mode {
"car" => "car",
"bicycle" => "bike",
"walking" => "walking",
m if m.starts_with("transit") => "public transport",
_ => "travel",
}
}
/// The reported travel time (median, or best-case when `use_best`) for a
/// postcode, or None when the destination has no data for it.
#[inline]
fn travel_minutes_for(data: &TravelData, postcode: &str, use_best: bool) -> Option<i16> {
data.get(postcode).map(|row| {
if use_best {
row.best_minutes.unwrap_or(row.minutes)
} else {
row.minutes
}
})
}
pub async fn get_export( pub async fn get_export(
State(shared): State<Arc<SharedState>>, State(shared): State<Arc<SharedState>>,
headers: HeaderMap, headers: HeaderMap,
Extension(user): Extension<OptionalUser>, Extension(user): Extension<OptionalUser>,
Extension(geo): Extension<crate::demo_zone::DemoZone>,
uri: Uri, uri: Uri,
Query(params): Query<ExportParams>, Query(params): Query<ExportParams>,
) -> Result<impl IntoResponse, axum::response::Response> { ) -> Result<impl IntoResponse, axum::response::Response> {
let state = shared.load_state(); let state = shared.load_state();
// Exporting requires an account — no anonymous/demo exports.
if user.0.is_none() {
return Err((
StatusCode::UNAUTHORIZED,
[(header::CONTENT_TYPE, "application/json")],
r#"{"error":"account_required","message":"Sign in to export data"}"#,
)
.into_response());
}
// Two modes: bounds-based (default) and explicit postcode list. // Two modes: bounds-based (default) and explicit postcode list.
let postcode_list = match params.postcodes.as_deref() { let postcode_list = match params.postcodes.as_deref() {
Some(raw) if !raw.trim().is_empty() => { Some(raw) if !raw.trim().is_empty() => {
@ -418,7 +502,7 @@ 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), 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();
@ -450,20 +534,17 @@ pub async fn get_export(
} else { } else {
params.filters params.filters
}; };
let travel_entries = if is_postcode_mode { // Travel times are per-postcode global data, so they appear as columns in
Vec::new() // both bounds and list mode. The time-range *filter* is only applied on the
} else { // bounds path (`has_travel_filters` below); list mode never filters rows, so
parse_optional_travel(params.travel.as_deref()) // every requested postcode still shows up with its travel time.
.map_err(|err| (StatusCode::BAD_REQUEST, err).into_response())? let travel_entries = parse_optional_travel(params.travel.as_deref())
}; .map_err(|err| (StatusCode::BAD_REQUEST, err).into_response())?;
let has_travel_filters = travel_entries let has_travel_filters = !is_postcode_mode
&& travel_entries
.iter() .iter()
.any(|entry| entry.filter_min.is_some() && entry.filter_max.is_some()); .any(|entry| entry.filter_min.is_some() && entry.filter_max.is_some());
let travel_state_params = if is_postcode_mode { let travel_state_params = collect_travel_state_params(uri.query());
Vec::new()
} else {
collect_travel_state_params(uri.query())
};
let overlay_state_params = if is_postcode_mode { let overlay_state_params = if is_postcode_mode {
Vec::new() Vec::new()
} else { } else {
@ -553,6 +634,33 @@ pub async fn get_export(
let postcode_data = &state.postcode_data; let postcode_data = &state.postcode_data;
let poi_metrics = &state.data.poi_metrics; let poi_metrics = &state.data.poi_metrics;
let travel_data = load_travel_data(&state.travel_time_store, &travel_entries)?; let travel_data = load_travel_data(&state.travel_time_store, &travel_entries)?;
// One column per active travel destination. `travel_data` is built by
// mapping over `travel_entries` in order, so the two are index-aligned.
// Display labels come from the `tt` frontend-state params.
let travel_labels = parse_travel_labels(&travel_state_params);
let travel_columns: Vec<TravelColumn> = travel_entries
.iter()
.zip(travel_data.iter())
.map(|(entry, data)| {
let header = travel_labels
.get(&(entry.mode.clone(), entry.slug.clone()))
.cloned()
.unwrap_or_else(|| humanize_slug(&entry.slug));
let description = format!(
"Travel time by {}{} (minutes)",
pretty_travel_mode(&entry.mode),
if entry.use_best { ", best case" } else { "" }
);
TravelColumn {
header,
description,
data: Arc::clone(data),
use_best: entry.use_best,
}
})
.collect();
let poi_offset = num_features; let poi_offset = num_features;
let total_export_features = num_features + poi_metrics.num_features(); let total_export_features = num_features + poi_metrics.num_features();
let (pc_interner, pc_keys) = state.data.postcode_parts(); let (pc_interner, pc_keys) = state.data.postcode_parts();
@ -701,6 +809,15 @@ pub async fn get_export(
ordered ordered
}; };
// The "All Data" sheet lists property/area features only. POI amenity
// counts & distances (virtual indices >= poi_offset) are reserved for the
// "Selected" sheet, where they show up when the user filters/selects them.
let all_data_feature_indices: Vec<usize> = all_feature_indices
.iter()
.copied()
.filter(|&idx| idx < poi_offset)
.collect();
// Filter-only feature indices for the Selected sheet // Filter-only feature indices for the Selected sheet
let filter_feature_indices: Vec<usize> = filter_feature_names let filter_feature_indices: Vec<usize> = filter_feature_names
.iter() .iter()
@ -839,6 +956,12 @@ pub async fn get_export(
let group_label_fmt = Format::new().set_bold().set_font_color("#1F4E79"); let group_label_fmt = Format::new().set_bold().set_font_color("#1F4E79");
let group_count_fmt = Format::new().set_bold(); let group_count_fmt = Format::new().set_bold();
// Travel-time cells render the integer minute count with a " min" suffix.
let travel_num_fmt = Format::new().set_num_format("0\" min\"");
// Postcode-centroid coordinates (WGS84), ~1 m precision at 5 decimals.
let coord_num_fmt = Format::new().set_num_format("0.00000");
// Dashboard URL // Dashboard URL
let dashboard_url = format!( let dashboard_url = format!(
"{}/dashboard?{}", "{}/dashboard?{}",
@ -847,14 +970,16 @@ pub async fn get_export(
); );
// Two sheets in both modes: "Selected" (just the features behind the // Two sheets in both modes: "Selected" (just the features behind the
// active filters) and "All Data" (every feature). The Selected sheet // active filters, including any POI amenity metrics) and "All Data"
// carries the dashboard link + screenshot only in bounds mode, where the // (every property/area feature, but no POI amenity counts/distances —
// export is tied to a map view; in list mode the user picked the // those live only on the Selected sheet). The Selected sheet carries the
// postcodes explicitly, so there's no map view to link or screenshot. // dashboard link + screenshot only in bounds mode, where the export is
// tied to a map view; in list mode the user picked the postcodes
// explicitly, so there's no map view to link or screenshot.
let is_list_mode = postcode_list_entries.is_some(); let is_list_mode = postcode_list_entries.is_some();
let sheet_configs: Vec<(&str, &[usize], bool)> = vec![ let sheet_configs: Vec<(&str, &[usize], bool)> = vec![
("Selected", &filter_feature_indices, !is_list_mode), ("Selected", &filter_feature_indices, !is_list_mode),
("All Data", &all_feature_indices, false), ("All Data", &all_data_feature_indices, false),
]; ];
for (sheet_name, feat_indices, include_header) in &sheet_configs { for (sheet_name, feat_indices, include_header) in &sheet_configs {
@ -904,6 +1029,10 @@ pub async fn get_export(
// Header row // Header row
let header_row = current_row; let header_row = current_row;
// Travel-time columns sit to the right of the feature columns, with
// the Latitude/Longitude pair last.
let travel_col_base = (feat_indices.len() + 2) as u16;
let coord_col_base = travel_col_base + travel_columns.len() as u16;
sheet sheet
.write_string_with_format(header_row, 0, "Postcode", &header_fmt) .write_string_with_format(header_row, 0, "Postcode", &header_fmt)
.map_err(|e| format!("Failed to write header: {e}"))?; .map_err(|e| format!("Failed to write header: {e}"))?;
@ -923,6 +1052,24 @@ pub async fn get_export(
.map_err(|e| format!("Failed to write header: {e}"))?; .map_err(|e| format!("Failed to write header: {e}"))?;
} }
for (i, tc) in travel_columns.iter().enumerate() {
sheet
.write_string_with_format(
header_row,
travel_col_base + i as u16,
&tc.header,
&header_fmt,
)
.map_err(|e| format!("Failed to write travel header: {e}"))?;
}
sheet
.write_string_with_format(header_row, coord_col_base, "Latitude", &header_fmt)
.map_err(|e| format!("Failed to write header: {e}"))?;
sheet
.write_string_with_format(header_row, coord_col_base + 1, "Longitude", &header_fmt)
.map_err(|e| format!("Failed to write header: {e}"))?;
// Description row // Description row
let desc_row = header_row + 1; let desc_row = header_row + 1;
sheet sheet
@ -943,6 +1090,29 @@ pub async fn get_export(
.map_err(|e| format!("Failed to write desc: {e}"))?; .map_err(|e| format!("Failed to write desc: {e}"))?;
} }
for (i, tc) in travel_columns.iter().enumerate() {
sheet
.write_string_with_format(
desc_row,
travel_col_base + i as u16,
&tc.description,
&desc_fmt,
)
.map_err(|e| format!("Failed to write travel desc: {e}"))?;
}
sheet
.write_string_with_format(desc_row, coord_col_base, "Postcode centroid", &desc_fmt)
.map_err(|e| format!("Failed to write desc: {e}"))?;
sheet
.write_string_with_format(
desc_row,
coord_col_base + 1,
"Postcode centroid",
&desc_fmt,
)
.map_err(|e| format!("Failed to write desc: {e}"))?;
// Put the collapse/expand controls above each group so the bold // Put the collapse/expand controls above each group so the bold
// outcode summary row acts as the header for its postcodes. // outcode summary row acts as the header for its postcodes.
sheet.group_symbols_above(true); sheet.group_symbols_above(true);
@ -976,6 +1146,63 @@ pub async fn get_export(
&integer_feature_indices, &integer_feature_indices,
&feat_num_fmts, &feat_num_fmts,
)?; )?;
// Travel time rolled up across the group's postcodes, weighted by
// property count to match the property-weighted feature means.
for (i, tc) in travel_columns.iter().enumerate() {
let mut weighted_sum = 0.0_f64;
let mut weight = 0u32;
for &member in &group.members {
let (member_pc_idx, member_agg) = &postcode_aggs[member];
let pc = &postcode_data.postcodes[*member_pc_idx];
if let Some(minutes) = travel_minutes_for(&tc.data, pc, tc.use_best) {
weighted_sum += minutes as f64 * member_agg.count as f64;
weight += member_agg.count;
}
}
if weight > 0 {
let mean = (weighted_sum / weight as f64).round();
sheet
.write_number_with_format(
summary_row,
travel_col_base + i as u16,
mean,
&travel_num_fmt,
)
.map_err(|e| format!("Failed to write travel summary: {e}"))?;
}
}
// Property-weighted centroid of the group's postcodes.
{
let mut lat_sum = 0.0_f64;
let mut lon_sum = 0.0_f64;
let mut weight = 0u32;
for &member in &group.members {
let (member_pc_idx, member_agg) = &postcode_aggs[member];
let (lat, lon) = postcode_data.centroids[*member_pc_idx];
lat_sum += lat as f64 * member_agg.count as f64;
lon_sum += lon as f64 * member_agg.count as f64;
weight += member_agg.count;
}
if weight > 0 {
let w = weight as f64;
sheet
.write_number_with_format(
summary_row,
coord_col_base,
lat_sum / w,
&coord_num_fmt,
)
.map_err(|e| format!("Failed to write latitude: {e}"))?;
sheet
.write_number_with_format(
summary_row,
coord_col_base + 1,
lon_sum / w,
&coord_num_fmt,
)
.map_err(|e| format!("Failed to write longitude: {e}"))?;
}
}
row += 1; row += 1;
// Individual postcode rows for this outcode. // Individual postcode rows for this outcode.
@ -999,6 +1226,34 @@ pub async fn get_export(
&integer_feature_indices, &integer_feature_indices,
&feat_num_fmts, &feat_num_fmts,
)?; )?;
for (i, tc) in travel_columns.iter().enumerate() {
if let Some(minutes) = travel_minutes_for(
&tc.data,
&postcode_data.postcodes[*pc_idx],
tc.use_best,
) {
sheet
.write_number_with_format(
row,
travel_col_base + i as u16,
minutes as f64,
&travel_num_fmt,
)
.map_err(|e| format!("Failed to write travel time: {e}"))?;
}
}
let (lat, lon) = postcode_data.centroids[*pc_idx];
sheet
.write_number_with_format(row, coord_col_base, lat as f64, &coord_num_fmt)
.map_err(|e| format!("Failed to write latitude: {e}"))?;
sheet
.write_number_with_format(
row,
coord_col_base + 1,
lon as f64,
&coord_num_fmt,
)
.map_err(|e| format!("Failed to write longitude: {e}"))?;
row += 1; row += 1;
} }
@ -1012,7 +1267,8 @@ pub async fn get_export(
// Sample note // Sample note
if was_sampled { if was_sampled {
let note_row = row + 1; let note_row = row + 1;
let total_cols = (feat_indices.len() + 2) as u16; // +2 for the Latitude/Longitude columns.
let total_cols = (feat_indices.len() + 2 + travel_columns.len() + 2) as u16;
sheet sheet
.merge_range( .merge_range(
note_row, note_row,
@ -1043,6 +1299,18 @@ pub async fn get_export(
.set_column_width(col, width) .set_column_width(col, width)
.map_err(|e| format!("Failed to set column width: {e}"))?; .map_err(|e| format!("Failed to set column width: {e}"))?;
} }
for (i, tc) in travel_columns.iter().enumerate() {
let width = (tc.header.chars().count() as f64 * 1.1).clamp(10.0, 30.0);
sheet
.set_column_width(travel_col_base + i as u16, width)
.map_err(|e| format!("Failed to set travel column width: {e}"))?;
}
sheet
.set_column_width(coord_col_base, 11)
.map_err(|e| format!("Failed to set coordinate column width: {e}"))?;
sheet
.set_column_width(coord_col_base + 1, 11)
.map_err(|e| format!("Failed to set coordinate column width: {e}"))?;
} }
let buf = workbook let buf = workbook
@ -1128,6 +1396,50 @@ mod tests {
assert_eq!(outcode_of("E14"), "E14"); assert_eq!(outcode_of("E14"), "E14");
} }
#[test]
fn parse_travel_labels_decodes_label_keyed_by_mode_and_slug() {
// As produced by collect_travel_state_params (outer URL layer already
// decoded); the label is still encodeURIComponent-encoded.
let params = vec![
"transit:bank-tube-station:Bank%20tube%20station:0:52".to_string(),
"car:heathrow-airport:Heathrow%20Airport:b:0:90".to_string(),
];
let labels = parse_travel_labels(&params);
assert_eq!(
labels.get(&("transit".to_string(), "bank-tube-station".to_string())),
Some(&"Bank tube station".to_string())
);
assert_eq!(
labels.get(&("car".to_string(), "heathrow-airport".to_string())),
Some(&"Heathrow Airport".to_string())
);
}
#[test]
fn parse_travel_labels_skips_entries_without_a_label() {
// Empty label (encodeURIComponent("")) and a too-short value are ignored.
let params = vec![
"transit:bank-tube-station::b:0:52".to_string(),
"car:somewhere".to_string(),
];
assert!(parse_travel_labels(&params).is_empty());
}
#[test]
fn humanize_slug_falls_back_to_a_readable_name() {
assert_eq!(humanize_slug("bank-tube-station"), "Bank tube station");
assert_eq!(humanize_slug("london"), "London");
}
#[test]
fn pretty_travel_mode_maps_transit_variants() {
assert_eq!(pretty_travel_mode("transit"), "public transport");
assert_eq!(pretty_travel_mode("transit-no-buses"), "public transport");
assert_eq!(pretty_travel_mode("car"), "car");
assert_eq!(pretty_travel_mode("bicycle"), "bike");
assert_eq!(pretty_travel_mode("walking"), "walking");
}
#[test] #[test]
fn export_query_deserializes_when_tt_is_a_single_string() { fn export_query_deserializes_when_tt_is_a_single_string() {
let uri: Uri = "/api/export?bounds=1,2,3,4&tt=transit%3Abank%3ABank%2520station%3A0%3A52" let uri: Uri = "/api/export?bounds=1,2,3,4&tt=transit%3Abank%3ABank%2520station%3A0%3A52"

View file

@ -33,6 +33,7 @@ pub struct FilterCountsResponse {
pub async fn get_filter_counts( pub async fn get_filter_counts(
State(shared): State<Arc<SharedState>>, State(shared): State<Arc<SharedState>>,
Extension(user): Extension<OptionalUser>, Extension(user): Extension<OptionalUser>,
Extension(geo): Extension<crate::demo_zone::DemoZone>,
Query(params): Query<FilterCountsParams>, Query(params): Query<FilterCountsParams>,
) -> Result<Json<FilterCountsResponse>, axum::response::Response> { ) -> Result<Json<FilterCountsResponse>, axum::response::Response> {
let state = shared.load_state(); let state = shared.load_state();
@ -40,7 +41,7 @@ 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), 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();

View file

@ -436,6 +436,7 @@ pub(super) fn top_filter_exclusions(
pub async fn get_hexagon_stats( pub async fn get_hexagon_stats(
State(shared): State<Arc<SharedState>>, State(shared): State<Arc<SharedState>>,
Extension(user): Extension<OptionalUser>, Extension(user): Extension<OptionalUser>,
Extension(geo): Extension<crate::demo_zone::DemoZone>,
Query(params): Query<HexagonStatsParams>, Query(params): Query<HexagonStatsParams>,
) -> Result<Json<HexagonStatsResponse>, axum::response::Response> { ) -> Result<Json<HexagonStatsResponse>, axum::response::Response> {
let state = shared.load_state(); let state = shared.load_state();
@ -455,7 +456,7 @@ pub async fn get_hexagon_stats(
// License check using H3 cell bounds // License check using H3 cell bounds
let h3_bounds = h3_cell_bounds(cell, 0.0); let h3_bounds = h3_cell_bounds(cell, 0.0);
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, h3_bounds, share_bounds)?; check_license_bounds(&user.0, h3_bounds, geo.free_zone, share_bounds)?;
let h3_str = params.h3; let h3_str = params.h3;
let quant = state.data.quant_ref(); let quant = state.data.quant_ref();

View file

@ -252,6 +252,7 @@ fn build_feature_maps(
pub async fn get_hexagons( pub async fn get_hexagons(
State(shared): State<Arc<SharedState>>, State(shared): State<Arc<SharedState>>,
Extension(user): Extension<OptionalUser>, Extension(user): Extension<OptionalUser>,
Extension(geo): Extension<crate::demo_zone::DemoZone>,
Query(params): Query<HexagonParams>, Query(params): Query<HexagonParams>,
) -> Result<Json<HexagonsResponse>, axum::response::Response> { ) -> Result<Json<HexagonsResponse>, axum::response::Response> {
let state = shared.load_state(); let state = shared.load_state();
@ -262,7 +263,7 @@ 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), 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();

View file

@ -57,6 +57,7 @@ fn destination_coordinates(place_data: &PlaceData, slug: &str) -> Option<(f32, f
pub async fn get_journey( pub async fn get_journey(
State(shared): State<Arc<SharedState>>, State(shared): State<Arc<SharedState>>,
Extension(user): Extension<OptionalUser>, Extension(user): Extension<OptionalUser>,
Extension(geo): Extension<crate::demo_zone::DemoZone>,
query: axum::extract::Query<JourneyQuery>, query: axum::extract::Query<JourneyQuery>,
) -> Result<Json<JourneyResponse>, axum::response::Response> { ) -> Result<Json<JourneyResponse>, axum::response::Response> {
let state = shared.load_state(); let state = shared.load_state();
@ -72,7 +73,7 @@ pub async fn get_journey(
let (lat, lon) = state.postcode_data.centroids[pc_idx]; let (lat, lon) = state.postcode_data.centroids[pc_idx];
let share_bounds = resolve_share_code(&state, query.share.as_deref()).await; let share_bounds = resolve_share_code(&state, query.share.as_deref()).await;
check_license_point(&user.0, lat as f64, lon as f64, share_bounds)?; check_license_point(&user.0, lat as f64, lon as f64, geo.free_zone, share_bounds)?;
if !store.has_destination(&query.mode, &query.slug) { if !store.has_destination(&query.mode, &query.slug) {
return Err(( return Err((

View file

@ -41,6 +41,7 @@ pub struct PostcodePropertiesParams {
pub async fn get_postcode_properties( pub async fn get_postcode_properties(
State(shared): State<Arc<SharedState>>, State(shared): State<Arc<SharedState>>,
Extension(user): Extension<OptionalUser>, Extension(user): Extension<OptionalUser>,
Extension(geo): Extension<crate::demo_zone::DemoZone>,
Query(params): Query<PostcodePropertiesParams>, Query(params): Query<PostcodePropertiesParams>,
) -> Result<Json<PropertyListResponse>, axum::response::Response> { ) -> Result<Json<PropertyListResponse>, axum::response::Response> {
let state = shared.load_state(); let state = shared.load_state();
@ -64,6 +65,7 @@ pub async fn get_postcode_properties(
&user.0, &user.0,
centroid_lat as f64, centroid_lat as f64,
centroid_lon as f64, centroid_lon as f64,
geo.free_zone,
share_bounds, share_bounds,
)?; )?;

View file

@ -15,7 +15,7 @@ use crate::state::SharedState;
use crate::utils::normalize_postcode; use crate::utils::normalize_postcode;
use super::hexagon_stats::{ use super::hexagon_stats::{
parse_area_stats_field_set, top_filter_exclusions, HexagonStatsResponse, parse_area_stats_field_set, top_filter_exclusions, EnumFeatureStats, HexagonStatsResponse,
}; };
use super::stats; use super::stats;
use super::travel_time::{load_travel_data, parse_optional_travel, row_passes_travel_filters}; use super::travel_time::{load_travel_data, parse_optional_travel, row_passes_travel_filters};
@ -38,6 +38,7 @@ pub struct PostcodeStatsParams {
pub async fn get_postcode_stats( pub async fn get_postcode_stats(
State(shared): State<Arc<SharedState>>, State(shared): State<Arc<SharedState>>,
Extension(user): Extension<OptionalUser>, Extension(user): Extension<OptionalUser>,
Extension(geo): Extension<crate::demo_zone::DemoZone>,
Query(params): Query<PostcodeStatsParams>, Query(params): Query<PostcodeStatsParams>,
) -> Result<Json<HexagonStatsResponse>, axum::response::Response> { ) -> Result<Json<HexagonStatsResponse>, axum::response::Response> {
let state = shared.load_state(); let state = shared.load_state();
@ -63,6 +64,7 @@ pub async fn get_postcode_stats(
&user.0, &user.0,
centroid_lat as f64, centroid_lat as f64,
centroid_lon as f64, centroid_lon as f64,
geo.free_zone,
share_bounds, share_bounds,
)?; )?;
@ -157,7 +159,7 @@ pub async fn get_postcode_stats(
&field_set, &field_set,
); );
let (mut numeric_features, enum_features_out) = stats::compute_feature_stats( let (mut numeric_features, mut enum_features_out) = stats::compute_feature_stats(
&matching_rows, &matching_rows,
&state.data, &state.data,
&state.data.feature_names, &state.data.feature_names,
@ -173,6 +175,31 @@ pub async fn get_postcode_stats(
&field_set, &field_set,
)); ));
// The council-house count is an attribute of the postcode itself, not of
// the currently filter-matching subset: recompute it over every property
// in the postcode so toggling filters never changes it. (When no filters
// are applied, `area_rows == matching_rows` and this is a no-op.)
if let Some(&council_idx) = state
.feature_name_to_index
.get(crate::consts::FORMER_COUNCIL_HOUSE_FEATURE)
{
enum_features_out.retain(|f| f.name != crate::consts::FORMER_COUNCIL_HOUSE_FEATURE);
let included = !fields_specified
|| field_set.contains(crate::consts::FORMER_COUNCIL_HOUSE_FEATURE);
if included {
if let Some(counts) =
stats::compute_enum_feature_counts(&area_rows, &state.data, council_idx)
{
if !counts.is_empty() {
enum_features_out.push(EnumFeatureStats {
name: crate::consts::FORMER_COUNCIL_HOUSE_FEATURE.to_string(),
counts,
});
}
}
}
}
let elapsed = start_time.elapsed(); let elapsed = start_time.elapsed();
info!( info!(
postcode = %postcode_str, postcode = %postcode_str,

View file

@ -54,6 +54,7 @@ pub struct PostcodeParams {
pub async fn get_postcodes( pub async fn get_postcodes(
State(shared): State<Arc<SharedState>>, State(shared): State<Arc<SharedState>>,
Extension(user): Extension<OptionalUser>, Extension(user): Extension<OptionalUser>,
Extension(geo): Extension<crate::demo_zone::DemoZone>,
Query(params): Query<PostcodeParams>, Query(params): Query<PostcodeParams>,
) -> Result<Json<PostcodesResponse>, axum::response::Response> { ) -> Result<Json<PostcodesResponse>, axum::response::Response> {
let state = shared.load_state(); let state = shared.load_state();
@ -61,7 +62,7 @@ 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), 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();

View file

@ -11,7 +11,7 @@ use tracing::{info, warn};
use crate::auth::OptionalUser; use crate::auth::OptionalUser;
use crate::consts::PROPERTIES_LIMIT; use crate::consts::PROPERTIES_LIMIT;
use crate::data::{HistoricalPrice, RenovationEvent}; use crate::data::{HistoricalPrice, RenovationEvent, TenureEvent};
use crate::licensing::{check_license_bounds, resolve_share_code}; use crate::licensing::{check_license_bounds, resolve_share_code};
use crate::parsing::{ use crate::parsing::{
cell_for_row_cached, h3_cell_bounds, needs_parent, parse_field_indices_with_poi, cell_for_row_cached, h3_cell_bounds, needs_parent, parse_field_indices_with_poi,
@ -67,6 +67,9 @@ pub struct Property {
#[serde(skip_serializing_if = "Vec::is_empty")] #[serde(skip_serializing_if = "Vec::is_empty")]
pub historical_prices: Vec<HistoricalPrice>, pub historical_prices: Vec<HistoricalPrice>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub tenure_history: Vec<TenureEvent>,
#[serde(flatten)] #[serde(flatten)]
pub features: FxHashMap<String, f32>, pub features: FxHashMap<String, f32>,
} }
@ -240,6 +243,7 @@ pub fn build_property(
lon: state.data.lon[row], lon: state.data.lon[row],
renovation_history: state.data.renovation_history(row).to_vec(), renovation_history: state.data.renovation_history(row).to_vec(),
historical_prices: state.data.historical_prices(row).to_vec(), historical_prices: state.data.historical_prices(row).to_vec(),
tenure_history: state.data.tenure_history(row).to_vec(),
property_sub_type: state.data.property_sub_type(row).map(String::from), property_sub_type: state.data.property_sub_type(row).map(String::from),
price_qualifier: state.data.price_qualifier(row).map(String::from), price_qualifier: state.data.price_qualifier(row).map(String::from),
former_council_house: lookup_enum_value( former_council_house: lookup_enum_value(
@ -270,6 +274,7 @@ pub fn build_property(
pub async fn get_hexagon_properties( pub async fn get_hexagon_properties(
State(shared): State<Arc<SharedState>>, State(shared): State<Arc<SharedState>>,
Extension(user): Extension<OptionalUser>, Extension(user): Extension<OptionalUser>,
Extension(geo): Extension<crate::demo_zone::DemoZone>,
Query(params): Query<HexagonPropertiesParams>, Query(params): Query<HexagonPropertiesParams>,
) -> Result<Json<PropertyListResponse>, axum::response::Response> { ) -> Result<Json<PropertyListResponse>, axum::response::Response> {
let state = shared.load_state(); let state = shared.load_state();
@ -289,7 +294,7 @@ pub async fn get_hexagon_properties(
// License check using H3 cell bounds // License check using H3 cell bounds
let h3_bounds = h3_cell_bounds(cell, 0.0); let h3_bounds = h3_cell_bounds(cell, 0.0);
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, h3_bounds, share_bounds)?; check_license_bounds(&user.0, h3_bounds, geo.free_zone, share_bounds)?;
let h3_str = params.h3; let h3_str = params.h3;
let quant = state.data.quant_ref(); let quant = state.data.quant_ref();

View file

@ -255,6 +255,36 @@ pub fn compute_feature_stats(
(numeric_features, enum_features_out) (numeric_features, enum_features_out)
} }
/// Count occurrences of each variant of a single enum feature across `rows`.
///
/// Unlike [`compute_feature_stats`], which is driven by the filter-matching
/// subset, this lets a caller compute a count that should reflect a whole area
/// regardless of the active filters (e.g. the council-house count, which is an
/// attribute of the postcode itself, not of the currently filtered properties).
/// Returns `None` if `feature_idx` is not an enum feature.
pub fn compute_enum_feature_counts(
rows: &[usize],
data: &PropertyData,
feature_idx: usize,
) -> Option<HashMap<String, u64>> {
let variants = data.enum_values.get(&feature_idx)?;
let mut value_counts = vec![0u64; variants.len()];
for &row in rows {
let value = data.get_feature(row, feature_idx);
if value.is_finite() && value >= 0.0 && (value as usize) < value_counts.len() {
value_counts[value as usize] += 1;
}
}
Some(
value_counts
.iter()
.enumerate()
.filter(|(_, &count)| count > 0)
.map(|(idx, &count)| (variants[idx].clone(), count))
.collect(),
)
}
/// Compute property-weighted per-year crime means across the selection. /// Compute property-weighted per-year crime means across the selection.
/// ///
/// Each matching property contributes its postcode's per-year counts (incidents /// Each matching property contributes its postcode's per-year counts (incidents
@ -447,3 +477,42 @@ pub fn compute_poi_feature_stats(
out out
} }
#[cfg(test)]
mod tests {
use super::*;
use crate::consts::NAN_U16;
fn enum_data(values: &[u16]) -> PropertyData {
let mut data = PropertyData::empty_for_tests();
data.num_features = 1;
data.num_numeric = 0; // single enum feature at index 0
data.feature_data = values.to_vec();
data.enum_values
.insert(0, vec!["Yes".to_string(), "No".to_string()]);
data
}
#[test]
fn enum_counts_tally_only_given_rows() {
// Rows: Yes, No, Yes, <missing>
let data = enum_data(&[0, 1, 0, NAN_U16]);
let all = compute_enum_feature_counts(&[0, 1, 2, 3], &data, 0).unwrap();
assert_eq!(all.get("Yes"), Some(&2));
assert_eq!(all.get("No"), Some(&1));
// A filter-matching subset would yield a different tally — confirming
// the count is driven purely by the rows passed in (so callers can pass
// the full area to make it filter-independent).
let subset = compute_enum_feature_counts(&[0, 3], &data, 0).unwrap();
assert_eq!(subset.get("Yes"), Some(&1));
assert_eq!(subset.get("No"), None);
}
#[test]
fn enum_counts_none_for_non_enum_feature() {
let data = enum_data(&[0, 1]);
assert!(compute_enum_feature_counts(&[0, 1], &data, 1).is_none());
}
}

View file

@ -17,7 +17,7 @@ use crate::utils::GridIndex;
pub struct AppState { pub struct AppState {
pub data: PropertyData, pub data: PropertyData,
pub grid: GridIndex, pub grid: GridIndex,
/// h3_cells[row_idx] = precomputed H3 cell ID at max resolution (12). /// h3_cells[row_idx] = precomputed H3 cell ID at max resolution (9).
/// Parent cells for lower resolutions derived via CellIndex::parent(). /// Parent cells for lower resolutions derived via CellIndex::parent().
pub h3_cells: Vec<u64>, pub h3_cells: Vec<u64>,
/// O(1) lookup: feature name → index in feature_names/feature_data /// O(1) lookup: feature name → index in feature_names/feature_data
@ -85,6 +85,8 @@ pub struct AppState {
pub stripe_referral_coupon_id: String, pub stripe_referral_coupon_id: String,
/// Bugsink/Sentry-compatible browser error reporting config injected into served HTML. /// Bugsink/Sentry-compatible browser error reporting config injected into served HTML.
pub bugsink_frontend_config: Option<BugsinkFrontendConfig>, pub bugsink_frontend_config: Option<BugsinkFrontendConfig>,
/// Per-key rate limiter for unlicensed ("demo") API traffic.
pub demo_rate_limiter: Arc<crate::ratelimit::DemoRateLimiter>,
} }
#[cfg(test)] #[cfg(test)]
@ -182,6 +184,7 @@ impl AppState {
stripe_webhook_secret: "whsec_test_secret".to_string(), stripe_webhook_secret: "whsec_test_secret".to_string(),
stripe_referral_coupon_id: "couponTest30".to_string(), stripe_referral_coupon_id: "couponTest30".to_string(),
bugsink_frontend_config: None, bugsink_frontend_config: None,
demo_rate_limiter: Arc::new(crate::ratelimit::DemoRateLimiter::new()),
} }
} }
} }