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

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

View file

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

View file

@ -9,7 +9,19 @@ describe('compactHistogramLabel', () => {
compactHistogramLabel(index, 5, 0, 5.95, center, fmt, true)
);
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', () => {

View file

@ -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)}+`;

View file

@ -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'));

View file

@ -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}

View file

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

View file

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

View file

@ -22,10 +22,23 @@ import {
travelFieldKey,
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}
/>
);
}

View file

@ -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

View file

@ -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);
});
});

View file

@ -1,6 +1,10 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import type { ReactNode } from 'react';
// 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);

View file

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

View file

@ -9,6 +9,7 @@ import {
formatYearMonth,
} 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

View file

@ -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,45 +188,47 @@ export function DesktopMapPage({
<div data-tutorial="map" className="flex-1 relative">
<IndeterminateProgressBar show={mapData.loading && !initialLoading} />
<Suspense fallback={<MapFallback />}>
<Map
data={mapData.data}
postcodeData={mapData.postcodeData}
usePostcodeView={mapData.usePostcodeView}
pois={pois}
activeOverlays={activeOverlays}
activeCrimeTypes={activeCrimeTypes}
basemap={basemap}
colorOpacity={colorOpacity}
onViewChange={mapData.handleViewChange}
viewFeature={mapViewFeature}
colorRange={mapData.colorRange}
filterRange={filterRange}
viewSource={viewSource}
onCancelPin={onCancelPin}
onResetPreviewScale={mapData.handleResetPreviewScale}
canResetPreviewScale={mapData.canResetPreviewScale}
features={features}
selectedHexagonId={selectedHexagonId}
hoveredHexagonId={hoveredHexagonId}
onHexagonClick={onHexagonClick}
onHexagonHover={onHexagonHover}
initialViewState={initialViewState}
flyToRef={flyToRef}
theme={theme}
filters={filters}
selectedPostcodeGeometry={selectedPostcodeGeometry}
onLocationSearched={onLocationSearched}
onCurrentLocationFound={onCurrentLocationFound}
currentLocation={currentLocation}
actualListings={actualListings}
bounds={mapData.bounds}
hideTopCardsWhenNarrow
travelTimeEntries={travelTimeEntries}
densityLabel={densityLabel}
totalCount={totalCount}
/>
</Suspense>
<MapErrorBoundary>
<Suspense fallback={<MapFallback />}>
<Map
data={mapData.data}
postcodeData={mapData.postcodeData}
usePostcodeView={mapData.usePostcodeView}
pois={pois}
activeOverlays={activeOverlays}
activeCrimeTypes={activeCrimeTypes}
basemap={basemap}
colorOpacity={colorOpacity}
onViewChange={mapData.handleViewChange}
viewFeature={mapViewFeature}
colorRange={mapData.colorRange}
filterRange={filterRange}
viewSource={viewSource}
onCancelPin={onCancelPin}
onResetPreviewScale={mapData.handleResetPreviewScale}
canResetPreviewScale={mapData.canResetPreviewScale}
features={features}
selectedHexagonId={selectedHexagonId}
hoveredHexagonId={hoveredHexagonId}
onHexagonClick={onHexagonClick}
onHexagonHover={onHexagonHover}
initialViewState={initialViewState}
flyToRef={flyToRef}
theme={theme}
filters={filters}
selectedPostcodeGeometry={selectedPostcodeGeometry}
onLocationSearched={onLocationSearched}
onCurrentLocationFound={onCurrentLocationFound}
currentLocation={currentLocation}
actualListings={actualListings}
bounds={mapData.bounds}
hideTopCardsWhenNarrow
travelTimeEntries={travelTimeEntries}
densityLabel={densityLabel}
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

View file

@ -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,50 +137,63 @@ 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} />
<Suspense fallback={<MapFallback />}>
<Map
data={mapData.data}
postcodeData={mapData.postcodeData}
usePostcodeView={mapData.usePostcodeView}
pois={pois}
activeOverlays={activeOverlays}
activeCrimeTypes={activeCrimeTypes}
basemap={basemap}
colorOpacity={colorOpacity}
onViewChange={mapData.handleViewChange}
viewFeature={mapViewFeature}
colorRange={mapData.colorRange}
filterRange={filterRange}
viewSource={viewSource}
onCancelPin={onCancelPin}
onResetPreviewScale={mapData.handleResetPreviewScale}
canResetPreviewScale={mapData.canResetPreviewScale}
features={features}
selectedHexagonId={selectedHexagonId}
hoveredHexagonId={hoveredHexagonId}
onHexagonClick={onHexagonClick}
onHexagonHover={onHexagonHover}
initialViewState={initialViewState}
flyToRef={flyToRef}
theme={theme}
filters={filters}
selectedPostcodeGeometry={selectedPostcodeGeometry}
onLocationSearched={onLocationSearched}
onCurrentLocationFound={onCurrentLocationFound}
currentLocation={currentLocation}
actualListings={actualListings}
bounds={mapData.bounds}
hideLegend
travelTimeEntries={travelTimeEntries}
bottomScreenInset={bottomScreenInset}
/>
</Suspense>
<MapErrorBoundary>
<Suspense fallback={<MapFallback />}>
<Map
data={mapData.data}
postcodeData={mapData.postcodeData}
usePostcodeView={mapData.usePostcodeView}
pois={pois}
activeOverlays={activeOverlays}
activeCrimeTypes={activeCrimeTypes}
basemap={basemap}
colorOpacity={colorOpacity}
onViewChange={mapData.handleViewChange}
viewFeature={mapViewFeature}
colorRange={mapData.colorRange}
filterRange={filterRange}
viewSource={viewSource}
onCancelPin={onCancelPin}
onResetPreviewScale={mapData.handleResetPreviewScale}
canResetPreviewScale={mapData.canResetPreviewScale}
features={features}
selectedHexagonId={selectedHexagonId}
hoveredHexagonId={hoveredHexagonId}
onHexagonClick={onHexagonClick}
onHexagonHover={onHexagonHover}
initialViewState={initialViewState}
flyToRef={flyToRef}
theme={theme}
filters={filters}
selectedPostcodeGeometry={selectedPostcodeGeometry}
onLocationSearched={onLocationSearched}
onCurrentLocationFound={onCurrentLocationFound}
currentLocation={currentLocation}
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">

View file

@ -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>;

View file

@ -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!;