diff --git a/frontend/public/video/poster.jpg b/frontend/public/video/poster.jpg
index 039f491..c2927df 100644
Binary files a/frontend/public/video/poster.jpg and b/frontend/public/video/poster.jpg differ
diff --git a/frontend/public/video/recording.mp4 b/frontend/public/video/recording.mp4
index 17e3a76..13908a2 100644
Binary files a/frontend/public/video/recording.mp4 and b/frontend/public/video/recording.mp4 differ
diff --git a/frontend/src/components/map/Map.tsx b/frontend/src/components/map/Map.tsx
index d054a53..bda7cad 100644
--- a/frontend/src/components/map/Map.tsx
+++ b/frontend/src/components/map/Map.tsx
@@ -233,7 +233,6 @@ export default memo(function Map({
}, [viewState, dimensions, onViewChange]);
const handleMove = useCallback((evt: { viewState: ViewState }) => {
- if (window.__demoRecording) window.__demoMapIdle = false;
setInternalViewState((prev) => {
const next = evt.viewState;
// 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(() => {
if (screenshotMode) window.__map_idle = true;
- if (window.__demoRecording) {
- window.__demoMapIdle = true;
- window.__demoMapIdleVersion = (window.__demoMapIdleVersion ?? 0) + 1;
- }
}, [screenshotMode]);
const handleFlyTo = useCallback((lat: number, lng: number, zoom: number) => {
diff --git a/frontend/src/components/map/MapPage.tsx b/frontend/src/components/map/MapPage.tsx
index 21e8a8e..a277804 100644
--- a/frontend/src/components/map/MapPage.tsx
+++ b/frontend/src/components/map/MapPage.tsx
@@ -55,18 +55,6 @@ const MapPageSelectionPane = lazy(() =>
const UpgradeModal = lazy(() => import('../ui/UpgradeModal'));
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() {
return (
@@ -231,10 +219,6 @@ export default function MapPage({
travelTimeEntries: entries,
shareCode,
});
- const demoMapHasData = mapData.usePostcodeView
- ? mapData.postcodeData.length > 0
- : mapData.data.length > 0;
-
const handleAiFilterSubmit = useCallback(
async (query: string) => {
// Build context from current filters for conversational refinement
@@ -432,48 +416,6 @@ export default function MapPage({
setRightPaneTab(initialTab);
}, []); // 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)
useEffect(() => {
if (!initialPostcode) return;
@@ -1124,7 +1066,6 @@ export default function MapPage({
onClose={handleCloseSelection}
renderAreaPane={renderAreaPane}
renderPropertiesPane={renderPropertiesPane}
- demoReady={Boolean(areaStats && !loadingAreaStats)}
/>
)}
diff --git a/frontend/src/components/map/MapPageSelectionPane.tsx b/frontend/src/components/map/MapPageSelectionPane.tsx
index 4a56558..dc6d697 100644
--- a/frontend/src/components/map/MapPageSelectionPane.tsx
+++ b/frontend/src/components/map/MapPageSelectionPane.tsx
@@ -18,7 +18,6 @@ interface MapPageSelectionPaneProps {
onClose: () => void;
renderAreaPane: () => ReactNode;
renderPropertiesPane: () => ReactNode;
- demoReady?: boolean;
}
export function MapPageSelectionPane({
@@ -30,12 +29,10 @@ export function MapPageSelectionPane({
onClose,
renderAreaPane,
renderPropertiesPane,
- demoReady = false,
}: MapPageSelectionPaneProps) {
return (
diff --git a/video/src/browser.ts b/video/src/browser.ts
index 67e3a49..d0ec46d 100644
--- a/video/src/browser.ts
+++ b/video/src/browser.ts
@@ -36,8 +36,6 @@ export async function launchRecordingBrowser(): Promise {
async function suppressDevServerNoise(context: BrowserContext) {
await context.addInitScript(() => {
- (window as typeof window & { __demoRecording?: boolean }).__demoRecording = true;
-
const RealWS = window.WebSocket;
window.WebSocket = new Proxy(RealWS, {
construct(target, args) {
diff --git a/video/src/dom.ts b/video/src/dom.ts
index 419182a..06aff79 100644
--- a/video/src/dom.ts
+++ b/video/src/dom.ts
@@ -451,67 +451,3 @@ export async function waitForAnimationFrames(page: Page, frames = 3): Promise {
- return page.evaluate(
- () =>
- (
- window as typeof window & {
- __demoMapSettleVersion?: number;
- }
- ).__demoMapSettleVersion ?? 0
- );
-}
-
-export async function waitForDemoMapSettled(
- page: Page,
- timeoutMs = 12000,
- afterVersion = -1
-): Promise {
- 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 {
- 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 {
- await page.waitForFunction(
- () =>
- (
- window as typeof window & {
- __demoSelectionReady?: boolean;
- }
- ).__demoSelectionReady === true,
- undefined,
- { timeout: timeoutMs }
- );
- await waitForAnimationFrames(page, 4);
-}
diff --git a/video/src/scenes.ts b/video/src/scenes.ts
index 202b3cf..ef6c19d 100644
--- a/video/src/scenes.ts
+++ b/video/src/scenes.ts
@@ -1,4 +1,5 @@
import type { Page } from 'playwright';
+import type { DashboardRecorder, HexagonClickTarget } from './dashboard.js';
import {
AI_ZOOM_SCALE,
BRAND_NAME,
@@ -13,14 +14,9 @@ import {
import {
clearVignette,
flashRect,
- getDemoMapSettleVersion,
hideCaption,
showCaption,
showOutro,
- visualClick,
- waitForDemoMapSettled,
- waitForCurrentDemoMapSettled,
- waitForDemoSelectionReady,
zoomReset,
zoomTo,
} from './dom.js';
@@ -28,6 +24,7 @@ import { fakeType, sleep, smoothMove, smoothDragSliderThumb } from './motion.js'
export interface SceneCtx {
page: Page;
+ dashboard: DashboardRecorder;
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.
*/
export async function sceneAiCloseUp(ctx: SceneCtx): Promise {
- const { page } = ctx;
+ const { page, dashboard } = ctx;
await clearVignette(page);
await showCaption(page, 'Brief: flats or terraces under £450k near central Manchester.');
@@ -53,13 +50,13 @@ export async function sceneAiCloseUp(ctx: SceneCtx): Promise {
{ timeout: 1800 }
)
.catch(() => null);
- const mapVersion = await getDemoMapSettleVersion(page);
+ const mapVersion = dashboard.getMapDataVersion();
await page.evaluate(() => {
document.querySelector('[data-tutorial="ai-filters"] form')?.requestSubmit();
});
await aiResponse;
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 sleep(560);
await hideCaption(page);
@@ -91,7 +88,7 @@ export async function sceneZoomOutResults(ctx: SceneCtx): Promise {
* the selector or this scene will time out.
*/
export async function sceneTravelTimeSlider(ctx: SceneCtx): Promise {
- const { page } = ctx;
+ const { page, dashboard } = ctx;
await showCaption(
page,
`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 {
// Slider goes 0..120, target = 20 → fraction 0.166...
const toFraction = TT_DRAG_TO_MIN / TT_SLIDER_MAX;
- const mapVersion = await getDemoMapSettleVersion(page);
+ const mapVersion = dashboard.getMapDataVersion();
ctx.cursor = await smoothDragSliderThumb(
page,
@@ -121,7 +118,7 @@ export async function sceneTravelTimeSlider(ctx: SceneCtx): Promise {
);
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 sleep(720);
await hideCaption(page);
@@ -147,13 +144,8 @@ export async function sceneClusterClick(ctx: SceneCtx): Promise {
ctx.cursor = cluster;
await sleep(220);
- await zoomMapWithWheel(page, cluster);
-
- 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 zoomMapWithWheel(ctx, cluster);
+ ctx.cursor = await clickVisibleHexagon(ctx);
await sleep(360);
await showCaption(
page,
@@ -163,35 +155,48 @@ export async function sceneClusterClick(ctx: SceneCtx): Promise {
await hideCaption(page);
}
-async function clickHexagon(
- page: Page,
- target: { x: number; y: number }
-): 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 {
+async function zoomMapWithWheel(ctx: SceneCtx, target: { x: number; y: number }): Promise {
+ const { page, dashboard } = ctx;
+ const mapVersion = dashboard.getMapDataVersion();
await page.mouse.move(target.x, target.y);
for (let i = 0; i < 5; i++) {
await page.mouse.wheel(0, -120);
await sleep(95);
}
- await waitForCurrentDemoMapSettled(page, 16000);
+ await dashboard.waitForMapSettled(mapVersion, 16000);
await sleep(260);
}
-async function openDemoHexagon(page: Page): Promise {
- const selected = await page.evaluate(
- () =>
- (
- window as typeof window & {
- __demoOpenBestHexagon?: () => string | null;
- }
- ).__demoOpenBestHexagon?.() ?? null
+async function clickVisibleHexagon(ctx: SceneCtx): Promise<{ x: number; y: number }> {
+ const candidates = await ctx.dashboard.visibleHexagonTargets(8);
+ const startedAt = ctx.dashboard.getSelectionStatsVersion();
+ let lastError: Error | null = null;
+
+ for (const target of candidates) {
+ await moveAndClickHexagon(ctx, target);
+ 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 {
+ 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. */
diff --git a/video/src/timeline.ts b/video/src/timeline.ts
index e7fa2f2..e86b343 100644
--- a/video/src/timeline.ts
+++ b/video/src/timeline.ts
@@ -1,5 +1,6 @@
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 { dashboardUrl } from './routes.js';
import {
@@ -18,19 +19,21 @@ export interface TimelineResult {
}
export async function prepareTimeline(page: Page): Promise {
+ const dashboard = new DashboardRecorder(page);
+ const initialMapVersion = dashboard.getMapDataVersion();
await page.goto(dashboardUrl(), { waitUntil: 'domcontentloaded' });
await page.waitForLoadState('load', { timeout: 15000 }).catch(() => {});
await page
.locator('[data-tutorial="ai-filters"]')
.waitFor({ state: 'visible', 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 installZoomWrapper(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 prepareAiBox(ctx);
await sleep(80);