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