new demo mode & tenure
This commit is contained in:
parent
7656f24544
commit
4a0f00f2a4
64 changed files with 2875 additions and 338 deletions
|
|
@ -12,9 +12,9 @@ const ROUTES = [
|
|||
{
|
||||
path: '/',
|
||||
output: 'index.html',
|
||||
title: 'Stop searching the wrong places | Perfect Postcode',
|
||||
title: 'Find the best-value postcode in England | Perfect Postcode',
|
||||
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',
|
||||
|
|
|
|||
|
|
@ -12,7 +12,13 @@ import {
|
|||
} from './lib/seoRoutes';
|
||||
import Header, { type Page } from './components/ui/Header';
|
||||
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 { parseUrlState } from './lib/url-state';
|
||||
import pb from './lib/pocketbase';
|
||||
|
|
@ -209,11 +215,35 @@ export default function App() {
|
|||
}, []);
|
||||
const initialRoute = useMemo(() => pathToPage(window.location.pathname), []);
|
||||
const [mapUrlState, setMapUrlState] = useState(urlState);
|
||||
// IP-derived initial map centre (fetched from /api/geo on a fresh visit). Null
|
||||
// until resolved; `geoChecked` flips once the lookup settles (or times out) so
|
||||
// the dashboard map mounts with the right centre instead of a London flash.
|
||||
const [ipViewState, setIpViewState] = useState<typeof INITIAL_VIEW_STATE | null>(null);
|
||||
const [geoChecked, setGeoChecked] = useState(() => urlState.hasExplicitView);
|
||||
// Demo location: a fresh visitor (no explicit URL view) is offered the "check
|
||||
// your area" prompt (in MapPage). A choice made earlier this session is reused —
|
||||
// GPS coords re-centre the map and are sent to the server (via lib/api), while
|
||||
// 'declined' keeps Canary Wharf. Resolve it once, before children fetch, so the
|
||||
// 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(() =>
|
||||
window.location.pathname === '/dashboard' ? window.location.search : ''
|
||||
);
|
||||
|
|
@ -228,8 +258,8 @@ export default function App() {
|
|||
() =>
|
||||
mapUrlState.hasExplicitView
|
||||
? mapUrlState.viewState
|
||||
: (ipViewState ?? INITIAL_VIEW_STATE),
|
||||
[mapUrlState.hasExplicitView, mapUrlState.viewState, ipViewState]
|
||||
: (demoView ?? INITIAL_VIEW_STATE),
|
||||
[mapUrlState.hasExplicitView, mapUrlState.viewState, demoView]
|
||||
);
|
||||
|
||||
const isScreenshotMode = useMemo(() => {
|
||||
|
|
@ -282,6 +312,16 @@ export default function App() {
|
|||
refreshAuth,
|
||||
clearError,
|
||||
} = 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 [showAuthModal, setShowAuthModal] = useState(false);
|
||||
const [authModalTab, setAuthModalTab] = useState<'login' | 'register'>('login');
|
||||
|
|
@ -424,44 +464,6 @@ export default function App() {
|
|||
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(
|
||||
(page: Page, hash?: string, infoFeature?: string) => {
|
||||
const targetHash = normalizeHash(hash);
|
||||
|
|
@ -769,8 +771,6 @@ export default function App() {
|
|||
refreshAuth();
|
||||
}}
|
||||
/>
|
||||
) : !geoChecked ? (
|
||||
<PageFallback />
|
||||
) : (
|
||||
<MapPage
|
||||
key={dashboardRouteKey}
|
||||
|
|
@ -778,6 +778,7 @@ export default function App() {
|
|||
poiCategoryGroups={poiCategoryGroups}
|
||||
initialFilters={mapUrlState.filters}
|
||||
initialViewState={initialViewState}
|
||||
offerDemoLocation={offerDemoLocation}
|
||||
initialPOICategories={mapUrlState.poiCategories}
|
||||
initialOverlays={mapUrlState.overlays}
|
||||
initialCrimeTypes={mapUrlState.crimeTypes}
|
||||
|
|
|
|||
71
frontend/src/components/map/DemoLocationPrompt.tsx
Normal file
71
frontend/src/components/map/DemoLocationPrompt.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -9,7 +9,19 @@ describe('compactHistogramLabel', () => {
|
|||
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', () => {
|
||||
|
|
|
|||
|
|
@ -52,7 +52,11 @@ export function compactHistogramLabel(
|
|||
if (index === 0) {
|
||||
if (!integerLabels) return `<${formatLabel(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 (!integerLabels) return `${formatLabel(p99)}+`;
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { useState, useCallback, useRef, useEffect } from 'react';
|
|||
import { useTranslation } from 'react-i18next';
|
||||
import type { MapFlyToOptions, PostcodeGeometry } from '../../types';
|
||||
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 {
|
||||
useLocationSearch,
|
||||
|
|
@ -56,11 +56,6 @@ const ZOOM_FOR_TYPE: Record<string, number> = {
|
|||
retail: 15,
|
||||
};
|
||||
|
||||
const DEV_CURRENT_LOCATION = {
|
||||
latitude: 51.5033635,
|
||||
longitude: -0.1276248,
|
||||
};
|
||||
|
||||
export default function LocationSearch({
|
||||
onFlyTo,
|
||||
onLocationSearched,
|
||||
|
|
@ -288,7 +283,7 @@ export default function LocationSearch({
|
|||
search.close();
|
||||
try {
|
||||
const { latitude, longitude } = __DEV__
|
||||
? DEV_CURRENT_LOCATION
|
||||
? DEV_LOCATION
|
||||
: await new Promise<GeolocationCoordinates>((resolve, reject) => {
|
||||
if (!navigator.geolocation) {
|
||||
reject(new Error('Geolocation unsupported'));
|
||||
|
|
|
|||
|
|
@ -82,6 +82,9 @@ interface MapProps {
|
|||
hideLegend?: boolean;
|
||||
hideLocationSearch?: 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[];
|
||||
densityLabel?: string;
|
||||
totalCount?: number;
|
||||
|
|
@ -227,6 +230,7 @@ export default memo(function Map({
|
|||
hideLegend = false,
|
||||
hideLocationSearch = false,
|
||||
hideTopCardsWhenNarrow = false,
|
||||
topCardsRightInset = 0,
|
||||
travelTimeEntries = EMPTY_TRAVEL_ENTRIES,
|
||||
densityLabel: densityLabelProp,
|
||||
totalCount: totalCountProp,
|
||||
|
|
@ -337,6 +341,31 @@ export default memo(function Map({
|
|||
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(
|
||||
(lat: number, lng: number, zoom: number, options?: MapFlyToOptions) => {
|
||||
const targetPoint =
|
||||
|
|
@ -486,7 +515,7 @@ export default memo(function Map({
|
|||
activeCrimeTypes={activeCrimeTypes}
|
||||
zoom={viewState.zoom}
|
||||
/>
|
||||
<DeckOverlay layers={layers} getTooltip={null} />
|
||||
<DeckOverlay key={deckOverlayKey} layers={layers} getTooltip={null} />
|
||||
{!screenshotMode && <ScaleControl position="bottom-left" maxWidth={100} unit="metric" />}
|
||||
</MapGL>
|
||||
{basemap === 'satellite' && (
|
||||
|
|
@ -549,6 +578,7 @@ export default memo(function Map({
|
|||
layoutClass={topCardsLayoutClass}
|
||||
showLocationSearch={showLocationSearch}
|
||||
showLegend={showLegend}
|
||||
rightInset={topCardsRightInset}
|
||||
onFlyTo={handleFlyTo}
|
||||
onLocationSearched={onLocationSearched}
|
||||
onCurrentLocationFound={onCurrentLocationFound}
|
||||
|
|
|
|||
95
frontend/src/components/map/MapErrorBoundary.test.tsx
Normal file
95
frontend/src/components/map/MapErrorBoundary.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
141
frontend/src/components/map/MapErrorBoundary.tsx
Normal file
141
frontend/src/components/map/MapErrorBoundary.tsx
Normal 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'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>;
|
||||
}
|
||||
}
|
||||
|
|
@ -22,10 +22,23 @@ import {
|
|||
travelFieldKey,
|
||||
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 { 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 type { OverlayId } from '../../lib/overlays';
|
||||
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';
|
||||
|
||||
declare const __DEV__: boolean;
|
||||
|
||||
const EMPTY_ACTUAL_LISTINGS: ActualListing[] = [];
|
||||
|
||||
export default function MapPage({
|
||||
|
|
@ -79,6 +94,7 @@ export default function MapPage({
|
|||
poiCategoryGroups,
|
||||
initialFilters,
|
||||
initialViewState,
|
||||
offerDemoLocation,
|
||||
initialPOICategories,
|
||||
initialOverlays,
|
||||
initialCrimeTypes,
|
||||
|
|
@ -138,6 +154,12 @@ export default function MapPage({
|
|||
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 {
|
||||
filters,
|
||||
activeFeature,
|
||||
|
|
@ -160,6 +182,8 @@ export default function MapPage({
|
|||
} = useFilters({
|
||||
initialFilters,
|
||||
features,
|
||||
filterLimit: filtersUnlimited ? null : DEMO_MAX_FILTERS,
|
||||
onFilterLimitReached: handleFilterLimitReached,
|
||||
});
|
||||
|
||||
const {
|
||||
|
|
@ -461,10 +485,78 @@ export default function MapPage({
|
|||
const [upgradeModalDismissed, setUpgradeModalDismissed] = useState(false);
|
||||
const handleZoomToFreeZone = useCallback(() => {
|
||||
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);
|
||||
}, [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 overlaysZoomedIn = (mapData.currentView?.zoom ?? 0) >= POSTCODE_ZOOM_THRESHOLD;
|
||||
const actualListingsFilterParam = useMemo(
|
||||
|
|
@ -648,6 +740,15 @@ export default function MapPage({
|
|||
}
|
||||
}, [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(() => {
|
||||
onNavigateTo('pricing');
|
||||
}, [onNavigateTo]);
|
||||
|
|
@ -968,8 +1069,14 @@ export default function MapPage({
|
|||
</div>
|
||||
) : 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 =
|
||||
mapData.licenseRequired && !upgradeModalDismissed ? (
|
||||
showZoneUpgradeModal || showFilterUpgradeModal ? (
|
||||
<Suspense fallback={null}>
|
||||
<UpgradeModal
|
||||
isLoggedIn={!!user}
|
||||
|
|
@ -982,12 +1089,31 @@ export default function MapPage({
|
|||
: onRegisterClick()
|
||||
}
|
||||
onStartCheckout={() => license.startCheckout(checkoutReturnPath)}
|
||||
onZoomToFreeZone={handleZoomToFreeZone}
|
||||
isShareReturn={!!shareReturnViewRef.current}
|
||||
onZoomToFreeZone={
|
||||
showFilterUpgradeModal ? () => setFilterLimitHit(false) : handleZoomToFreeZone
|
||||
}
|
||||
isShareReturn={showZoneUpgradeModal && !!shareReturnViewRef.current}
|
||||
/>
|
||||
</Suspense>
|
||||
) : 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) {
|
||||
return (
|
||||
<MobileMapPage
|
||||
|
|
@ -1039,7 +1165,7 @@ export default function MapPage({
|
|||
renderAreaPane={renderAreaPane}
|
||||
renderPropertiesPane={renderPropertiesPane}
|
||||
toasts={toasts}
|
||||
upgradeModal={upgradeModal}
|
||||
upgradeModal={overlayModals}
|
||||
editingBar={editingBar}
|
||||
/>
|
||||
);
|
||||
|
|
@ -1099,7 +1225,7 @@ export default function MapPage({
|
|||
renderAreaPane={renderAreaPane}
|
||||
renderPropertiesPane={renderPropertiesPane}
|
||||
toasts={toasts}
|
||||
upgradeModal={upgradeModal}
|
||||
upgradeModal={overlayModals}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,12 @@ interface MapTopCardsProps {
|
|||
layoutClass: string;
|
||||
showLocationSearch: 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;
|
||||
onLocationSearched?: (location: SearchedLocation | null) => void;
|
||||
onCurrentLocationFound?: (lat: number, lng: number) => void;
|
||||
|
|
@ -40,6 +46,7 @@ export const MapTopCards = memo(function MapTopCards({
|
|||
layoutClass,
|
||||
showLocationSearch,
|
||||
showLegend,
|
||||
rightInset = 0,
|
||||
onFlyTo,
|
||||
onLocationSearched,
|
||||
onCurrentLocationFound,
|
||||
|
|
@ -65,6 +72,7 @@ export const MapTopCards = memo(function MapTopCards({
|
|||
return (
|
||||
<div
|
||||
className={`absolute top-3 left-3 right-3 z-20 flex gap-2 pointer-events-none ${layoutClass}`}
|
||||
style={rightInset ? { paddingRight: rightInset } : undefined}
|
||||
>
|
||||
{showLocationSearch && (
|
||||
<LocationSearch
|
||||
|
|
|
|||
|
|
@ -154,4 +154,40 @@ describe('MobileBottomSheet keyboard avoidance', () => {
|
|||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
// 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 {
|
||||
height: number;
|
||||
bottomInset: number;
|
||||
|
|
@ -117,15 +121,39 @@ export default function MobileBottomSheet({
|
|||
const scrollIntoViewTimerRef = useRef<number | null>(null);
|
||||
const [height, setHeight] = useState<number | null>(null);
|
||||
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 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 {
|
||||
min: Math.min(132, Math.max(104, available * 0.22)),
|
||||
min,
|
||||
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);
|
||||
|
||||
|
|
|
|||
58
frontend/src/components/map/PropertiesPane.test.tsx
Normal file
58
frontend/src/components/map/PropertiesPane.test.tsx
Normal 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([]);
|
||||
});
|
||||
});
|
||||
|
|
@ -9,6 +9,7 @@ import {
|
|||
formatYearMonth,
|
||||
} from '../../lib/format';
|
||||
import { getNum } from '../../lib/property-fields';
|
||||
import { buildEpcCertificateUrl } from '../../lib/external-search';
|
||||
import { useRetainedScrollTop } from '../../hooks/useRetainedScrollTop';
|
||||
import InfoPopup from '../ui/InfoPopup';
|
||||
import { SearchInput } from '../ui/SearchInput';
|
||||
|
|
@ -292,6 +293,29 @@ function PropertyCard({ property }: { property: Property }) {
|
|||
)}
|
||||
</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} />
|
||||
</div>
|
||||
);
|
||||
|
|
@ -300,9 +324,10 @@ function PropertyCard({ property }: { property: Property }) {
|
|||
type TimelineEvent =
|
||||
| { kind: 'sale'; year: number; month: number; price: number; 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 };
|
||||
|
||||
function buildTimelineEvents(property: Property): TimelineEvent[] {
|
||||
export function buildTimelineEvents(property: Property): TimelineEvent[] {
|
||||
const events: TimelineEvent[] = [];
|
||||
|
||||
// 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 approximate = property.is_construction_date_approximate ?? true;
|
||||
if (builtYear !== undefined && Number.isFinite(builtYear) && builtYear > 0) {
|
||||
|
|
@ -355,6 +390,22 @@ function PropertyTimeline({ property }: { property: Property }) {
|
|||
const events = useMemo(() => buildTimelineEvents(property), [property]);
|
||||
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 (
|
||||
<div className="mt-3">
|
||||
<div className="text-xs text-warm-500 dark:text-warm-400 mb-1.5">
|
||||
|
|
@ -383,6 +434,16 @@ function PropertyTimeline({ property }: { property: Property }) {
|
|||
</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' && (
|
||||
<>
|
||||
<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 (
|
||||
<span
|
||||
aria-hidden
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import { SpinnerIcon } from '../../ui/icons/SpinnerIcon';
|
|||
import { IndeterminateProgressBar } from '../../ui/IndeterminateProgressBar';
|
||||
import type { MapFlyTo, PaneResizeHandlers } from './types';
|
||||
import { MapFallback, PaneFallback } from './Fallbacks';
|
||||
import { MapErrorBoundary } from '../MapErrorBoundary';
|
||||
import { LoadingOverlay } from './LoadingOverlay';
|
||||
import { Joyride, Map, MapPageSelectionPane } from './lazyComponents';
|
||||
|
||||
|
|
@ -187,6 +188,7 @@ export function DesktopMapPage({
|
|||
|
||||
<div data-tutorial="map" className="flex-1 relative">
|
||||
<IndeterminateProgressBar show={mapData.loading && !initialLoading} />
|
||||
<MapErrorBoundary>
|
||||
<Suspense fallback={<MapFallback />}>
|
||||
<Map
|
||||
data={mapData.data}
|
||||
|
|
@ -226,6 +228,7 @@ export function DesktopMapPage({
|
|||
totalCount={totalCount}
|
||||
/>
|
||||
</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">
|
||||
{onToggleActualListings && (
|
||||
<button
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import { SpinnerIcon } from '../../ui/icons/SpinnerIcon';
|
|||
import { IndeterminateProgressBar } from '../../ui/IndeterminateProgressBar';
|
||||
import type { MapFlyTo } from './types';
|
||||
import { MapFallback, PaneFallback } from './Fallbacks';
|
||||
import { MapErrorBoundary } from '../MapErrorBoundary';
|
||||
import { LoadingOverlay } from './LoadingOverlay';
|
||||
import { Map, MobileDrawer } from './lazyComponents';
|
||||
|
||||
|
|
@ -136,12 +137,23 @@ export function MobileMapPage({
|
|||
bottomScreenInset
|
||||
)}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 (
|
||||
<div className="flex-1 overflow-hidden relative">
|
||||
<LoadingOverlay show={initialLoading} />
|
||||
|
||||
<div className="absolute inset-0">
|
||||
<IndeterminateProgressBar show={mapData.loading && !initialLoading} />
|
||||
<MapErrorBoundary>
|
||||
<Suspense fallback={<MapFallback />}>
|
||||
<Map
|
||||
data={mapData.data}
|
||||
|
|
@ -176,10 +188,12 @@ export function MobileMapPage({
|
|||
actualListings={actualListings}
|
||||
bounds={mapData.bounds}
|
||||
hideLegend
|
||||
topCardsRightInset={topCardsRightInset}
|
||||
travelTimeEntries={travelTimeEntries}
|
||||
bottomScreenInset={bottomScreenInset}
|
||||
/>
|
||||
</Suspense>
|
||||
</MapErrorBoundary>
|
||||
</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">
|
||||
|
|
|
|||
|
|
@ -26,6 +26,8 @@ export interface MapPageProps {
|
|||
poiCategoryGroups: POICategoryGroup[];
|
||||
initialFilters: FeatureFilters;
|
||||
initialViewState: ViewState;
|
||||
/** Offer the "check your area" GPS prompt (fresh demo visit, no URL view). */
|
||||
offerDemoLocation?: boolean;
|
||||
initialPOICategories: Set<string>;
|
||||
initialOverlays?: Set<OverlayId>;
|
||||
initialCrimeTypes?: Set<string>;
|
||||
|
|
|
|||
|
|
@ -156,6 +156,12 @@ export function useExportController({
|
|||
// drive which rows appear.
|
||||
const filterStr = buildFilterString(filters, features);
|
||||
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);
|
||||
} else {
|
||||
const { south, west, north, east } = bounds!;
|
||||
|
|
|
|||
|
|
@ -59,6 +59,101 @@ describe('useFilters', () => {
|
|||
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', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useFilters({
|
||||
|
|
|
|||
|
|
@ -47,6 +47,10 @@ import {
|
|||
interface UseFiltersOptions {
|
||||
initialFilters: FeatureFilters;
|
||||
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 {
|
||||
|
|
@ -104,13 +108,25 @@ function getNextNumericKeyId(
|
|||
return max + 1;
|
||||
}
|
||||
|
||||
export function useFilters({ initialFilters, features }: UseFiltersOptions) {
|
||||
export function useFilters({
|
||||
initialFilters,
|
||||
features,
|
||||
filterLimit,
|
||||
onFilterLimitReached,
|
||||
}: UseFiltersOptions) {
|
||||
const initialFiltersRef = useRef<FeatureFilters | null>(null);
|
||||
if (!initialFiltersRef.current) {
|
||||
initialFiltersRef.current = normalizeFilters(initialFilters);
|
||||
}
|
||||
|
||||
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 [dragValue, setDragValue] = useState<[number, number] | null>(null);
|
||||
const [pinnedFeature, setPinnedFeature] = useState<string | null>(null);
|
||||
|
|
@ -175,6 +191,26 @@ export function useFilters({ initialFilters, features }: UseFiltersOptions) {
|
|||
) {
|
||||
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 });
|
||||
setFilters((prev) => {
|
||||
undoStackRef.current.push(prev);
|
||||
|
|
@ -470,7 +506,16 @@ export function useFilters({ initialFilters, features }: UseFiltersOptions) {
|
|||
}, []);
|
||||
|
||||
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);
|
||||
setDragValue(null);
|
||||
setPinnedFeature(null);
|
||||
|
|
|
|||
|
|
@ -80,6 +80,18 @@ describe('useHexagonSelection', () => {
|
|||
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') {
|
||||
return Promise.resolve(
|
||||
jsonResponse({ properties: [], total: 0, offset: 0, truncated: false })
|
||||
|
|
@ -428,4 +440,60 @@ describe('useHexagonSelection', () => {
|
|||
expect(allPropertiesParams.has('filters')).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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -21,6 +21,14 @@ interface SelectedHexagon {
|
|||
type: 'hexagon' | 'postcode';
|
||||
resolution: number;
|
||||
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 {
|
||||
|
|
@ -331,7 +339,12 @@ export function useHexagonSelection({
|
|||
setSelectedPostcodeGeometry(null);
|
||||
} else {
|
||||
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();
|
||||
invalidatePropertyRequests();
|
||||
trackEvent('Hexagon Click', { type });
|
||||
|
|
@ -445,7 +458,7 @@ export function useHexagonSelection({
|
|||
(usePostcodeView &&
|
||||
selection.type === 'hexagon' &&
|
||||
!selection.lockedResolution &&
|
||||
areaStats?.central_postcode != null) ||
|
||||
(selection.anchorPostcode != null || areaStats?.central_postcode != null)) ||
|
||||
(!usePostcodeView && selection.type === 'postcode' && !selection.lockedResolution) ||
|
||||
(!usePostcodeView &&
|
||||
selection.type === 'hexagon' &&
|
||||
|
|
@ -483,19 +496,29 @@ export function useHexagonSelection({
|
|||
let nextStats: HexagonStatsResponse | null = null;
|
||||
|
||||
if (usePostcodeView && selection.type === 'hexagon' && !selection.lockedResolution) {
|
||||
if (!areaStats?.central_postcode) return;
|
||||
const lookup = await fetchPostcodeLookup(areaStats.central_postcode, controller.signal);
|
||||
nextSelection = { id: lookup.postcode, type: 'postcode', resolution };
|
||||
nextGeometry = lookup.geometry;
|
||||
nextStats = await fetchPostcodeStats(
|
||||
lookup.postcode,
|
||||
controller.signal,
|
||||
areaStatsUseFilters
|
||||
);
|
||||
// Prefer the anchored postcode (the one the user originally tracked) so
|
||||
// a zoom-out/zoom-in round-trip restores it exactly, rather than the
|
||||
// hexagon's current central_postcode, which may be a neighbour.
|
||||
const anchor = selection.anchorPostcode;
|
||||
const postcode = anchor?.id ?? areaStats?.central_postcode;
|
||||
if (!postcode) return;
|
||||
let geometry = anchor?.geometry ?? null;
|
||||
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') {
|
||||
const lookup = await fetchPostcodeLookup(selection.id, controller.signal);
|
||||
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(
|
||||
nextId,
|
||||
resolution,
|
||||
|
|
@ -514,7 +537,12 @@ export function useHexagonSelection({
|
|||
? cellToParent(selection.id, resolution)
|
||||
: overlappingHexagon?.h3;
|
||||
if (!nextId) return;
|
||||
nextSelection = { id: nextId, type: 'hexagon', resolution };
|
||||
nextSelection = {
|
||||
id: nextId,
|
||||
type: 'hexagon',
|
||||
resolution,
|
||||
anchorPostcode: selection.anchorPostcode,
|
||||
};
|
||||
nextStats = await fetchHexagonStats(
|
||||
nextId,
|
||||
resolution,
|
||||
|
|
|
|||
|
|
@ -457,6 +457,10 @@ export function useMapData({
|
|||
if (requestKey === latestDataRequestKeyRef.current && !isAbortError(err)) {
|
||||
logNonAbortError('Failed to fetch data', err);
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -834,11 +834,15 @@ const de: Translations = {
|
|||
listedBuildingBadge: 'Wahrscheinlich Listed Building',
|
||||
epcRating: 'EPC-Bewertung:',
|
||||
epcPotential: 'EPC-Potenzial:',
|
||||
viewEpcCertificate: 'EPC-Zertifikat ansehen',
|
||||
renovations: 'Renovierungen',
|
||||
perSqm: '/m²',
|
||||
historyTitle: 'Historie',
|
||||
historySale: 'Verkauf',
|
||||
historyBuilt: 'Baujahr',
|
||||
tenureOwnerOccupied: 'Eigennutzung',
|
||||
tenureRentedPrivate: 'Privat vermietet',
|
||||
tenureRentedSocial: 'Sozialwohnung',
|
||||
searchPlaceholder: 'Nach Adresse oder Postcode suchen...',
|
||||
propertyData: 'Immobiliendaten',
|
||||
propertyDataDesc:
|
||||
|
|
@ -923,6 +927,14 @@ const de: Translations = {
|
|||
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 ──────────────────────────────────
|
||||
mobileDrawer: {
|
||||
closeDrawer: 'Drawer schließen',
|
||||
|
|
|
|||
|
|
@ -820,11 +820,15 @@ const en = {
|
|||
listedBuildingBadge: 'Likely listed',
|
||||
epcRating: 'EPC rating:',
|
||||
epcPotential: 'EPC potential:',
|
||||
viewEpcCertificate: 'View EPC certificate',
|
||||
renovations: 'Renovations',
|
||||
perSqm: '/m²',
|
||||
historyTitle: 'History',
|
||||
historySale: 'Sale',
|
||||
historyBuilt: 'Built',
|
||||
tenureOwnerOccupied: 'Owner-occupied',
|
||||
tenureRentedPrivate: 'Privately rented',
|
||||
tenureRentedSocial: 'Social housing',
|
||||
searchPlaceholder: 'Search by address or postcode...',
|
||||
propertyData: 'Property Data',
|
||||
propertyDataDesc:
|
||||
|
|
@ -907,6 +911,15 @@ const en = {
|
|||
geolocationFailed: 'Couldn’t 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 ──────────────────────────────────
|
||||
mobileDrawer: {
|
||||
closeDrawer: 'Close drawer',
|
||||
|
|
|
|||
|
|
@ -848,11 +848,15 @@ const fr: Translations = {
|
|||
listedBuildingBadge: 'Bâtiment probablement classé',
|
||||
epcRating: 'Note EPC :',
|
||||
epcPotential: 'Potentiel EPC :',
|
||||
viewEpcCertificate: 'Voir le certificat EPC',
|
||||
renovations: 'Rénovations',
|
||||
perSqm: '/m²',
|
||||
historyTitle: 'Historique',
|
||||
historySale: 'Vente',
|
||||
historyBuilt: 'Construit',
|
||||
tenureOwnerOccupied: 'Propriétaire occupant',
|
||||
tenureRentedPrivate: 'Loué (privé)',
|
||||
tenureRentedSocial: 'Logement social',
|
||||
searchPlaceholder: 'Rechercher par adresse ou code postal...',
|
||||
propertyData: 'Données immobilières',
|
||||
propertyDataDesc:
|
||||
|
|
@ -936,6 +940,14 @@ const fr: Translations = {
|
|||
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 ──────────────────────────────────
|
||||
mobileDrawer: {
|
||||
closeDrawer: 'Fermer le tiroir',
|
||||
|
|
|
|||
|
|
@ -808,11 +808,15 @@ const hi: Translations = {
|
|||
listedBuildingBadge: 'शायद सूचीबद्ध',
|
||||
epcRating: 'EPC रेटिंग:',
|
||||
epcPotential: 'संभावित EPC:',
|
||||
viewEpcCertificate: 'EPC प्रमाणपत्र देखें',
|
||||
renovations: 'नवीनीकरण',
|
||||
perSqm: '/वर्ग मी',
|
||||
historyTitle: 'इतिहास',
|
||||
historySale: 'बिक्री',
|
||||
historyBuilt: 'निर्मित',
|
||||
tenureOwnerOccupied: 'मालिक का निवास',
|
||||
tenureRentedPrivate: 'निजी किराये पर',
|
||||
tenureRentedSocial: 'सामाजिक किराया आवास',
|
||||
searchPlaceholder: 'पते या पोस्टकोड से खोजें...',
|
||||
propertyData: 'संपत्ति डेटा',
|
||||
propertyDataDesc:
|
||||
|
|
@ -891,6 +895,14 @@ const hi: Translations = {
|
|||
geolocationFailed: 'आपका स्थान निर्धारित नहीं किया जा सका',
|
||||
},
|
||||
|
||||
demoLocation: {
|
||||
title: 'अपना क्षेत्र देखें',
|
||||
body: 'क्या आप अपने आसपास की संपत्ति डेटा देखना चाहते हैं? अपना स्थान उपयोग करें, या Canary Wharf से डेमो शुरू करें।',
|
||||
useLocation: 'मेरा स्थान उपयोग करें',
|
||||
locating: 'स्थान खोजा जा रहा है…',
|
||||
useDefault: 'Canary Wharf दिखाएं',
|
||||
},
|
||||
|
||||
mobileDrawer: {
|
||||
closeDrawer: 'ड्रॉअर बंद करें',
|
||||
},
|
||||
|
|
|
|||
|
|
@ -836,11 +836,15 @@ const hu: Translations = {
|
|||
listedBuildingBadge: 'Talán műemlék',
|
||||
epcRating: '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',
|
||||
perSqm: '/m²',
|
||||
historyTitle: 'Előzmények',
|
||||
historySale: 'Eladá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...',
|
||||
propertyData: 'Ingatlanadatok',
|
||||
propertyDataDesc:
|
||||
|
|
@ -924,6 +928,14 @@ const hu: Translations = {
|
|||
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 ──────────────────────────────────
|
||||
mobileDrawer: {
|
||||
closeDrawer: 'Panel bezárása',
|
||||
|
|
|
|||
|
|
@ -781,11 +781,15 @@ const zh: Translations = {
|
|||
listedBuildingBadge: '可能为受保护建筑',
|
||||
epcRating: 'EPC 评级:',
|
||||
epcPotential: '潜在 EPC 评级:',
|
||||
viewEpcCertificate: '查看 EPC 证书',
|
||||
renovations: '改造记录',
|
||||
perSqm: '/m²',
|
||||
historyTitle: '历史',
|
||||
historySale: '成交',
|
||||
historyBuilt: '建成',
|
||||
tenureOwnerOccupied: '自住',
|
||||
tenureRentedPrivate: '私人出租',
|
||||
tenureRentedSocial: '社会租赁',
|
||||
searchPlaceholder: '按地址或邮编搜索...',
|
||||
propertyData: '房产数据',
|
||||
propertyDataDesc:
|
||||
|
|
@ -866,6 +870,14 @@ const zh: Translations = {
|
|||
geolocationFailed: '无法确定您的位置',
|
||||
},
|
||||
|
||||
demoLocation: {
|
||||
title: '探索您的区域',
|
||||
body: '想查看您周围的房产数据吗?使用您的位置,或从金丝雀码头开始演示。',
|
||||
useLocation: '使用我的位置',
|
||||
locating: '正在定位…',
|
||||
useDefault: '显示金丝雀码头',
|
||||
},
|
||||
|
||||
// ── Mobile Drawer ──────────────────────────────────
|
||||
mobileDrawer: {
|
||||
closeDrawer: '关闭侧栏',
|
||||
|
|
|
|||
|
|
@ -6,8 +6,8 @@
|
|||
<meta name="theme-color" content="#fafaf9" media="(prefers-color-scheme: light)" />
|
||||
<meta name="theme-color" content="#0a0e1a" media="(prefers-color-scheme: dark)" />
|
||||
<meta name="referrer" content="no-referrer" />
|
||||
<title>Perfect Postcode - Find where to buy before browsing listings</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." />
|
||||
<title>Perfect Postcode - Find the best-value postcode in England</title>
|
||||
<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__" />
|
||||
<script id="perfect-postcode-bugsink-config" type="application/json">__PERFECT_POSTCODE_BUGSINK_CONFIG__</script>
|
||||
<script>
|
||||
|
|
|
|||
|
|
@ -38,9 +38,77 @@ export function authHeaders(init?: RequestInit): RequestInit {
|
|||
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 {
|
||||
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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -13,16 +13,28 @@ export const MAP_MIN_ZOOM = 5.5;
|
|||
|
||||
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 = {
|
||||
longitude: (FREE_ZONE_BOUNDS.west + FREE_ZONE_BOUNDS.east) / 2,
|
||||
latitude: (FREE_ZONE_BOUNDS.south + FREE_ZONE_BOUNDS.north) / 2,
|
||||
longitude: CANARY_WHARF.longitude,
|
||||
latitude: CANARY_WHARF.latitude,
|
||||
zoom: 14,
|
||||
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.
|
||||
* 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: 10.5, resolution: 7 },
|
||||
{ maxZoom: 11.5, resolution: 8 },
|
||||
{ maxZoom: 14, resolution: 9 },
|
||||
] as const;
|
||||
|
||||
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
|
||||
// zoomed out. (Each overlay additionally can't render below its own tile-data
|
||||
// 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 FEATURE_GRADIENT: { t: number; color: [number, number, number] }[] = [
|
||||
|
|
|
|||
|
|
@ -92,6 +92,14 @@ interface SearchUrlOptions {
|
|||
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(
|
||||
postcode: string,
|
||||
targetUrl: string
|
||||
|
|
|
|||
|
|
@ -124,7 +124,7 @@ export const SEO_LANDING_PAGES: Record<SeoLandingKey, SeoLandingContent> = {
|
|||
{
|
||||
question: 'Is this a replacement for Rightmove or Zoopla?',
|
||||
answer:
|
||||
'No. Use it before and alongside listing portals. Perfect Postcode helps decide where to look; listing portals show what’s currently for sale.',
|
||||
'No, it does a different job. Listing portals show what’s for sale today; Perfect Postcode shows where the value is across England’s postcodes.',
|
||||
},
|
||||
{
|
||||
question: 'Can I compare price with schools or commute time?',
|
||||
|
|
|
|||
|
|
@ -63,6 +63,9 @@ const CRIME_OVERLAY_ID: OverlayId = 'crime-hotspots';
|
|||
|
||||
export interface UrlState {
|
||||
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;
|
||||
poiCategories: Set<string>;
|
||||
overlays: Set<OverlayId>;
|
||||
|
|
@ -221,6 +224,7 @@ export function parseUrlState(): UrlState {
|
|||
const params = new URLSearchParams(window.location.search);
|
||||
const result: UrlState = {
|
||||
viewState: INITIAL_VIEW_STATE,
|
||||
hasExplicitView: false,
|
||||
filters: parseFilters(params),
|
||||
poiCategories: new Set(),
|
||||
overlays: new Set(),
|
||||
|
|
@ -246,6 +250,7 @@ export function parseUrlState(): UrlState {
|
|||
const zoomN = Number(zoom);
|
||||
if (!isNaN(latN) && !isNaN(lonN) && !isNaN(zoomN)) {
|
||||
result.viewState = { latitude: latN, longitude: lonN, zoom: zoomN, pitch: 0 };
|
||||
result.hasExplicitView = true;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -202,6 +202,14 @@ export interface RenovationEvent {
|
|||
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 {
|
||||
year: number;
|
||||
month: number;
|
||||
|
|
@ -229,6 +237,7 @@ export interface Property {
|
|||
is_construction_date_approximate?: boolean;
|
||||
renovation_history?: RenovationEvent[];
|
||||
historical_prices?: HistoricalPrice[];
|
||||
tenure_history?: TenureEvent[];
|
||||
|
||||
// All other numeric features (dynamic, including construction_age_band)
|
||||
[key: string]:
|
||||
|
|
@ -237,6 +246,7 @@ export interface Property {
|
|||
| boolean
|
||||
| RenovationEvent[]
|
||||
| HistoricalPrice[]
|
||||
| TenureEvent[]
|
||||
| string[]
|
||||
| undefined;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = [
|
||||
"address",
|
||||
# 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(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 = (
|
||||
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}")
|
||||
|
||||
# Left-join events and social tenure back onto dedup EPC
|
||||
# Left-join events, tenure history and social tenure back onto dedup EPC
|
||||
epc = (
|
||||
epc.join(
|
||||
events.lazy(),
|
||||
on=["_epc_match_address", "_epc_match_postcode"],
|
||||
how="left",
|
||||
)
|
||||
.join(
|
||||
tenure_events.lazy(),
|
||||
on=["_epc_match_address", "_epc_match_postcode"],
|
||||
how="left",
|
||||
)
|
||||
.join(
|
||||
social_tenure.lazy(),
|
||||
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.
|
||||
outliers = flag_price_outliers(
|
||||
price_paid_base.filter(value_ok)
|
||||
.select(
|
||||
"_pp_group_address", "_pp_group_postcode", "date_of_transfer", "price"
|
||||
)
|
||||
.select("_pp_group_address", "_pp_group_postcode", "date_of_transfer", "price")
|
||||
.collect(engine="streaming")
|
||||
)
|
||||
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",
|
||||
left_variant_cols=["pp_address_loc"],
|
||||
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")
|
||||
# Audit trail: keep the fuzzy-match confidence (100 = exact address
|
||||
# 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(
|
||||
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
|
||||
print(f"Unique properties: {total}")
|
||||
print(f"Matched: {matched.height} ({100 * matched.height / total:.1f}%)")
|
||||
print(f"Unmatched: {total - matched.height}")
|
||||
print(
|
||||
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
|
||||
# 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"
|
||||
|
||||
# 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(
|
||||
pl.when(is_new_build & transfer_year.is_not_null())
|
||||
.then(transfer_year)
|
||||
.otherwise(epc_band_year)
|
||||
.otherwise(capped_band_year)
|
||||
.alias("construction_age_band"),
|
||||
pl.when(is_new_build & transfer_year.is_not_null())
|
||||
.then(pl.lit(0, dtype=pl.UInt8))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
columns for those postcodes; require full coverage instead.
|
||||
"""
|
||||
iod_lsoas = pl.read_parquet(
|
||||
iod_path, columns=["LSOA code (2021)"]
|
||||
).rename({"LSOA code (2021)": "lsoa21"})
|
||||
iod_lsoas = pl.read_parquet(iod_path, columns=["LSOA code (2021)"]).rename(
|
||||
{"LSOA code (2021)": "lsoa21"}
|
||||
)
|
||||
|
||||
ethnicity_lsoas = pl.read_parquet(ethnicity_path, columns=["lsoa21"])
|
||||
missing_ethnicity = iod_lsoas.join(
|
||||
ethnicity_lsoas, on="lsoa21", how="anti"
|
||||
).sort("lsoa21")
|
||||
missing_ethnicity = iod_lsoas.join(ethnicity_lsoas, on="lsoa21", how="anti").sort(
|
||||
"lsoa21"
|
||||
)
|
||||
if missing_ethnicity.height > 0:
|
||||
raise ValueError(
|
||||
"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(
|
||||
iod_path: Path, rental_prices_path: Path
|
||||
) -> None:
|
||||
def _validate_lad_source_coverage(iod_path: Path, rental_prices_path: Path) -> None:
|
||||
iod_lads = (
|
||||
pl.read_parquet(
|
||||
iod_path,
|
||||
|
|
@ -845,18 +843,32 @@ def _remap_terminated_postcodes(
|
|||
|
||||
|
||||
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
|
||||
successor, collapsing the same physical address onto a single
|
||||
(postcode, pp_address) key with conflicting sale records. Keep the row with
|
||||
the latest date_of_transfer so the headline price/date reflect the most
|
||||
recent transaction; genuinely distinct addresses (a different pp_address) are
|
||||
untouched. pp_address is non-null here (join_epc_pp filters it), so the key
|
||||
never merges unrelated rows.
|
||||
(postcode, address) key with conflicting sale records. Keep the row with the
|
||||
latest date_of_transfer so the headline price/date reflect the most recent
|
||||
transaction; genuinely distinct addresses are untouched.
|
||||
|
||||
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(
|
||||
subset=["postcode", "pp_address"], keep="first", maintain_order=True
|
||||
return (
|
||||
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")
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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("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
|
||||
# post-normalisation address match).
|
||||
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):
|
||||
# Two certificates for the same property. The cert with the garbled,
|
||||
# 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)
|
||||
|
||||
assert df.height == 1
|
||||
assert df.select(
|
||||
# The low-score EPC must NOT attach its facts to the price-paid property.
|
||||
sold = df.filter(pl.col("pp_address").is_not_null())
|
||||
assert sold.select(
|
||||
"pp_address",
|
||||
"epc_address",
|
||||
"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):
|
||||
# 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]
|
||||
|
||||
|
||||
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):
|
||||
# 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
|
||||
|
|
@ -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]
|
||||
|
||||
|
||||
def _write_epc_zip(zip_path: Path) -> None:
|
||||
"""Write a minimal domestic zip with the default certificate row."""
|
||||
def _write_epc_zip(zip_path: Path, rows: list[dict[str, str]] | None = None) -> None:
|
||||
"""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:
|
||||
csv_buffer = io.StringIO()
|
||||
writer = csv.DictWriter(csv_buffer, fieldnames=EPC_SOURCE_COLUMNS)
|
||||
writer.writeheader()
|
||||
writer.writerow(_row())
|
||||
writer.writerows(rows if rows is not None else [_row()])
|
||||
archive.writestr("certificates-2024.csv", csv_buffer.getvalue())
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
# LSOAs are required to all be present.
|
||||
pl.DataFrame(
|
||||
{"lsoa21": ["E01000001", "E01000002", "E01000003"]}
|
||||
).write_parquet(ethnicity_path)
|
||||
pl.DataFrame({"lsoa21": ["E01000001", "E01000002", "E01000003"]}).write_parquet(
|
||||
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"],
|
||||
"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": [
|
||||
datetime(1990, 1, 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
|
||||
# A genuinely distinct address in the same postcode is untouched.
|
||||
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:
|
||||
|
|
|
|||
|
|
@ -81,6 +81,7 @@ def fuzzy_join_on_postcode(
|
|||
min_score_without_numbers: int = MIN_FUZZY_SCORE_WITHOUT_NUMBERS,
|
||||
left_variant_cols: Sequence[str] = (),
|
||||
right_variant_cols: Sequence[str] = (),
|
||||
keep_unmatched_right: bool = False,
|
||||
) -> pl.LazyFrame:
|
||||
"""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
|
||||
the accepted match (exact matches score 100). Unmatched rows have null
|
||||
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())
|
||||
|
|
@ -153,7 +162,9 @@ def fuzzy_join_on_postcode(
|
|||
.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 = [
|
||||
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")
|
||||
.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:
|
||||
shutil.rmtree(tmpdir, ignore_errors=True)
|
||||
|
||||
|
|
|
|||
|
|
@ -220,6 +220,77 @@ def test_fuzzy_join_matches_high_score_number_less_pair():
|
|||
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():
|
||||
# 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.
|
||||
|
|
@ -252,9 +323,7 @@ def test_numbers_compatible_gates_single_letter_flats():
|
|||
assert not _numbers_compatible(
|
||||
"FLAT D 39 GERTRUDE STREET", "FLAT F 39 GERTRUDE STREET"
|
||||
)
|
||||
assert _numbers_compatible(
|
||||
"FLAT D 39 GERTRUDE STREET", "39 GERTRUDE STREET FLAT D"
|
||||
)
|
||||
assert _numbers_compatible("FLAT D 39 GERTRUDE STREET", "39 GERTRUDE STREET FLAT D")
|
||||
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.
|
||||
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():
|
||||
# Locality words may differ between a variant and its primary; digits and
|
||||
# flat designators may not (the gate ran on the primary only).
|
||||
assert _admissible_variants(
|
||||
"12 OAK ROAD", ["12 OAK ROAD HALE", "12 OAK ROAD"]
|
||||
) == ("12 OAK ROAD HALE",)
|
||||
assert _admissible_variants("12 OAK ROAD", ["12 OAK ROAD HALE", "12 OAK ROAD"]) == (
|
||||
"12 OAK ROAD HALE",
|
||||
)
|
||||
# Dropping "FLAT 1" (digit) or "FLAT B" (flat designator) is inadmissible:
|
||||
# the variant would score a single flat as the whole building.
|
||||
assert (
|
||||
|
|
@ -278,9 +347,7 @@ def test_admissible_variants_allows_locality_suffix_only():
|
|||
# disagrees with the combined address must not smuggle in a different
|
||||
# street for scoring.
|
||||
assert _admissible_variants("12 OAK ROAD", ["12 ELM ROAD"]) == ()
|
||||
assert (
|
||||
_admissible_variants("1 TOTALLY DIFFERENT ROAD", ["1 EXAMPLE STREET"]) == ()
|
||||
)
|
||||
assert _admissible_variants("1 TOTALLY DIFFERENT ROAD", ["1 EXAMPLE STREET"]) == ()
|
||||
|
||||
|
||||
def test_fuzzy_join_variant_recovers_locality_suffix_mismatch():
|
||||
|
|
|
|||
|
|
@ -3,9 +3,9 @@ pub const QUANT_SCALE: f32 = 65534.0;
|
|||
|
||||
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_MAX: u8 = 12;
|
||||
pub const H3_REQUEST_MAX: u8 = 9;
|
||||
|
||||
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 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 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).
|
||||
pub const SERVICE_CALL_TIMEOUT: u64 = 120;
|
||||
|
||||
/// Demo free zone bounds (south, west, north, east) — inner London, roughly zone 1.
|
||||
/// Users without a license can only query data within these bounds.
|
||||
pub const FREE_ZONE_BOUNDS: (f64, f64, f64, f64) = (51.44, -0.31, 51.59, 0.05);
|
||||
/// Half-spans of the demo free zone box, in degrees. The box is built this wide
|
||||
/// around the visitor's approximate (IP-derived) location — or the fallback
|
||||
/// 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_MAX_ENTRIES: usize = 1024;
|
||||
|
|
|
|||
|
|
@ -67,6 +67,6 @@ pub use poi::{resolve_poi_category_filter, POICategoryGroup, POIData, SchoolMeta
|
|||
pub use postcodes::{OutcodeData, PostcodeData};
|
||||
pub use property::{
|
||||
precompute_h3, FeatureStats, Histogram, HistoricalPrice, PostcodePoiMetrics, PropertyData,
|
||||
QuantRef, RenovationEvent,
|
||||
QuantRef, RenovationEvent, TenureEvent,
|
||||
};
|
||||
pub use travel_time::{slugify, TravelTimeStore};
|
||||
|
|
|
|||
|
|
@ -251,6 +251,26 @@ pub(super) fn address_search_tokens(text: &str) -> Vec<String> {
|
|||
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 {
|
||||
if looks_like_postcode_fragment(token) {
|
||||
return false;
|
||||
|
|
@ -929,6 +949,60 @@ mod tests {
|
|||
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]
|
||||
fn address_search_tokens_keep_actual_address_terms_for_scoring() {
|
||||
let tokens = address_search_tokens("Flat 2, 10 Downing Cour");
|
||||
|
|
|
|||
|
|
@ -14,11 +14,11 @@ use crate::consts::{NAN_U16, QUANT_SCALE};
|
|||
use crate::features::{self, Bounds};
|
||||
|
||||
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::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 COUNTRY_COLUMN_CANDIDATES: &[&str] = &[
|
||||
|
|
@ -330,6 +330,10 @@ impl PropertyData {
|
|||
if has_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
|
||||
.filter(col("lat").is_not_null().and(col("lon").is_not_null()))
|
||||
.select(select_exprs)
|
||||
|
|
@ -439,7 +443,6 @@ impl PropertyData {
|
|||
.collect()
|
||||
};
|
||||
|
||||
let address_raw = extract_string_col(&df, "Address per Property Register")?;
|
||||
let postcode_raw = extract_string_col(&df, "Postcode")?;
|
||||
|
||||
// Extract optional string columns
|
||||
|
|
@ -468,6 +471,13 @@ impl PropertyData {
|
|||
};
|
||||
let property_sub_type_raw = extract_optional_string_col(&df, "Property sub-type")?;
|
||||
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");
|
||||
// enum_col_major: Vec<(values_list, encoded_as_f32)>
|
||||
|
|
@ -675,6 +685,57 @@ impl PropertyData {
|
|||
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.
|
||||
drop(df);
|
||||
|
||||
|
|
@ -712,9 +773,21 @@ impl PropertyData {
|
|||
})
|
||||
.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");
|
||||
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_offsets = 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_lengths = Vec::with_capacity(row_count);
|
||||
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 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_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_length = search_tokens.len().min(u16::MAX as usize) as u16;
|
||||
address_search_token_offsets.push(token_offset);
|
||||
|
|
@ -834,6 +912,18 @@ impl PropertyData {
|
|||
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
|
||||
let property_sub_type: FxHashMap<u32, String> = {
|
||||
let mut map = FxHashMap::default();
|
||||
|
|
@ -968,6 +1058,7 @@ impl PropertyData {
|
|||
approx_build_date_bits,
|
||||
renovation_history,
|
||||
historical_prices,
|
||||
tenure_history,
|
||||
property_sub_type,
|
||||
price_qualifier,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -40,6 +40,16 @@ pub struct HistoricalPrice {
|
|||
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 lat: Vec<f32>,
|
||||
pub lon: Vec<f32>,
|
||||
|
|
@ -100,6 +110,9 @@ pub struct PropertyData {
|
|||
/// Per-row historical sale transactions (Land Registry price-paid).
|
||||
/// Keyed by (permuted) row index. Only rows with prices are present.
|
||||
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>,
|
||||
price_qualifier: FxHashMap<u32, String>,
|
||||
}
|
||||
|
|
@ -153,6 +166,14 @@ impl PropertyData {
|
|||
.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.
|
||||
pub fn property_sub_type(&self, row: usize) -> Option<&str> {
|
||||
self.property_sub_type
|
||||
|
|
@ -231,6 +252,7 @@ impl PropertyData {
|
|||
approx_build_date_bits: Vec::new(),
|
||||
renovation_history: FxHashMap::default(),
|
||||
historical_prices: FxHashMap::default(),
|
||||
tenure_history: FxHashMap::default(),
|
||||
property_sub_type: FxHashMap::default(),
|
||||
price_qualifier: FxHashMap::default(),
|
||||
}
|
||||
|
|
|
|||
127
server-rs/src/demo_zone.rs
Normal file
127
server-rs/src/demo_zone.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -10,9 +10,10 @@ use url::form_urlencoded;
|
|||
|
||||
use crate::auth::PocketBaseUser;
|
||||
use crate::consts::{
|
||||
FREE_ZONE_BOUNDS, MAX_SHARE_LAT_SPAN, MAX_SHARE_LON_SPAN, MAX_SHARE_ZOOM, MIN_SHARE_ZOOM,
|
||||
SHARE_CACHE_MAX_ENTRIES, SHARE_CACHE_TTL_SECS,
|
||||
MAX_SHARE_LAT_SPAN, MAX_SHARE_LON_SPAN, MAX_SHARE_ZOOM, MIN_SHARE_ZOOM, SHARE_CACHE_MAX_ENTRIES,
|
||||
SHARE_CACHE_TTL_SECS,
|
||||
};
|
||||
use crate::demo_zone::FreeZone;
|
||||
use crate::pocketbase::get_superuser_token;
|
||||
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.
|
||||
/// Licensed users and admins bypass the check entirely.
|
||||
/// Free/anonymous users get 403 unless the bounds fall inside the free zone
|
||||
/// or inside the bbox granted by a valid share code.
|
||||
/// Free/anonymous users get 403 unless the bounds fall inside their free zone
|
||||
/// (a box centred on their IP-approximate location) or inside the bbox granted
|
||||
/// by a valid share code.
|
||||
#[allow(clippy::result_large_err)]
|
||||
pub fn check_license_bounds(
|
||||
user: &Option<PocketBaseUser>,
|
||||
bounds: (f64, f64, f64, f64),
|
||||
free_zone: FreeZone,
|
||||
share_bounds: Option<ShareBounds>,
|
||||
) -> Result<(), axum::response::Response> {
|
||||
if let Some(u) = user {
|
||||
|
|
@ -240,9 +243,12 @@ pub fn check_license_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(());
|
||||
}
|
||||
|
||||
|
|
@ -256,10 +262,10 @@ pub fn check_license_bounds(
|
|||
"error": "license_required",
|
||||
"message": "A license is required to view data outside the demo area",
|
||||
"free_zone": {
|
||||
"south": fz_south,
|
||||
"west": fz_west,
|
||||
"north": fz_north,
|
||||
"east": fz_east,
|
||||
"south": free_zone.south,
|
||||
"west": free_zone.west,
|
||||
"north": free_zone.north,
|
||||
"east": free_zone.east,
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -272,9 +278,10 @@ pub fn check_license_point(
|
|||
user: &Option<PocketBaseUser>,
|
||||
lat: f64,
|
||||
lon: f64,
|
||||
free_zone: FreeZone,
|
||||
share_bounds: Option<ShareBounds>,
|
||||
) -> 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)]
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ mod bugsink;
|
|||
mod checkout_sessions;
|
||||
mod consts;
|
||||
mod data;
|
||||
mod demo_zone;
|
||||
mod features;
|
||||
mod language;
|
||||
mod licensing;
|
||||
|
|
@ -15,6 +16,7 @@ mod og_middleware;
|
|||
pub mod parsing;
|
||||
mod pocketbase;
|
||||
mod pocketbase_locks;
|
||||
mod ratelimit;
|
||||
mod routes;
|
||||
mod state;
|
||||
pub mod utils;
|
||||
|
|
@ -720,6 +722,7 @@ async fn main() -> anyhow::Result<()> {
|
|||
stripe_webhook_secret: cli.stripe_webhook_secret,
|
||||
stripe_referral_coupon_id: cli.stripe_referral_coupon_id,
|
||||
bugsink_frontend_config,
|
||||
demo_rate_limiter: Arc::new(ratelimit::DemoRateLimiter::new()),
|
||||
};
|
||||
|
||||
let shared = Arc::new(SharedState::new(app_state));
|
||||
|
|
@ -999,6 +1002,8 @@ async fn main() -> anyhow::Result<()> {
|
|||
api
|
||||
}
|
||||
.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(
|
||||
move |req: axum::extract::Request, next: middleware::Next| {
|
||||
|
|
|
|||
256
server-rs/src/ratelimit.rs
Normal file
256
server-rs/src/ratelimit.rs
Normal 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"));
|
||||
}
|
||||
}
|
||||
|
|
@ -51,6 +51,7 @@ const LISTING_BOUNDS_EPSILON_DEGREES: f64 = 0.00001;
|
|||
pub async fn get_actual_listings(
|
||||
State(shared): State<Arc<SharedState>>,
|
||||
Extension(user): Extension<OptionalUser>,
|
||||
Extension(geo): Extension<crate::demo_zone::DemoZone>,
|
||||
Query(params): Query<ActualListingsParams>,
|
||||
) -> Result<Json<ActualListingsResponse>, Response> {
|
||||
let state = shared.load_state();
|
||||
|
|
@ -70,7 +71,7 @@ pub async fn get_actual_listings(
|
|||
require_bounds(params.bounds).map_err(IntoResponse::into_response)?;
|
||||
|
||||
let share_bounds = resolve_share_code(&state, params.share.as_deref()).await;
|
||||
check_license_bounds(&user.0, (south, west, north, east), share_bounds)?;
|
||||
check_license_bounds(&user.0, (south, west, north, east), geo.free_zone, share_bounds)?;
|
||||
|
||||
let quant = state.data.quant_ref();
|
||||
let poi_quant = state.data.poi_metrics.quant_ref();
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ use tracing::{info, warn};
|
|||
|
||||
use crate::auth::OptionalUser;
|
||||
use crate::consts::NAN_U16;
|
||||
use crate::data::travel_time::TravelData;
|
||||
use crate::data::{PostcodePoiMetrics, QuantRef};
|
||||
use crate::features;
|
||||
use crate::licensing::{check_license_bounds, resolve_share_code};
|
||||
|
|
@ -369,15 +370,98 @@ fn bounds_for_postcode_indices(
|
|||
(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(
|
||||
State(shared): State<Arc<SharedState>>,
|
||||
headers: HeaderMap,
|
||||
Extension(user): Extension<OptionalUser>,
|
||||
Extension(geo): Extension<crate::demo_zone::DemoZone>,
|
||||
uri: Uri,
|
||||
Query(params): Query<ExportParams>,
|
||||
) -> Result<impl IntoResponse, axum::response::Response> {
|
||||
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.
|
||||
let postcode_list = match params.postcodes.as_deref() {
|
||||
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;
|
||||
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 poi_quant = state.data.poi_metrics.quant_ref();
|
||||
|
|
@ -450,20 +534,17 @@ pub async fn get_export(
|
|||
} else {
|
||||
params.filters
|
||||
};
|
||||
let travel_entries = if is_postcode_mode {
|
||||
Vec::new()
|
||||
} else {
|
||||
parse_optional_travel(params.travel.as_deref())
|
||||
.map_err(|err| (StatusCode::BAD_REQUEST, err).into_response())?
|
||||
};
|
||||
let has_travel_filters = travel_entries
|
||||
// Travel times are per-postcode global data, so they appear as columns in
|
||||
// both bounds and list mode. The time-range *filter* is only applied on the
|
||||
// bounds path (`has_travel_filters` below); list mode never filters rows, so
|
||||
// every requested postcode still shows up with its travel time.
|
||||
let travel_entries = parse_optional_travel(params.travel.as_deref())
|
||||
.map_err(|err| (StatusCode::BAD_REQUEST, err).into_response())?;
|
||||
let has_travel_filters = !is_postcode_mode
|
||||
&& travel_entries
|
||||
.iter()
|
||||
.any(|entry| entry.filter_min.is_some() && entry.filter_max.is_some());
|
||||
let travel_state_params = if is_postcode_mode {
|
||||
Vec::new()
|
||||
} else {
|
||||
collect_travel_state_params(uri.query())
|
||||
};
|
||||
let travel_state_params = collect_travel_state_params(uri.query());
|
||||
let overlay_state_params = if is_postcode_mode {
|
||||
Vec::new()
|
||||
} else {
|
||||
|
|
@ -553,6 +634,33 @@ pub async fn get_export(
|
|||
let postcode_data = &state.postcode_data;
|
||||
let poi_metrics = &state.data.poi_metrics;
|
||||
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 total_export_features = num_features + poi_metrics.num_features();
|
||||
let (pc_interner, pc_keys) = state.data.postcode_parts();
|
||||
|
|
@ -701,6 +809,15 @@ pub async fn get_export(
|
|||
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
|
||||
let filter_feature_indices: Vec<usize> = filter_feature_names
|
||||
.iter()
|
||||
|
|
@ -839,6 +956,12 @@ pub async fn get_export(
|
|||
let group_label_fmt = Format::new().set_bold().set_font_color("#1F4E79");
|
||||
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
|
||||
let dashboard_url = format!(
|
||||
"{}/dashboard?{}",
|
||||
|
|
@ -847,14 +970,16 @@ pub async fn get_export(
|
|||
);
|
||||
|
||||
// Two sheets in both modes: "Selected" (just the features behind the
|
||||
// active filters) and "All Data" (every feature). The Selected sheet
|
||||
// carries the 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.
|
||||
// active filters, including any POI amenity metrics) and "All Data"
|
||||
// (every property/area feature, but no POI amenity counts/distances —
|
||||
// those live only on the Selected sheet). The Selected sheet carries the
|
||||
// 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 sheet_configs: Vec<(&str, &[usize], bool)> = vec![
|
||||
("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 {
|
||||
|
|
@ -904,6 +1029,10 @@ pub async fn get_export(
|
|||
|
||||
// Header 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
|
||||
.write_string_with_format(header_row, 0, "Postcode", &header_fmt)
|
||||
.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}"))?;
|
||||
}
|
||||
|
||||
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
|
||||
let desc_row = header_row + 1;
|
||||
sheet
|
||||
|
|
@ -943,6 +1090,29 @@ pub async fn get_export(
|
|||
.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
|
||||
// outcode summary row acts as the header for its postcodes.
|
||||
sheet.group_symbols_above(true);
|
||||
|
|
@ -976,6 +1146,63 @@ pub async fn get_export(
|
|||
&integer_feature_indices,
|
||||
&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;
|
||||
|
||||
// Individual postcode rows for this outcode.
|
||||
|
|
@ -999,6 +1226,34 @@ pub async fn get_export(
|
|||
&integer_feature_indices,
|
||||
&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;
|
||||
}
|
||||
|
||||
|
|
@ -1012,7 +1267,8 @@ pub async fn get_export(
|
|||
// Sample note
|
||||
if was_sampled {
|
||||
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
|
||||
.merge_range(
|
||||
note_row,
|
||||
|
|
@ -1043,6 +1299,18 @@ pub async fn get_export(
|
|||
.set_column_width(col, width)
|
||||
.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
|
||||
|
|
@ -1128,6 +1396,50 @@ mod tests {
|
|||
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(¶ms);
|
||||
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(¶ms).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]
|
||||
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"
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ pub struct FilterCountsResponse {
|
|||
pub async fn get_filter_counts(
|
||||
State(shared): State<Arc<SharedState>>,
|
||||
Extension(user): Extension<OptionalUser>,
|
||||
Extension(geo): Extension<crate::demo_zone::DemoZone>,
|
||||
Query(params): Query<FilterCountsParams>,
|
||||
) -> Result<Json<FilterCountsResponse>, axum::response::Response> {
|
||||
let state = shared.load_state();
|
||||
|
|
@ -40,7 +41,7 @@ pub async fn get_filter_counts(
|
|||
let (south, west, north, east) =
|
||||
require_bounds(params.bounds).map_err(IntoResponse::into_response)?;
|
||||
let share_bounds = resolve_share_code(&state, params.share.as_deref()).await;
|
||||
check_license_bounds(&user.0, (south, west, north, east), share_bounds)?;
|
||||
check_license_bounds(&user.0, (south, west, north, east), geo.free_zone, share_bounds)?;
|
||||
|
||||
let quant = state.data.quant_ref();
|
||||
let poi_quant = state.data.poi_metrics.quant_ref();
|
||||
|
|
|
|||
|
|
@ -436,6 +436,7 @@ pub(super) fn top_filter_exclusions(
|
|||
pub async fn get_hexagon_stats(
|
||||
State(shared): State<Arc<SharedState>>,
|
||||
Extension(user): Extension<OptionalUser>,
|
||||
Extension(geo): Extension<crate::demo_zone::DemoZone>,
|
||||
Query(params): Query<HexagonStatsParams>,
|
||||
) -> Result<Json<HexagonStatsResponse>, axum::response::Response> {
|
||||
let state = shared.load_state();
|
||||
|
|
@ -455,7 +456,7 @@ pub async fn get_hexagon_stats(
|
|||
// License check using H3 cell bounds
|
||||
let h3_bounds = h3_cell_bounds(cell, 0.0);
|
||||
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 quant = state.data.quant_ref();
|
||||
|
|
|
|||
|
|
@ -252,6 +252,7 @@ fn build_feature_maps(
|
|||
pub async fn get_hexagons(
|
||||
State(shared): State<Arc<SharedState>>,
|
||||
Extension(user): Extension<OptionalUser>,
|
||||
Extension(geo): Extension<crate::demo_zone::DemoZone>,
|
||||
Query(params): Query<HexagonParams>,
|
||||
) -> Result<Json<HexagonsResponse>, axum::response::Response> {
|
||||
let state = shared.load_state();
|
||||
|
|
@ -262,7 +263,7 @@ pub async fn get_hexagons(
|
|||
require_bounds(params.bounds).map_err(IntoResponse::into_response)?;
|
||||
|
||||
let share_bounds = resolve_share_code(&state, params.share.as_deref()).await;
|
||||
check_license_bounds(&user.0, (south, west, north, east), share_bounds)?;
|
||||
check_license_bounds(&user.0, (south, west, north, east), geo.free_zone, share_bounds)?;
|
||||
|
||||
let quant = state.data.quant_ref();
|
||||
let poi_quant = state.data.poi_metrics.quant_ref();
|
||||
|
|
|
|||
|
|
@ -57,6 +57,7 @@ fn destination_coordinates(place_data: &PlaceData, slug: &str) -> Option<(f32, f
|
|||
pub async fn get_journey(
|
||||
State(shared): State<Arc<SharedState>>,
|
||||
Extension(user): Extension<OptionalUser>,
|
||||
Extension(geo): Extension<crate::demo_zone::DemoZone>,
|
||||
query: axum::extract::Query<JourneyQuery>,
|
||||
) -> Result<Json<JourneyResponse>, axum::response::Response> {
|
||||
let state = shared.load_state();
|
||||
|
|
@ -72,7 +73,7 @@ pub async fn get_journey(
|
|||
let (lat, lon) = state.postcode_data.centroids[pc_idx];
|
||||
|
||||
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) {
|
||||
return Err((
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ pub struct PostcodePropertiesParams {
|
|||
pub async fn get_postcode_properties(
|
||||
State(shared): State<Arc<SharedState>>,
|
||||
Extension(user): Extension<OptionalUser>,
|
||||
Extension(geo): Extension<crate::demo_zone::DemoZone>,
|
||||
Query(params): Query<PostcodePropertiesParams>,
|
||||
) -> Result<Json<PropertyListResponse>, axum::response::Response> {
|
||||
let state = shared.load_state();
|
||||
|
|
@ -64,6 +65,7 @@ pub async fn get_postcode_properties(
|
|||
&user.0,
|
||||
centroid_lat as f64,
|
||||
centroid_lon as f64,
|
||||
geo.free_zone,
|
||||
share_bounds,
|
||||
)?;
|
||||
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ use crate::state::SharedState;
|
|||
use crate::utils::normalize_postcode;
|
||||
|
||||
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::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(
|
||||
State(shared): State<Arc<SharedState>>,
|
||||
Extension(user): Extension<OptionalUser>,
|
||||
Extension(geo): Extension<crate::demo_zone::DemoZone>,
|
||||
Query(params): Query<PostcodeStatsParams>,
|
||||
) -> Result<Json<HexagonStatsResponse>, axum::response::Response> {
|
||||
let state = shared.load_state();
|
||||
|
|
@ -63,6 +64,7 @@ pub async fn get_postcode_stats(
|
|||
&user.0,
|
||||
centroid_lat as f64,
|
||||
centroid_lon as f64,
|
||||
geo.free_zone,
|
||||
share_bounds,
|
||||
)?;
|
||||
|
||||
|
|
@ -157,7 +159,7 @@ pub async fn get_postcode_stats(
|
|||
&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,
|
||||
&state.data,
|
||||
&state.data.feature_names,
|
||||
|
|
@ -173,6 +175,31 @@ pub async fn get_postcode_stats(
|
|||
&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();
|
||||
info!(
|
||||
postcode = %postcode_str,
|
||||
|
|
|
|||
|
|
@ -54,6 +54,7 @@ pub struct PostcodeParams {
|
|||
pub async fn get_postcodes(
|
||||
State(shared): State<Arc<SharedState>>,
|
||||
Extension(user): Extension<OptionalUser>,
|
||||
Extension(geo): Extension<crate::demo_zone::DemoZone>,
|
||||
Query(params): Query<PostcodeParams>,
|
||||
) -> Result<Json<PostcodesResponse>, axum::response::Response> {
|
||||
let state = shared.load_state();
|
||||
|
|
@ -61,7 +62,7 @@ pub async fn get_postcodes(
|
|||
require_bounds(params.bounds).map_err(IntoResponse::into_response)?;
|
||||
|
||||
let share_bounds = resolve_share_code(&state, params.share.as_deref()).await;
|
||||
check_license_bounds(&user.0, (south, west, north, east), share_bounds)?;
|
||||
check_license_bounds(&user.0, (south, west, north, east), geo.free_zone, share_bounds)?;
|
||||
|
||||
let quant = state.data.quant_ref();
|
||||
let poi_quant = state.data.poi_metrics.quant_ref();
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ use tracing::{info, warn};
|
|||
|
||||
use crate::auth::OptionalUser;
|
||||
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::parsing::{
|
||||
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")]
|
||||
pub historical_prices: Vec<HistoricalPrice>,
|
||||
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub tenure_history: Vec<TenureEvent>,
|
||||
|
||||
#[serde(flatten)]
|
||||
pub features: FxHashMap<String, f32>,
|
||||
}
|
||||
|
|
@ -240,6 +243,7 @@ pub fn build_property(
|
|||
lon: state.data.lon[row],
|
||||
renovation_history: state.data.renovation_history(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),
|
||||
price_qualifier: state.data.price_qualifier(row).map(String::from),
|
||||
former_council_house: lookup_enum_value(
|
||||
|
|
@ -270,6 +274,7 @@ pub fn build_property(
|
|||
pub async fn get_hexagon_properties(
|
||||
State(shared): State<Arc<SharedState>>,
|
||||
Extension(user): Extension<OptionalUser>,
|
||||
Extension(geo): Extension<crate::demo_zone::DemoZone>,
|
||||
Query(params): Query<HexagonPropertiesParams>,
|
||||
) -> Result<Json<PropertyListResponse>, axum::response::Response> {
|
||||
let state = shared.load_state();
|
||||
|
|
@ -289,7 +294,7 @@ pub async fn get_hexagon_properties(
|
|||
// License check using H3 cell bounds
|
||||
let h3_bounds = h3_cell_bounds(cell, 0.0);
|
||||
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 quant = state.data.quant_ref();
|
||||
|
|
|
|||
|
|
@ -255,6 +255,36 @@ pub fn compute_feature_stats(
|
|||
(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.
|
||||
///
|
||||
/// Each matching property contributes its postcode's per-year counts (incidents
|
||||
|
|
@ -447,3 +477,42 @@ pub fn compute_poi_feature_stats(
|
|||
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ use crate::utils::GridIndex;
|
|||
pub struct AppState {
|
||||
pub data: PropertyData,
|
||||
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().
|
||||
pub h3_cells: Vec<u64>,
|
||||
/// O(1) lookup: feature name → index in feature_names/feature_data
|
||||
|
|
@ -85,6 +85,8 @@ pub struct AppState {
|
|||
pub stripe_referral_coupon_id: String,
|
||||
/// Bugsink/Sentry-compatible browser error reporting config injected into served HTML.
|
||||
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)]
|
||||
|
|
@ -182,6 +184,7 @@ impl AppState {
|
|||
stripe_webhook_secret: "whsec_test_secret".to_string(),
|
||||
stripe_referral_coupon_id: "couponTest30".to_string(),
|
||||
bugsink_frontend_config: None,
|
||||
demo_rate_limiter: Arc::new(crate::ratelimit::DemoRateLimiter::new()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue