Hacky demo changes

This commit is contained in:
Andras Schmelczer 2026-05-06 19:36:04 +01:00
parent 7cba369308
commit ea7afd618c
39 changed files with 2041 additions and 745 deletions

View file

@ -13,13 +13,16 @@ import {
import {
clearVignette,
flashRect,
getDemoMapSettleVersion,
hideCaption,
scrollPaneTo,
showCaption,
showOutro,
visualClick,
waitForDemoMapSettled,
waitForCurrentDemoMapSettled,
waitForDemoSelectionReady,
zoomReset,
zoomTo,
zoomToInstant,
} from './dom.js';
import { fakeType, sleep, smoothMove, smoothDragSliderThumb } from './motion.js';
@ -29,37 +32,36 @@ export interface SceneCtx {
}
/**
* Scene 1: open already zoomed in on the AI prompt card. Caption fades in,
* the user types their request, and the already-preloaded filters are revealed
* behind the zoomed wrapper. Keeping this beat visual avoids slow dev-server
* data refreshes eating the 15-second timeline.
*
* Pre-conditions (set up by record.ts before scene timer starts):
* - The AI box is already expanded (textarea visible, ready to focus).
* - The wrapper is already zoomed at AI_ZOOM_SCALE on the AI box centre.
* - The vignette is up.
* 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 } = ctx;
await clearVignette(page);
await showCaption(page, 'Brief: flats or terraces under £450k near central Manchester.');
await sleep(160);
await sleep(180);
await fakeType(page, '[data-tutorial="ai-filters"] textarea', PROMPT_TEXT, 14);
await sleep(120);
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 = await getDemoMapSettleVersion(page);
await page.evaluate(() => {
document.querySelector<HTMLFormElement>('[data-tutorial="ai-filters"] form')?.requestSubmit();
});
await aiResponse;
await sleep(160);
await waitForDemoMapSettled(page, 15000, mapVersion);
await showCaption(page, 'The filters are already live on the map.');
await sleep(360);
await sleep(560);
await hideCaption(page);
}
@ -72,10 +74,10 @@ 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, 560);
await sleep(420);
await zoomReset(page, 860);
await sleep(980);
await hideCaption(page);
await sleep(80);
await sleep(180);
}
/**
@ -107,6 +109,7 @@ export async function sceneTravelTimeSlider(ctx: SceneCtx): Promise<void> {
// Slider goes 0..120, target = 20 → fraction 0.166...
const toFraction = TT_DRAG_TO_MIN / TT_SLIDER_MAX;
const mapVersion = await getDemoMapSettleVersion(page);
ctx.cursor = await smoothDragSliderThumb(
page,
@ -114,87 +117,90 @@ export async function sceneTravelTimeSlider(ctx: SceneCtx): Promise<void> {
trackSelector,
ctx.cursor,
toFraction,
520
1180
);
await sleep(120);
await sleep(220);
await waitForDemoMapSettled(page, 16000, mapVersion);
await showCaption(page, 'The map redraws around the areas that still work.');
await sleep(440);
await sleep(720);
await hideCaption(page);
await sleep(60);
await sleep(180);
}
/**
* Scene 4: zoom into a cluster of filtered postcodes (using deck.gl's own
* camera, via wheel events), click one, and as the right pane fills, pan
* the framing rightward while scrolling the pane content.
*
* Why two zoom mechanisms across this scene:
* - Pre-click: native deck.gl wheel-zoom. CSS-transforming the wrapper
* changes `canvas.getBoundingClientRect()` (scaled rect) without changing
* `canvas.width`. deck.gl's hit-test uses the rect for screenbuffer
* mapping, returns a partial picked object, and React re-renders mid-paint
* leaving a null layer reference that crashes `MapboxLayer.render`.
* Native wheel-zoom recomputes deck.gl's camera in-place; layers stay coherent.
* - Post-click: CSS transform to pan the framing rightward. By this point
* the postcode is selected and layers are stable, so the transform is safe.
* 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, 'Open one promising area and check the detail before shortlisting.');
await showCaption(page, 'Zoom into the result clusters, then click a promising hexagon.');
// Click point: roughly map centre. After AI flew the camera to Manchester, this
// sits in the densely-filtered city core where hexagons reliably cover any
// pixel. Earlier iterations wheel-zoomed first to "feel cinematic", but
// that crossed the hexagon→postcode layer-swap threshold mid-flight and
// clicks landed in a layer gap (no pane opened).
const cluster = {
x: 360 + (viewport.width - 360) * 0.5,
y: viewport.height * 0.45,
x: 360 + (viewport.width - 360) * 0.35,
y: viewport.height * 0.52,
};
await smoothMove(page, ctx.cursor, cluster, { durationMs: 260 });
await smoothMove(page, ctx.cursor, cluster, { durationMs: 520 });
ctx.cursor = cluster;
await sleep(70);
await sleep(220);
// The right pane was opened at page load via ?pc= — no need to drive a
// real selection through deck.gl's hit-test, which is flaky in headless
// Chromium. The mouse.click here is purely for the visible cursor ripple
// animation; the pane is already populated with real postcode data.
await page.mouse.click(cluster.x, cluster.y);
await sleep(130);
await zoomMapWithWheel(page, cluster);
// NOW zoom in toward the cluster, pan rightward to centre the right pane,
// and scroll the pane content — all in parallel. Layers are stable so the
// CSS transform is safe.
const rightShift = 240;
await Promise.all([
zoomTo(page, {
scale: 1.35,
focusX: cluster.x + rightShift,
focusY: cluster.y,
durationMs: 520,
}),
scrollPaneTo(page, '[data-tutorial="right-pane"]', 480),
]);
await sleep(320);
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 sleep(360);
await showCaption(
page,
'This is the useful pause: local stats, matching homes, and street context together.'
);
await sleep(520);
await sleep(1000);
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<void> {
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 sleep(260);
}
async function openDemoHexagon(page: Page): Promise<void> {
const selected = await page.evaluate(
() =>
(
window as typeof window & {
__demoOpenBestHexagon?: () => string | null;
}
).__demoOpenBestHexagon?.() ?? null
);
if (!selected) throw new Error('Could not open a demo hexagon selection');
}
/** 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, 460);
await sleep(240);
await zoomReset(page, 680);
await sleep(520);
const exportButton = page.locator('button[title="Export to Excel"]').first();
await exportButton.waitFor({ state: 'visible', timeout: 4000 });
@ -202,34 +208,25 @@ export async function sceneExportAndOutro(ctx: SceneCtx): Promise<void> {
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: 360 });
await smoothMove(page, ctx.cursor, target, { durationMs: 620 });
ctx.cursor = target;
await sleep(70);
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(280);
await sleep(680);
await hideCaption(page);
await showOutro(ctx.page, BRAND_NAME, BRAND_TAGLINE, BRAND_URL);
void download;
await sleep(2400);
await sleep(2200);
}
/**
* Helper used by record.ts: after navigation but BEFORE the scene timer
* starts, click the AI-prompt button so its textarea is mounted, then snap
* the wrapper to its zoomed-on-AI starting state.
*
* Splitting this out keeps the scene timer honest: the textarea's mount
* animation and the zoom snap don't eat into the 15s budget.
*/
export async function preZoomToAiBox(ctx: SceneCtx): Promise<void> {
/** Open the AI prompt before the timed scene starts. */
export async function prepareAiBox(ctx: SceneCtx): Promise<void> {
const { page } = ctx;
// Open the AI prompt. The collapsed state shows a single button; clicking
// it expands the form and reveals the textarea.
const aiRoot = page.locator('[data-tutorial="ai-filters"]').first();
await aiRoot.waitFor({ state: 'visible', timeout: 15000 });
@ -247,13 +244,13 @@ export async function preZoomToAiBox(ctx: SceneCtx): Promise<void> {
}
await textarea.waitFor({ state: 'visible', timeout: 15000 });
await sleep(100);
}
// Snap-zoom to the AI card centre. The recording opens already zoomed in
// — there's no awkward "from 1× to 2.4×" intro animation.
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 zoomToInstant(page, { scale: AI_ZOOM_SCALE, focusX, focusY });
await zoomTo(page, { scale: AI_ZOOM_SCALE, focusX, focusY, durationMs });
}