Hacky demo changes
This commit is contained in:
parent
7cba369308
commit
ea7afd618c
39 changed files with 2041 additions and 745 deletions
|
|
@ -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 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.
|
||||
* 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 });
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue