new demo mode & tenure
This commit is contained in:
parent
7656f24544
commit
4a0f00f2a4
64 changed files with 2875 additions and 338 deletions
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,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
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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!;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue