Add video
This commit is contained in:
parent
589de0c5ac
commit
7c36cbfdd4
18 changed files with 2292 additions and 333 deletions
|
|
@ -1,14 +1,25 @@
|
|||
import type { Page } from 'playwright';
|
||||
import {
|
||||
AI_ZOOM_SCALE,
|
||||
BRAND_NAME,
|
||||
BRAND_TAGLINE,
|
||||
BRAND_URL,
|
||||
PROMPT_TEXT,
|
||||
DRAG_FILTER_NAME,
|
||||
DRAG_TO_FRACTION,
|
||||
TT_CARD_SELECTOR,
|
||||
TT_DRAG_FROM_MIN,
|
||||
TT_DRAG_TO_MIN,
|
||||
TT_SLIDER_MAX,
|
||||
} from './config.js';
|
||||
import {
|
||||
clearVignette,
|
||||
flashRect,
|
||||
hideCaption,
|
||||
scrollPaneTo,
|
||||
showCaption,
|
||||
showOutro,
|
||||
zoomReset,
|
||||
zoomTo,
|
||||
zoomToInstant,
|
||||
} from './dom.js';
|
||||
import { fakeType, sleep, smoothMove, smoothDragSliderThumb } from './motion.js';
|
||||
|
||||
|
|
@ -17,109 +28,232 @@ export interface SceneCtx {
|
|||
cursor: { x: number; y: number };
|
||||
}
|
||||
|
||||
/** Cold open. Vignette fades; cursor parks at a "natural" rest position. */
|
||||
export async function sceneColdOpen(ctx: SceneCtx): Promise<void> {
|
||||
await clearVignette(ctx.page);
|
||||
await ctx.page.mouse.move(ctx.cursor.x, ctx.cursor.y);
|
||||
await sleep(1100);
|
||||
}
|
||||
|
||||
/**
|
||||
* AI prompt scene: click the collapsed AI box, type the prompt, submit,
|
||||
* watch the (stubbed) response apply.
|
||||
* 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.
|
||||
*/
|
||||
export async function sceneAiPrompt(ctx: SceneCtx): Promise<void> {
|
||||
export async function sceneAiCloseUp(ctx: SceneCtx): Promise<void> {
|
||||
const { page } = ctx;
|
||||
|
||||
await showCaption(page, 'Describe the area you want.');
|
||||
await clearVignette(page);
|
||||
await showCaption(page, 'Brief: flats or terraces under £450k near central Manchester.');
|
||||
await sleep(160);
|
||||
|
||||
const aiButton = page.locator('[data-tutorial="ai-filters"] button').first();
|
||||
const btnBox = await aiButton.boundingBox();
|
||||
if (!btnBox) throw new Error('AI button not found');
|
||||
|
||||
const target = { x: btnBox.x + btnBox.width / 2, y: btnBox.y + btnBox.height / 2 };
|
||||
await smoothMove(page, ctx.cursor, target, { durationMs: 400 });
|
||||
ctx.cursor = target;
|
||||
|
||||
await page.mouse.click(target.x, target.y);
|
||||
|
||||
const textarea = page.locator('[data-tutorial="ai-filters"] textarea');
|
||||
await textarea.waitFor({ state: 'visible', timeout: 3000 });
|
||||
await fakeType(page, '[data-tutorial="ai-filters"] textarea', PROMPT_TEXT, 14);
|
||||
await sleep(120);
|
||||
|
||||
const taBox = await textarea.boundingBox();
|
||||
if (taBox) {
|
||||
const into = { x: taBox.x + 30, y: taBox.y + taBox.height / 2 };
|
||||
await smoothMove(page, ctx.cursor, into, { durationMs: 220 });
|
||||
ctx.cursor = into;
|
||||
}
|
||||
|
||||
// fakeType runs the typing animation inside the browser to avoid CDP
|
||||
// round-trip overhead per keystroke (which can quadruple total typing time).
|
||||
await fakeType(page, '[data-tutorial="ai-filters"] textarea', PROMPT_TEXT, 35);
|
||||
await sleep(180);
|
||||
|
||||
await page.keyboard.press('Enter');
|
||||
await sleep(700);
|
||||
const aiResponse = page
|
||||
.waitForResponse(
|
||||
(response) => response.url().includes('/api/ai-filters') && response.status() === 200,
|
||||
{ timeout: 1800 }
|
||||
)
|
||||
.catch(() => null);
|
||||
await page.evaluate(() => {
|
||||
document.querySelector<HTMLFormElement>('[data-tutorial="ai-filters"] form')?.requestSubmit();
|
||||
});
|
||||
await aiResponse;
|
||||
await showCaption(page, 'The filters are already live on the map.');
|
||||
await sleep(360);
|
||||
await hideCaption(page);
|
||||
await sleep(150);
|
||||
}
|
||||
|
||||
/**
|
||||
* Slider scene: pan to a numeric filter's right thumb and drag it inward.
|
||||
* The whole point: the user sees the map react in real time to a human action,
|
||||
* driving home that AI sets a starting point but you stay in control.
|
||||
* 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 sceneSliderControl(ctx: SceneCtx): Promise<void> {
|
||||
export async function sceneZoomOutResults(ctx: SceneCtx): Promise<void> {
|
||||
const { page } = ctx;
|
||||
await showCaption(page, 'You stay in control.');
|
||||
await showCaption(page, 'Now the view lands on central Manchester with the filters already set.');
|
||||
await zoomReset(page, 560);
|
||||
await sleep(420);
|
||||
await hideCaption(page);
|
||||
await sleep(80);
|
||||
}
|
||||
|
||||
const card = page.locator(`[data-filter-name="${DRAG_FILTER_NAME}"]`);
|
||||
await card.waitFor({ state: 'visible', timeout: 3000 });
|
||||
/**
|
||||
* 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 } = 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(120);
|
||||
await sleep(60);
|
||||
|
||||
const thumbSelector = `[data-filter-name="${DRAG_FILTER_NAME}"] [role="slider"] >> nth=1`;
|
||||
const trackSelector = `[data-filter-name="${DRAG_FILTER_NAME}"] [data-orientation="horizontal"] >> nth=0`;
|
||||
// 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;
|
||||
|
||||
ctx.cursor = await smoothDragSliderThumb(
|
||||
page,
|
||||
thumbSelector,
|
||||
trackSelector,
|
||||
ctx.cursor,
|
||||
DRAG_TO_FRACTION,
|
||||
1100
|
||||
toFraction,
|
||||
520
|
||||
);
|
||||
|
||||
await sleep(550);
|
||||
await sleep(120);
|
||||
await showCaption(page, 'The map redraws around the areas that still work.');
|
||||
await sleep(440);
|
||||
await hideCaption(page);
|
||||
await sleep(150);
|
||||
await sleep(60);
|
||||
}
|
||||
|
||||
/** Property reveal: click a postcode on the map to open the side pane with charts. */
|
||||
export async function scenePropertyReveal(ctx: SceneCtx): Promise<void> {
|
||||
/**
|
||||
* 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 screen→buffer
|
||||
* 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.
|
||||
*/
|
||||
export async function sceneClusterClick(ctx: SceneCtx): Promise<void> {
|
||||
const { page } = ctx;
|
||||
const viewport = page.viewportSize() ?? { width: 1920, height: 1080 };
|
||||
|
||||
const target = {
|
||||
x: 360 + (viewport.width - 360) * 0.55,
|
||||
y: viewport.height * 0.5,
|
||||
await showCaption(page, 'Open one promising area and check the detail before shortlisting.');
|
||||
|
||||
// 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,
|
||||
};
|
||||
|
||||
await smoothMove(page, ctx.cursor, target, { durationMs: 500 });
|
||||
ctx.cursor = target;
|
||||
await smoothMove(page, ctx.cursor, cluster, { durationMs: 260 });
|
||||
ctx.cursor = cluster;
|
||||
await sleep(70);
|
||||
|
||||
await page.mouse.click(target.x, target.y);
|
||||
await sleep(1300);
|
||||
}
|
||||
// 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);
|
||||
|
||||
/** Outro: full-screen logo card with brand + URL. */
|
||||
export async function sceneOutro(ctx: SceneCtx): Promise<void> {
|
||||
await showOutro(
|
||||
ctx.page,
|
||||
'Perfect Postcodes',
|
||||
'Find where you actually want to live.',
|
||||
'perfectpostcodes.com'
|
||||
// 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);
|
||||
await showCaption(
|
||||
page,
|
||||
'This is the useful pause: local stats, matching homes, and street context together.'
|
||||
);
|
||||
await sleep(1800);
|
||||
await sleep(520);
|
||||
await hideCaption(page);
|
||||
}
|
||||
|
||||
/** 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);
|
||||
|
||||
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: 360 });
|
||||
ctx.cursor = target;
|
||||
await sleep(70);
|
||||
|
||||
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 hideCaption(page);
|
||||
await showOutro(ctx.page, BRAND_NAME, BRAND_TAGLINE, BRAND_URL);
|
||||
void download;
|
||||
await sleep(2400);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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> {
|
||||
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 });
|
||||
|
||||
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);
|
||||
|
||||
// Snap-zoom to the AI card centre. The recording opens already zoomed in
|
||||
// — there's no awkward "from 1× to 2.4×" intro animation.
|
||||
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 });
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue