This commit is contained in:
Andras Schmelczer 2026-05-06 19:54:50 +01:00
parent e3e8a4522e
commit 58bb3cb4f8
9 changed files with 49 additions and 174 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 370 KiB

After

Width:  |  Height:  |  Size: 375 KiB

Before After
Before After

Binary file not shown.

View file

@ -233,7 +233,6 @@ export default memo(function Map({
}, [viewState, dimensions, onViewChange]); }, [viewState, dimensions, onViewChange]);
const handleMove = useCallback((evt: { viewState: ViewState }) => { const handleMove = useCallback((evt: { viewState: ViewState }) => {
if (window.__demoRecording) window.__demoMapIdle = false;
setInternalViewState((prev) => { setInternalViewState((prev) => {
const next = evt.viewState; const next = evt.viewState;
// Skip re-render when viewport values haven't changed (e.g. container resize // Skip re-render when viewport values haven't changed (e.g. container resize
@ -254,10 +253,6 @@ export default memo(function Map({
const handleIdle = useCallback(() => { const handleIdle = useCallback(() => {
if (screenshotMode) window.__map_idle = true; if (screenshotMode) window.__map_idle = true;
if (window.__demoRecording) {
window.__demoMapIdle = true;
window.__demoMapIdleVersion = (window.__demoMapIdleVersion ?? 0) + 1;
}
}, [screenshotMode]); }, [screenshotMode]);
const handleFlyTo = useCallback((lat: number, lng: number, zoom: number) => { const handleFlyTo = useCallback((lat: number, lng: number, zoom: number) => {

View file

@ -55,18 +55,6 @@ const MapPageSelectionPane = lazy(() =>
const UpgradeModal = lazy(() => import('../ui/UpgradeModal')); const UpgradeModal = lazy(() => import('../ui/UpgradeModal'));
const Joyride = lazy(() => import('react-joyride')); const Joyride = lazy(() => import('react-joyride'));
declare global {
interface Window {
__demoRecording?: boolean;
__demoOpenBestHexagon?: () => string | null;
__demoMapSettled?: boolean;
__demoMapSettleVersion?: number;
__demoMapIdle?: boolean;
__demoMapIdleVersion?: number;
__demoSelectionReady?: boolean;
}
}
function MapFallback() { function MapFallback() {
return ( return (
<div className="flex h-full w-full items-center justify-center bg-warm-100 dark:bg-navy-950"> <div className="flex h-full w-full items-center justify-center bg-warm-100 dark:bg-navy-950">
@ -231,10 +219,6 @@ export default function MapPage({
travelTimeEntries: entries, travelTimeEntries: entries,
shareCode, shareCode,
}); });
const demoMapHasData = mapData.usePostcodeView
? mapData.postcodeData.length > 0
: mapData.data.length > 0;
const handleAiFilterSubmit = useCallback( const handleAiFilterSubmit = useCallback(
async (query: string) => { async (query: string) => {
// Build context from current filters for conversational refinement // Build context from current filters for conversational refinement
@ -432,48 +416,6 @@ export default function MapPage({
setRightPaneTab(initialTab); setRightPaneTab(initialTab);
}, []); // eslint-disable-line react-hooks/exhaustive-deps }, []); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
if (!window.__demoRecording) return;
void import('./MapPageSelectionPane');
void import('./AreaPane');
void import('./PropertiesPane');
}, []);
useEffect(() => {
if (!window.__demoRecording) return;
window.__demoMapSettled = !mapData.loading && demoMapHasData;
if (window.__demoMapSettled) {
window.__demoMapSettleVersion = (window.__demoMapSettleVersion ?? 0) + 1;
}
return () => {
window.__demoMapSettled = false;
};
}, [demoMapHasData, mapData.loading]);
useEffect(() => {
if (!window.__demoRecording) return;
window.__demoSelectionReady = Boolean(selectedHexagon && areaStats && !loadingAreaStats);
return () => {
window.__demoSelectionReady = false;
};
}, [areaStats, loadingAreaStats, selectedHexagon]);
useEffect(() => {
if (!window.__demoRecording) return;
window.__demoOpenBestHexagon = () => {
const best = mapData.data.reduce<(typeof mapData.data)[number] | null>((winner, item) => {
if (!winner || item.count > winner.count) return item;
return winner;
}, null);
if (!best) return null;
handleHexagonClick(best.h3);
return best.h3;
};
return () => {
delete window.__demoOpenBestHexagon;
};
}, [handleHexagonClick, mapData.data]);
// Navigate to a specific postcode on mount (e.g. from saved properties) // Navigate to a specific postcode on mount (e.g. from saved properties)
useEffect(() => { useEffect(() => {
if (!initialPostcode) return; if (!initialPostcode) return;
@ -1124,7 +1066,6 @@ export default function MapPage({
onClose={handleCloseSelection} onClose={handleCloseSelection}
renderAreaPane={renderAreaPane} renderAreaPane={renderAreaPane}
renderPropertiesPane={renderPropertiesPane} renderPropertiesPane={renderPropertiesPane}
demoReady={Boolean(areaStats && !loadingAreaStats)}
/> />
</Suspense> </Suspense>
)} )}

View file

@ -18,7 +18,6 @@ interface MapPageSelectionPaneProps {
onClose: () => void; onClose: () => void;
renderAreaPane: () => ReactNode; renderAreaPane: () => ReactNode;
renderPropertiesPane: () => ReactNode; renderPropertiesPane: () => ReactNode;
demoReady?: boolean;
} }
export function MapPageSelectionPane({ export function MapPageSelectionPane({
@ -30,12 +29,10 @@ export function MapPageSelectionPane({
onClose, onClose,
renderAreaPane, renderAreaPane,
renderPropertiesPane, renderPropertiesPane,
demoReady = false,
}: MapPageSelectionPaneProps) { }: MapPageSelectionPaneProps) {
return ( return (
<div <div
data-tutorial="right-pane" data-tutorial="right-pane"
data-demo-ready={demoReady ? 'true' : 'false'}
className="flex bg-white dark:bg-navy-950 shadow-lg z-10" className="flex bg-white dark:bg-navy-950 shadow-lg z-10"
style={{ width }} style={{ width }}
> >

View file

@ -36,8 +36,6 @@ export async function launchRecordingBrowser(): Promise<RecordingBrowser> {
async function suppressDevServerNoise(context: BrowserContext) { async function suppressDevServerNoise(context: BrowserContext) {
await context.addInitScript(() => { await context.addInitScript(() => {
(window as typeof window & { __demoRecording?: boolean }).__demoRecording = true;
const RealWS = window.WebSocket; const RealWS = window.WebSocket;
window.WebSocket = new Proxy(RealWS, { window.WebSocket = new Proxy(RealWS, {
construct(target, args) { construct(target, args) {

View file

@ -451,67 +451,3 @@ export async function waitForAnimationFrames(page: Page, frames = 3): Promise<vo
frames frames
); );
} }
export async function getDemoMapSettleVersion(page: Page): Promise<number> {
return page.evaluate(
() =>
(
window as typeof window & {
__demoMapSettleVersion?: number;
}
).__demoMapSettleVersion ?? 0
);
}
export async function waitForDemoMapSettled(
page: Page,
timeoutMs = 12000,
afterVersion = -1
): Promise<void> {
await page.waitForFunction(
(version) => {
const demo = window as typeof window & {
__demoMapSettled?: boolean;
__demoMapSettleVersion?: number;
__demoMapIdle?: boolean;
};
return (
demo.__demoMapSettled === true &&
demo.__demoMapIdle === true &&
(demo.__demoMapSettleVersion ?? 0) > version
);
},
afterVersion,
{ timeout: timeoutMs }
);
await waitForAnimationFrames(page, 4);
}
export async function waitForCurrentDemoMapSettled(page: Page, timeoutMs = 12000): Promise<void> {
await page.waitForFunction(
() => {
const demo = window as typeof window & {
__demoMapSettled?: boolean;
__demoMapIdle?: boolean;
};
return demo.__demoMapSettled === true && demo.__demoMapIdle === true;
},
undefined,
{ timeout: timeoutMs }
);
await waitForAnimationFrames(page, 4);
}
export async function waitForDemoSelectionReady(page: Page, timeoutMs = 12000): Promise<void> {
await page.waitForFunction(
() =>
(
window as typeof window & {
__demoSelectionReady?: boolean;
}
).__demoSelectionReady === true,
undefined,
{ timeout: timeoutMs }
);
await waitForAnimationFrames(page, 4);
}

View file

@ -1,4 +1,5 @@
import type { Page } from 'playwright'; import type { Page } from 'playwright';
import type { DashboardRecorder, HexagonClickTarget } from './dashboard.js';
import { import {
AI_ZOOM_SCALE, AI_ZOOM_SCALE,
BRAND_NAME, BRAND_NAME,
@ -13,14 +14,9 @@ import {
import { import {
clearVignette, clearVignette,
flashRect, flashRect,
getDemoMapSettleVersion,
hideCaption, hideCaption,
showCaption, showCaption,
showOutro, showOutro,
visualClick,
waitForDemoMapSettled,
waitForCurrentDemoMapSettled,
waitForDemoSelectionReady,
zoomReset, zoomReset,
zoomTo, zoomTo,
} from './dom.js'; } from './dom.js';
@ -28,6 +24,7 @@ import { fakeType, sleep, smoothMove, smoothDragSliderThumb } from './motion.js'
export interface SceneCtx { export interface SceneCtx {
page: Page; page: Page;
dashboard: DashboardRecorder;
cursor: { x: number; y: number }; cursor: { x: number; y: number };
} }
@ -36,7 +33,7 @@ export interface SceneCtx {
* stubbed, while the map filters and right pane are loaded from the real app. * stubbed, while the map filters and right pane are loaded from the real app.
*/ */
export async function sceneAiCloseUp(ctx: SceneCtx): Promise<void> { export async function sceneAiCloseUp(ctx: SceneCtx): Promise<void> {
const { page } = ctx; const { page, dashboard } = ctx;
await clearVignette(page); await clearVignette(page);
await showCaption(page, 'Brief: flats or terraces under £450k near central Manchester.'); await showCaption(page, 'Brief: flats or terraces under £450k near central Manchester.');
@ -53,13 +50,13 @@ export async function sceneAiCloseUp(ctx: SceneCtx): Promise<void> {
{ timeout: 1800 } { timeout: 1800 }
) )
.catch(() => null); .catch(() => null);
const mapVersion = await getDemoMapSettleVersion(page); const mapVersion = dashboard.getMapDataVersion();
await page.evaluate(() => { await page.evaluate(() => {
document.querySelector<HTMLFormElement>('[data-tutorial="ai-filters"] form')?.requestSubmit(); document.querySelector<HTMLFormElement>('[data-tutorial="ai-filters"] form')?.requestSubmit();
}); });
await aiResponse; await aiResponse;
await sleep(160); await sleep(160);
await waitForDemoMapSettled(page, 15000, mapVersion); await dashboard.waitForMapSettled(mapVersion, 15000);
await showCaption(page, 'The filters are already live on the map.'); await showCaption(page, 'The filters are already live on the map.');
await sleep(560); await sleep(560);
await hideCaption(page); await hideCaption(page);
@ -91,7 +88,7 @@ export async function sceneZoomOutResults(ctx: SceneCtx): Promise<void> {
* the selector or this scene will time out. * the selector or this scene will time out.
*/ */
export async function sceneTravelTimeSlider(ctx: SceneCtx): Promise<void> { export async function sceneTravelTimeSlider(ctx: SceneCtx): Promise<void> {
const { page } = ctx; const { page, dashboard } = ctx;
await showCaption( await showCaption(
page, page,
`Then tighten the commute: ${TT_DRAG_FROM_MIN} minutes down to ${TT_DRAG_TO_MIN}.` `Then tighten the commute: ${TT_DRAG_FROM_MIN} minutes down to ${TT_DRAG_TO_MIN}.`
@ -109,7 +106,7 @@ export async function sceneTravelTimeSlider(ctx: SceneCtx): Promise<void> {
// Slider goes 0..120, target = 20 → fraction 0.166... // Slider goes 0..120, target = 20 → fraction 0.166...
const toFraction = TT_DRAG_TO_MIN / TT_SLIDER_MAX; const toFraction = TT_DRAG_TO_MIN / TT_SLIDER_MAX;
const mapVersion = await getDemoMapSettleVersion(page); const mapVersion = dashboard.getMapDataVersion();
ctx.cursor = await smoothDragSliderThumb( ctx.cursor = await smoothDragSliderThumb(
page, page,
@ -121,7 +118,7 @@ export async function sceneTravelTimeSlider(ctx: SceneCtx): Promise<void> {
); );
await sleep(220); await sleep(220);
await waitForDemoMapSettled(page, 16000, mapVersion); await dashboard.waitForMapSettled(mapVersion, 16000);
await showCaption(page, 'The map redraws around the areas that still work.'); await showCaption(page, 'The map redraws around the areas that still work.');
await sleep(720); await sleep(720);
await hideCaption(page); await hideCaption(page);
@ -147,13 +144,8 @@ export async function sceneClusterClick(ctx: SceneCtx): Promise<void> {
ctx.cursor = cluster; ctx.cursor = cluster;
await sleep(220); await sleep(220);
await zoomMapWithWheel(page, cluster); await zoomMapWithWheel(ctx, cluster);
ctx.cursor = await clickVisibleHexagon(ctx);
const clicked = await clickHexagon(page, cluster);
ctx.cursor = clicked;
await openDemoHexagon(page);
await page.locator('[data-tutorial="right-pane"]').waitFor({ state: 'visible', timeout: 5000 });
await waitForDemoSelectionReady(page, 16000);
await sleep(360); await sleep(360);
await showCaption( await showCaption(
page, page,
@ -163,35 +155,48 @@ export async function sceneClusterClick(ctx: SceneCtx): Promise<void> {
await hideCaption(page); await hideCaption(page);
} }
async function clickHexagon( async function zoomMapWithWheel(ctx: SceneCtx, target: { x: number; y: number }): Promise<void> {
page: Page, const { page, dashboard } = ctx;
target: { x: number; y: number } const mapVersion = dashboard.getMapDataVersion();
): Promise<{ x: number; y: number }> {
await visualClick(page, target);
await sleep(140);
return target;
}
async function zoomMapWithWheel(page: Page, target: { x: number; y: number }): Promise<void> {
await page.mouse.move(target.x, target.y); await page.mouse.move(target.x, target.y);
for (let i = 0; i < 5; i++) { for (let i = 0; i < 5; i++) {
await page.mouse.wheel(0, -120); await page.mouse.wheel(0, -120);
await sleep(95); await sleep(95);
} }
await waitForCurrentDemoMapSettled(page, 16000); await dashboard.waitForMapSettled(mapVersion, 16000);
await sleep(260); await sleep(260);
} }
async function openDemoHexagon(page: Page): Promise<void> { async function clickVisibleHexagon(ctx: SceneCtx): Promise<{ x: number; y: number }> {
const selected = await page.evaluate( const candidates = await ctx.dashboard.visibleHexagonTargets(8);
() => const startedAt = ctx.dashboard.getSelectionStatsVersion();
( let lastError: Error | null = null;
window as typeof window & {
__demoOpenBestHexagon?: () => string | null; for (const target of candidates) {
} await moveAndClickHexagon(ctx, target);
).__demoOpenBestHexagon?.() ?? null try {
await ctx.dashboard.waitForSelectionReady(startedAt, 7000);
return { x: target.x, y: target.y };
} catch (error) {
if (ctx.dashboard.getSelectionStatsVersion() > startedAt) {
return { x: target.x, y: target.y };
}
lastError = error instanceof Error ? error : new Error(String(error));
}
}
throw new Error(
`Could not open a map selection from the visible hexagons${
lastError ? `: ${lastError.message}` : ''
}`
); );
if (!selected) throw new Error('Could not open a demo hexagon selection'); }
async function moveAndClickHexagon(ctx: SceneCtx, target: HexagonClickTarget): Promise<void> {
await smoothMove(ctx.page, ctx.cursor, { x: target.x, y: target.y }, { durationMs: 420 });
ctx.cursor = { x: target.x, y: target.y };
await ctx.page.mouse.click(target.x, target.y);
await sleep(140);
} }
/** Export the current shortlist, then reveal the URL. */ /** Export the current shortlist, then reveal the URL. */

View file

@ -1,5 +1,6 @@
import type { Page } from 'playwright'; import type { Page } from 'playwright';
import { installCursor, installZoomWrapper, waitForCurrentDemoMapSettled } from './dom.js'; import { installCursor, installZoomWrapper } from './dom.js';
import { DashboardRecorder } from './dashboard.js';
import { sleep } from './motion.js'; import { sleep } from './motion.js';
import { dashboardUrl } from './routes.js'; import { dashboardUrl } from './routes.js';
import { import {
@ -18,19 +19,21 @@ export interface TimelineResult {
} }
export async function prepareTimeline(page: Page): Promise<SceneCtx> { export async function prepareTimeline(page: Page): Promise<SceneCtx> {
const dashboard = new DashboardRecorder(page);
const initialMapVersion = dashboard.getMapDataVersion();
await page.goto(dashboardUrl(), { waitUntil: 'domcontentloaded' }); await page.goto(dashboardUrl(), { waitUntil: 'domcontentloaded' });
await page.waitForLoadState('load', { timeout: 15000 }).catch(() => {}); await page.waitForLoadState('load', { timeout: 15000 }).catch(() => {});
await page await page
.locator('[data-tutorial="ai-filters"]') .locator('[data-tutorial="ai-filters"]')
.waitFor({ state: 'visible', timeout: 15000 }); .waitFor({ state: 'visible', timeout: 15000 });
await page.locator('canvas').first().waitFor({ state: 'attached', timeout: 15000 }); await page.locator('canvas').first().waitFor({ state: 'attached', timeout: 15000 });
await waitForCurrentDemoMapSettled(page, 15000); await dashboard.waitForMapSettled(initialMapVersion, 15000);
await new Promise((r) => setTimeout(r, 400)); await new Promise((r) => setTimeout(r, 400));
await installZoomWrapper(page); await installZoomWrapper(page);
await installCursor(page); await installCursor(page);
const ctx: SceneCtx = { page, cursor: { x: 200, y: 240 } }; const ctx: SceneCtx = { page, dashboard, cursor: { x: 200, y: 240 } };
await page.mouse.move(ctx.cursor.x, ctx.cursor.y); await page.mouse.move(ctx.cursor.x, ctx.cursor.y);
await prepareAiBox(ctx); await prepareAiBox(ctx);
await sleep(80); await sleep(80);