This commit is contained in:
Andras Schmelczer 2026-06-28 11:59:44 +01:00
parent f1601257c7
commit c2070693fb
68 changed files with 2305 additions and 212 deletions

View file

@ -29,7 +29,7 @@ type FormFactor = 'desktop' | 'mobile';
* whenever the map is the story.
* 4. Filter names MUST match live /api/features exactly (e.g.
* "Serious crime (/yr, 7y)", "Distance to nearest amenity (Waitrose)
* (km)") wrong names silently no-op and the map never changes.
* (km)"). Wrong names silently no-op and the map never changes.
* preflight.ts validates every stubbed name against the live API.
*
* `name` doubles as the on-disk slug. The pipeline writes per-storyboard
@ -47,14 +47,14 @@ type FormFactor = 'desktop' | 'mobile';
// to modelled catchment counts ("Good+ primary school catchments"), which the
// local stack already serves; prod still serves the older "…within 2km" names
// until that deploy lands. Preflight fails loudly if these drift from
// whichever API render.sh is pointed at — flip them back if you render against
// whichever API render.sh is pointed at. Flip them back if you render against
// prod before the catchment model deploys there.
const SCHOOL_GOOD_PRIMARY = 'Good+ primary school catchments';
const SCHOOL_OUTSTANDING_PRIMARY = 'Outstanding primary school catchments';
// Cold-open lean-in on the AI card. Desktop only; kept moderate so the
// map remains visible on the right (zoomTo clamps the pan so the app
// always covers the full frame no backdrop voids).
// always covers the full frame: no backdrop voids).
const AI_ZOOM_SCALE_DESKTOP = 1.45;
const TT_CARD_SELECTOR = '[data-filter-name="tt_0"]';
@ -62,7 +62,7 @@ const TT_SLIDER_MAX = 120;
const TT_DRAG_FROM_MIN = 35;
// 25 (not 20): tight enough that the drag visibly prunes the map, loose
// enough that street-level central London keeps plenty of matching
// postcodes — at 20 the brief emptied the centre and the postcode tap had
// postcodes. At 20 the brief emptied the centre and the postcode tap had
// nothing fresh to land on (the drawer then opened in its "filtered stats
// are empty" fallback).
const TT_DRAG_TO_MIN = 25;
@ -356,8 +356,8 @@ function createCues(locale: RecordingLocale, formFactor: FormFactor): Storyboard
{ kind: 'cursorScale', scale: isMobile ? 1 : 1.4, durationMs: 200 },
{
// Zoom AROUND the strongest visible match (hex()) so the
// destination area is guaranteed to contain matching postcodes
// a fixed pixel target can dive into a part of town the filters
// destination area is guaranteed to contain matching postcodes.
// A fixed pixel target can dive into a part of town the filters
// just emptied. Settled so the next cue's hex() click projects
// against the postcode response for the FINAL viewport.
kind: 'mapZoom',
@ -424,7 +424,7 @@ function createCues(locale: RecordingLocale, formFactor: FormFactor): Storyboard
// The drawer's close-button aria-label is localized
// (mobileDrawer.closeDrawer), so use the per-locale string.
// clickIfVisible keeps a label mismatch from crashing the
// take — worst case the drawer lingers behind the zoom-out.
// take. Worst case, the drawer lingers behind the zoom-out.
kind: 'clickIfVisible',
target: el(`button[aria-label="${copy.closeDrawerLabel}"]`),
durationMs: 650,
@ -442,7 +442,7 @@ function createCues(locale: RecordingLocale, formFactor: FormFactor): Storyboard
{ kind: 'zoomReset', durationMs: 800 },
{
// IfVisible: the export button only renders on ≥1024px-wide
// dashboards with a licensed user — a missing button must not
// dashboards with a licensed user. A missing button must not
// crash the take, just skip the ripple.
kind: 'clickIfVisible',
target: el(`button[title="${copy.exportButtonTitle}"]`),
@ -510,13 +510,13 @@ function buildVideoConfig(formFactor: FormFactor): VideoConfig {
outputFps: 50,
minDurationS: 10,
maxDurationS: 75,
// Street-level zoom with the sheet collapsed the map is the frame.
// Street-level zoom with the sheet collapsed: the map is the frame.
posterTimeS: 25,
};
}
return {
aspect: '16x9',
// Native 1920x1080. NOTE: don't be tempted by a narrower CSS viewport
// Native 1920x1080. NOTE: don't be tempted by a narrower CSS viewport:
// the dashboard header switches to tablet sidebar nav between 768 and
// 1023px and the Export button (cue 7) disappears.
captureScale: 1,
@ -528,7 +528,7 @@ function buildVideoConfig(formFactor: FormFactor): VideoConfig {
// Right-pane inspection: London map, filters applied, data pane
// FULLY populated (Street View + price history). 31s caught the pane
// mid-load on prod (empty white + spinner); 35s lands a few seconds into
// the "open" cue once the evidence has rendered the strongest preview.
// the "open" cue once the evidence has rendered: the strongest preview.
posterTimeS: 35,
};
}
@ -545,7 +545,7 @@ function createRecordingStoryboard(
// Both form factors open at zoom 12 so the cold-open frame lands on a
// densely-populated inner-London slice rather than the low-density outer
// edges. Desktop was 11.5, but the opening lean-in panned the visible map
// toward the sparse NW suburbs (Wembley/Ealing) a soft first frame for
// toward the sparse NW suburbs (Wembley/Ealing), a soft first frame for
// the hero. 12 keeps central London (rich default-density colouring) in
// shot from t=0.
const initialZoom = 12;
@ -586,10 +586,10 @@ function createRecordingStoryboard(
// £600k (not £315k like the old Manchester cut): central London is
// pricier, and at £315k the price+crime combo emptied the inner
// boroughs. At £600k ~51k postcodes pass in-frame, dropping to ~10k
// once the commute is dragged to 25 min a visible prune that still
// once the commute is dragged to 25 min, a visible prune that still
// leaves the zoom something to land on (verified via /api/filter-counts).
'Estimated current price': [0, 600000],
// Loose enough to keep the central-London map richly populated — a
// Loose enough to keep the central-London map richly populated. A
// cap of 20 emptied the city centre and left the zoom with nothing
// to land on.
'Serious crime (/yr, 7y)': [0, 40],
@ -646,7 +646,7 @@ const DEMO_STORYBOARDS: Storyboard[] = RECORDING_LOCALES.flatMap((locale) =>
// the story, and a fingertip-style cursor sells the gestures as touch.
// ---------------------------------------------------------------------------
type CityKey = 'manchester' | 'birmingham' | 'bristol' | 'london' | 'leeds';
type CityKey = 'manchester' | 'birmingham' | 'bristol' | 'london' | 'leeds' | 'liverpool';
interface DemoAdCueConfig {
text: string;
@ -675,9 +675,11 @@ interface DemoAdStoryboardConfig {
travelTimeFilters?: TravelTimeFilter[];
posterTimeS?: number;
initialZoom?: number;
/** Override the city centre (e.g. to frame a specific cheaper-twin pair). */
center?: { lat: number; lon: number };
/** Plausible per-city "{{count}} properties match" total for the AI summary. */
matchCount?: number;
/** Activities to run before the cue loop starts (silent keep short). */
/** Activities to run before the cue loop starts (silent, keep short). */
prePrime?: Activity[];
/** Spoken line during the outro card. Must NOT repeat the card's text. */
outroLine: string;
@ -692,7 +694,7 @@ const AD_VIDEO: VideoConfig = {
webmBitrate: '4M',
outputFps: 50,
minDurationS: 8,
// Generous upper bound ads with a deep mapZoom drift a second or two
// Generous upper bound: ads with a deep mapZoom drift a second or two
// past their declared budgets while tiles load.
maxDurationS: 35,
posterTimeS: 5,
@ -734,6 +736,7 @@ const CITY_VIEWS: Record<CityKey, { lat: number; lon: number; zoom: number }> =
bristol: { lat: 51.3245, lon: -2.5879, zoom: 11.3 },
london: { lat: 51.4272, lon: -0.1276, zoom: 10.4 },
leeds: { lat: 53.7308, lon: -1.5491, zoom: 11.0 },
liverpool: { lat: 53.4084, lon: -2.9916, zoom: 11.2 },
};
// -- small helpers used by the per-ad cue lists -------------------------------
@ -782,7 +785,7 @@ const mapZoomIn = (durationMs = 1500, steps = 5): Activity => ({
});
/**
* Tap the centre of the highest-priority visible postcode polygon from the
* latest map response (robust to zoom drift a fixed pixel target at deep
* latest map response (robust to zoom drift: a fixed pixel target at deep
* zoom can land on a road or river and the drawer never opens).
*/
const tapHex = (durationMs = 1000): Activity => ({
@ -814,9 +817,12 @@ function createDemoAdStoryboard(ad: DemoAdStoryboardConfig): Storyboard {
ad.promptText ??
'Flat under £350k, good commute, good schools, lower crime, quieter streets';
const cityView = CITY_VIEWS[ad.city];
const initialMapView = {
...CITY_VIEWS[ad.city],
zoom: ad.initialZoom ?? CITY_VIEWS[ad.city].zoom,
...cityView,
lat: ad.center?.lat ?? cityView.lat,
lon: ad.center?.lon ?? cityView.lon,
zoom: ad.initialZoom ?? cityView.zoom,
};
return {
@ -877,7 +883,7 @@ function createDemoAdStoryboard(ad: DemoAdStoryboardConfig): Storyboard {
const AD_CONFIGS: DemoAdStoryboardConfig[] = [
// -------------------------------------------------------------------
// 01 The hero feature: the whole brief in one sentence.
// 01. The hero feature: the whole brief in one sentence.
// Cold open ON the typing; map reveal as the payoff.
// -------------------------------------------------------------------
{
@ -922,7 +928,7 @@ const AD_CONFIGS: DemoAdStoryboardConfig[] = [
},
// -------------------------------------------------------------------
// 02 The commute slider. Cold open on a travel-time-coloured map
// 02. The commute slider. Cold open on a travel-time-coloured map
// (filter + colour applied in pre), then the 60→20 drag is the story.
// -------------------------------------------------------------------
{
@ -973,8 +979,8 @@ const AD_CONFIGS: DemoAdStoryboardConfig[] = [
},
// -------------------------------------------------------------------
// 03 Tap a postcode, read its file. The drawer (sold prices, Street
// View, schools, crime) is the wow — most viewers don't know this exists.
// 03. Tap a postcode, read its file. The drawer (sold prices, Street
// View, schools, crime) is the wow. Most viewers don't know this exists.
// -------------------------------------------------------------------
{
name: 'ad-03-postcode-files',
@ -1029,7 +1035,7 @@ const AD_CONFIGS: DemoAdStoryboardConfig[] = [
},
// -------------------------------------------------------------------
// 04 Noise. The strongest single line in the set; the product proves it.
// 04. Noise. The strongest single line in the set; the product proves it.
// -------------------------------------------------------------------
{
name: 'ad-04-quiet-streets',
@ -1066,7 +1072,7 @@ const AD_CONFIGS: DemoAdStoryboardConfig[] = [
},
// -------------------------------------------------------------------
// 05 Families / the school run. Leeds for non-London variety.
// 05. Families / the school run. Leeds for non-London variety.
// -------------------------------------------------------------------
{
name: 'ad-05-school-run',
@ -1106,7 +1112,7 @@ const AD_CONFIGS: DemoAdStoryboardConfig[] = [
},
// -------------------------------------------------------------------
// 06 Lifestyle amenities. The Waitrose line is the hook; tube + park
// 06. Lifestyle amenities. The Waitrose line is the hook; tube + park
// make it practical. Names match the live amenity-distance features.
// -------------------------------------------------------------------
{
@ -1145,7 +1151,7 @@ const AD_CONFIGS: DemoAdStoryboardConfig[] = [
},
// -------------------------------------------------------------------
// 07 Renters. Every tool is for buyers; rent is a live feature here.
// 07. Renters. Every tool is for buyers; rent is a live feature here.
// -------------------------------------------------------------------
{
name: 'ad-07-renters-map',
@ -1185,7 +1191,7 @@ const AD_CONFIGS: DemoAdStoryboardConfig[] = [
},
// -------------------------------------------------------------------
// 08 Value. The cost of overpaying for a name vs the one-off fee.
// 08. Value. The cost of overpaying for a name vs the one-off fee.
// -------------------------------------------------------------------
{
name: 'ad-08-value',
@ -1228,6 +1234,187 @@ const AD_CONFIGS: DemoAdStoryboardConfig[] = [
const AD_STORYBOARDS = AD_CONFIGS.map(createDemoAdStoryboard);
// ---------------------------------------------------------------------------
// Cheaper-twin spots: pair-centred value reveals generated from the
// analysis/ twin index. Filter names verified against live /api/features.
// Render with: VIDEO_STORYBOARD_SET=twins ./render.sh --prod
// ---------------------------------------------------------------------------
const TWIN_AD_CONFIGS: DemoAdStoryboardConfig[] = [
{
name: 'twin-beckenham-croydon',
matchCount: 1840,
city: 'london',
center: { lat: 51.38969, lon: -0.04244 },
initialZoom: 12.2,
promptText: 'Good schools, best value per square metre near Beckenham and Croydon',
filters: {
'Est. price per sqm': [0, 5200],
'Good+ secondary school catchments': [1, 11],
},
posterTimeS: 7,
outroLine: "Beckenham's cheaper twin is on this map. Find yours, free.",
cues: [
{
text: 'Beckenham and Croydon sit side by side: same trains, same school catchment.',
caption: 'Same station. Same schools.',
during: [
typeAct('Good schools, best value per square metre near Beckenham and Croydon', 2600),
],
tail: [wait(150)],
},
{
text: 'Rank them by what each pound of floor space actually buys.',
caption: 'Ranked by £ per m²',
during: [submitSettled(1400)],
tail: [sheetDown(800), wait(250)],
},
{
text: 'One postcode over, the same home quietly costs about a third less.',
caption: 'The cheaper twin',
during: [mapZoomIn(1400, 3)],
tail: [wait(400)],
},
],
},
{
name: 'twin-ha7-2-vs-ha3-0',
matchCount: 1620,
city: 'london',
center: { lat: 51.59199, lon: -0.3079 },
initialZoom: 12.0,
promptText: 'Good schools, best value per square metre near Stanmore and Kenton',
filters: {
'Est. price per sqm': [0, 5900],
'Good+ secondary school catchments': [1, 11],
},
posterTimeS: 7,
outroLine: "Stanmore's cheaper twin is on this map. Find yours, free.",
cues: [
{
text: 'Stanmore and Kenton sit right next door, with the same schools and transport links.',
caption: 'Same area. Same schools.',
during: [typeAct('Good schools, best value per square metre near Stanmore and Kenton', 2600)],
tail: [wait(150)],
},
{
text: 'Rank every postcode by what each pound of floor space actually buys.',
caption: 'Ranked by £ per m²',
during: [submitSettled(1400)],
tail: [sheetDown(800), wait(250)],
},
{
text: 'One postcode over, the same home quietly costs about a sixth less.',
caption: 'The cheaper twin',
during: [mapZoomIn(1400, 3)],
tail: [wait(400)],
},
],
},
{
name: 'twin-m40-5-vs-m9-4',
matchCount: 2480,
city: 'manchester',
center: { lat: 53.51293, lon: -2.19574 },
initialZoom: 12.0,
promptText: 'Good schools, best value per square metre near Newton Heath and Harpurhey',
filters: {
'Est. price per sqm': [0, 1700],
'Good+ secondary school catchments': [1, 11],
},
posterTimeS: 7,
outroLine: "Newton Heath's cheaper twin is on this map. Find yours, free.",
cues: [
{
text: 'Newton Heath and Harpurhey sit right next door, with the same schools and transport links.',
caption: 'Same area. Same schools.',
during: [typeAct('Good schools, best value per square metre near Newton Heath and Harpurhey', 2600)],
tail: [wait(150)],
},
{
text: 'Rank every postcode by what each pound of floor space actually buys.',
caption: 'Ranked by £ per m²',
during: [submitSettled(1400)],
tail: [sheetDown(800), wait(250)],
},
{
text: 'One postcode over, the same home quietly costs over a third less.',
caption: 'The cheaper twin',
during: [mapZoomIn(1400, 3)],
tail: [wait(400)],
},
],
},
{
name: 'twin-l16-7-vs-l14-6',
matchCount: 1740,
city: 'liverpool',
center: { lat: 53.40344, lon: -2.88529 },
initialZoom: 12.0,
promptText: 'Good schools, best value per square metre near Childwall and Broadgreen',
filters: {
'Est. price per sqm': [0, 3000],
'Good+ secondary school catchments': [1, 11],
},
posterTimeS: 7,
outroLine: "Childwall's cheaper twin is on this map. Find yours, free.",
cues: [
{
text: 'Childwall and Broadgreen sit right next door, with the same schools and transport links.',
caption: 'Same area. Same schools.',
during: [typeAct('Good schools, best value per square metre near Childwall and Broadgreen', 2600)],
tail: [wait(150)],
},
{
text: 'Rank every postcode by what each pound of floor space actually buys.',
caption: 'Ranked by £ per m²',
during: [submitSettled(1400)],
tail: [sheetDown(800), wait(250)],
},
{
text: 'One postcode over, the same home quietly costs about a third less.',
caption: 'The cheaper twin',
during: [mapZoomIn(1400, 3)],
tail: [wait(400)],
},
],
},
{
name: 'twin-rm14-2-vs-rm12-5',
matchCount: 1980,
city: 'london',
center: { lat: 51.54892, lon: 0.22193 },
initialZoom: 12.0,
promptText: 'Good schools, best value per square metre near Upminster and Hornchurch',
filters: {
'Est. price per sqm': [0, 5300],
'Good+ secondary school catchments': [1, 11],
},
posterTimeS: 7,
outroLine: "Upminster's cheaper twin is on this map. Find yours, free.",
cues: [
{
text: 'Upminster and Hornchurch sit right next door, with the same schools and transport links.',
caption: 'Same area. Same schools.',
during: [typeAct('Good schools, best value per square metre near Upminster and Hornchurch', 2600)],
tail: [wait(150)],
},
{
text: 'Rank every postcode by what each pound of floor space actually buys.',
caption: 'Ranked by £ per m²',
during: [submitSettled(1400)],
tail: [sheetDown(800), wait(250)],
},
{
text: 'One postcode over, the same home quietly costs about a fifth less.',
caption: 'The cheaper twin',
during: [mapZoomIn(1400, 3)],
tail: [wait(400)],
},
],
},
];
const TWIN_AD_STORYBOARDS = TWIN_AD_CONFIGS.map(createDemoAdStoryboard);
const STORYBOARD_SET = process.env.VIDEO_STORYBOARD_SET ?? 'homepage-en';
export const storyboards: Storyboard[] = (() => {
@ -1238,6 +1425,8 @@ export const storyboards: Storyboard[] = (() => {
return DEMO_STORYBOARDS;
case 'all':
return [...AD_STORYBOARDS, ...DEMO_STORYBOARDS];
case 'twins':
return TWIN_AD_STORYBOARDS;
case 'ads':
default:
return AD_STORYBOARDS;