This commit is contained in:
Andras Schmelczer 2026-05-06 22:40:46 +01:00
parent 28323f145e
commit 94f9c0d594
76 changed files with 3238 additions and 1230 deletions

View file

@ -28,6 +28,14 @@ export interface SceneCtx {
cursor: { x: number; y: number };
}
const AI_CLOSEUP_ZOOM_MS = 1400;
const RESULTS_ZOOM_OUT_MS = 1500;
const EXPORT_ZOOM_OUT_MS = 1100;
const PROMPT_TYPING_DELAY_MS = 64;
const MAP_ZOOM_WHEEL_STEPS = 18;
const MAP_ZOOM_WHEEL_DELTA = -120;
const MAP_ZOOM_WHEEL_PAUSE_MS = 70;
/**
* 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.
@ -39,10 +47,15 @@ export async function sceneAiCloseUp(ctx: SceneCtx): Promise<void> {
await showCaption(page, 'Brief: flats or terraces under £450k near central Manchester.');
await sleep(180);
await zoomToAiBox(page, 720);
await sleep(760);
await zoomToAiBox(page, AI_CLOSEUP_ZOOM_MS);
await sleep(AI_CLOSEUP_ZOOM_MS + 160);
await fakeType(page, '[data-tutorial="ai-filters"] textarea', PROMPT_TEXT, 18);
await fakeType(
page,
'[data-tutorial="ai-filters"] textarea',
PROMPT_TEXT,
PROMPT_TYPING_DELAY_MS
);
await sleep(160);
const aiResponse = page
.waitForResponse(
@ -71,17 +84,16 @@ export async function sceneAiCloseUp(ctx: SceneCtx): Promise<void> {
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 zoomReset(page, RESULTS_ZOOM_OUT_MS);
await sleep(RESULTS_ZOOM_OUT_MS + 160);
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.
* 35 to 20 minutes. The slider has step=1 over 0120, so the drag is paced
* with real pointer updates instead of jumping the value directly.
*
* 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
@ -135,17 +147,18 @@ export async function sceneClusterClick(ctx: SceneCtx): Promise<void> {
await showCaption(page, 'Zoom into the result clusters, then click a promising hexagon.');
const cluster = {
const defaultCluster = {
x: 360 + (viewport.width - 360) * 0.35,
y: viewport.height * 0.52,
};
const cluster = await pickMapZoomTarget(ctx, defaultCluster);
await smoothMove(page, ctx.cursor, cluster, { durationMs: 520 });
ctx.cursor = cluster;
await sleep(220);
await zoomMapWithWheel(ctx, cluster);
ctx.cursor = await clickVisibleHexagon(ctx);
ctx.cursor = await clickVisibleHexagon(ctx, cluster);
await sleep(360);
await showCaption(
page,
@ -155,24 +168,43 @@ export async function sceneClusterClick(ctx: SceneCtx): Promise<void> {
await hideCaption(page);
}
async function pickMapZoomTarget(
ctx: SceneCtx,
fallback: { x: number; y: number }
): Promise<{ x: number; y: number }> {
const [target] = await ctx.dashboard.visibleHexagonTargets(1).catch(() => []);
return target ? { x: target.x, y: target.y } : fallback;
}
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);
for (let i = 0; i < MAP_ZOOM_WHEEL_STEPS; i++) {
await page.mouse.wheel(0, MAP_ZOOM_WHEEL_DELTA);
await sleep(MAP_ZOOM_WHEEL_PAUSE_MS);
}
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);
async function clickVisibleHexagon(
ctx: SceneCtx,
fallbackTarget: { x: number; y: number }
): Promise<{ x: number; y: number }> {
const candidates = await ctx.dashboard.visibleHexagonTargets(8).catch((error) => {
console.log(
`[scene] Falling back to direct map click targets: ${
error instanceof Error ? error.message : String(error)
}`
);
return [];
});
const clickTargets = await addFallbackClickTargets(ctx, candidates, fallbackTarget);
const startedAt = ctx.dashboard.getSelectionStatsVersion();
let lastError: Error | null = null;
for (const target of candidates) {
for (const target of clickTargets) {
await moveAndClickHexagon(ctx, target);
try {
await ctx.dashboard.waitForSelectionReady(startedAt, 7000);
@ -192,6 +224,39 @@ async function clickVisibleHexagon(ctx: SceneCtx): Promise<{ x: number; y: numbe
);
}
async function addFallbackClickTargets(
ctx: SceneCtx,
candidates: HexagonClickTarget[],
fallbackTarget: { x: number; y: number }
): Promise<HexagonClickTarget[]> {
const mapBox = await ctx.page.locator('[data-tutorial="map"]').boundingBox();
const fallbacks: HexagonClickTarget[] = [
{
h3: 'direct-target',
x: fallbackTarget.x,
y: fallbackTarget.y,
count: 1,
},
];
if (mapBox) {
fallbacks.push({
h3: 'map-center',
x: mapBox.x + mapBox.width / 2,
y: mapBox.y + mapBox.height / 2,
count: 1,
});
}
const seen = new Set<string>();
return [...candidates, ...fallbacks].filter((target) => {
const key = `${Math.round(target.x / 12)},${Math.round(target.y / 12)}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
}
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 };
@ -204,8 +269,8 @@ 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);
await zoomReset(page, EXPORT_ZOOM_OUT_MS);
await sleep(EXPORT_ZOOM_OUT_MS + 120);
const exportButton = page.locator('button[title="Export to Excel"]').first();
await exportButton.waitFor({ state: 'visible', timeout: 4000 });