261 lines
8.8 KiB
TypeScript
261 lines
8.8 KiB
TypeScript
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 0–120, 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 });
|
||
}
|