// HUD for the RPO replay, sized for phone playback: a 16:9 video in a feed // plays ~390 px wide (≈0.2×), so body copy is ≥34 px at 1080p and nothing // that must be read is under ~24 px. Every animation is a pure function of // the video time `s`, so frames render deterministically. export function createHud(ctx) { const { D, W, H, U, PORTRAIT, sec, rate, sOfTau, sepAt, CA_T, VANTOR_T, BURNS, CUTS, project } = ctx; const $ = (q) => document.querySelector(q); const CA_M = Math.round(D.ca.m / 5) * 5; // ------------------------------------------------------------------------- // Story // ------------------------------------------------------------------------- const CHAPTERS = [ { key: 'climb', label: 'Climb', s0: 4.4, s1: 12.5 }, { key: 'drop', label: 'Drop in', s0: 12.5, s1: 22.0 }, { key: 'close', label: 'Close in', s0: 22.0, s1: 29.7 }, { key: 'closest', label: 'Closest', s0: 29.7, s1: 35.6 }, { key: 'leave', label: 'Leave', s0: 35.6, s1: 41.2 }, ]; const CAPTIONS = [ { s0: -1, s1: 4.4, hook: true, title: `A Russian military satellite shadowed a U.S.-registered one — to ~${CA_M} m`, body: 'How Cosmos 2612 matched ICEYE-X36\'s orbit over 22 days, rebuilt from public tracking data.', }, { s0: 4.4, s1: 12.5, title: 'Climb higher to fall behind', body: 'A higher orbit is slower. Lifted 47 km, it drifts back and laps its target.', }, { s0: 12.5, s1: 17.2, title: 'Drop into the same orbit', body: 'Oct 12: it dives 47 km and lands about 480 km behind.', }, { s0: 17.2, s1: 22.0, title: 'Speed up, then brake', body: 'Oct 15: a 2 km dip makes it faster — closing ~350 km a day — then it brakes 3 km behind.', }, { s0: 22.0, s1: 29.7, title: 'Park behind it. Creep closer.', body: 'Each orbit its path loops 1.6 km, inching in for two days.', }, { s0: 29.7, s1: 32.3, title: 'Into Earth\'s shadow', body: 'Closing at strolling pace (0.9 m/s) — while both fly at 27,000 km/h.', }, { s0: 32.3, s1: 35.6, title: `Closest pass: ~${CA_M} m`, body: 'Shown at true size. Published estimates: 150–650 m.', }, { s0: 35.6, s1: 41.2, title: 'Back off — and wait', body: 'It burns away. Today it sits 480 km behind, in the same orbit.', }, ]; const BURN_NOTES = { 'Phasing raise': 'raises its orbit 46 km', 'Plane trim': 'tilts its orbit into line', 'Descent to X36 orbit': 'drops 47 km', 'Closing burn': 'dips 2.3 km to speed up', 'Braking burn': 'rises 2.5 km to stop', 'Back-off': 'rises 130 m', 'Departure burn': 'rises 2.4 km to drift away', 'Re-match orbit': 'drops 2.6 km to hold position', }; const EVENTS = [ ...BURNS.map((b) => ({ t: b.t, kind: 'burn', title: 'Engine burn', sub: `${BURN_NOTES[b.label] || b.label} · Δv ${b.dv < 1 ? b.dv.toFixed(1) : Math.round(b.dv)} m/s`, })), { t: sec('2026-10-05T11:55:00Z'), kind: 'note', title: 'Passes overhead', sub: '~50 km above its target' }, { t: sec('2026-10-12T00:10:00Z'), kind: 'note', title: 'Directly overhead', sub: '47 km above its target' }, { t: VANTOR_T, kind: 'vantor', title: 'Vantor photographs the pair', sub: `from its own satellite · ~${D.vantor.km.toFixed(0)} km apart · Oct 17` }, { t: CA_T, kind: 'ca', title: 'Closest approach', sub: `${D.ca.t.slice(11, 16)} UTC, Oct 18` }, ].map((e) => ({ ...e, s: sOfTau(e.t) })).sort((a, b) => a.s - b.s); // ------------------------------------------------------------------------- // DOM // ------------------------------------------------------------------------- const fade = (s, s0, s1, ramp = 0.4) => Math.min(1, Math.max(0, (s - s0) / ramp), Math.max(0, (s1 - s) / ramp)); const capEl = $('#caption'); const tagsEl = $('#tags'); const leaders = $('#leaders'); const evEl = $('#events'); leaders.setAttribute('viewBox', `0 0 ${W} ${H}`); $('#chapters').innerHTML = CHAPTERS.map((c) => `
${c.label}
`).join(''); // Distance-over-time strip (log scale), drawn once, revealed by a clip. const CH = { t0: sec('2026-09-30T00:00:00Z'), t1: sec('2026-10-21T18:00:00Z'), lo: 0.1, hi: 20000 }; const chartBox = $('#chart'); const chart = (() => { const cw = chartBox.clientWidth; const ch = chartBox.clientHeight; const padL = (PORTRAIT ? 120 : 132) * U; const padT = 10 * U; const padB = 40 * U; const x = (t) => padL + ((t - CH.t0) / (CH.t1 - CH.t0)) * (cw - padL); const y = (km) => padT + (1 - (Math.log10(km) - Math.log10(CH.lo)) / (Math.log10(CH.hi) - Math.log10(CH.lo))) * (ch - padT - padB); const NS = D.sep_km.length; const cols = Math.round((cw - padL) / 2); let lo = ''; for (let c = 0; c <= cols; c++) { const ta = CH.t0 + (c / cols) * (CH.t1 - CH.t0); const tb = CH.t0 + ((c + 1) / cols) * (CH.t1 - CH.t0); let mn = Infinity; for (let i = Math.floor(ta / D.dt); i <= Math.ceil(tb / D.dt); i++) mn = Math.min(mn, D.sep_km[Math.min(Math.max(i, 0), NS - 1)]); lo += `${c ? 'L' : 'M'}${(padL + c * 2).toFixed(1)},${y(Math.max(mn, CH.lo)).toFixed(1)}`; } const fs = (PORTRAIT ? 24 : 26) * U; const grid = [[10000, '10,000 km'], [100, '100 km'], [1, '1 km']] .map(([v, lab]) => ` ${lab}`).join(''); const dates = [['2026-09-30', 'SEP 30'], ['2026-10-12', 'OCT 12'], ['2026-10-18', 'OCT 18']] .map(([d, lab], k) => `${lab}`).join(''); const burns = BURNS.map((b) => ``).join(''); chartBox.innerHTML = ` ${grid}${dates}${burns} `; return { x, y }; })(); function tag(id, name, meta, cls) { let el = document.getElementById(id); if (!el) { el = document.createElement('div'); el.id = id; el.className = 'tag'; el.dataset.meta = meta; el.innerHTML = `
${name}
${meta}
`; tagsEl.appendChild(el); } return el; } const fmtNum = (km) => (km >= 100 ? Math.round(km).toLocaleString('en-US') : km >= 10 ? km.toFixed(0) : km.toFixed(1)); function fmtSep(m) { if (m >= 10e3) return `${fmtNum(m / 1000)}km`; if (m >= 1000) return `${(m / 1000).toFixed(1)}km`; return `${Math.round(m / 5) * 5}m`; } function perSecond(r) { if (r < 90) return `${Math.round(r)} sec`; if (r < 5400) return `${Math.round(r / 60)} min`; if (r < 129600) return `${(r / 3600).toFixed(r < 36000 ? 1 : 0)} hours`.replace('1.0 hours', '1 hour'); return `${(r / 86400).toFixed(1)} days`.replace('.0 days', ' days'); } // ------------------------------------------------------------------------- // Per-frame update // ------------------------------------------------------------------------- function update(s, t, b, st) { const endA = fade(s, 41.2, 99, 0.5); const live = 1 - endA; // Chapters for (const c of CHAPTERS) { const el = document.querySelector(`.ch[data-k="${c.key}"]`); const p = Math.min(1, Math.max(0, (s - c.s0) / (c.s1 - c.s0))); el.classList.toggle('on', s >= c.s0 && s < c.s1); el.querySelector('b').style.width = `${p * 100}%`; } $('#chapters').style.opacity = fade(s, 4.2, 41.2, 0.4); // Caption (one at a time, quick crossfade) let cap = null; let capA = 0; for (const c of CAPTIONS) { const a = fade(s, c.s0, c.s1, 0.3); if (a > capA) { cap = c; capA = a; } } if (cap) { capEl.classList.toggle('hook', !!cap.hook); capEl.querySelector('h1').innerHTML = cap.title; capEl.querySelector('p').innerHTML = cap.body; } capEl.style.opacity = capA * live; capEl.style.transform = `translateY(${(1 - capA) * 14 * U}px)`; // Clock + speed const d = new Date(Date.parse(D.t0) + t * 1000); const mon = d.toLocaleString('en-US', { month: 'short', timeZone: 'UTC' }).toUpperCase(); $('#clock .date').textContent = `${mon} ${d.getUTCDate()}`; $('#clock .time').textContent = `${d.toISOString().slice(11, 16)} UTC`; $('#clock .rate').innerHTML = `1 sec = ${perSecond(rate(s))}`; $('#clock').style.opacity = live; // Distance readout const ro = $('#readout'); ro.style.opacity = fade(s, 4.4, 41.2, 0.4); ro.querySelector('.val').innerHTML = fmtSep(st.sep); const [r, tt] = st.rel; let ctxLine; const caS = EVENTS.find((e) => e.kind === 'ca').s; if (st.sep < 1500) ctxLine = `≈ ${Math.max(1, Math.round(st.sep / 100))} football fields`; else if (st.sep < 2e6) ctxLine = `${Math.abs(tt) >= 1e4 ? fmtNum(Math.abs(tt) / 1000) + ' km' : (Math.abs(tt) / 1000).toFixed(1) + ' km'} ${tt < 0 ? 'behind' : 'ahead'}${Math.abs(r) > 20e3 ? ` · ${fmtNum(Math.abs(r) / 1000)} km ${r > 0 ? 'higher' : 'lower'}` : ''}`; else ctxLine = `${Math.abs(r) > 20e3 ? `${fmtNum(Math.abs(r) / 1000)} km higher · ` : ''}far side of the orbit`; if (s > caS && s < 36.5) ctxLine += ` · closest ${CA_M} m`; ro.querySelector('.ctx').textContent = ctxLine; // Scale chip: amber when enlarged, white at true size. const magEl = $('#mag'); const enlarged = st.mag > 1.5; const trueSize = !enlarged && st.camDistX < 200; magEl.style.opacity = (enlarged || trueSize) ? fade(s, 4.4, 41.2, 0.3) : 0; magEl.classList.toggle('enlarged', enlarged); magEl.textContent = enlarged ? `Satellites enlarged ${Math.round(st.mag)}×` : 'True size'; // Chart const px = chart.x(Math.min(Math.max(t, CH.t0), CH.t1)); document.getElementById('pastRect').setAttribute('width', px); const ph = document.getElementById('playhead'); ph.setAttribute('x1', px); ph.setAttribute('x2', px); const pd = document.getElementById('playdot'); pd.setAttribute('cx', px); pd.setAttribute('cy', chart.y(Math.max(sepAt(t), CH.lo))); for (const g of chartBox.querySelectorAll('.burn')) g.style.opacity = Number(g.dataset.t) <= t ? 1 : 0.3; $('#chartwrap').style.opacity = fade(s, 4.4, 41.2, 0.4); // Event toasts (max two, newest on top) let html = ''; const quiet = s > 31.85 && s < 33.95; // true-size shot: headline + pulse carry it if (!quiet && st.lx < 0.5 && st.sep < 5e3 && s < 41.2) html += '
In Earth\'s shadow
orbital night · sunrise in minutes
'; const active = EVENTS.map((e) => ({ e, a: fade(s, e.s, e.s + (e.kind === 'ca' ? 3.6 : e.kind === 'vantor' ? 2.8 : 2.0), 0.2) })) .filter((x) => x.a > 0).slice(-2).reverse(); for (const { e, a } of quiet ? [] : active) { html += `
${e.title}
${e.sub}
`; } evEl.innerHTML = html; // Satellite tags + in-scene dimension line const pX = project(st.x36W); const pC = project(st.cosW); const tX = tag('tagX', 'ICEYE-X36', 'U.S.-registered radar satellite', 'c-x36'); const tC = tag('tagC', 'COSMOS 2612', 'Russian military', 'c-cos'); const tagA = fade(s, 4.0, 41.2, 0.4); const onScreen = (p) => p && p.x > -40 && p.y > -40 && p.x < W + 40 && p.y < H + 40; let svg = ''; // Tags try four quadrants around their satellite and take the first that // stays on screen and clear of HUD text; the other tag counts too. const M = 24 * U; const obstacles = []; for (const q of ['#logo', '#clock', '#caption', '#readout', '#chartwrap', '#chapters', '#mag']) { const el = $(q); if (Number(el.style.opacity || 1) > 0.05) obstacles.push(el.getBoundingClientRect()); } for (const el of evEl.children) obstacles.push(el.getBoundingClientRect()); const overlap = (r) => obstacles.reduce((acc, o) => acc + Math.max(0, Math.min(r.r, o.right + 8 * U) - Math.max(r.l, o.left - 8 * U)) * Math.max(0, Math.min(r.b, o.bottom + 8 * U) - Math.max(r.t, o.top - 8 * U)), 0); const hits = (r) => obstacles.some((o) => r.l < o.right + 8 * U && r.r > o.left - 8 * U && r.t < o.bottom + 8 * U && r.b > o.top - 8 * U); const place = (el, p, prefer, color) => { if (!onScreen(p)) { el.style.opacity = 0; return; } const a = tagA; el.style.opacity = a; el.querySelector('.meta').textContent = p.hidden ? 'far side of Earth' : el.dataset.meta; const w = el.offsetWidth; const h = el.offsetHeight; const quads = [prefer, [prefer[0], -prefer[1]], [-prefer[0], prefer[1]], [-prefer[0], -prefer[1]]]; let best = null; for (const [sx, sy] of quads) { const lx = p.x + sx * 66 * U; const ly = p.y + sy * 56 * U; const l = sx > 0 ? lx + 10 * U : lx - 10 * U - w; const r = { l, r: l + w, t: ly - h / 2, b: ly + h / 2 }; const inside = r.l > M && r.r < W - M && r.t > M && r.b < H - M; const cand = { sx, sy, lx, ly, r }; cand.cost = (inside ? 0 : 1e9) + overlap(r); if (!best || cand.cost < best.cost) best = cand; if (cand.cost === 0) break; } if (best.cost >= 1e9) { const [sx, sy] = prefer; const lx = p.x + sx * 66 * U; const ly = p.y + sy * 56 * U; let l = sx > 0 ? lx + 10 * U : lx - 10 * U - w; l = Math.min(Math.max(l, M), W - M - w); best = { sx, sy, lx, ly, r: { l, r: l + w, t: ly - h / 2, b: ly + h / 2 } }; } el.style.left = `${best.r.l}px`; el.style.top = `${best.r.t}px`; el.style.transform = 'none'; el.style.textAlign = best.sx > 0 ? 'left' : 'right'; obstacles.push({ left: best.r.l, right: best.r.r, top: best.r.t, bottom: best.r.b }); svg += ``; svg += ``; }; // Dimension line + pill (offset perpendicular so it clears the line's ends). const dim = $('#dim'); const gap = onScreen(pX) && onScreen(pC) ? Math.hypot(pC.x - pX.x, pC.y - pX.y) : 0; const dimA = st.sep < 60e3 && gap > 160 * U ? tagA : 0; if (dimA > 0) { svg += ``; const nx = -(pC.y - pX.y) / gap; const ny = (pC.x - pX.x) / gap; const side = ny > 0 ? -1 : 1; // put the pill above the line dim.innerHTML = fmtSep(st.sep).replace(/<\/?small>/g, ' ').trim(); const mx = (pX.x + pC.x) / 2 + side * nx * 40 * U; const my = (pX.y + pC.y) / 2 + side * ny * 40 * U; dim.style.left = `${mx}px`; dim.style.top = `${my}px`; const w = dim.offsetWidth; const h = dim.offsetHeight; obstacles.push({ left: mx - w / 2, right: mx + w / 2, top: my - h / 2, bottom: my + h / 2 }); } dim.style.opacity = dimA; // Pulse rings on both satellites at the closest moment. const caS2 = EVENTS.find((e) => e.kind === 'ca').s; const k = (s - caS2) / 1.2; if (k > 0 && k < 1) { for (const p of [pX, pC]) { if (!onScreen(p)) continue; svg += ``; } } let pX0 = [1, -1]; let pC0 = [-1, 1]; if (onScreen(pX) && onScreen(pC)) { const sx = pC.x - pX.x >= 0 ? -1 : 1; const sy = pC.y - pX.y >= 0 ? -1 : 1; pX0 = [sx, sy]; pC0 = [-sx, -sy]; } place(tX, pX, pX0, '#5ce1e6'); place(tC, pC, pC0, '#ff5a3c'); leaders.innerHTML = svg; // Burn flash ring on Cosmos // (drawn by the scene; nothing here) // Dip at cuts let dip = 0; for (const c of CUTS) dip = Math.max(dip, 1 - Math.abs(s - c) / 0.14); $('#dip').style.opacity = Math.max(0, dip); // End card const ec = $('#endcard'); ec.style.opacity = endA; ec.style.background = `rgba(2,4,8,${0.8 * endA})`; if (!ec.dataset.built) { ec.dataset.built = '1'; ec.innerHTML = `
~${CA_M} m
estimated · published range 150–650 m
Closest pass between Cosmos 2612 and ICEYE-X36 · Oct 18, in Earth's shadow
22days in pursuit
8engine burns
0.9 m/sspeed at closest pass
`; } $('#endlogo').style.opacity = endA; $('#logo').style.opacity = live; } return { update }; }