wip
This commit is contained in:
parent
f1601257c7
commit
c2070693fb
68 changed files with 2305 additions and 212 deletions
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue