/* open.js — the opening scene. Runs BEFORE the five letter clips. * * Cade's spec, 2026-08-04, second pass (his mockup + answers): * - the screen starts BLACK * - "This is SCOUT" types, SCOUT in the trademark green, no full stop * - then boom: the nav and all four tabs populate at once and start moving * - The Verse | Rankings over Press | Predict, four equal cells, all visible together * - the whole thing is five seconds, no longer * - "The A&R tool that…" is the START OF THE NEXT SCENE, not this one * * The rule this file is written under (SHOWCASE-HANDOFF.md §1): do not recreate SCOUT's UI, * lift it. The sphere is the dashboard's sphere — same equal-area wrap, same FOV, same depth * tiers, same palette. The table is the dashboard's table. The press rows are newsItemRow()'s * markup. Nothing here is a lookalike. * * Every number comes from intro-data.json (build-intro-data.mjs) and the Verse from * verse-full.json / intro-verse.json (build-intro-verse.mjs). Nothing on screen is typed. */ /* ── the dials worth turning ────────────────────────────────────────────────── * Seconds, reading top to bottom as the scene plays. Total must stay <= 5. */ const T = { black: 0.30, // dead black before anything typeChar: 0.055, // per character typed descChar: 0.021, // and in the description box, which is a much longer line delChar: 0.022, // per character deleted — backspacing is quicker than typing, and // at the typing rate a 17-character delete alone ate a full second boomGap: 0.24, // beat between the statement landing and the boom navIn: 0.28, // the chrome arriving cardIn: 0.34, // one card arriving stagger: 0.14, // gap between cards: Verse -> Rankings -> Press -> Predict scroll: 2.70, // rankings / press travel time scrollFrac: 0.58, // and how MUCH of the list they cross in it verseIn: 3.30, // the sphere pulling back out of its opening close-up verseZoom: 3.60, // and how close it starts (Cade, 2026-08-05) line: 1.50, // the Predict line generating end: 5.00, // opening scene hands off here /* Scene 2 — SCAN */ beat: 0.45, // the pause that makes an action read as deliberate zoom: 1.15, // a card growing to fill the screen, and shrinking back fly: 2.60, // travelling round the sphere to prettifun — he starts on the far side zoomFar: 4.20, // how hard the camera punches in on him zoomWeb: 2.45, // and eases back to, so his ties stay on screen dash: 0.85, // his connections drawing outward drag: 1.05, // the chip crossing into Rankings toBand: 1.60, // and the board running down to his bracket while he is still held score: 0.95, // his SCOUT Score counting up once he is on the board board: 2.60, // Rankings scrolling from prettifun down to matt proxy hold: 1.45, // a box that has something to READ stays up longer than a beat /* Scene 4 — OBSERVE */ page: 0.55, // his artist page arriving over the board count: 0.90, // the score and the metrics counting up ml: 3.40, // the listener history generating, left to right settle: 1.90, // and the finished chart holding, so it gets read rather than passed /* Scene 5 — UNDERSTAND */ toBio: 1.40, // the page travelling down from the chart to the report bio: 2.60, // and on down the report to the coverage under it cite: 0.62, // each finding lit alongside the article it was read out of }; // scrollFrac is the slowdown lever Cade asked for. Stretching `scroll` alone would run past // the five seconds, so the scrolls now cross less of the list in about the same time: // Rankings drops from 159 to 99 px/s and Press from 188 to 117 px/s. Press still finishes // on today's coverage; Rankings gets a few brackets deep instead of hitting the bottom. // The dashboard's Verse drifts at dt*0.00002 rad/ms (index.repointed.html, sphereLoop). // Cade asked for "slightly faster than it is in the backend dashboard" — this is 3x. const VERSE_SPIN = 0.00006; /* The statement line, as segments. A segment marked `mark` renders in the trademark green. * Retyping between two lines only deletes back to their shared prefix, so * "…that… Scans the industry." -> "…that… Collects promising artists." keeps the lead-in on * screen and swaps the tail, which is what Cade described. */ const LINES = { open: [['This is ', 0], ['S·C·O·U·T', 1]], scan: [['The A&R tool that… ', 0], ['Scans', 1], [' the industry.', 0]], collect: [['The A&R tool that… ', 0], ['Collects', 1], [' promising artists.', 0]], observe: [['The A&R tool that… ', 0], ['Observes', 1], [' artist trajectory.', 0]], understand: [['The A&R tool that… ', 0], ['Understands', 1], [' the story behind it.', 0]], }; /* The one-line description of the section, in the box in the content area. Cade wrote SCAN, * COLLECT and OBSERVE verbatim (2026-08-05); UNDERSTAND follows their shape and is mine to * confirm. The verb is the green segment, matching the statement line above it. */ const DESC = { scan: [['Scans', 1], [': finds new artists via links to our industry map', 0]], collect: [['Collects', 1], [': ranks artists via personal rating and streaming analytics', 0]], observe: [['Observes', 1], [': tracks every release and effect on streaming numbers', 0]], understand: [['Understands', 1], [': sources trusted press articles', 0]], }; const $ = (s) => document.querySelector(s); /* ── the dashboard's own formatters ───────────────────────────────────────── */ const fmt = (n) => n == null ? '—' : n >= 1e6 ? (n / 1e6).toFixed(1) + 'M' : n >= 1e3 ? Math.round(n / 1e3) + 'K' : String(Math.round(n)); const MON = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']; const fmtDShort = (d) => { if (!d) return ''; const p = d.split('-'); return `${MON[+p[1] - 1]} ${+p[2]}`; }; const fmtMonthYear = (d) => { if (!d) return ''; const p = d.split('-'); return `${MON[+p[1] - 1]} ${p[0]}`; }; // index.repointed.html's fmtD. Date-only strings are parsed at LOCAL midnight on purpose — // bare new Date('YYYY-MM-DD') is UTC midnight, which showed every snapshot a day early in // US timezones. const fmtD = (s) => { if (!s || s.length < 8) return '—'; const d = /^\d{4}-\d{2}-\d{2}$/.test(s) ? new Date(s + 'T00:00:00') : new Date(s); return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: '2-digit' }); }; // and its fmtAxis, which picks decimals from the visible range so ticks stay distinct const fmtAxis = (v, lo, hi) => { if (v == null) return '—'; const span = (hi != null && lo != null) ? Math.abs(hi - lo) : null; if (v >= 1e6) { let d = 2; if (span > 0) { const rel = span / Math.max(1, Math.abs(v)); d = rel < 0.005 ? 4 : rel < 0.02 ? 3 : 2; } return (v / 1e6).toFixed(d) + 'M'; } if (v >= 1e5) return (v / 1e3).toFixed(0) + 'K'; if (v >= 1000) return (v / 1e3).toFixed(span != null && span < 5000 ? 1 : 0) + 'K'; return String(Math.round(v)); }; const esc = (s) => String(s).replace(/[&<>]/g, c => ({ '&': '&', '<': '<', '>': '>' }[c])); /* ── Verse palette + geometry, lifted from index.repointed.html ───────────── */ const POP_COL = { scout: '#e6edf5', map: '#a78bfa', external: '#6f9bd1' }; const ROLE_COL = { producer: '#ff8c00', songwriter: '#f5c518', engineer: '#f472b6', performer: '#22d3ee' }; const logR = (ml) => { const lo = 3, hi = 7.6; let t = (Math.log10(Math.max(ml || 0, 1000)) - lo) / (hi - lo); t = Math.max(0, Math.min(1, t)); return 6 + t * 24; }; const isArtist = (n) => n.kind === 'artist'; const inDB = (n) => n.pop === 'scout' || n.pop === 'map'; const fillOf = (n) => isArtist(n) ? (POP_COL[n.pop] || '#e6edf5') : (ROLE_COL[n.role] || '#7a8699'); // Unknown role strings ('writer', 'Producer', 'mixing engineer') fall through to grey here // exactly as they do in the dashboard. That is the live behaviour, not a gap to patch. const radiusOf = (n) => { if (isArtist(n)) { if (n.ml && n.ml > 0) return logR(n.ml); return 5 + Math.sqrt(n.deg || 1) * 2.0; } const rch = n.deg || 1; return rch <= 1 ? 2.6 : 3 + Math.sqrt(rch) * 1.7; }; let D, O, V = null, SCAN = null, OBS = null; // OBS = who the Collect scroll lands on let mark = () => {}; // phase timing, wired up once the scene starts /* Frame driver. rAF when the browser delivers it, timer when it does not — an embedded or * backgrounded tab suspends rAF, and the sphere must never freeze. Lifted from the * dashboard's nextFrame() for the same reason it exists there. */ function nextFrame(cb) { let done = false; const go = (t) => { if (!done) { done = true; cb(t); } }; requestAnimationFrame(go); setTimeout(() => go(performance.now()), 40); } const wait = (ms) => new Promise(r => setTimeout(r, ms)); /* Run a GSAP tween without letting anything depend on it ticking. * * A paused rAF pauses GSAP, so the timer releases the await AND snaps the target to the * tween's end values. It must also KILL the tween: a tab that comes back to life lets the * stalled tween resume from its old progress and drag the value back toward the start, * which is how a chart that had been forced to "finished" rewound to 2% generated and both * scroll ports crawled back to where they began. Forcing an end state is only safe if * nothing can still animate away from it. */ function tween(target, vars) { const ms = (vars.duration || 0) * 1000; const { duration, ease, onComplete, onUpdate, delay, ...end } = vars; return new Promise(res => { let done = false; let tw = null; const fin = (snap) => { if (done) return; done = true; if (snap) { if (tw) tw.kill(); gsap.set(target, end); if (onUpdate) onUpdate(); } res(); }; tw = gsap.to(target, { ...vars, onComplete: () => fin(false) }); setTimeout(() => fin(true), ms + (delay || 0) * 1000 + 300); }); } /* Same guarantee for a tween nothing awaits: it either finishes on its own or it is killed * and its end state forced. Never left half-run. */ function guard(tw, land, ms) { let settled = false; tw.eventCallback('onComplete', () => { settled = true; land(); }); setTimeout(() => { if (!settled) { settled = true; tw.kill(); land(); } }, ms + 400); return tw; } /* Canvas sizing. Buffer and drawing code MUST agree on the fallback, or the scene renders * into a 1px canvas in any zero-size context. Clamped because a canvas whose CSS size can * depend on its buffer size feeds back until the allocation fails. */ function sizeCanvas(c) { const r = c.getBoundingClientRect(); const dpr = Math.min(devicePixelRatio || 1, 2); const w = Math.min(Math.round(r.width) || 700, 4000); const h = Math.min(Math.round(r.height) || 340, 3000); c.width = Math.max(1, w * dpr); c.height = Math.max(1, h * dpr); c.getContext('2d').setTransform(dpr, 0, 0, dpr, 0, 0); return { w, h }; } /* ══ CELL 1 — THE VERSE ════════════════════════════════════════════════════ * The baked 2D layout wrapped onto a sphere by rank-based equal-area mapping, then * projected. This is index.repointed.html's sphereize() + projectAll() + draw(), adapted * only for the compact field names (pop/ml/deg vs population/size_ml/degree). * * Cade, 2026-08-04: "is it possible to have the verse in all of its entirety? it looks much * more impressive on the backend SCOUT." Yes — verse-full.json is all 16,556 nodes and * 47,145 edges (426KB brotli). See loadVerse() for why it is not what boots the scene. */ const vcv = $('#verse'), vcx = vcv.getContext('2d'); // Opens INSIDE the sphere and pulls back (Cade, 2026-08-05: "the verse started zoomed in and // then zoomed out and rotated as its intro"). Resting zoom is 1; the opening tweens down to // it while the idle drift keeps turning, so the reveal is a camera move rather than a fade. const cam = { yaw: 0, tilt: -0.18, zoom: 1 }; const FOV = 3.4; let pulseT = 0, labelOrder = []; let spinning = true; // the idle drift; switched off while the camera is flying /* Scan-scene overlay state. `on` swaps prettifun out of the normal star pass and draws him * as a node that is NOT in the database yet, with dashed lines to his three real ties. */ const fx = { on: false, hero: null, ties: [], dash: 0, tags: false, name: false, dim: 0, focus: null, ants: 0 }; // marching-ants offset on the tie lines, advanced by the main loop function sphereize(v) { const ns = v.nodes; let x0 = 1e9, y0 = 1e9, x1 = -1e9, y1 = -1e9; for (const n of ns) { if (n.x < x0) x0 = n.x; if (n.x > x1) x1 = n.x; if (n.y < y0) y0 = n.y; if (n.y > y1) y1 = n.y; } const cx = (x0 + x1) / 2, cy = (y0 + y1) / 2; const order = ns.map((n, i) => ({ i, r: Math.hypot(n.x - cx, n.y - cy) })) .sort((a, b) => a.r - b.r); const N = order.length; order.forEach((o, rank) => { const n = ns[o.i]; const phi = Math.acos(1 - 2 * (rank + 0.5) / N); // uniform over sphere area const a = Math.atan2(n.y - cy, n.x - cx); n.vx = Math.sin(phi) * Math.cos(a); n.vy = Math.sin(phi) * Math.sin(a); n.vz = Math.cos(phi); n.pulse = (o.i * 2.399963) % 6.283; n.px = 0; n.py = 0; n.pz = -1; n.pr = 0; }); v.byId = Object.fromEntries(ns.map(n => [n.id, n])); labelOrder = ns.slice().sort((a, b) => (radiusOf(b) + (inDB(b) ? 5 : 0) + Math.min(4, (b.deg || 0) * 0.12)) - (radiusOf(a) + (inDB(a) ? 5 : 0) + Math.min(4, (a.deg || 0) * 0.12))); return v; } function drawVerse() { const { w: W, h: H } = sizeCanvas(vcv); const ctx = vcx; ctx.fillStyle = '#0d1016'; ctx.fillRect(0, 0, W, H); if (!V) return; const cyw = Math.cos(cam.yaw), syw = Math.sin(cam.yaw); const ct = Math.cos(cam.tilt), st = Math.sin(cam.tilt); const R = Math.min(W, H) * 0.42 * cam.zoom, zk = Math.min(cam.zoom, 1.6); for (const n of V.nodes) { const xr = n.vx * cyw + n.vz * syw; const z1 = -n.vx * syw + n.vz * cyw; const y2 = n.vy * ct - z1 * st; const z2 = n.vy * st + z1 * ct; const p = FOV / (FOV - z2); n.px = W / 2 + xr * R * p; n.py = H / 2 + y2 * R * p; n.pz = z2; const pulse = 1 + 0.09 * Math.sin(pulseT * 0.0016 + n.pulse); n.pr = Math.max(1.1, Math.min(6.5, radiusOf(n) * 0.21 * zk * p)) * pulse; } // whisper of a shell so the globe reads as a body, not floating dots ctx.strokeStyle = 'rgba(120,150,200,0.055)'; ctx.lineWidth = 1; ctx.beginPath(); ctx.arc(W / 2, H / 2, R, 0, 6.283); ctx.stroke(); ctx.strokeStyle = 'rgba(120,150,200,0.03)'; ctx.beginPath(); ctx.arc(W / 2, H / 2, R * 0.985, 0, 6.283); ctx.stroke(); // edges in three depth buckets (quantized alpha keeps batching fast) const EB = [[], [], []]; for (const e of V.edges) { // during the Scan beat the hero has NO links except the three the overlay draws if (fx.on && fx.hero && (e.f === fx.hero.id || e.t === fx.hero.id)) continue; const a = V.byId[e.f], b = V.byId[e.t]; if (!a || !b) continue; const za = (a.pz + b.pz) / 2; if (za < -0.5) continue; (za > 0.35 ? EB[2] : za > -0.1 ? EB[1] : EB[0]).push(a.px, a.py, b.px, b.py); } // Ego-focus dim, lifted from the dashboard: when one node is the subject, everything else // recedes. Without it the discovery moment is unreadable — prettifun and his three ties // sit inside a wall of 16,000 other stars and 80 other labels. const dim = fx.on ? fx.dim : 0; const near = (n) => !fx.focus || fx.focus.has(n.id); const EA = [0.035, 0.085, 0.16]; for (let q = 0; q < 3; q++) { const eb = EB[q]; if (!eb.length) continue; ctx.strokeStyle = 'rgba(148,166,200,' + (EA[q] * (1 - 0.92 * dim)).toFixed(4) + ')'; ctx.beginPath(); for (let i = 0; i < eb.length; i += 4) { ctx.moveTo(eb[i], eb[i + 1]); ctx.lineTo(eb[i + 2], eb[i + 3]); } ctx.stroke(); } // stars: 4 depth tiers back→front, batched per colour+tier const TIER_A = [0.10, 0.26, 0.58, 1]; // Two passes of the SAME batch shape: FADE holds the nodes that are not the subject, drawn // in their own colour at their own radius and only turned down in alpha. // // They used to become 1.4px grey squares, which Cade read as the rest of the Verse // vanishing when prettifun was clicked (2026-08-05) — losing both the colour that says what // kind of node it is and the size that says how big the artist is. Translucent keeps the map // underneath the moment. const CORE = [{}, {}, {}, {}], FADE = [{}, {}, {}, {}], RIMS = [], HALO = {}; for (const n of V.nodes) { if (n.pz < -0.55) continue; if (fx.on && n === fx.hero) continue; // drawn by the scan overlay instead const qd = n.pz > 0.45 ? 3 : n.pz > 0 ? 2 : n.pz > -0.3 ? 1 : 0; const col = fillOf(n); if (dim > 0 && !near(n)) { (FADE[qd][col] = FADE[qd][col] || []).push(n.px, n.py, n.pr); continue; } (CORE[qd][col] = CORE[qd][col] || []).push(n.px, n.py, n.pr); if (qd === 3 && n.pr >= 4.5) (HALO[col] = HALO[col] || []).push(n.px, n.py, n.pr); if (qd === 3 && isArtist(n) && inDB(n) && n.pr >= 3) RIMS.push(n.px, n.py, n.pr); } const discs = (byCol) => { for (const ck in byCol) { const cp = byCol[ck]; ctx.fillStyle = ck; ctx.beginPath(); for (let i = 0; i < cp.length; i += 3) { ctx.moveTo(cp[i] + cp[i + 2], cp[i + 1]); ctx.arc(cp[i], cp[i + 1], cp[i + 2], 0, 6.283); } ctx.fill(); } }; for (let q = 0; q < 4; q++) { // 0.14 at full dim: still legibly there, never competing with the subject ctx.globalAlpha = TIER_A[q] * (1 - 0.86 * dim); discs(FADE[q]); ctx.globalAlpha = TIER_A[q]; discs(CORE[q]); ctx.globalAlpha = 1; } ctx.globalAlpha = 0.09; for (const hk in HALO) { const hp = HALO[hk]; ctx.fillStyle = hk; ctx.beginPath(); for (let i = 0; i < hp.length; i += 3) { ctx.moveTo(hp[i] + hp[i + 2] * 1.9, hp[i + 1]); ctx.arc(hp[i], hp[i + 1], hp[i + 2] * 1.9, 0, 6.283); } ctx.fill(); } ctx.globalAlpha = 1; if (RIMS.length) { ctx.strokeStyle = 'rgba(255,255,255,0.22)'; ctx.lineWidth = 1; ctx.beginPath(); for (let i = 0; i < RIMS.length; i += 3) { ctx.moveTo(RIMS[i] + RIMS[i + 2] + 0.7, RIMS[i + 1]); ctx.arc(RIMS[i], RIMS[i + 1], RIMS[i + 2] + 0.7, 0, 6.283); } ctx.stroke(); } /* ── the Scan overlay ──────────────────────────────────────────────────── * prettifun drawn as an artist SCOUT has not taken yet — external blue, dashed outline — * with dashed lines running out to Yeat, slayr and F1LTHY. All three edges are real * `credit` edges in web-graph.json; build-intro-data.mjs fails the build if any is missing. */ if (fx.on && fx.hero) { const h = fx.hero; // ONLY the three named ties. Cade, 2026-08-04: in this scene he is a recommended add, // so he must not read as already connected to everything — drawing all 239 of his real // credits contradicted the premise the scene is built on. ctx.save(); ctx.setLineDash([4, 5]); // marching ants, so the ties read as live traffic between him and the map rather than // three static rules (Cade, 2026-08-05). Negative offset runs the dashes outward, from // prettifun toward the artists he is connected to. ctx.lineDashOffset = -fx.ants; ctx.lineWidth = 1.6; ctx.strokeStyle = `rgba(0,208,132,${(0.95 * fx.dash).toFixed(3)})`; ctx.beginPath(); for (const t of fx.ties) { if (!t || t.pz < -0.25) continue; ctx.moveTo(h.px, h.py); ctx.lineTo(h.px + (t.px - h.px) * fx.dash, h.py + (t.py - h.py) * fx.dash); } ctx.stroke(); ctx.restore(); // HIS name, up before anything is clicked and bigger than the tie labels — he is the // subject of the scene, they are the evidence for him if (fx.name) { ctx.textAlign = 'center'; ctx.font = '600 13px Inter, sans-serif'; const nw = ctx.measureText(h.id).width; const ny = h.py - Math.max(9, h.pr * 2.2) - 16; ctx.fillStyle = 'rgba(11,13,18,0.82)'; ctx.fillRect(h.px - nw / 2 - 7, ny - 13, nw + 14, 19); ctx.fillStyle = '#ffffff'; ctx.fillText(h.id, h.px, ny); } // the tie names, forced on regardless of the label budget if (fx.tags) { ctx.textAlign = 'center'; ctx.font = '10px "JetBrains Mono",monospace'; for (const t of fx.ties) { if (!t || t.pz < -0.25) continue; const tw = ctx.measureText(t.id).width; const ly = t.py - t.pr - 7; ctx.fillStyle = 'rgba(11,13,18,0.72)'; ctx.fillRect(t.px - tw / 2 - 4, ly - 9, tw + 8, 12); ctx.fillStyle = 'rgba(255,255,255,0.9)'; ctx.fillText(t.id, t.px, ly); } } // the node itself: uncharted blue, dashed ring, no white in-DB rim. Drawn larger than // its true radius — at this size a real node is 4px and the subject of the scene needs // to be findable without the viewer hunting for it. const hr = Math.max(9, h.pr * 2.2); ctx.globalAlpha = 0.18; ctx.fillStyle = '#6f9bd1'; ctx.beginPath(); ctx.arc(h.px, h.py, hr * 2.4, 0, 6.283); ctx.fill(); ctx.globalAlpha = 1; ctx.beginPath(); ctx.arc(h.px, h.py, hr, 0, 6.283); ctx.fill(); ctx.save(); ctx.setLineDash([2, 3]); ctx.strokeStyle = 'rgba(111,155,209,0.9)'; ctx.lineWidth = 1.3; ctx.beginPath(); ctx.arc(h.px, h.py, hr + 6, 0, 6.283); ctx.stroke(); ctx.restore(); } // labels: facing hemisphere only, collision-avoided, priority order const rects = []; const free = (a, b, c, d) => !rects.some(r => a < r[2] && c > r[0] && b < r[3] && d > r[1]); // The dashboard shows up to 80 labels across a full-screen canvas. Carrying that count // into a quarter-screen card turns the sphere into a word cloud and buries the stars, so // the cap scales with AREA — same label density as the dashboard, a fifth of the space. let shown = 0; const MAXL = Math.max(10, Math.min(80, Math.round(80 * (W * H) / 1.0e6))); ctx.textAlign = 'center'; ctx.font = '10px "JetBrains Mono",monospace'; for (let i = 0; i < labelOrder.length && shown < MAXL; i++) { const n = labelOrder[i]; if (fx.on && (n === fx.hero || fx.ties.includes(n))) continue; // the overlay names these if (dim > 0.5 && !near(n)) continue; // ego focus: only the subject if (n.pz < 0.3) continue; if (!(inDB(n) || (n.deg || 0) >= 6) || n.pr < 2.2) continue; if (n.px < -90 || n.px > W + 90 || n.py < -24 || n.py > H + 24) continue; const tw = ctx.measureText(n.id).width; const ly = n.py + n.pr + 11; if (!free(n.px - tw / 2 - 3, ly - 9, n.px + tw / 2 + 3, ly + 3)) continue; rects.push([n.px - tw / 2 - 3, ly - 9, n.px + tw / 2 + 3, ly + 3]); ctx.fillStyle = 'rgba(11,13,18,0.6)'; ctx.fillRect(n.px - tw / 2 - 3, ly - 9, tw + 6, 12); ctx.fillStyle = n.pop === 'map' ? 'rgba(202,188,247,0.92)' : isArtist(n) ? 'rgba(255,255,255,0.88)' : 'rgba(255,255,255,0.55)'; ctx.fillText(n.id, n.px, ly); shown++; } if (fx.on && fx.hero) syncFX(); } /* The DOM furniture over the canvas — the ring and the recommended-add box — rides the node's * projected position, so it stays glued to prettifun while the sphere is still moving. * * The box takes the side of him the ties are NOT on. flyTo centres him, so the ties fan out * around the middle of the card; a fixed offset put a 236px panel straight over the three * connections the panel is about. */ function syncFX() { const h = fx.hero; const put = (el, dx, dy) => { el.style.left = (h.px + dx) + 'px'; el.style.top = (h.py + dy) + 'px'; }; put($('#vring'), 0, 0); const right = fx.ties.filter(t => t.px > h.px).length; put($('#vcard'), (right * 2 >= fx.ties.length ? -1 : 1) * 186, -56); } /* Two-tier load. The scene is five seconds and autoplays, so it can never sit waiting on a * 426KB fetch: it boots from the 82KB-gzip subset and upgrades to the whole graph the moment * that lands, keeping cam.yaw so the rotation is continuous through the swap. On any decent * connection the full sphere is already in place before the boom. */ function loadVerse() { const setV = (v, label) => { V = sphereize(v); $('#verseSub').textContent = `${v.nodes.length.toLocaleString()} nodes · ${v.edges.length.toLocaleString()} links`; drawVerse(); return label; }; const full = fetch('verse-full.json').then(r => r.ok ? r.json() : null).catch(() => null); const sub = fetch('intro-verse.json?v=' + Date.now()).then(r => r.json()); // whichever arrives first paints; the full graph always wins in the end full.then(v => { if (v) setV(v, 'full'); }); return sub.then(v => { if (!V) setV(v, 'subset'); return full; }); } /* ══ CELL 2 — RANKINGS ═════════════════════════════════════════════════════ * Cade chose full-size type over the complete column set for the quarter-screen cells * (2026-08-04), so this is the Leaderboard row trimmed to the six columns that carry the * argument. Same th/td rules, same band pills and colours, same sticky header, top 3 of * every bracket. */ /* The board is rendered as it looked BEFORE prettifun was taken, because the Collect scene * adds him to it (Cade, 2026-08-05). So his row is held back, everyone below him in BREAKING * shows the rank they had without him, and the bracket count is one lower. Every row carries * both numbers, and dropHero() flips them over — nothing is re-rendered, so the rows the * visitor was just reading do not blink. * * Bracket headers carry their LISTENER RANGE beside the count, which is the answer to why * prettifun is being carried to BREAKING rather than anywhere else. */ let HERO_BAND = null, HERO_RANK = 0; function buildRankings() { const hero = D.scan.hero.name; const hrow = O.rankings.find(r => !r.header && r.name === hero); HERO_BAND = hrow ? hrow.band : null; HERO_RANK = hrow ? hrow.rank : 0; const TH = ['#', 'Artist', 'Status', 'Current ML', 'Growth', 'SCOUT Score']; let out = '' + TH.map(h => `${h}`).join('') + ''; for (const r of O.rankings) { if (r.header) { const mine = r.band === HERO_BAND; const nWith = r.count, nWithout = mine ? r.count - 1 : r.count; out += `` + ``; continue; } const isHero = r.name === hero; // ranks below him in his own band held one place higher until he lands const shift = !isHero && r.band === HERO_BAND && r.rank > HERO_RANK; const shown = shift ? r.rank - 1 : r.rank; const gC = (r.growth ?? 0) >= 0 ? 'g' : 'r'; const sc = r.score >= 75 ? 'var(--green)' : r.score >= 65 ? 'var(--orange)' : 'var(--t2)'; out += `` + `` + `` + `` + `` + `` // the hero's score arrives empty and counts up once he is on the board + `` + ''; } $('#rankTrack').innerHTML = out + '
` + `${r.band}` + `` + `${r.range} · ${nWithout} artists
${String(shown).padStart(2, '0')}${esc(r.name)}${r.band}${fmt(r.ml)}${(r.growth ?? 0) >= 0 ? '+' : ''}${(r.growth ?? 0).toFixed(1)}%` + `${isHero ? '—' : r.score.toFixed(1)}
'; $('#rankSub').textContent = `${O.counts.scored - 1} scored · top 3 of every bracket`; } /* He lands: his row appears, his band takes its real ranks and count back. */ function dropHero() { const tr = rankRow(D.scan.hero.name); if (tr) { tr.classList.remove('pending'); tr.classList.add('landed'); } document.querySelectorAll('#rankTrack tr[data-rank-full]').forEach(row => { row.firstElementChild.textContent = row.dataset.rankFull; }); const bn = document.querySelector(`#rankTrack tr[data-band="${HERO_BAND}"] .band-n`); if (bn) bn.innerHTML = bn.dataset.full; $('#rankSub').textContent = `${O.counts.scored} scored · top 3 of every bracket`; return tr; } /* The card holds the whole 351-artist board but only shows the top 3 of each bracket while * it is small. Expanding it reveals the rest. */ function revealFullBoard(on) { const n = O.counts.scored - (rankRow(D.scan.hero.name)?.classList.contains('pending') ? 1 : 0); $('#rankPort').classList.toggle('full', on); $('#rankSub').textContent = on ? `${n} scored · every graded artist, ranked in band` : `${n} scored · top 3 of every bracket`; } /* ══ CELL 3 — PRESS ════════════════════════════════════════════════════════ * newsItemRow()'s markup, verbatim. Newest first, exactly like the News tab, so travelling * UP the list travels toward today. */ function buildPress() { $('#pressTrack').innerHTML = O.press.map(p => '
' + `
${esc(p.title)}
` + '
' + `${esc(p.artist)} · ${esc(p.source)} · ${fmtDShort(p.date)}
` + '
').join(''); $('#pressSub').textContent = `${O.press.length} articles · ${O.counts.pressArtists} artists`; } /* ══ CELL 4 — PREDICT ══════════════════════════════════════════════════════ * The Predict tab's left chart: what actually happened over the most recent Sunday period, * ascending, so fallers sit left and gainers right (Cade 2026-07-17 — every Movers graph in * SCOUT runs negative-left to positive-right). * * Drawn on canvas rather than through Chart.js so the line can generate left to right. The * visual constants are Chart.js's own, copied from renderPredictCharts(): borderWidth 2, * tension .35, per-segment green/red, fill to origin at .14, pointRadius 4, zero gridline at * .25 and the rest at .05, #5a6b85 9px ticks. */ const mcv = $('#movers'), mcx = mcv.getContext('2d'); let lineT = 0; // 0..1, how much of the line has generated // Chart.js's spline control points (core.helpers splineCurve, monotone off). function controlPoints(pts, tension) { const cp = []; for (let i = 0; i < pts.length; i++) { const prev = pts[i - 1] || pts[i], next = pts[i + 1] || pts[i]; const d01 = Math.hypot(pts[i][0] - prev[0], pts[i][1] - prev[1]); const d12 = Math.hypot(next[0] - pts[i][0], next[1] - pts[i][1]); let s01 = d01 / (d01 + d12), s12 = d12 / (d01 + d12); s01 = isNaN(s01) ? 0 : s01; s12 = isNaN(s12) ? 0 : s12; const fa = tension * s01, fb = tension * s12; cp.push([[pts[i][0] - fa * (next[0] - prev[0]), pts[i][1] - fa * (next[1] - prev[1])], [pts[i][0] + fb * (next[0] - prev[0]), pts[i][1] + fb * (next[1] - prev[1])]]); } return cp; } function tracePath(ctx, pts, cp) { ctx.moveTo(pts[0][0], pts[0][1]); for (let i = 1; i < pts.length; i++) { ctx.bezierCurveTo(cp[i - 1][1][0], cp[i - 1][1][1], cp[i][0][0], cp[i][0][1], pts[i][0], pts[i][1]); } } // Chart.js's "nice" linear ticks, so the axis lands on the numbers the dashboard shows. function niceScale(lo, hi, count) { const span = hi - lo || 1; const raw = span / count; const mag = Math.pow(10, Math.floor(Math.log10(raw))); const norm = raw / mag; const step = (norm >= 7.5 ? 10 : norm >= 3 ? 5 : norm >= 1.5 ? 2 : 1) * mag; return { min: Math.floor(lo / step) * step, max: Math.ceil(hi / step) * step, step }; } function drawMovers() { const { w: W, h: H } = sizeCanvas(mcv); const ctx = mcx; ctx.clearRect(0, 0, W, H); const rows = O && O.movers; if (!rows || rows.length < 2) return; // Axis padding is MEASURED, the way Chart.js sizes its scales. Fixed padding clipped // "SUPERNOVA BOY" off the left edge and buried the longest names below the frame — the // labels are rotated, so each one reaches cos(θ) left and sin(θ) down from its point. const ROT = Math.PI / 5; // ~36°, inside the dashboard's 30–45° ctx.font = '9px Inter, sans-serif'; const widest = Math.max(...rows.map(r => ctx.measureText(r.name).width)); const padL = Math.max(34, Math.round(widest * Math.cos(ROT)) + 10); const padR = 18, padT = 10; const padB = Math.round(widest * Math.sin(ROT)) + 20; const lo = Math.min(...rows.map(r => r.act)), hi = Math.max(...rows.map(r => r.act)); const sc = niceScale(lo, hi, 5); const X = (i) => padL + (i / (rows.length - 1)) * (W - padL - padR); const Y = (v) => H - padB - ((v - sc.min) / (sc.max - sc.min)) * (H - padT - padB); const y0 = Y(0); ctx.textAlign = 'right'; ctx.lineWidth = 1; for (let v = sc.min; v <= sc.max + 1e-9; v += sc.step) { const gy = Y(v); ctx.strokeStyle = Math.abs(v) < 1e-9 ? 'rgba(255,255,255,.25)' : 'rgba(255,255,255,.05)'; ctx.beginPath(); ctx.moveTo(padL, gy); ctx.lineTo(W - padR, gy); ctx.stroke(); ctx.fillStyle = '#5a6b85'; ctx.fillText(Math.round(v) + '%', padL - 6, gy + 3); } const pts = rows.map((r, i) => [X(i), Y(r.act)]); const cp = controlPoints(pts, 0.35); // everything below is clipped to the generated width — the line, its fill and its points // appear together as the sweep passes them, which is what "generating" means const cut = padL + lineT * (W - padL - padR) + 1; ctx.save(); ctx.beginPath(); ctx.rect(0, 0, cut, H); ctx.clip(); for (const side of ['above', 'below']) { ctx.save(); ctx.beginPath(); ctx.rect(padL, side === 'above' ? padT : y0, W - padL - padR, side === 'above' ? y0 - padT : H - padB - y0); ctx.clip(); ctx.beginPath(); tracePath(ctx, pts, cp); ctx.lineTo(pts[pts.length - 1][0], y0); ctx.lineTo(pts[0][0], y0); ctx.closePath(); ctx.fillStyle = side === 'above' ? 'rgba(0,208,132,.14)' : 'rgba(255,92,92,.14)'; ctx.fill(); ctx.restore(); } ctx.lineWidth = 2; for (let i = 1; i < pts.length; i++) { ctx.strokeStyle = (rows[i - 1].act + rows[i].act) / 2 >= 0 ? '#00d084' : '#ff5c5c'; ctx.beginPath(); ctx.moveTo(pts[i - 1][0], pts[i - 1][1]); ctx.bezierCurveTo(cp[i - 1][1][0], cp[i - 1][1][1], cp[i][0][0], cp[i][0][1], pts[i][0], pts[i][1]); ctx.stroke(); } ctx.lineWidth = 1; for (let i = 0; i < pts.length; i++) { ctx.fillStyle = rows[i].act >= 0 ? '#00d084' : '#ff5c5c'; ctx.beginPath(); ctx.arc(pts[i][0], pts[i][1], 3.4, 0, 6.283); ctx.fill(); } ctx.restore(); // x labels, revealed with the sweep ctx.textAlign = 'right'; ctx.font = '9px Inter, sans-serif'; for (let i = 0; i < rows.length; i++) { if (pts[i][0] > cut) continue; ctx.save(); ctx.translate(pts[i][0], H - padB + 11); ctx.rotate(-ROT); ctx.fillStyle = '#5a6b85'; ctx.fillText(rows[i].name, 0, 0); ctx.restore(); } } /* ══ OBSERVE — the artist detail panel + Listener History ══════════════════ * renderMLHistory() from index.repointed.html, drawn on canvas instead of through Chart.js * for the same reason the movers chart is: the line has to generate left to right, and the * release markers have to land as the sweep reaches them. * * Everything visual is Chart.js's own, copied from that function: tension .25, borderWidth 2, * the rgba(0,208,132,.28)->0 fill, gold founding point / green current point / faint green * middle, REL_COLORS by release type, the .4-alpha dashed marker line and its 3px triangle * hanging 5px under the plot, #5a6b85 9px ticks, y grid at .05. */ const mlcv = $('#mlchart'), mlcx = mlcv.getContext('2d'); const REL_COLORS = { single: '#ff8c42', ep: '#f472b6', album: '#38bdf8', feature: '#a3e635' }; const relColor = (t) => REL_COLORS[t] || REL_COLORS.single; let mlT = 0; // 0..1, how much of the history has generated // where the last draw put things, so the two callouts can be pinned to real pixels const mlGeom = { ok: false, x: [], y: [], xForDate: null, top: 0, bottom: 0 }; function mlSeries() { const o = D.observe; const hist = o.snapshots; const d0 = hist[0].date, d1 = hist[hist.length - 1].date; return { hist, rels: o.releases.filter(r => r.date >= d0 && r.date <= d1) }; } function drawML() { const { w: W, h: H } = sizeCanvas(mlcv); const ctx = mlcx; ctx.clearRect(0, 0, W, H); if (!D || !D.observe) return; const { hist, rels } = mlSeries(); const dates = hist.map(s => s.date), vals = hist.map(s => s.ml); const last = dates.length - 1; const sc = niceScale(Math.min(...vals), Math.max(...vals), 5); ctx.font = '9px Inter, sans-serif'; let widestY = 0; for (let v = sc.min; v <= sc.max + 1e-9; v += sc.step) widestY = Math.max(widestY, ctx.measureText(fmtAxis(v, sc.min, sc.max)).width); // Both side paddings are MEASURED, the way Chart.js sizes its scales. The x labels are // CENTRED on their points, so the first and last each reach half their own width past the // end of the plot — an 18px right pad clipped "Aug 2, 26" to "Aug 2, 2". const halfX = Math.max(ctx.measureText(fmtD(dates[0])).width, ctx.measureText(fmtD(dates[last])).width) / 2; const padL = Math.max(Math.round(widestY) + 12, Math.round(halfX) + 4); const padR = Math.max(18, Math.round(halfX) + 4), padT = 12; const padB = 30; // x labels, plus the 5px the marker triangles hang const X = (i) => padL + (i / last) * (W - padL - padR); const Y = (v) => H - padB - ((v - sc.min) / (sc.max - sc.min)) * (H - padT - padB); const top = padT, bottom = H - padB; // A date between two snapshots lands proportionally between their two x positions — the // x scale is CATEGORICAL (one slot per snapshot), not time, so a release two days after a // snapshot is not two days along the axis. This is renderMLHistory's xForDate(). const xForDate = (d) => { if (d <= dates[0]) return X(0); if (d >= dates[last]) return X(last); for (let i = 0; i < last; i++) { if (d >= dates[i] && d <= dates[i + 1]) { const span = (new Date(dates[i + 1]) - new Date(dates[i])) || 1; const t = (new Date(d) - new Date(dates[i])) / span; return X(i) + t * (X(i + 1) - X(i)); } } return null; }; /* y grid + ticks */ ctx.textAlign = 'right'; ctx.lineWidth = 1; for (let v = sc.min; v <= sc.max + 1e-9; v += sc.step) { const gy = Y(v); ctx.strokeStyle = 'rgba(255,255,255,.05)'; ctx.beginPath(); ctx.moveTo(padL, gy); ctx.lineTo(W - padR, gy); ctx.stroke(); ctx.fillStyle = '#5a6b85'; ctx.fillText(fmtAxis(v, sc.min, sc.max), padL - 6, gy + 3); } const pts = vals.map((v, i) => [X(i), Y(v)]); const cp = controlPoints(pts, 0.25); const cut = padL + mlT * (W - padL - padR) + 1; /* the line, its fill and its points, all clipped to the generated width */ ctx.save(); ctx.beginPath(); ctx.rect(0, 0, cut, H); ctx.clip(); const grad = ctx.createLinearGradient(0, top, 0, bottom); grad.addColorStop(0, 'rgba(0,208,132,.28)'); grad.addColorStop(1, 'rgba(0,208,132,0)'); ctx.beginPath(); tracePath(ctx, pts, cp); ctx.lineTo(pts[last][0], bottom); ctx.lineTo(pts[0][0], bottom); ctx.closePath(); ctx.fillStyle = grad; ctx.fill(); ctx.strokeStyle = '#00d084'; ctx.lineWidth = 2; ctx.beginPath(); tracePath(ctx, pts, cp); ctx.stroke(); ctx.lineWidth = 1; for (let i = 0; i <= last; i++) { const col = hist[i].source === 'inception' ? '#f5c518' : i === last ? '#00d084' : 'rgba(0,208,132,.55)'; ctx.fillStyle = col; ctx.beginPath(); ctx.arc(pts[i][0], pts[i][1], 3.5, 0, 6.283); ctx.fill(); } ctx.restore(); /* release markers — each one arrives as the sweep passes it rather than being clipped in * half, which is what a hard clip would do to a 6px-wide triangle */ for (const r of rels) { const px = xForDate(r.date); if (px == null) continue; const a = Math.max(0, Math.min(1, (cut - px) / 14)); if (a <= 0) continue; const col = relColor(r.type); ctx.save(); ctx.globalAlpha = 0.4 * a; ctx.strokeStyle = col; ctx.lineWidth = 1; ctx.setLineDash([3, 3]); ctx.beginPath(); ctx.moveTo(px, top); ctx.lineTo(px, bottom); ctx.stroke(); ctx.globalAlpha = a; ctx.setLineDash([]); ctx.fillStyle = col; ctx.beginPath(); ctx.moveTo(px, bottom); ctx.lineTo(px - 3, bottom + 5); ctx.lineTo(px + 3, bottom + 5); ctx.closePath(); ctx.fill(); ctx.restore(); } /* x labels, revealed with the sweep */ ctx.textAlign = 'center'; ctx.fillStyle = '#5a6b85'; ctx.font = '9px Inter, sans-serif'; for (let i = 0; i <= last; i++) { if (pts[i][0] > cut) continue; ctx.fillText(fmtD(dates[i]), pts[i][0], H - padB + 18); } mlGeom.ok = true; mlGeom.top = top; mlGeom.bottom = bottom; mlGeom.padL = padL; mlGeom.plotW = W - padL - padR; mlGeom.x = pts.map(p => p[0]); mlGeom.y = pts.map(p => p[1]); mlGeom.xForDate = xForDate; } /* The panel itself. Numbers are the artist's own leaderboard row, formatted by the same * helpers the dashboard formats them with. */ function buildDetail() { const o = D.observe; const r = O.rankings.find(x => x.name === o.name); const { hist, rels } = mlSeries(); $('#dp-name').textContent = o.name; const fp = $('#dp-flag'); fp.textContent = r.band; fp.style.cssText = `background:${r.color}1f;color:${r.color};border:1px solid ${r.color}4d`; $('#dp-genre').textContent = o.genre; // the dashboard's own fallback when an artist has no stored spotify_id $('#dp-spotify').href = 'https://open.spotify.com/search/' + encodeURIComponent(o.name); $('#dp-date').textContent = 'Found ' + fmtD(r.found); $('#dp-found').textContent = fmt(r.foundML); $('#dp-current').textContent = fmt(r.ml); const ge = $('#dp-growth'); ge.textContent = (r.growth >= 0 ? '+' : '') + r.growth.toFixed(1) + '%'; ge.className = 'dm-val ' + (r.growth > 0 ? 'g' : 'r'); // The most recent week is DOWN 1.5%, and it says so. A tool that only ever showed the // flattering number would not be worth showing anyone. const gp = $('#dp-growth-period'); gp.textContent = (r.periodD.pct >= 0 ? '+' : '') + r.periodD.pct.toFixed(1) + '%'; gp.className = 'dm-val ' + (r.periodD.d >= 0 ? 'g' : 'r'); const sub = `${fmtD(hist[0].date)} → ${fmtD(hist[hist.length - 1].date)}` + ` · ${fmt(hist[0].ml)} → ${fmt(hist[hist.length - 1].ml)}`; const order = ['single', 'ep', 'album', 'feature']; const labelT = { single: 'single', ep: 'EP', album: 'album', feature: 'feature' }; const present = new Set(rels.map(r2 => r2.type || 'single')); const legend = order.filter(t => present.has(t)) .map(t => ` ${labelT[t]}`).join('  '); $('#dp-chart-sub').innerHTML = sub + `  ·  ${rels.length} release${rels.length > 1 ? 's' : ''}:  ${legend}`; return r; } /* ══ UNDERSTAND — his report and his coverage ══════════════════════════════ * renderReportHTML() from index.repointed.html, same rules: exact-match section headings in * green with a fading rule, `[ catalog]` lines collapsed into one CATALOG SCAN block of * label/value rows, `**bold**` to a --t1 strong, and bullets with a green dot. * * It is fed the brief WITHOUT its ANALYSIS section (build-intro-data strips it and asserts on * the result) and without the four findings bullets that ARE the coverage list below. */ function reportHTML(name, brief) { let h = '
' + 'SCOUT REPORT — ' + esc(name.toUpperCase()) + '
'; const head = (label) => '
' + '
' + label + '
' + '
'; const body = (line) => { // a trailing [n] is a REFERENCE to the numbered coverage row this finding was read out of const t = line.replace(/\*\*([^*]+)\*\*/g, '$1') .replace(/\s*\[(\d+)\]\s*$/, ' $1'); if (/^[-•]/.test(t)) return '
' + '
' + t.replace(/^[-•]\s*/, '') + '
'; return '
' + t + '
'; }; h += head('ARTIST OVERVIEW'); const cat = []; for (const line of brief.overview) { const m = /^\[(\d{4}-\d{2}-\d{2}) catalog\]\s*(.*)$/.exec(line); if (m) { cat.push([m[1], m[2]]); continue; } h += body(line); } if (cat.length) { h += head('CATALOG SCAN · ' + cat[0][0]); for (const [, row] of cat) { const pm = /^([A-Za-z ]+):\s*(.+)$/.exec(row); h += '
' + '
' + (pm ? pm[1].toUpperCase() : 'CATALOG') + '
' + '
' + (pm ? pm[2] : row) + '
'; } } if (brief.findingsBody.length) { h += head('INTELLIGENCE FINDINGS'); for (const line of brief.findingsBody) h += body(line); } return h; } /* Coverage, in the dashboard's own press-row markup. news.json carries nothing for matt * proxy — this is the journalism cited inside his brief, parsed back out by * build-intro-data.mjs, newest first. */ function buildBio() { const u = D.understand; $('#bioBox').innerHTML = reportHTML(u.artist.name, u.brief); // Numbered, because the findings above cite these rows by number. `read` is shown as it is // recorded: an article nobody could open says so rather than quietly looking like the rest. $('#pressList').innerHTML = u.coverage.map((c, i) => `
` + `${c.ref}` + `
${esc(c.outlet)}` + `${fmtD(c.date)}` + (c.byline ? `${esc(c.byline)}` : '') + (c.read === 'unread' ? 'NOT READ' : '') + `
${esc(c.headline)}
`).join(''); $('#dp-press-count').textContent = `${u.coverage.length} articles · ` + `${u.coverage.filter(c => c.summary).length} read into the report`; } /* Light a finding and the coverage row it came from at the same time, so the reference is * something the scene demonstrates rather than a number the viewer has to chase. */ function linkRef(n, on) { document.querySelectorAll(`.ref[data-ref="${n}"]`).forEach(e => e.classList.toggle('lit', on)); document.querySelectorAll(`.dp-pi[data-ref="${n}"]`).forEach(e => e.classList.toggle('cited', on)); } /* ══ the typewriters ═══════════════════════════════════════════════════════ * One mechanism, two targets. It types, deletes and retypes — Cade, 2026-08-04: "The top text * will type delete and re type" — and green words come from the segment tables above. * * say — the statement line over the chrome. It and the nav are the two bands no scene ever * covers. * desc — the box in the content area saying what the section actually does, added * 2026-08-05 because the Verse and how prettifun was found were not explaining * themselves. Same animation, deliberately: it reads as the same voice. */ const typer = (elSel, caretSel) => ({ el: $(elSel), caret: $(caretSel), text: '', colour: [], // colour[i] = 1 if character i is part of a green segment shown: 0, tw: null, }); const say = typer('#sayText', '#caret'); const desc = typer('#descText', '#descCaret'); function flatten(segments) { let text = '', colour = []; for (const [t, c] of segments) { text += t; for (let i = 0; i < t.length; i++) colour.push(c); } return { text, colour }; } function paint(st, n) { let html = '', run = '', cur = st.colour[0] || 0; for (let i = 0; i < n; i++) { const c = st.colour[i] || 0; if (c !== cur) { html += cur ? `${esc(run)}` : esc(run); run = ''; cur = c; } run += st.text[i]; } html += cur ? `${esc(run)}` : esc(run); st.el.innerHTML = html; } /* Type `st` from its `shown` up to the full length of `segments`, or delete down to `target`. * * Every one of these carries the same guarantee as the rest of the scene: a plain timer * lands the end state, and the tween is KILLED first so a tab that comes back to life * cannot resume from stale progress and rewind the line. */ function typeTo(st, segments, opts = {}) { const { text, colour } = flatten(segments); // only delete back to what the two lines already share — this is what makes the retype // read as swapping the tail rather than starting the sentence over let common = 0; while (common < text.length && common < st.text.length && text[common] === st.text[common]) common++; const deleteTo = opts.fullDelete ? 0 : common; const delMs = Math.abs(st.shown - deleteTo) * (opts.delRate || T.delChar) * 1000; const typMs = Math.abs(text.length - deleteTo) * (opts.rate || T.typeChar) * 1000; st.caret.classList.add('on'); st.caret.classList.remove('blink'); return new Promise(res => { let finished = false; const land = () => { if (finished) return; finished = true; if (st.tw) st.tw.kill(); st.text = text; st.colour = colour; st.shown = text.length; paint(st, st.shown); st.caret.classList.add('blink'); res(); }; const startTyping = () => { st.text = text; st.colour = colour; // adopt the new line's colours const s = { i: deleteTo }; st.tw = gsap.to(s, { i: text.length, duration: typMs / 1000, ease: 'none', onUpdate() { const n = Math.round(s.i); if (n !== st.shown) { st.shown = n; paint(st, n); } }, onComplete: land }); }; if (st.shown > deleteTo) { const s = { i: st.shown }; st.tw = gsap.to(s, { i: deleteTo, duration: delMs / 1000, ease: 'none', onUpdate() { const n = Math.round(s.i); if (n !== st.shown) { st.shown = n; paint(st, n); } }, onComplete: startTyping }); } else { startTyping(); } setTimeout(land, delMs + typMs + 450); }); } /* The description box types its line, and fades itself in the first time it has one. */ async function sayDesc(segments) { const box = $('#desc'); const typing = typeTo(desc, segments, { fullDelete: true, rate: T.descChar }); if (+getComputedStyle(box).opacity < 0.5) fade(box, 1, 0.3); await typing; } /* Start a phase. Cade, 2026-08-05: the previous explanation just GOES — "no need to waste * time with the delete type on the explanation boxes" — and then the header and the new * explanation type at the same rates every phase before it used. * * The header still deletes back to the shared lead-in, because that is the effect Cade signed * off on 08-04: "The A&R tool that…" stays on screen and only the tail swaps. */ async function startPhase(letter, line, description) { const box = $('#desc'); if (+getComputedStyle(box).opacity > 0.01) { await fade(box, 0, 0.22); desc.text = ''; desc.colour = []; desc.shown = 0; desc.el.innerHTML = ''; } litLetter(letter); await typeTo(say, line); await sayDesc(description); } /* ══ scene furniture ═══════════════════════════════════════════════════════ */ const cells = () => [...document.querySelectorAll('.cell')]; /* EXCLUSIVE: only the letter of the scene you are in is green (Cade, 2026-08-04) — S during * Scan, C during Collect, and the previous one goes back to grey. */ const litLetter = (ch) => document.querySelectorAll('.logo-text span') .forEach(el => el.classList.toggle('lit', el.dataset.l === ch)); const litCell = (i, on = true) => cells()[i].classList.toggle('lit', on); const setTab = (name) => document.querySelectorAll('.nt') .forEach(t => t.classList.toggle('active', t.dataset.tab === name)); const fade = (el, to, dur) => tween(el, { opacity: to, duration: dur, ease: 'power2.out' }); /* ── the pointer ─────────────────────────────────────────────────────────── * One cursor, in PAGE coordinates, because it has to leave the Verse and click on the * Rankings card. Everything it does is guarded like the rest of the scene. */ const cursor = { async to(x, y, dur = 0.5) { await tween('#cursor', { left: x, top: y, duration: dur, ease: 'power2.inOut' }); }, async show(x, y) { gsap.set('#cursor', { left: x, top: y }); await fade($('#cursor'), 1, 0.2); }, async hide() { await fade($('#cursor'), 0, 0.25); }, /* a press: the arrow dips, a ring pulses out from the point */ async tap(x, y) { const el = $('#cursor'); el.classList.add('tap'); gsap.set('#tapfx', { left: x, top: y, scale: 0.5, opacity: 0.9 }); tween('#tapfx', { scale: 2.2, opacity: 0, duration: 0.45, ease: 'power2.out' }); await wait(180); el.classList.remove('tap'); }, }; /* The pointer never lets go of the chip between the Verse and the bracket header, so the two * move as one thing. The arrow rides just inside the chip's top-left corner, which is where a * hand would be. */ function carry(x, y, dur = 0.5) { return Promise.all([ tween('#chip', { left: x, top: y, duration: dur, ease: 'power2.inOut' }), tween('#cursor', { left: x + 12, top: y + 12, duration: dur, ease: 'power2.inOut' }), ]); } /* page coordinates of a point inside the Verse canvas */ const versePt = (px, py) => { const r = $('#verse').getBoundingClientRect(); return { x: r.left + px, y: r.top + py }; }; const centreOf = (el) => { const r = el.getBoundingClientRect(); return { x: r.left + r.width / 2, y: r.top + r.height / 2 }; }; /* The camera angle that puts a node front and centre. Lifted from index.repointed.html's * flyTo(): yaw = -atan2(vx,vz), tilt = atan2(vy, hypot(vx,vz)). */ const facing = (n) => ({ yaw: -Math.atan2(n.vx, n.vz), tilt: Math.atan2(n.vy, Math.hypot(n.vx, n.vz) || 1e-6) }); /* The opening pull-back and the Scan fly-in both own cam.zoom, and GSAP does not overwrite by * default — left alone they tween the same property in opposite directions at once. Whoever * takes the camera next cancels the opening move outright instead of racing its timing. */ let camIntro = null; function takeCamera() { if (camIntro) { camIntro.kill(); camIntro = null; } } /* Park the camera on the FAR side of a node, so reaching it means travelling most of the * way round the sphere (Cade, 2026-08-04: "prettifun should be on the opposite side of the * verse so that you have to scroll across in order to see it"). Just shy of a true antipode, * which keeps the direction of travel deterministic instead of a coin flip. */ function parkOpposite(n) { takeCamera(); const f = facing(n); cam.yaw = f.yaw + Math.PI * 0.94; cam.tilt = -f.tilt * 0.5; drawVerse(); } /* Fly to a node, shortest way round. The idle drift is suspended for the duration or it * fights the tween for cam.yaw. */ async function flyTo(n, dur, zoom) { takeCamera(); const f = facing(n); const dy = ((f.yaw - cam.yaw) % 6.28318 + 9.42477) % 6.28318 - 3.14159; spinning = false; await tween(cam, { yaw: cam.yaw + dy, tilt: f.tilt, zoom, duration: dur, ease: 'power2.inOut', onUpdate: drawVerse }); drawVerse(); } /* Lift a card out of the grid to fill the content area, and put it back. * It never covers the statement or the nav — the target is the area the four cards occupy, * measured from the cards themselves rather than from the viewport. */ const slots = {}; async function zoomCard(i, into, onUpdate) { const C = cells(), card = C[i], others = C.filter((_, k) => k !== i); if (into) { const r = card.getBoundingClientRect(); slots[i] = { left: r.left, top: r.top, width: r.width, height: r.height }; // the full content area = the union of all four cards, so the two top bands stay clear const boxes = C.map(c => c.getBoundingClientRect()); const full = { left: Math.min(...boxes.map(b => b.left)), top: Math.min(...boxes.map(b => b.top)), width: Math.max(...boxes.map(b => b.right)) - Math.min(...boxes.map(b => b.left)), height: Math.max(...boxes.map(b => b.bottom)) - Math.min(...boxes.map(b => b.top)), }; card.classList.add('zoom'); gsap.set(card, slots[i]); fade(others, 0, 0.4); await tween(card, { ...full, duration: T.zoom, ease: 'power2.inOut', onUpdate }); } else { await tween(card, { ...slots[i], duration: T.zoom, ease: 'power2.inOut', onUpdate }); card.classList.remove('zoom'); gsap.set(card, { clearProps: 'left,top,width,height' }); fade(others, 1, 0.4); } if (onUpdate) onUpdate(); } const zoomVerse = (into) => zoomCard(0, into, drawVerse); /* ══ SCENE 2 — SCAN ════════════════════════════════════════════════════════ * Cade's Scene 2 spec, 2026-08-04. The line retypes to "…Scans the industry.", the Verse * lights up and takes the screen, the camera finds prettifun, he reads as an artist nobody * has taken yet, and the scene appears to add him — which flips the line to "Collects…" * and drops him into Rankings. * * FRAMING, and it matters on a hiring site: prettifun IS in the roster — added 2024-11-11 * at 43.7K, now 615K. This is a RE-ENACTMENT of that call, not a claim that he is unsigned * today, so the node carries his found-date numbers. The row that lands in Rankings then * carries the real current ones, which is the whole point: 43.7K to 615K, +1307%. */ async function sceneScan() { const hero = V.byId[SCAN.hero]; const ties = SCAN.ties.map(t => V.byId[t]).filter(Boolean); fx.hero = hero; fx.ties = ties; /* the line retypes, and "Scans" landing is what lights the Verse and the S */ const typing = typeTo(say, LINES.scan, { fullDelete: true }); // "Scans" ends 24 characters in; light the tab and the letter as that word completes const scansAt = (LINES.scan[0][0].length + LINES.scan[1][0].length) * T.typeChar * 1000; setTimeout(() => { litCell(0); litLetter('S'); }, Math.round(scansAt + say.shown * T.typeChar * 1000)); await typing; await sayDesc(DESC.scan); mark('scan:typed'); /* into the Verse — parked on the far side, so finding him means travelling across it */ await wait(T.beat * 1000); parkOpposite(hero); await zoomVerse(true); setTab('The Verse'); mark('scan:zoomed'); /* the long way round, then hard in on him */ await flyTo(hero, T.fly, T.zoomFar); mark('scan:found'); /* he separates out of the field, with his real ties dashed out. Everything that is not him * or one of the three goes TRANSLUCENT — its own colour, its own size, turned down (Cade, * 2026-08-05: greying them out read as the rest of the Verse disappearing). Without some * form of ego-focus the beat is unreadable: he is one star among 16,000. */ // The NAMES are up from the first frame of the reveal (Cade, 2026-08-05: "when the verse // first hones in on prettifun and his connections, the name should be immediately visible, // right now it takes a little for them to come up"). They used to wait out the dim and the // dash — 1.4 seconds of looking at unlabelled dots. fx.on = true; fx.dash = 0; fx.dim = 0; fx.tags = true; fx.name = true; fx.focus = new Set([hero.id, ...ties.map(t => t.id)]); drawVerse(); await tween(fx, { dim: 1, duration: 0.55, ease: 'power2.inOut', onUpdate: drawVerse }); // ease back out to a framing that fits his ties on screen. Punching all the way in and // staying there loses Yeat and F1LTHY off the edge; punching in and then pulling back is // what makes the reveal of his web land. await Promise.all([ tween(cam, { zoom: T.zoomWeb, duration: T.dash, ease: 'power2.inOut', onUpdate: drawVerse }), tween(fx, { dash: 1, duration: T.dash, ease: 'power2.out', onUpdate: drawVerse }), ]); await wait(T.beat * 1000); /* the click, and the box that says why SCOUT is pointing at him */ const at = versePt(hero.px, hero.py); await cursor.show(at.x + 150, at.y + 110); await cursor.to(at.x + 5, at.y + 5); await Promise.all([cursor.tap(at.x, at.y), fade($('#vring'), 1, 0.3)]); $('#vcName').textContent = SCAN.hero; $('#vcMeta').textContent = `${fmt(SCAN.foundML)} monthly listeners · ${SCAN.foundLabel}`; $('#vcTies').innerHTML = SCAN.ties.map(t => `${esc(t)}`).join(''); syncFX(); await fade($('#vcard'), 1, 0.32); mark('scan:node-clicked'); // long enough to actually read the three names it is arguing from await wait(T.hold * 1000); mark('scan:recommended'); } /* ══ the COLLECT hand-off ══════════════════════════════════════════════════ * Cade, 2026-08-05: "you should appear to grab prettifun drag him out of the verse, drag him * into the rankings, and drop him on the breaking bracket." * * So he is GRABBED off the node while the Verse is still full-screen, and the pointer never * lets go: the Verse shrinks back to the homepage around a chip that is already in hand. Only * once the grid is back does the phase change — the Scan line goes, and Collect types. */ async function sceneCollectHandoff() { const hero = fx.hero; const vbox = $('#verse').getBoundingClientRect(); const heroPt = { x: vbox.left + hero.px, y: vbox.top + hero.py }; /* GRAB — the pointer presses on the node and comes up holding him */ const chip = $('#chip'); chip.querySelector('.cn').textContent = SCAN.hero; chip.querySelector('.cp').textContent = SCAN.band; await cursor.to(heroPt.x, heroPt.y, 0.3); await cursor.tap(heroPt.x, heroPt.y); gsap.set(chip, { left: heroPt.x - 60, top: heroPt.y - 18, scale: 0.85 }); await tween(chip, { opacity: 1, scale: 1, duration: 0.26, ease: 'back.out(2)' }); fade([$('#vring'), $('#vcard')], 0, 0.28); mark('collect:grabbed'); /* out of the Verse: the focus lifts and the card shrinks back to the grid, with him in hand * the whole way. Everything is guarded — a stalled ticker must not leave the sphere parked * at 2.45x and overflowing its card. */ await tween(fx, { dim: 0, duration: 0.4, ease: 'power2.inOut', onUpdate: drawVerse }); fx.on = false; fx.focus = null; fx.name = false; drawVerse(); litCell(0, false); spinning = true; const camBack = tween(cam, { zoom: 1, tilt: -0.18, duration: T.zoom, ease: 'power2.inOut', onUpdate: drawVerse }); const rank = cells()[1].getBoundingClientRect(); // carried toward Rankings as the Verse closes, so the two moves are one gesture const carrying = carry(rank.left + rank.width / 2 - 70, rank.top + rank.height / 2 - 20, T.zoom); await zoomVerse(false); await Promise.all([camBack, carrying]); drawVerse(); setTab('Dashboard'); litCell(1); mark('collect:over-rankings'); /* back on the homepage holding him — THIS is where Collect begins */ await startPhase('C', LINES.collect, DESC.collect); mark('collect:typed'); } // matched on the dataset rather than a selector — artist names carry quotes and dots const rankRow = (name) => [...document.querySelectorAll('#rankTrack tbody tr[data-a]')] .find(tr => tr.dataset.a === name); /* ══ SCENE 3 — COLLECT ═════════════════════════════════════════════════════ * Cade, 2026-08-05: "you should drag him over rankings, be brought into ranking still holding * him, and then be brought down to the breaking bracket ... prettifun should be dropped on the * bracket header of breaking and then you should see him show up as third and see his scout * score count up and populate accordingly, once thats done you should start the scroll down to * matt proxy". * * So the card is not clicked open — it opens BECAUSE something is being carried into it, and * the chip stays held right through the opening, the scroll and the drop. Every bracket header * carries its listener range, which is the answer to why he is going to BREAKING. */ async function sceneCollect() { const chip = $('#chip'); /* the card opens under him, and he rides it up to the top of the board */ const opening = zoomCard(1, true); setTab('Rankings'); revealFullBoard(true); const port = $('#rankPort'); port.scrollTop = 0; await opening; const box = cells()[1].getBoundingClientRect(); await carry(box.left + box.width / 2 - 70, box.top + 96, 0.5); mark('collect:board-open'); const centre = (row) => Math.max(0, Math.min(port.scrollHeight - port.clientHeight, row.offsetTop - port.clientHeight / 2 + row.offsetHeight / 2)); /* down to his bracket, still holding him */ const band = document.querySelector(`#rankTrack tr[data-band="${SCAN.band}"]`); if (band) { const s = { v: port.scrollTop }; await tween(s, { v: centre(band), duration: T.toBand, ease: 'power2.inOut', onUpdate() { port.scrollTop = s.v; } }); band.classList.add('target'); await wait(T.beat * 1000); /* and dropped on the bracket header — the pointer releases him here, which is the first * time it has let go since the Verse */ const br = band.getBoundingClientRect(); const p = { x: br.left + br.width / 2 - 70, y: br.top + br.height / 2 - 20 }; await carry(p.x, p.y, 0.45); await cursor.tap(p.x + 12, p.y + 12); await tween(chip, { opacity: 0, scale: 0.8, duration: 0.26, ease: 'power2.in' }); await cursor.hide(); band.classList.remove('target'); } else { await tween(chip, { opacity: 0, duration: 0.2 }); await cursor.hide(); } mark('collect:dropped'); /* he appears at his rank, and the bracket takes its real count back */ const hit = dropHero(); if (hit) port.scrollTop = centre(hit); await wait(T.beat * 1000); /* the score arrives last, counting up — it is the thing SCOUT computed about him, not * something that came with him */ const sv = { v: 0 }, target = O.rankings.find(r => r.name === SCAN.hero).score; await tween(sv, { v: target, duration: T.score, ease: 'power2.out', onUpdate() { const el = $('#heroScore'); if (el) el.textContent = sv.v.toFixed(1); } }); mark('collect:scored'); await wait(T.beat * 1000); /* down the board to matt proxy — out of BREAKING and into RISING. It carries on from where * the drop left the board rather than jumping back to the top, so the whole scene is one * continuous move down the list. * Order is SCOUT Score within band and deliberately does NOT track listeners. */ const dest = rankRow(OBS); if (dest) { const to = centre(dest); const s = { v: port.scrollTop }; await tween(s, { v: to, duration: T.board, ease: 'power2.inOut', onUpdate() { port.scrollTop = s.v; } }); port.scrollTop = to; } mark('collect:reached-' + OBS); /* and he gets clicked, the same way prettifun did */ await wait(T.beat * 1000); if (dest) { const r = dest.getBoundingClientRect(); const p = { x: r.left + 190, y: r.top + r.height / 2 }; await cursor.show(p.x + 230, p.y + 130); await cursor.to(p.x, p.y, 0.55); dest.classList.add('landed'); await cursor.tap(p.x, p.y); await cursor.hide(); } mark('collect:clicked-' + OBS); await wait(T.beat * 1000); } /* ══ SCENE 4 — OBSERVE ═════════════════════════════════════════════════════ * The row Collect clicked opens, the way clicking a Leaderboard row opens an artist in the * dashboard. Then his listener history generates left to right and the releases land on it. * * The argument the scene makes: matt proxy was taken at 88K in February, `trojan horse` * landed on 2026-06-19, and the line went 153,738 -> 391,033 in a month. The tracking is * continuous and dated — every point is a real snapshot with a real date, which is the whole * difference between this and a chart assembled for a portfolio. * * It also shows the last week DOWN 1.5%, because that is what the last week did. */ async function sceneObserve() { const o = D.observe; /* matt proxy has just been clicked, so Collect is over: its explanation goes without a * delete-type and Observe types in its place (Cade, 2026-08-05). * * His page opens WHILE that types rather than after it. Sequenced, the board would sit there * for three seconds doing nothing, which is the dead air Cade has been cutting everywhere * else — and the page opening IS what clicking his row did. */ const phase = startPhase('O', LINES.observe, DESC.observe); const r = buildDetail(); const dp = $('#dp'); gsap.set(['#mlkey', '#mlpeak'], { opacity: 0 }); mlT = 0; dp.classList.add('show'); dp.scrollTop = 0; gsap.set(dp, { opacity: 0, x: 30 }); drawML(); await tween(dp, { opacity: 1, x: 0, duration: T.page, ease: 'power2.out' }); // the green moves off the card and onto the section the scene is actually on litCell(1, false); $('#dpChart').classList.add('dp-focus'); mark('observe:page'); /* Everything below runs TOGETHER with the header typing. Waiting for the phase, then the * score, then starting the graph put four and a half seconds between his page opening and * the line moving — "Observe is a bit slow in parts, you can start the movement of the graph * a little sooner" (Cade, 2026-08-05). */ drawML(); // one draw to establish geometry before timing const frac = (x) => x == null ? 0.5 : (x - mlGeom.padL) / mlGeom.plotW; /* BOTH callouts land as the sweep passes their point — the peak used to wait for the whole * line to finish, so the number arrived detached from the moment it describes. */ const at = (date, dy) => { const i = o.snapshots.findIndex(s => s.date === date); return { left: mlGeom.xForDate(date), top: (i >= 0 ? mlGeom.y[i] : mlGeom.bottom) + dy }; }; const tag = (el, html, date, dy, name) => { let done = false; return () => { if (done) return; done = true; el.innerHTML = html; gsap.set(el, at(date, dy)); fade(el, 1, 0.3); mark(name); }; }; const showKey = tag($('#mlkey'), `${esc(o.key.name)} · ${esc(String(o.key.type).toUpperCase())} · ${fmtDShort(o.key.date)}`, o.key.date, -44, 'observe:key'); const showPeak = tag($('#mlpeak'), `${fmt(o.peak.ml)} PEAK · +${o.growthPct}% since ${esc(o.key.name)}`, o.peak.date, 22, 'observe:peak'); const keyFrac = frac(mlGeom.xForDate && mlGeom.xForDate(o.key.date)); const peakFrac = frac(mlGeom.xForDate && mlGeom.xForDate(o.peak.date)); const sv = { v: 0 }; const counting = tween(sv, { v: r.score, duration: T.count, ease: 'power2.out', onUpdate() { $('#dp-score').textContent = sv.v.toFixed(1); } }); const g = { v: 0 }; const sweeping = tween(g, { v: 1, duration: T.ml, ease: 'power1.inOut', onUpdate() { mlT = g.v; drawML(); if (mlT >= keyFrac) showKey(); if (mlT >= peakFrac) showPeak(); } }); await Promise.all([phase, counting, sweeping]); mlT = 1; drawML(); showKey(); showPeak(); mark('observe:charted'); /* and it SITS. A finished chart that moves on immediately never gets read (Cade). */ await wait(T.settle * 1000); } /* ══ SCENE 5 — UNDERSTAND ══════════════════════════════════════════════════ * Cade, 2026-08-05: "the page should scroll down and the green border should move from the * streaming graph to the artist bio ... It should move down the bio and at the bottom should * be a recent addition from the press release on trojan horse. This is when you segue into * the article." * * The report is his real SCOUT brief with the ANALYSIS section removed — the framework keeps * written analysis private, so what ships is the overview, the catalog credits and the sourced * findings. The coverage under it is the journalism cited in that brief: Pitchfork, MSN, The * Fader, Stereogum and the welcomejpeg feature, newest first. * * The scene opens the FEATURE rather than the Pitchfork review, and that is the point of the * letter: the reviews say what critics made of the record, the feature says who he is. */ async function sceneUnderstand() { const dp = $('#dp'); const scrollTo = (v, dur) => { const s = { v: dp.scrollTop }; return tween(s, { v, duration: dur, ease: 'power2.inOut', onUpdate() { dp.scrollTop = s.v; } }); }; /* down from the chart to the report, and the green goes with it */ const bio = $('#dpBio'), box = $('#bioBox'); await scrollTo(Math.max(0, box.offsetTop - 40), T.toBio); $('#dpChart').classList.remove('dp-focus'); box.classList.add('dp-focus'); mark('understand:border-moved'); /* the border has moved, so Observe is over and Understand types */ await startPhase('U', LINES.understand, DESC.understand); mark('understand:typed'); /* on down the report to the coverage sitting under it */ const list = $('#pressList'); await scrollTo(dp.scrollHeight - dp.clientHeight, T.bio); box.classList.remove('dp-focus'); list.classList.add('dp-focus'); mark('understand:coverage'); /* Each finding lights with the article it was read out of. This is the whole point of the * section: the bullets are not a list of links, they are what the coverage SAYS, and the * reference is how you get from the claim back to the page it came from. */ const cited = D.understand.coverage.filter(c => c.summary).map(c => c.ref); for (const n of cited) { linkRef(n, true); await wait(T.cite * 1000); linkRef(n, false); } mark('understand:refs'); await wait(T.beat * 1000); /* and the feature gets opened */ const i = D.understand.coverage.findIndex(c => c.url === D.understand.opens); const row = list.querySelector(`.dp-pi[data-i="${i}"]`); if (row) { const r = row.getBoundingClientRect(); const p = { x: r.left + 180, y: r.top + r.height / 2 }; await cursor.show(p.x + 220, p.y + 120); await cursor.to(p.x, p.y, 0.55); row.classList.add('hot'); await cursor.tap(p.x, p.y); await cursor.hide(); } mark('understand:opened-' + (i >= 0 ? D.understand.coverage[i].outlet : '?')); await wait(T.hold * 1000); } /* ══ runner ════════════════════════════════════════════════════════════════ */ function scrollPort(sel, up, delay) { // Scroll a port for real, so its sticky column headers stay pinned the way the // dashboard's do. A transformed track carries its own sticky header off-screen with it. // // `up` travels toward the TOP of the list (Press: toward today's coverage), `down` away // from it (Rankings: down through the brackets). Only scrollFrac of the list is crossed, // which is what makes the movement read slower without costing the scene any time. const port = $(sel); const dist = Math.max(0, port.scrollHeight - port.clientHeight) * T.scrollFrac; const from = up ? dist : 0, to = up ? 0 : dist; port.scrollTop = from; const s = { v: from }; guard(gsap.to(s, { v: to, duration: T.scroll, delay, ease: 'power1.inOut', onUpdate() { port.scrollTop = s.v; } }), () => { port.scrollTop = to; }, (delay + T.scroll) * 1000); return dist; } /* Jump straight to the state a later scene starts from: `open.html?scene=observe`. * DEVELOPMENT ONLY — nothing links to it and the visitor never sees it. It exists because * verifying scene 4 otherwise means sitting through 25 seconds of scenes 1-3, and scenes 5 * and 6 are further down the same queue. It reproduces the END STATE of the scenes it skips * rather than a hand-made approximation, so what it hands to the next scene is what the real * run would hand it. */ async function fastForward(to) { const order = ['scan', 'collect', 'observe', 'understand']; if (!order.includes(to)) return; // opening scene landed: chrome and all four cards up, sphere turning gsap.set('#navBar', { opacity: 1 }); gsap.set('.cell', { opacity: 1, y: 0 }); await typeTo(say, LINES.open, { rate: 0.001 }); if (to === 'scan') return; // scan + the hand-off landed: the line reads "Collects…" and prettifun is IN HAND, over the // Rankings card — the hand-off ends holding him, it does not put him down await typeTo(say, LINES.collect, { rate: 0.001, delRate: 0.001 }); await typeTo(desc, DESC.collect, { rate: 0.001, delRate: 0.001, fullDelete: true }); gsap.set('#desc', { opacity: 1 }); litLetter('C'); litCell(1); const chip = $('#chip'), rank = cells()[1].getBoundingClientRect(); chip.querySelector('.cn').textContent = SCAN.hero; chip.querySelector('.cp').textContent = SCAN.band; gsap.set(chip, { opacity: 1, scale: 1, left: rank.left + rank.width / 2 - 70, top: rank.top + rank.height / 2 - 20 }); if (to === 'collect') return; // collect landed: he is on the board and scored, and the scroll has reached matt proxy gsap.set(chip, { opacity: 0 }); await zoomCard(1, true); setTab('Rankings'); revealFullBoard(true); dropHero(); const hs = $('#heroScore'); if (hs) hs.textContent = O.rankings.find(r => r.name === SCAN.hero).score.toFixed(1); const port = $('#rankPort'), dest = rankRow(OBS); if (dest) { port.scrollTop = Math.max(0, Math.min(port.scrollHeight - port.clientHeight, dest.offsetTop - port.clientHeight / 2 + dest.offsetHeight / 2)); dest.classList.add('landed'); } if (to === 'observe') return; // observe landed: his page is open, the history is fully drawn and both callouts are up await typeTo(say, LINES.observe, { rate: 0.001, delRate: 0.001 }); await typeTo(desc, DESC.observe, { rate: 0.001, delRate: 0.001, fullDelete: true }); gsap.set('#desc', { opacity: 1 }); litLetter('O'); const o = D.observe, r = buildDetail(); $('#dp-score').textContent = r.score.toFixed(1); const dp = $('#dp'); dp.classList.add('show'); gsap.set(dp, { opacity: 1, x: 0 }); dp.scrollTop = 0; litCell(1, false); $('#dpChart').classList.add('dp-focus'); mlT = 1; drawML(); const ki = o.snapshots.findIndex(s => s.date === o.key.date); const kEl = $('#mlkey'); kEl.innerHTML = `${esc(o.key.name)} · ${esc(String(o.key.type).toUpperCase())} · ${fmtDShort(o.key.date)}`; gsap.set(kEl, { opacity: 1, left: mlGeom.xForDate(o.key.date), top: (ki >= 0 ? mlGeom.y[ki] : mlGeom.bottom) - 44 }); const pi = o.snapshots.findIndex(s => s.date === o.peak.date); const pEl = $('#mlpeak'); pEl.innerHTML = `${fmt(o.peak.ml)} PEAK · +${o.growthPct}% since ${esc(o.key.name)}`; gsap.set(pEl, { opacity: 1, left: pi >= 0 ? mlGeom.x[pi] : mlGeom.padL + mlGeom.plotW * 0.7, top: (pi >= 0 ? mlGeom.y[pi] : mlGeom.top) + 22 }); } async function run() { D = await fetch('intro-data.json?v=' + Date.now()).then(r => r.json()); O = D.open; buildRankings(); buildPress(); buildBio(); $('#predSub').textContent = `${fmtDShort(O.moversPeriod.from)} → ${fmtDShort(O.moversPeriod.to)}`; // Scan-scene cast, all read from the pipeline. build-intro-data.mjs already fails the // build if prettifun's three named ties are not real edges in web-graph.json. const pf = O.rankings.find(r => r.name === D.scan.hero.name); SCAN = { hero: D.scan.hero.name, ties: D.scan.ties.map(t => t.name), foundML: pf ? pf.foundML : null, foundLabel: pf ? fmtMonthYear(pf.found) : '', band: pf ? pf.band : '', }; OBS = D.observe.name; // matt proxy — build-intro-data asserts he sits below prettifun // start both Verse fetches immediately; the statement types over them const verseReady = loadVerse(); // the sphere turns for the whole scene, on its own clock, never gated on anything else let last = 0; (function loop(t) { nextFrame(loop); const dt = Math.min(120, (t - last) || 16); last = t; pulseT = t; // suspended while the camera is flying to a node, or it fights the tween for cam.yaw if (spinning) cam.yaw += dt * VERSE_SPIN; fx.ants = (fx.ants + dt * 0.022) % 9; // 9 = the [4,5] dash period, so it never jumps drawVerse(); })(0); drawMovers(); addEventListener('resize', () => { drawVerse(); drawMovers(); if (mlGeom.ok) drawML(); }); // t0 is the moment the scene actually starts — after the 9KB data fetch, not before it, // so the five seconds are five seconds of scene rather than five seconds of network. const t0 = performance.now(); // Phase marks, so the five-second budget can be checked rather than assumed: // read window.SCOUT_OPEN.marks in the console after it plays. window.SCOUT_OPEN = { marks: [] }; mark = (name) => window.SCOUT_OPEN.marks.push([name, Math.round(performance.now() - t0)]); mark('start'); const skip = new URLSearchParams(location.search).get('scene'); if (skip) { await Promise.race([verseReady, wait(2000)]); await fastForward(skip); mark('fast-forward:' + skip); const from = { scan: 0, collect: 1, observe: 2, understand: 3 }[skip]; if (from <= 0) { await sceneScan(); await sceneCollectHandoff(); } if (from <= 1) await sceneCollect(); if (from <= 2) await sceneObserve(); await sceneUnderstand(); mark('understand:done'); return; } await wait(T.black * 1000); await typeTo(say, LINES.open); mark('statement'); // Give the full graph the last moment before the boom to land, so the reveal is the whole // sphere rather than the subset. Capped — the five seconds are not negotiable, and the // swap is seamless if it arrives late anyway. await Promise.race([verseReady, wait(250)]); mark('verse:' + (V ? V.nodes.length : 0)); await wait(T.boomGap * 1000); /* ── BOOM ── the chrome, then each card a beat after the last */ mark('boom'); guard(gsap.to('#navBar', { opacity: 1, duration: T.navIn, ease: 'power2.out' }), () => gsap.set('#navBar', { opacity: 1 }), T.navIn * 1000); // Verse -> Rankings -> Press -> Predict, each landing slightly after the one before, and // each starting its OWN motion as it lands rather than all four kicking off together. const cells = [...document.querySelectorAll('.cell')]; cells.forEach((c, i) => { const d = i * T.stagger; guard(gsap.fromTo(c, { opacity: 0, y: 10 }, { opacity: 1, y: 0, duration: T.cardIn, delay: d, ease: 'power2.out' }), () => gsap.set(c, { opacity: 1, y: 0 }), (d + T.cardIn) * 1000); }); drawVerse(); drawMovers(); mark('cards'); // the sphere pulls back out of its close-up as its own card lands. Guarded like the rest: // a stalled ticker must never leave the camera parked inside the globe. Handed to // takeCamera() so the Scan fly-in can cancel it outright rather than racing it for cam.zoom. cam.zoom = T.verseZoom; camIntro = guard(gsap.to(cam, { zoom: 1, duration: T.verseIn, ease: 'power2.out', onUpdate: drawVerse }), () => { if (camIntro) { camIntro = null; cam.zoom = 1; drawVerse(); } }, T.verseIn * 1000); // the sphere is already turning; the other three start on their own card's beat. // Both lists travel DOWN (Cade, 2026-08-05) — Press used to run up toward today's coverage, // and two lists moving in opposite directions read as a glitch rather than as two feeds. scrollPort('#rankPort', false, T.stagger * 1); scrollPort('#pressPort', false, T.stagger * 2); lineT = 0; const ls = { v: 0 }; guard(gsap.to(ls, { v: 1, duration: T.line, delay: T.stagger * 3 + T.cardIn * 0.6, ease: 'power1.inOut', onUpdate() { lineT = ls.v; drawMovers(); } }), () => { lineT = 1; drawMovers(); }, (T.stagger * 3 + T.cardIn * 0.6 + T.line) * 1000); await wait(Math.max(0, T.end * 1000 - (performance.now() - t0))); mark('open:done'); document.dispatchEvent(new CustomEvent('scout:open-done')); /* ── Scene 2 · SCAN, and the hand-off that starts Collect ── */ await sceneScan(); await sceneCollectHandoff(); document.dispatchEvent(new CustomEvent('scout:scan-done')); /* ── Scene 3 · COLLECT ── */ await sceneCollect(); mark('collect:done'); document.dispatchEvent(new CustomEvent('scout:collect-done')); /* ── Scene 4 · OBSERVE ── */ await sceneObserve(); mark('observe:done'); document.dispatchEvent(new CustomEvent('scout:observe-done')); /* ── Scene 5 · UNDERSTAND ── */ await sceneUnderstand(); mark('understand:done'); document.dispatchEvent(new CustomEvent('scout:understand-done')); /* and out into the real thing */ await land(); } /* The sequence exists to hand someone over to the dashboard, so it always ends there — and * SKIP gets there immediately. `?scene=` runs stay put, because leaving is not what you were * checking when you fast-forwarded to a scene. */ const LANDING = 'dashboard.html'; let landed = false; async function land() { if (landed) return; landed = true; if (new URLSearchParams(location.search).get('scene')) { mark('land:held-for-dev'); return; } mark('land'); // fade the sequence out rather than cutting, then hand over. Timed, not tween-gated: the // handover must happen even if the ticker is asleep. const veil = document.createElement('div'); veil.style.cssText = 'position:fixed;inset:0;z-index:200;background:var(--bg);opacity:0;' + 'transition:opacity .42s ease;pointer-events:none'; document.body.appendChild(veil); requestAnimationFrame(() => { veil.style.opacity = '1'; }); await wait(460); location.href = LANDING; } $('#skip').addEventListener('click', land); run();