perfect-postcode/video/src/scenes.ts
2026-05-06 19:54:50 +01:00

261 lines
8.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import type { Page } from 'playwright';
import type { DashboardRecorder, HexagonClickTarget } from './dashboard.js';
import {
AI_ZOOM_SCALE,
BRAND_NAME,
BRAND_TAGLINE,
BRAND_URL,
PROMPT_TEXT,
TT_CARD_SELECTOR,
TT_DRAG_FROM_MIN,
TT_DRAG_TO_MIN,
TT_SLIDER_MAX,
} from './config.js';
import {
clearVignette,
flashRect,
hideCaption,
showCaption,
showOutro,
zoomReset,
zoomTo,
} from './dom.js';
import { fakeType, sleep, smoothMove, smoothDragSliderThumb } from './motion.js';
export interface SceneCtx {
page: Page;
dashboard: DashboardRecorder;
cursor: { x: number; y: number };
}
/**
* Scene 1: start wide, then zoom into the AI prompt. The AI response is
* stubbed, while the map filters and right pane are loaded from the real app.
*/
export async function sceneAiCloseUp(ctx: SceneCtx): Promise<void> {
const { page, dashboard } = ctx;
await clearVignette(page);
await showCaption(page, 'Brief: flats or terraces under £450k near central Manchester.');
await sleep(180);
await zoomToAiBox(page, 720);
await sleep(760);
await fakeType(page, '[data-tutorial="ai-filters"] textarea', PROMPT_TEXT, 18);
await sleep(160);
const aiResponse = page
.waitForResponse(
(response) => response.url().includes('/api/ai-filters') && response.status() === 200,
{ timeout: 1800 }
)
.catch(() => null);
const mapVersion = dashboard.getMapDataVersion();
await page.evaluate(() => {
document.querySelector<HTMLFormElement>('[data-tutorial="ai-filters"] form')?.requestSubmit();
});
await aiResponse;
await sleep(160);
await dashboard.waitForMapSettled(mapVersion, 15000);
await showCaption(page, 'The filters are already live on the map.');
await sleep(560);
await hideCaption(page);
}
/**
* Scene 2: animate the wrapper back to scale 1 so the full dashboard is
* revealed. The map has already pan-flown to Manchester (MapPage's
* own flyTo fires when AI travel-time filters are applied), so the zoom-out
* lands on a useful, filtered view.
*/
export async function sceneZoomOutResults(ctx: SceneCtx): Promise<void> {
const { page } = ctx;
await showCaption(page, 'Now the view lands on central Manchester with the filters already set.');
await zoomReset(page, 860);
await sleep(980);
await hideCaption(page);
await sleep(180);
}
/**
* Scene 3: drag the right thumb of the AI-applied travel-time slider from
* 35 to 20 minutes. The slider has step=1 over 0120, so the 15-minute
* range crosses 15 step boundaries — at our pace each one gets ~20+ recorded
* frames, so the thumb reads as a continuous slide rather than incremental.
*
* The card we drag (`tt_0`) only exists because the AI filter step inserted
* exactly one travel-time entry; if you change the AI stub's count, update
* the selector or this scene will time out.
*/
export async function sceneTravelTimeSlider(ctx: SceneCtx): Promise<void> {
const { page, dashboard } = ctx;
await showCaption(
page,
`Then tighten the commute: ${TT_DRAG_FROM_MIN} minutes down to ${TT_DRAG_TO_MIN}.`
);
const card = page.locator(TT_CARD_SELECTOR);
await card.waitFor({ state: 'visible', timeout: 4000 });
await card.scrollIntoViewIfNeeded();
await sleep(60);
// Two thumbs in a Radix range slider; the second one is the max.
const thumbSelector = `${TT_CARD_SELECTOR} [role="slider"] >> nth=1`;
// Track is the first horizontal-orientation element inside the card.
const trackSelector = `${TT_CARD_SELECTOR} [data-orientation="horizontal"] >> nth=0`;
// Slider goes 0..120, target = 20 → fraction 0.166...
const toFraction = TT_DRAG_TO_MIN / TT_SLIDER_MAX;
const mapVersion = dashboard.getMapDataVersion();
ctx.cursor = await smoothDragSliderThumb(
page,
thumbSelector,
trackSelector,
ctx.cursor,
toFraction,
1180
);
await sleep(220);
await dashboard.waitForMapSettled(mapVersion, 16000);
await showCaption(page, 'The map redraws around the areas that still work.');
await sleep(720);
await hideCaption(page);
await sleep(180);
}
/**
* Scene 4: after the filtered result map is visible, zoom into Manchester,
* click a hexagon, then let the right pane open from that selection.
*/
export async function sceneClusterClick(ctx: SceneCtx): Promise<void> {
const { page } = ctx;
const viewport = page.viewportSize() ?? { width: 1920, height: 1080 };
await showCaption(page, 'Zoom into the result clusters, then click a promising hexagon.');
const cluster = {
x: 360 + (viewport.width - 360) * 0.35,
y: viewport.height * 0.52,
};
await smoothMove(page, ctx.cursor, cluster, { durationMs: 520 });
ctx.cursor = cluster;
await sleep(220);
await zoomMapWithWheel(ctx, cluster);
ctx.cursor = await clickVisibleHexagon(ctx);
await sleep(360);
await showCaption(
page,
'This is the useful pause: local stats, matching homes, and street context together.'
);
await sleep(1000);
await hideCaption(page);
}
async function zoomMapWithWheel(ctx: SceneCtx, target: { x: number; y: number }): Promise<void> {
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 dashboard.waitForMapSettled(mapVersion, 16000);
await sleep(260);
}
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}` : ''
}`
);
}
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 async function sceneExportAndOutro(ctx: SceneCtx): Promise<void> {
const { page } = ctx;
await showCaption(page, 'When the shortlist feels right, export the exact filtered view.');
await zoomReset(page, 680);
await sleep(520);
const exportButton = page.locator('button[title="Export to Excel"]').first();
await exportButton.waitFor({ state: 'visible', timeout: 4000 });
const box = await exportButton.boundingBox();
if (!box) throw new Error('Export button has no bounding box');
const target = { x: box.x + box.width / 2, y: box.y + box.height / 2 };
await smoothMove(page, ctx.cursor, target, { durationMs: 620 });
ctx.cursor = target;
await sleep(160);
const download = page.waitForEvent('download', { timeout: 4000 }).catch(() => null);
await page.mouse.click(target.x, target.y);
await flashRect(page, box);
await sleep(680);
await hideCaption(page);
await showOutro(ctx.page, BRAND_NAME, BRAND_TAGLINE, BRAND_URL);
void download;
await sleep(2200);
}
/** Open the AI prompt before the timed scene starts. */
export async function prepareAiBox(ctx: SceneCtx): Promise<void> {
const { page } = ctx;
const aiRoot = page.locator('[data-tutorial="ai-filters"]').first();
await aiRoot.waitFor({ state: 'visible', timeout: 15000 });
const textarea = page.locator('[data-tutorial="ai-filters"] textarea');
if (!(await textarea.isVisible().catch(() => false))) {
const aiButton = aiRoot.locator('button').first();
await aiButton.waitFor({ state: 'visible', timeout: 8000 });
const btnBox = await aiButton.boundingBox();
if (btnBox) await page.mouse.click(btnBox.x + btnBox.width / 2, btnBox.y + btnBox.height / 2);
}
if (!(await textarea.isVisible().catch(() => false))) {
await page.evaluate(() => {
document.querySelector<HTMLElement>('[data-tutorial="ai-filters"] button')?.click();
});
}
await textarea.waitFor({ state: 'visible', timeout: 15000 });
await sleep(100);
}
async function zoomToAiBox(page: Page, durationMs: number): Promise<void> {
const aiCard = page.locator('[data-tutorial="ai-filters"]');
const cardBox = await aiCard.boundingBox();
if (!cardBox) throw new Error('AI card has no bounding box');
const focusX = cardBox.x + cardBox.width / 2;
const focusY = cardBox.y + cardBox.height / 2;
await zoomTo(page, { scale: AI_ZOOM_SCALE, focusX, focusY, durationMs });
}