tools(rpo-replay): v2 — phone-legible HUD, logo, chapters, dimension line, faster tiles

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_0187rSCa4AKJPGw6uiuPSGyX
This commit is contained in:
Claude committed 2026-10-25 05:38:35 +00:00
1 parent 36f99cabac
commit 9d3d541b66
4 files changed
+518 -420

No files matched your search

+344
View File
@@ -0,0 +1,344 @@
// 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: 'stalk', label: 'Stalk', s0: 22.0, s1: 35.6 },
{ key: 'leave', label: 'Leave', s0: 35.6, s1: 41.2 },
];
const CAPTIONS = [
{
s0: 0.0, s1: 4.4, hook: true,
title: `A Russian satellite stalked a U.S. radar satellite — to <span class="c-cos">~${CA_M} m</span>`,
body: 'How <b class="c-cos">Cosmos 2612</b> hunted <b class="c-x36">ICEYE-X36</b> for 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 <b>47 km</b>, it drifts back and laps its target.',
},
{
s0: 12.5, s1: 22.0,
title: 'Drop into the same orbit',
body: 'Oct 12: it dives 47 km, lands <b>480 km behind</b> — then closes ~350 km a day.',
},
{
s0: 22.0, s1: 29.7,
title: 'Park behind it. Creep closer.',
body: 'From 3 km back it loops <b>1.6 km</b> every orbit, inching in for two days.',
},
{
s0: 29.7, s1: 35.6,
title: `Closest pass: <span class="c-cos">~${CA_M} meters</span>`,
body: 'In Earth\'s shadow, at walking pace — while both fly at <b>27,000 km/h</b>.',
},
{
s0: 35.6, s1: 41.2,
title: 'Back off — and wait',
body: 'It burns away. Today it sits <b>480 km behind</b>, 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 another satellite · ~${D.vantor.km.toFixed(0)} km apart` },
{ t: CA_T, kind: 'ca', title: 'Closest approach', sub: `${D.ca.t.slice(11, 16)} UTC Oct 18 · est. 150–650 m` },
].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) => `<div class="ch" data-k="${c.key}"><i><b></b></i><span>${c.label}</span></div>`).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 = 24 * U;
const grid = [[10000, '10,000 km'], [100, '100 km'], [1, '1 km']]
.map(([v, lab]) => `<line x1="${padL}" x2="${cw}" y1="${y(v)}" y2="${y(v)}" stroke="rgba(238,242,246,0.14)" stroke-width="${1.5 * U}"/>
<text x="${padL - 14 * U}" y="${y(v) + fs * 0.35}" text-anchor="end" font-size="${fs}" fill="rgba(238,242,246,0.6)">${lab}</text>`).join('');
const dates = [['2026-09-30', 'SEP 30'], ['2026-10-12', 'OCT 12'], ['2026-10-18', 'OCT 18']]
.map(([d, lab], k) => `<text x="${x(sec(`${d}T00:00:00Z`))}" y="${ch - 6 * U}" text-anchor="${k ? 'middle' : 'start'}" font-size="${fs}" fill="rgba(238,242,246,0.6)">${lab}</text>`).join('');
const burns = BURNS.map((b) => `<circle class="burn" data-t="${b.t}" cx="${x(b.t)}" cy="${padT + 2 * U}" r="${5 * U}" fill="#ffb547"/>`).join('');
chartBox.innerHTML = `<svg viewBox="0 0 ${cw} ${ch}">
<defs><clipPath id="past"><rect id="pastRect" x="0" y="-40" width="0" height="${ch + 80}"/></clipPath></defs>
${grid}${dates}${burns}
<path d="${lo}" fill="none" stroke="rgba(255,90,60,0.28)" stroke-width="${2.5 * U}"/>
<g clip-path="url(#past)"><path d="${lo}" fill="none" stroke="#ff5a3c" stroke-width="${4 * U}" stroke-linejoin="round"/></g>
<line id="playhead" x1="0" x2="0" y1="${padT}" y2="${ch - padB}" stroke="#fff" stroke-opacity="0.7" stroke-width="${2 * U}"/>
<circle id="playdot" r="${8 * U}" fill="#fff"/>
</svg>`;
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.innerHTML = `<div class="name ${cls}">${name}</div><div class="meta">${meta}</div>`;
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)}<small>km</small>`;
if (m >= 1000) return `${(m / 1000).toFixed(1)}<small>km</small>`;
return `${Math.round(m / 5) * 5}<small>m</small>`;
}
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))}`;
const inShadow = st.lx < 0.5 && st.sep < 5e3;
$('#clock .shadow').style.opacity = inShadow ? 1 : 0;
$('#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;
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`;
ro.querySelector('.ctx').textContent = ctxLine;
// Scale note
const magEl = $('#mag');
const showMag = st.mag > 1.5 || (st.camDistX < 200 && st.sep < 2e3);
magEl.style.opacity = showMag ? live : 0;
magEl.textContent = st.mag > 1.5
? `Satellites drawn ${Math.round(st.mag / 5) * 5}× actual size`
: 'Actual scale · Cosmos 2612 shape is illustrative';
// 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 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 active) {
html += `<div class="event ${e.kind}" style="opacity:${a * live}"><i></i><div><div class="t">${e.title}</div><div class="sub">${e.sub}</div></div></div>`;
}
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. 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 * (p.hidden ? 0.4 : 1);
el.style.opacity = a;
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 += `<line x1="${p.x + best.sx * 12 * U}" y1="${p.y + best.sy * 12 * U}" x2="${best.lx}" y2="${best.ly}" stroke="${color}" stroke-opacity="${a * 0.85}" stroke-width="${2.5 * U}"/>`;
svg += `<circle cx="${p.x}" cy="${p.y}" r="${15 * U}" fill="none" stroke="${color}" stroke-opacity="${a * 0.9}" stroke-width="${2.5 * U}"/>`;
};
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');
// Dimension line with a distance pill, when the pair is close and both visible.
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 += `<line x1="${pX.x}" y1="${pX.y}" x2="${pC.x}" y2="${pC.y}" stroke="#fff" stroke-opacity="${0.75 * dimA}" stroke-width="${2.5 * U}" stroke-dasharray="${10 * U} ${8 * U}"/>`;
dim.style.left = `${(pX.x + pC.x) / 2}px`;
dim.style.top = `${(pX.y + pC.y) / 2}px`;
dim.innerHTML = fmtSep(st.sep).replace(/<\/?small>/g, ' ').trim();
}
dim.style.opacity = dimA;
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 = `<div class="panel">
<div class="big">~${CA_M} m</div>
<div class="lead">Russia's Cosmos 2612 got within about ${CA_M} meters of the U.S. radar satellite ICEYE-X36.</div>
<div class="stats">
<div><b>22</b><span>days of stalking</span></div>
<div><b>8</b><span>engine burns</span></div>
<div><b>0.9 m/s</b><span>closest-pass speed</span></div>
</div>
<div class="why">Tracking firm LeoLabs says maneuvers like these could support spying, inspection, interference — or preparing future attacks.</div>
</div>`;
}
$('#endlogo').style.opacity = endA;
$('#logo').style.opacity = live;
}
return { update };
}
+7 -3
View File
@@ -37,7 +37,9 @@ const [W, H] = FORMATS[format];
const FPS = Number(args.fps || 30);
const DUR = 45;
const total = Math.round(DUR * FPS);
const outDir = join(ROOT, 'output/rpo-replay', format);
const TAG = args.tag ? String(args.tag) : '';
const RS = Number(args.rs || 1);
const outDir = join(ROOT, 'output/rpo-replay', TAG, format);
const frameDir = join(outDir, 'frames');
const MIME = {
@@ -80,7 +82,7 @@ async function exists(p) {
}
function encode() {
const out = join(outDir, `cosmos2612-iceye-x36-${format}.mp4`);
const out = join(outDir, `cosmos2612-iceye-x36-${TAG ? TAG + '-' : ''}${format}.mp4`);
const r = spawnSync('ffmpeg', [
'-y', '-framerate', String(FPS), '-i', join(frameDir, '%05d.jpg'),
'-c:v', 'libx264', '-preset', 'slow', '-crf', '16', '-pix_fmt', 'yuv420p', '-movflags', '+faststart',
@@ -106,7 +108,7 @@ page.on('console', (m) => {
if (m.type() === 'error' || m.type() === 'warning') console.log(`[page ${m.type()}]`, m.text());
});
page.on('pageerror', (e) => console.log('[pageerror]', e.message));
await page.goto(`http://127.0.0.1:${port}/scene.html?w=${W}&h=${H}`);
await page.goto(`http://127.0.0.1:${port}/scene.html?w=${W}&h=${H}&rs=${RS}&settle=${args.stills ? 60 : Number(args.settle || 4)}`);
await page.waitForFunction(() => window.rpo?.ready, null, { timeout: 180000 });
if (args.stills) {
@@ -127,9 +129,11 @@ if (args.stills) {
for (let f = from; f < to; f++) {
const path = join(frameDir, `${String(f).padStart(5, '0')}.jpg`);
if (await exists(path)) continue;
const tf = Date.now();
await page.evaluate((x) => window.rpo.frame(x), f / FPS);
await page.screenshot({ path, type: 'jpeg', quality: 94 });
done++;
if (args.profile) console.log(`frame ${f} ${(Date.now() - tf) / 1000}s`);
if (done % 25 === 0) {
const per = (Date.now() - t0) / done;
console.log(`${format} frame ${f}/${to} ${(per / 1000).toFixed(2)} s/frame eta ${(((to - f) * per) / 60000).toFixed(1)} min`);
+132 -86
View File
@@ -4,124 +4,170 @@
<meta charset="utf-8">
<title>Cosmos 2612 / ICEYE-X36 replay</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=block" rel="stylesheet">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@500;600;700&display=block" rel="stylesheet">
<link href="/cesium/Widgets/widgets.css" rel="stylesheet">
<style>
/* Sizes are in --u: 1 px at 1920x1080 (landscape) or 1080x1920 (portrait).
Phone playback is ~0.2x (16:9 in a feed) to ~0.36x (9:16 full screen);
nothing that must be read is smaller than 24u. */
:root {
--x36: #5ce1e6;
--cosmos: #ff5a3c;
--burn: #ffb547;
--ink: #eef2f6;
--ink-2: rgba(238, 242, 246, 0.66);
--ink-3: rgba(238, 242, 246, 0.38);
--rule: rgba(238, 242, 246, 0.16);
--u: 1px; /* layout unit, scaled per format */
--ink: #f4f7fa;
--ink-2: rgba(244, 247, 250, 0.78);
--ink-3: rgba(244, 247, 250, 0.42);
--u: 1px;
--pad: calc(56 * var(--u));
}
html, body { margin: 0; height: 100%; background: #000; overflow: hidden; }
body { font-family: Inter, system-ui, sans-serif; color: var(--ink); -webkit-font-smoothing: antialiased; }
#globe { position: absolute; inset: 0; }
.cesium-viewer-bottom, .cesium-widget-credits { display: none !important; }
#hud { position: absolute; inset: 0; pointer-events: none; }
#vignette { position: absolute; inset: 0;
background: radial-gradient(ellipse at 50% 45%, transparent 55%, rgba(0,0,0,0.55) 100%); }
#topshade { position: absolute; left: 0; right: 0; top: 0; height: calc(380 * var(--u));
background: linear-gradient(rgba(0,0,0,0.55), transparent); }
#botshade { position: absolute; left: 0; right: 0; bottom: 0; height: calc(420 * var(--u));
background: linear-gradient(transparent, rgba(0,0,0,0.72)); }
.mono { font-family: 'JetBrains Mono', ui-monospace, monospace; font-feature-settings: 'tnum' 1, 'zero' 0; }
#caption { position: absolute; left: calc(64 * var(--u)); top: calc(56 * var(--u)); width: calc(760 * var(--u)); }
#caption .kicker { font: 600 calc(15 * var(--u))/1.2 'JetBrains Mono', monospace; letter-spacing: 0.14em;
text-transform: uppercase; color: var(--ink-2); margin-bottom: calc(14 * var(--u)); }
#caption .kicker b { color: var(--ink); font-weight: 600; }
#caption h1 { margin: 0 0 calc(14 * var(--u)); font: 700 calc(46 * var(--u))/1.04 Inter, sans-serif; letter-spacing: -0.02em; }
#caption p { margin: 0; font: 400 calc(21 * var(--u))/1.42 Inter, sans-serif; color: var(--ink-2); }
#caption p em { font-style: normal; color: var(--ink); font-weight: 500; }
.mono { font-family: 'JetBrains Mono', ui-monospace, monospace; font-feature-settings: 'tnum' 1; }
.c-x36 { color: var(--x36) !important; }
.c-cos { color: var(--cosmos) !important; }
.c-burn { color: var(--burn) !important; }
.shadow-text { text-shadow: 0 calc(2 * var(--u)) calc(14 * var(--u)) rgba(0,0,0,0.85), 0 0 calc(3 * var(--u)) rgba(0,0,0,0.6); }
#clock { position: absolute; right: calc(64 * var(--u)); top: calc(56 * var(--u)); text-align: right; }
#clock .date { font: 600 calc(30 * var(--u))/1 'JetBrains Mono', monospace; letter-spacing: -0.01em; }
#clock .time { font: 400 calc(22 * var(--u))/1 'JetBrains Mono', monospace; color: var(--ink-2); margin-top: calc(10 * var(--u)); }
#clock .rate { margin-top: calc(18 * var(--u)); display: inline-flex; gap: calc(10 * var(--u)); align-items: baseline;
font: 500 calc(17 * var(--u))/1 'JetBrains Mono', monospace; color: var(--ink); padding: calc(8 * var(--u)) calc(12 * var(--u));
border: 1px solid var(--rule); border-radius: calc(4 * var(--u)); background: rgba(0,0,0,0.28); }
#clock .rate span { color: var(--ink-2); }
#clock .shadow { margin-top: calc(12 * var(--u)); font: 600 calc(14 * var(--u))/1 'JetBrains Mono', monospace;
letter-spacing: 0.12em; color: #9fb4ff; }
#topshade { position: absolute; left: 0; right: 0; top: 0; height: calc(520 * var(--u));
background: linear-gradient(rgba(0,0,0,0.78), rgba(0,0,0,0.35) 55%, transparent); }
#botshade { position: absolute; left: 0; right: 0; bottom: 0; height: calc(420 * var(--u));
background: linear-gradient(transparent, rgba(0,0,0,0.8)); }
#readout { position: absolute; right: calc(64 * var(--u)); bottom: calc(262 * var(--u)); text-align: right; }
#readout .label { font: 600 calc(13 * var(--u))/1 'JetBrains Mono', monospace; letter-spacing: 0.16em; color: var(--ink-2); }
#readout .val { font: 600 calc(64 * var(--u))/1 'JetBrains Mono', monospace; letter-spacing: -0.03em; margin-top: calc(8 * var(--u)); }
#readout .val small { font-size: 0.45em; color: var(--ink-2); margin-left: 0.15em; letter-spacing: 0; }
#readout .ric { margin-top: calc(10 * var(--u)); font: 400 calc(15 * var(--u))/1.5 'JetBrains Mono', monospace; color: var(--ink-2); }
#readout .ric b { color: var(--ink); font-weight: 500; }
/* Brand */
#logo, #endlogo { position: absolute; display: flex; align-items: center; gap: calc(14 * var(--u)); }
#logo { left: var(--pad); top: calc(44 * var(--u)); }
#logo img { height: calc(58 * var(--u)); }
#logo span, #endlogo span { font: 800 calc(26 * var(--u))/1 Inter, sans-serif; letter-spacing: 0.16em; }
#chart { position: absolute; left: calc(64 * var(--u)); right: calc(64 * var(--u)); bottom: calc(44 * var(--u)); height: calc(190 * var(--u)); }
#chart svg { width: 100%; height: 100%; overflow: visible; }
#chart text { font-family: 'JetBrains Mono', monospace; }
/* Chapters */
#chapters { position: absolute; left: calc(520 * var(--u)); right: calc(440 * var(--u)); top: calc(58 * var(--u));
display: grid; grid-template-columns: repeat(4, 1fr); gap: calc(12 * var(--u)); }
.ch i { display: block; height: calc(6 * var(--u)); background: rgba(255,255,255,0.22); border-radius: calc(3 * var(--u)); overflow: hidden; }
.ch b { display: block; height: 100%; width: 0; background: #fff; }
.ch span { display: block; margin-top: calc(10 * var(--u)); font: 700 calc(24 * var(--u))/1 Inter, sans-serif;
letter-spacing: 0.06em; text-transform: uppercase; color: var(--ink-3); }
.ch.on span { color: var(--ink); }
.tag { position: absolute; transform: translate(0, -50%); white-space: nowrap; }
.tag .name { font: 600 calc(17 * var(--u))/1.1 Inter, sans-serif; letter-spacing: 0.01em; }
.tag .meta { font: 400 calc(13 * var(--u))/1.3 'JetBrains Mono', monospace; color: var(--ink-2); margin-top: calc(3 * var(--u)); }
/* Caption */
#caption { position: absolute; left: var(--pad); top: calc(150 * var(--u)); width: calc(1000 * var(--u)); }
#caption h1 { margin: 0 0 calc(14 * var(--u)); font: 800 calc(66 * var(--u))/1.04 Inter, sans-serif; letter-spacing: -0.025em; }
#caption p { margin: 0; font: 500 calc(36 * var(--u))/1.28 Inter, sans-serif; color: var(--ink-2); max-width: calc(900 * var(--u)); }
#caption p b { color: var(--ink); font-weight: 700; }
#caption.hook { top: calc(170 * var(--u)); width: calc(1240 * var(--u)); }
#caption.hook h1 { font-size: calc(84 * var(--u)); }
#caption.hook p { font-size: calc(38 * var(--u)); max-width: calc(1100 * var(--u)); }
/* Events */
#events { position: absolute; left: var(--pad); top: calc(420 * var(--u)); width: calc(900 * var(--u)); }
.event { display: flex; gap: calc(16 * var(--u)); align-items: flex-start; margin-bottom: calc(18 * var(--u)); }
.event i { flex: none; width: calc(18 * var(--u)); height: calc(18 * var(--u)); border-radius: 50%; margin-top: calc(8 * var(--u)); background: var(--burn);
box-shadow: 0 0 calc(16 * var(--u)) var(--burn); }
.event .t { font: 800 calc(32 * var(--u))/1.1 Inter, sans-serif; letter-spacing: 0.02em; text-transform: uppercase; color: var(--burn); }
.event .sub { font: 500 calc(28 * var(--u))/1.25 Inter, sans-serif; color: var(--ink-2); margin-top: calc(4 * var(--u)); }
.event.note i { background: #fff; box-shadow: 0 0 calc(12 * var(--u)) #fff; }
.event.note .t { color: #fff; }
.event.vantor i { background: #c9b8ff; box-shadow: 0 0 calc(12 * var(--u)) #c9b8ff; }
.event.vantor .t { color: #c9b8ff; }
.event.ca i { background: #fff; }
.event.ca .t { color: #fff; }
/* Clock */
#clock { position: absolute; right: var(--pad); top: calc(40 * var(--u)); text-align: right; }
#clock .date { font: 700 calc(48 * var(--u))/1 'JetBrains Mono', monospace; letter-spacing: -0.02em; }
#clock .time { font: 500 calc(28 * var(--u))/1 'JetBrains Mono', monospace; color: var(--ink-2); margin-top: calc(10 * var(--u)); }
#clock .rate { display: inline-block; margin-top: calc(16 * var(--u)); padding: calc(10 * var(--u)) calc(16 * var(--u));
font: 800 calc(30 * var(--u))/1 Inter, sans-serif; letter-spacing: 0.02em; color: #000; background: #fff; border-radius: calc(8 * var(--u)); }
#clock .shadow { margin-top: calc(14 * var(--u)); font: 700 calc(26 * var(--u))/1 Inter, sans-serif; letter-spacing: 0.04em; color: #b9c8ff; }
/* Readout */
#readout { position: absolute; right: var(--pad); bottom: calc(250 * var(--u)); text-align: right; }
#readout .label { font: 800 calc(26 * var(--u))/1 Inter, sans-serif; letter-spacing: 0.14em; color: var(--ink-2); }
#readout .val { font: 700 calc(132 * var(--u))/0.95 'JetBrains Mono', monospace; letter-spacing: -0.05em; margin-top: calc(8 * var(--u)); }
#readout .val small { font-size: 0.42em; color: var(--ink-2); margin-left: 0.12em; letter-spacing: 0; }
#readout .ctx { margin-top: calc(10 * var(--u)); font: 600 calc(32 * var(--u))/1.2 Inter, sans-serif; color: var(--ink); }
#mag { position: absolute; left: var(--pad); bottom: calc(262 * var(--u)); font: 600 calc(24 * var(--u))/1.3 Inter, sans-serif; color: var(--ink-3); }
/* Chart */
#chartwrap { position: absolute; left: var(--pad); right: var(--pad); bottom: calc(58 * var(--u)); }
#chartwrap .lab { font: 700 calc(22 * var(--u))/1 Inter, sans-serif; letter-spacing: 0.12em; color: var(--ink-3); margin-bottom: calc(10 * var(--u)); }
#chart { height: calc(160 * var(--u)); }
#chart svg { width: 100%; height: 100%; overflow: visible; font-family: 'JetBrains Mono', monospace; font-weight: 600; }
/* Tags + dimension */
#leaders { position: absolute; inset: 0; width: 100%; height: 100%; }
.tag { position: absolute; white-space: nowrap; }
.tag .name { font: 800 calc(34 * var(--u))/1.05 Inter, sans-serif; letter-spacing: 0.01em; }
.tag .meta { font: 600 calc(24 * var(--u))/1.2 Inter, sans-serif; color: var(--ink-2); margin-top: calc(4 * var(--u)); }
#dim { position: absolute; transform: translate(-50%, -50%); padding: calc(6 * var(--u)) calc(14 * var(--u));
font: 700 calc(30 * var(--u))/1 'JetBrains Mono', monospace; color: #000; background: #fff; border-radius: calc(6 * var(--u)); }
#callouts { position: absolute; left: calc(64 * var(--u)); top: calc(330 * var(--u)); width: calc(700 * var(--u)); }
.event { position: relative; padding-left: calc(22 * var(--u)); margin-bottom: calc(14 * var(--u));
font: 600 calc(16 * var(--u))/1.2 'JetBrains Mono', monospace; letter-spacing: 0.08em; }
.event i { position: absolute; left: 0; top: calc(4 * var(--u)); width: calc(9 * var(--u)); height: calc(9 * var(--u)); border-radius: 50%; }
.event .sub { font-weight: 400; letter-spacing: 0.02em; color: var(--ink-2); margin-top: calc(5 * var(--u)); font-size: calc(14 * var(--u)); }
#mag { position: absolute; left: calc(64 * var(--u)); bottom: calc(262 * var(--u));
font: 400 calc(14 * var(--u))/1.4 'JetBrains Mono', monospace; color: var(--ink-3); }
/* Attribution: present, deliberately quiet */
#credits { position: absolute; left: var(--pad); right: var(--pad); bottom: calc(16 * var(--u));
font: 400 calc(15 * var(--u))/1.3 Inter, sans-serif; color: rgba(244,247,250,0.36); white-space: nowrap; overflow: hidden; }
/* End card */
#endcard { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; }
#endcard .panel { width: calc(1180 * var(--u)); }
#endcard h2 { margin: 0 0 calc(28 * var(--u)); font: 700 calc(44 * var(--u))/1.1 Inter, sans-serif; letter-spacing: -0.02em; }
#endcard .stats { display: grid; grid-template-columns: repeat(4, 1fr); gap: calc(24 * var(--u)); margin-bottom: calc(36 * var(--u)); }
#endcard .stat .n { font: 600 calc(48 * var(--u))/1 'JetBrains Mono', monospace; letter-spacing: -0.03em; }
#endcard .stat .l { margin-top: calc(10 * var(--u)); font: 400 calc(16 * var(--u))/1.35 Inter, sans-serif; color: var(--ink-2); }
#endcard .src { font: 400 calc(14 * var(--u))/1.6 'JetBrains Mono', monospace; color: var(--ink-3); border-top: 1px solid var(--rule); padding-top: calc(18 * var(--u)); }
#endcard .src b { color: var(--ink-2); font-weight: 500; }
#endcard .panel { width: calc(1300 * var(--u)); text-align: center; }
#endcard .big { font: 800 calc(190 * var(--u))/0.9 'JetBrains Mono', monospace; letter-spacing: -0.05em; color: var(--cosmos); }
#endcard .lead { margin: calc(26 * var(--u)) auto 0; max-width: calc(1100 * var(--u)); font: 700 calc(44 * var(--u))/1.2 Inter, sans-serif; letter-spacing: -0.01em; }
#endcard .stats { display: flex; justify-content: center; gap: calc(80 * var(--u)); margin: calc(44 * var(--u)) 0; }
#endcard .stats b { display: block; font: 700 calc(64 * var(--u))/1 'JetBrains Mono', monospace; letter-spacing: -0.03em; }
#endcard .stats span { display: block; margin-top: calc(10 * var(--u)); font: 600 calc(28 * var(--u))/1.2 Inter, sans-serif; color: var(--ink-2); }
#endcard .why { max-width: calc(1100 * var(--u)); margin: 0 auto; font: 500 calc(32 * var(--u))/1.35 Inter, sans-serif; color: var(--ink-2); }
#endlogo { left: 50%; top: calc(70 * var(--u)); transform: translateX(-50%); }
#endlogo img { height: calc(84 * var(--u)); }
#endlogo span { font-size: calc(36 * var(--u)); }
/* Portrait */
body.portrait #caption { left: calc(56 * var(--u)); right: calc(56 * var(--u)); top: calc(170 * var(--u)); width: auto; }
body.portrait #caption h1 { font-size: calc(54 * var(--u)); }
body.portrait #caption p { font-size: calc(25 * var(--u)); }
body.portrait #clock { left: calc(56 * var(--u)); right: auto; top: calc(64 * var(--u)); text-align: left; display: flex; gap: calc(22 * var(--u)); align-items: baseline; flex-wrap: wrap; width: calc(968 * var(--u)); }
body.portrait #clock .time { margin-top: 0; }
body.portrait #clock .rate { margin-top: 0; margin-left: auto; }
body.portrait #clock .shadow { margin-top: 0; width: 100%; text-align: right; }
body.portrait #callouts { left: calc(56 * var(--u)); top: calc(560 * var(--u)); }
body.portrait #readout { right: auto; left: calc(56 * var(--u)); bottom: calc(400 * var(--u)); text-align: left; }
body.portrait #readout .val { font-size: calc(76 * var(--u)); }
body.portrait #mag { left: auto; right: calc(56 * var(--u)); bottom: calc(400 * var(--u)); text-align: right; }
body.portrait #chart { left: calc(56 * var(--u)); right: calc(56 * var(--u)); bottom: calc(110 * var(--u)); height: calc(250 * var(--u)); }
body.portrait #topshade { height: calc(820 * var(--u)); }
body.portrait #botshade { height: calc(880 * var(--u)); }
body.portrait #endcard .panel { width: calc(940 * var(--u)); }
body.portrait #endcard .stats { grid-template-columns: repeat(2, 1fr); gap: calc(34 * var(--u)); }
body.portrait #endcard h2 { font-size: calc(52 * var(--u)); }
body.portrait #endcard .stat .n { font-size: calc(60 * var(--u)); }
body.portrait #endcard .stat .l { font-size: calc(20 * var(--u)); }
body.portrait #endcard .src { font-size: calc(17 * var(--u)); }
/* ---------------- Portrait ---------------- */
body.portrait { --pad: calc(52 * var(--u)); }
body.portrait #logo { top: calc(64 * var(--u)); }
body.portrait #clock { top: calc(56 * var(--u)); }
body.portrait #clock .date { font-size: calc(44 * var(--u)); }
body.portrait #chapters { left: var(--pad); right: var(--pad); top: calc(236 * var(--u)); }
body.portrait #caption, body.portrait #caption.hook { top: calc(316 * var(--u)); width: auto; right: var(--pad); }
body.portrait #caption h1 { font-size: calc(70 * var(--u)); }
body.portrait #caption.hook h1 { font-size: calc(80 * var(--u)); }
body.portrait #caption p, body.portrait #caption.hook p { font-size: calc(38 * var(--u)); max-width: none; }
body.portrait #events { top: calc(640 * var(--u)); width: auto; right: var(--pad); }
body.portrait #readout { right: auto; left: var(--pad); bottom: calc(590 * var(--u)); text-align: left; }
body.portrait #readout .val { font-size: calc(140 * var(--u)); }
body.portrait #mag { left: auto; right: var(--pad); bottom: calc(600 * var(--u)); text-align: right; max-width: calc(380 * var(--u)); }
body.portrait #chartwrap { bottom: calc(340 * var(--u)); }
body.portrait #chart { height: calc(190 * var(--u)); }
body.portrait #topshade { height: calc(900 * var(--u)); }
body.portrait #botshade { height: calc(1100 * var(--u)); background: linear-gradient(transparent, rgba(0,0,0,0.78) 45%, rgba(0,0,0,0.85)); }
body.portrait #credits { white-space: normal; bottom: calc(250 * var(--u)); font-size: calc(17 * var(--u)); }
body.portrait #endcard .panel { width: calc(960 * var(--u)); }
body.portrait #endcard .big { font-size: calc(200 * var(--u)); }
body.portrait #endcard .lead { font-size: calc(48 * var(--u)); }
body.portrait #endcard .stats { gap: calc(40 * var(--u)); }
body.portrait #endcard .stats b { font-size: calc(58 * var(--u)); }
body.portrait #endcard .why { font-size: calc(36 * var(--u)); }
body.portrait #endlogo { top: calc(170 * var(--u)); }
</style>
</head>
<body>
<div id="globe"></div>
<div id="hud">
<div id="vignette"></div>
<div id="topshade"></div>
<div id="botshade"></div>
<svg id="leaders"></svg>
<div id="tags"></div>
<div id="callouts"></div>
<div id="caption"><div class="kicker"></div><h1></h1><p></p></div>
<div id="clock"><div class="date mono"></div><div class="time mono"></div><div class="rate"></div><div class="shadow"></div></div>
<div id="readout"><div class="label">SEPARATION</div><div class="val"></div><div class="ric"></div></div>
<div id="mag"></div>
<div id="chart"></div>
<div id="tags" class="shadow-text"></div>
<div id="dim"></div>
<div id="logo" class="shadow-text"><img src="logo.svg" alt=""><span>GOD'S EYE VIEW</span></div>
<div id="chapters" class="shadow-text"></div>
<div id="caption" class="shadow-text"><h1></h1><p></p></div>
<div id="events" class="shadow-text"></div>
<div id="clock" class="shadow-text"><div class="date"></div><div class="time"></div><div class="rate"></div><div class="shadow">● IN EARTH'S SHADOW</div></div>
<div id="readout" class="shadow-text"><div class="label">DISTANCE APART</div><div class="val"></div><div class="ctx"></div></div>
<div id="mag" class="shadow-text"></div>
<div id="chartwrap"><div class="lab">DISTANCE OVER 22 DAYS · LOG SCALE</div><div id="chart"></div></div>
<div id="endcard"></div>
<div id="endlogo"><img src="logo.svg" alt=""><span>GOD'S EYE VIEW</span></div>
<div id="credits">Reconstruction from public data (CelesTrak elements; burn timeline: KeepTrack, LeoLabs, M. Langbroek) · Imagery: Esri, Maxar, Earthstar Geographics; NASA Black Marble · Stars: Yale BSC, ESO/S. Brunier (CC BY 4.0) · Not tracking data</div>
<div id="dip" style="position:absolute;inset:0;background:#000;opacity:0"></div>
</div>
<script src="/cesium/Cesium.js"></script>
+35 -331
View File
@@ -3,6 +3,8 @@
// window.rpo.frame(seconds) sets every piece of state for one video instant,
// renders, and resolves once imagery and models have settled.
import { createHud } from './hud.js';
const params = new URLSearchParams(location.search);
const W = Number(params.get('w') || 1920);
const H = Number(params.get('h') || 1080);
@@ -17,6 +19,7 @@ const DT = D.dt;
const NS = D.sep_km.length;
const sec = (iso) => (Date.parse(iso) - T0) / 1000;
const DUR = 45;
const SETTLE = Number(params.get('settle') || 4);
// ---------------------------------------------------------------------------
// Interpolation
@@ -171,67 +174,7 @@ function sOfTau(t) {
// ---------------------------------------------------------------------------
// Story: captions, events, camera
// ---------------------------------------------------------------------------
const fmtKm = (km) => (km >= 100 ? `${Math.round(km).toLocaleString('en-US')}` : km >= 10 ? km.toFixed(0) : km.toFixed(1));
const CAPTIONS = [
{
s0: 0.2, s1: 4.3,
kicker: 'Low Earth orbit · 540 km · <b>Sep 30 – Oct 21, 2026</b>',
title: 'Hunting a radar satellite',
body: 'Over three weeks, Russia\'s military satellite <em class="c-cos">Cosmos 2612</em> stalked <em class="c-x36">ICEYE-X36</em>, a U.S.-registered radar imager whose pictures help Ukraine — and closed to a few hundred meters.',
},
{
s0: 4.5, s1: 12.3,
kicker: '<b>1</b> · Phasing',
title: 'Climb high, fall behind',
body: 'Two burns lift Cosmos 2612 <em>47 km</em> above its target. A higher orbit is slower, so it drifts back <em>~6,500 km a day</em> and laps X36 — while Earth\'s equatorial bulge slowly swings its orbit plane into line.',
},
{
s0: 12.6, s1: 21.9,
kicker: '<b>2</b> · Descent',
title: 'Drop into the same orbit',
body: 'Directly overhead on Oct 12, it drops 47 km into X36\'s orbit, <em>480 km behind</em>. On Oct 15 a 2 km dip makes it slightly faster: it closes <em>~350 km a day</em> — then brakes 3 km behind.',
},
{
s0: 22.1, s1: 29.6,
kicker: '<b>3</b> · Proximity',
title: 'Close enough to inspect',
body: 'For two days it holds station behind X36, its path looping <em>1.6 km</em> each orbit as it creeps forward. These are the maneuvers of an inspector: they need fuel, planning, and sensors that can find the target.',
},
{
s0: 29.8, s1: 35.6,
kicker: '<b>3</b> · Closest approach',
title: 'In Earth\'s shadow',
body: 'Early on Oct 18, during orbital night, it passes about <em>%CA% m</em> behind X36 at under 1 m/s. Published estimates range from <em>150 to 650 m</em>. Nine minutes later, the sun comes up.',
},
{
s0: 35.8, s1: 41.0,
kicker: '<b>4</b> · Departure',
title: 'Back off, and wait',
body: 'A 1.3 m/s burn lifts it 2.4 km and it falls away at <em>~350 km a day</em>. On Oct 19 it matches orbits again — and today it is still parked <em>480 km behind</em>.',
},
];
const BURNS = D.burns.map((b) => ({ ...b, t: sec(b.t) }));
const BURN_NOTES = {
'Phasing raise': '+46 km',
'Plane trim': 'out-of-plane',
'Descent to X36 orbit': '−47 km',
'Closing burn': '−2.3 km',
'Braking burn': '+2.5 km',
'Back-off': '+0.13 km',
'Departure burn': '+2.4 km',
'Re-match orbit': '−2.6 km',
};
const EVENTS = [
...BURNS.map((b) => ({ t: b.t, kind: 'burn', title: `BURN · Δv ${b.dv < 1 ? b.dv.toFixed(2) : b.dv.toFixed(1)} m/s`, sub: `${b.label} · ${BURN_NOTES[b.label] || ''}`, at: 'cosmos' })),
{ t: sec('2026-10-05T11:55:00Z'), kind: 'note', title: 'OVERHEAD PASS', sub: '~50 km above X36', at: 'cosmos' },
{ t: sec('2026-10-12T00:10:00Z'), kind: 'note', title: 'OVERHEAD PASS', sub: '47 km directly above', at: 'cosmos' },
{ t: VANTOR_T, kind: 'vantor', title: 'VANTOR IMAGES THE PAIR', sub: `from orbit · ~${D.vantor.km.toFixed(1)} km apart · Oct 17`, at: 'mid' },
{ t: CA_T, kind: 'ca', title: `CLOSEST APPROACH · ~${Math.round(D.ca.m / 5) * 5} m`, sub: `${D.ca.t.slice(11, 16)} UTC Oct 18 · est. range 150–650 m`, at: 'mid' },
].map((e) => ({ ...e, s: sOfTau(e.t) }));
// Two burns that land in the same instant only show once.
EVENTS.sort((a, b) => a.s - b.s);
// Camera keys, in X36's local frame (R up, T velocity, N orbit normal).
// dist: metres (or auto = f * separation, floored at min). az: 0 = behind X36
@@ -245,9 +188,9 @@ const CAM = [
{ s: 15.4, dist: 1.6e6, f: 2.8, az: 32, el: 36, look: 0.5, earth: 0, fov: 34, mag: 0, trail: 8 },
{ s: 18.0, dist: 1.4e6, f: 2.8, az: 26, el: 34, look: 0.5, earth: 0, fov: 34, mag: 0, trail: 12 },
{ s: 20.4, dist: 4.0e4, f: 2.8, az: 40, el: 32, look: 0.5, earth: 0, fov: 34, mag: 0, trail: 10 },
{ s: 22.4, dist: 6.5e3, f: 0, az: 58, el: 34, look: 0.45, earth: 0, fov: 36, mag: 1, trail: 9 },
{ s: 26.0, dist: 4.6e3, f: 0, az: 76, el: 30, look: 0.5, earth: 0, fov: 36, mag: 1, trail: 9 },
{ s: 28.8, dist: 2.2e3, f: 0, az: 100, el: 22, look: 0.4, earth: 0, fov: 36, mag: 1, trail: 4 },
{ s: 22.4, dist: 6.5e3, f: 2.4, az: 58, el: 34, look: 0.45, earth: 0, fov: 36, mag: 1, trail: 9 },
{ s: 26.0, dist: 4.6e3, f: 2.4, az: 76, el: 30, look: 0.5, earth: 0, fov: 36, mag: 1, trail: 9 },
{ s: 28.8, dist: 2.2e3, f: 2.4, az: 100, el: 22, look: 0.4, earth: 0, fov: 36, mag: 1, trail: 4 },
{ s: 29.9, dist: 12, f: 0, az: 96, el: 16, look: 0.0, earth: 0, fov: 40, mag: 0, trail: 0.3 },
{ s: 30.8, dist: 13, f: 0, az: 122, el: 14, look: 0.0, earth: 0, fov: 40, mag: 0, trail: 0.3 },
{ s: 31.6, dist: 18, f: 0, az: 168, el: 9, look: 0.0, earth: 0, fov: 40, mag: 0, trail: 0.3, aimRel: [0, -60, 0] },
@@ -295,7 +238,7 @@ function camAt(s) {
// ---------------------------------------------------------------------------
const esri = await Cesium.ArcGisMapServerImageryProvider.fromUrl(
'https://services.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer',
{ enablePickFeatures: false, maximumLevel: 11 },
{ enablePickFeatures: false, maximumLevel: 9 },
);
const viewer = new Cesium.Viewer('globe', {
baseLayer: new Cesium.ImageryLayer(esri, { brightness: 0.92, contrast: 1.08, gamma: 1.05 }),
@@ -345,7 +288,7 @@ globe.lightingFadeOutDistance = 1;
globe.lightingFadeInDistance = 2;
globe.nightFadeOutDistance = 1e10;
globe.nightFadeInDistance = 1e10;
globe.maximumScreenSpaceError = 1.6;
globe.maximumScreenSpaceError = 2.2;
globe.tileCacheSize = 1500;
globe.preloadSiblings = false;
globe.baseColor = Cesium.Color.fromCssColorString('#04070d');
@@ -360,7 +303,7 @@ scene.backgroundColor = Cesium.Color.BLACK;
scene.screenSpaceCameraController.enableInputs = false;
scene.camera.frustum.near = 0.5;
scene.camera.frustum.far = 1e9;
viewer.resolutionScale = 1;
viewer.resolutionScale = Number(params.get('rs') || 1);
const bloom = scene.postProcessStages.bloom;
bloom.enabled = false;
bloom.uniforms.contrast = 119;
@@ -425,113 +368,6 @@ function polyline(positions, colors, width) {
// ---------------------------------------------------------------------------
// HUD
// ---------------------------------------------------------------------------
const $ = (q) => document.querySelector(q);
const capEl = $('#caption');
const tagsEl = $('#tags');
const leaders = $('#leaders');
const callEl = $('#callouts');
leaders.setAttribute('viewBox', `0 0 ${W} ${H}`);
function fade(s, s0, s1, ramp = 0.45) {
return Math.min(1, Math.max(0, (s - s0) / ramp), Math.max(0, (s1 - s) / ramp));
}
function fmtRate(r) {
const p = 10 ** Math.floor(Math.log10(r) - 1);
const rr = Math.round(r / p) * p;
const real = rr;
let per;
if (real < 60) per = `${real.toFixed(0)} s`;
else if (real < 3600) per = `${(real / 60).toFixed(real < 600 ? 1 : 0)} min`;
else if (real < 86400) per = `${(real / 3600).toFixed(real < 36000 ? 1 : 0)} h`;
else per = `${(real / 86400).toFixed(1)} days`;
return `<span>×</span>${rr.toLocaleString('en-US')} <span>· 1 s = ${per}</span>`;
}
// Separation chart (log scale), drawn once and 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');
function buildChart() {
const cw = chartBox.clientWidth;
const ch = chartBox.clientHeight;
const padL = 70 * U;
const padT = 34 * U;
const padB = 26 * 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 cols = Math.round(cw - padL);
let lo = '';
let hi = '';
const ptsHi = [];
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;
let mx = 0;
for (let i = Math.floor(ta / DT); i <= Math.ceil(tb / DT); i++) {
const v = D.sep_km[Math.min(Math.max(i, 0), NS - 1)];
mn = Math.min(mn, v);
mx = Math.max(mx, v);
}
mn = Math.max(mn, CH.lo);
lo += `${c ? 'L' : 'M'}${(padL + c).toFixed(1)},${y(mn).toFixed(1)}`;
ptsHi.push(`${(padL + c).toFixed(1)},${y(mx).toFixed(1)}`);
}
hi = 'L' + ptsHi.reverse().join('L');
const grid = [0.1, 1, 10, 100, 1000, 10000]
.map((v) => {
const lab = v < 1 ? '100 m' : v < 1000 ? `${v} km` : `${(v).toLocaleString('en-US')} km`;
return `<line x1="${padL}" x2="${cw}" y1="${y(v)}" y2="${y(v)}" stroke="rgba(238,242,246,0.10)" stroke-width="1"/>
<text x="${padL - 10 * U}" y="${y(v) + 4 * U}" text-anchor="end" font-size="${12 * U}" fill="rgba(238,242,246,0.45)">${lab}</text>`;
})
.join('');
const days = [];
for (let d = 0; d <= 21; d++) {
const t = sec(`2026-${d < 1 ? '09-30' : `10-${String(d).padStart(2, '0')}`}T00:00:00Z`);
const major = d === 0 || d % (PORTRAIT ? 7 : 3) === 0;
days.push(`<line x1="${x(t)}" x2="${x(t)}" y1="${ch - padB}" y2="${ch - padB + (major ? 6 : 3) * U}" stroke="rgba(238,242,246,0.35)"/>`);
if (major) {
days.push(`<text x="${x(t)}" y="${ch - padB + 20 * U}" text-anchor="middle" font-size="${12 * U}" fill="rgba(238,242,246,0.5)">${d === 0 ? 'SEP 30' : `OCT ${d}`}</text>`);
}
}
const burns = BURNS.map(
(b) => `<g class="burn" data-t="${b.t}"><path d="M${x(b.t)},${padT - 12 * U} l${-5 * U},${-9 * U} h${10 * U} z" fill="#ffb547"/>
<line x1="${x(b.t)}" x2="${x(b.t)}" y1="${padT - 10 * U}" y2="${ch - padB}" stroke="rgba(255,181,71,0.22)" stroke-dasharray="${2 * U} ${3 * U}"/></g>`,
).join('');
const caX = x(CA_T);
const caY = y(D.ca.m / 1000);
chartBox.innerHTML = `<svg viewBox="0 0 ${cw} ${ch}">
<defs><clipPath id="past"><rect id="pastRect" x="0" y="-40" width="0" height="${ch + 80}"/></clipPath></defs>
<text x="${padL}" y="${padT - 26 * U}" font-size="${12 * U}" letter-spacing="0.14em" fill="rgba(238,242,246,0.62)">DISTANCE BETWEEN THE SATELLITES · LOG SCALE</text>
<text x="${cw}" y="${padT - 26 * U}" text-anchor="end" font-size="${12 * U}" letter-spacing="0.1em" fill="rgba(255,181,71,0.85)">▼ BURN</text>
${grid}${days.join('')}${burns}
<path d="${lo}${hi}Z" fill="rgba(255,90,60,0.07)"/>
<path d="${lo}" fill="none" stroke="rgba(255,90,60,0.22)" stroke-width="${1.4 * U}"/>
<g clip-path="url(#past)">
<path d="${lo}${hi}Z" fill="rgba(255,90,60,0.28)"/>
<path d="${lo}" fill="none" stroke="#ff5a3c" stroke-width="${2 * U}"/>
</g>
<g id="caMark" opacity="0"><circle cx="${caX}" cy="${caY}" r="${5 * U}" fill="none" stroke="#fff" stroke-width="${1.5 * U}"/>
<text x="${caX - 10 * U}" y="${caY + 4 * U}" text-anchor="end" font-size="${12 * U}" fill="#fff">~${Math.round(D.ca.m / 5) * 5} m</text></g>
<line id="playhead" x1="0" x2="0" y1="${padT - 4 * U}" y2="${ch - padB}" stroke="#fff" stroke-width="${1.2 * U}"/>
<circle id="playdot" r="${4.5 * U}" fill="#fff"/>
</svg>`;
return { x, y };
}
const chart = buildChart();
function tag(id, cls) {
let el = document.getElementById(id);
if (!el) {
el = document.createElement('div');
el.id = id;
el.className = `tag ${cls}`;
el.innerHTML = '<div class="name"></div><div class="meta"></div>';
tagsEl.appendChild(el);
}
return el;
}
function project(world) {
const p = Cesium.SceneTransforms.worldToWindowCoordinates(scene, world);
if (!p) return null;
@@ -544,6 +380,9 @@ function project(world) {
if (hit && hit.start * 1.0005 < v3.magnitude(d)) return { ...p, hidden: true };
return p;
}
const hud = createHud({
D, W, H, U, PORTRAIT, sec, rate, sOfTau, sepAt, CA_T, VANTOR_T, BURNS, CUTS, project,
});
// ---------------------------------------------------------------------------
// Frame
@@ -602,7 +441,7 @@ async function frame(s) {
v3.add(aim, v3.multiplyByScalar(RW, cam.aimRel[0], new v3()), aim);
v3.add(aim, v3.multiplyByScalar(TW, cam.aimRel[1], new v3()), aim);
v3.add(aim, v3.multiplyByScalar(NW, cam.aimRel[2], new v3()), aim);
const dist = Math.max(cam.dist, cam.f * sep);
const dist = Math.max(cam.dist, cam.f * sep) * (PORTRAIT && cam.earth > 0.5 ? 0.74 : 1);
const az = Cesium.Math.toRadians(cam.az);
const el = Cesium.Math.toRadians(cam.el);
const off = new v3();
@@ -616,8 +455,22 @@ async function frame(s) {
const minR = 6.371e6 + 1.2e5;
if (v3.magnitude(camPos) < minR) v3.multiplyByScalar(v3.normalize(camPos, camPos), minR, camPos);
const dir = v3.normalize(v3.subtract(aim, camPos, new v3()), new v3());
const right = v3.normalize(v3.cross(dir, RW, new v3()), new v3());
const up = v3.cross(right, dir, new v3());
let right = v3.normalize(v3.cross(dir, RW, new v3()), new v3());
let up = v3.cross(right, dir, new v3());
{
// Composition: put the aim point right of centre in 16:9 (the caption
// column is on the left); portrait keeps it centred between the text bands.
const fovR = Cesium.Math.toRadians(PORTRAIT ? cam.fov * 1.55 : cam.fov);
const aspect = W / H;
const tanH = PORTRAIT ? Math.tan(fovR / 2) * aspect : Math.tan(fovR / 2);
const tanV = PORTRAIT ? Math.tan(fovR / 2) : Math.tan(fovR / 2) / aspect;
const ndcX = PORTRAIT ? 0 : 0.2;
const ndcY = PORTRAIT ? -0.02 : -0.06;
v3.normalize(v3.add(v3.add(dir, v3.multiplyByScalar(right, -ndcX * tanH, new v3()), new v3()),
v3.multiplyByScalar(up, -ndcY * tanV, new v3()), new v3()), dir);
right = v3.normalize(v3.cross(dir, RW, new v3()), new v3());
up = v3.cross(right, dir, new v3());
}
scene.camera.setView({ destination: camPos, orientation: { direction: dir, up } });
scene.camera.frustum.fov = Cesium.Math.toRadians(PORTRAIT ? cam.fov * 1.55 : cam.fov);
{
@@ -663,8 +516,8 @@ async function frame(s) {
const pxC = (3.6 * mag * pxPerRad) / camDistC;
bX36.position = x36W;
bCos.position = cosW;
bX36.scale = (pxX > 24 ? 0.12 : 0.28) * U;
bCos.scale = (pxC > 24 ? 0.12 : 0.28) * U;
bX36.scale = (pxX > 24 ? 0.14 : 0.42) * U;
bCos.scale = (pxC > 24 ? 0.14 : 0.42) * U;
bX36.color = Cesium.Color.WHITE.withAlpha(pxX > 40 ? 0 : 1);
bCos.color = Cesium.Color.WHITE.withAlpha(pxC > 40 ? 0 : 1);
@@ -741,11 +594,12 @@ async function frame(s) {
void ax;
}
updateHud(s, t, b, { x36W, cosW, RW, TW, NW, rel, sep, mag, gridA, lx, camDistX, jd });
hud.update(s, t, b, { x36W, cosW, RW, TW, NW, rel, sep, mag, gridA, lx, camDistX, jd });
// Render; re-render only while imagery is still streaming in.
scene.render(jd);
for (let k = 0; k < 60 && !(globe.tilesLoaded && mX36.ready && mCos.ready); k++) {
await new Promise((r) => setTimeout(r, 30));
// Bounded: parents stand in for children still streaming; sequential frames converge.
for (let k = 0; k < SETTLE && !(globe.tilesLoaded && mX36.ready && mCos.ready); k++) {
await new Promise((r) => setTimeout(r, 60));
scene.render(jd);
}
const gl = scene.context._gl;
@@ -760,155 +614,5 @@ function sOfTauCached(t) {
return sOfTauMemo.get(t);
}
function fmtSep(m) {
if (m >= 10e3) return `${fmtKm(m / 1000)}<small>km</small>`;
if (m >= 1000) return `${(m / 1000).toFixed(2)}<small>km</small>`;
return `${Math.round(m)}<small>m</small>`;
}
function fmtComp(m) {
const a = Math.abs(m);
return a >= 10e3 ? `${fmtKm(a / 1000)} km` : a >= 1000 ? `${(a / 1000).toFixed(2)} km` : `${Math.round(a)} m`;
}
function updateHud(s, t, b, st) {
// Caption
let cap = null;
let capA = 0;
for (const c of CAPTIONS) {
const a = fade(s, c.s0, c.s1);
if (a > capA) {
cap = c;
capA = a;
}
}
if (cap) {
capEl.querySelector('.kicker').innerHTML = cap.kicker;
capEl.querySelector('h1').innerHTML = cap.title;
capEl.querySelector('p').innerHTML = cap.body.replace('%CA%', String(Math.round(D.ca.m / 5) * 5));
}
capEl.style.opacity = capA;
capEl.style.transform = `translateY(${(1 - capA) * 10 * U}px)`;
// Clock
const d = new Date(T0 + t * 1000);
const mon = d.toLocaleString('en-US', { month: 'short', timeZone: 'UTC' }).toUpperCase();
$('#clock .date').textContent = `${mon} ${d.getUTCDate()}, 2026`;
$('#clock .time').textContent = `${d.toISOString().slice(11, 16)} UTC`;
$('#clock .rate').innerHTML = fmtRate(rate(s));
const inShadow = st.lx < 0.5 && st.sep < 5e3;
$('#clock .shadow').textContent = inShadow ? '● IN EARTH\'S SHADOW' : '';
const endA = fade(s, 41.2, 60, 0.6);
$('#clock').style.opacity = 1 - endA;
// Readout
const ro = $('#readout');
ro.style.opacity = fade(s, 0.6, 41.2, 0.5);
ro.querySelector('.val').innerHTML = fmtSep(st.sep);
const [r, tt, n] = st.rel;
if (st.sep < 2e6) {
ro.querySelector('.ric').innerHTML =
`<b>${fmtComp(tt)}</b> ${tt < 0 ? 'behind' : 'ahead'} · <b>${fmtComp(r)}</b> ${r >= 0 ? 'above' : 'below'} · <b>${fmtComp(n)}</b> aside`;
} else {
const deg = (Math.abs(((tt / Cesium.Cartesian3.magnitude(x36Teme(b).r)) * 180) / Math.PI) % 360);
ro.querySelector('.ric').innerHTML = `<b>${(deg > 180 ? 360 - deg : deg).toFixed(0)}°</b> apart along the orbit · <b>${fmtComp(r)}</b> ${r >= 0 ? 'higher' : 'lower'}`;
}
// Magnification note
const magEl = $('#mag');
magEl.style.opacity = st.mag > 1.5 ? fade(s, 0, 41, 0.4) : st.mag <= 1.05 && st.camDistX < 200 ? fade(s, 0, 41, 0.4) : 0;
magEl.innerHTML = st.mag > 1.5
? `Spacecraft drawn ${Math.round(st.mag / 5) * 5 || 1}× actual size<br>Rings: 500 m · 1 km · 2 km in X36's orbit plane`
: 'Spacecraft at true scale<br>Cosmos 2612 model is illustrative — no public design imagery';
// Chart
const px = chart.x(Math.min(Math.max(t, CH.t0), CH.t1));
const sepKm = Math.max(sepAt(t), CH.lo);
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(sepKm));
document.getElementById('caMark').setAttribute('opacity', t > CA_T ? 1 : 0);
for (const g of chartBox.querySelectorAll('.burn')) g.style.opacity = Number(g.dataset.t) <= t ? 1 : 0.28;
chartBox.style.opacity = fade(s, 0.6, 41.2, 0.5);
// Satellite tags
const pX = project(st.x36W);
const pC = project(st.cosW);
const tX = tag('tagX', 'x36');
const tC = tag('tagC', 'cos');
tX.querySelector('.name').innerHTML = '<span class="c-x36">ICEYE-X36</span>';
tX.querySelector('.meta').textContent = 'SAR imager · U.S.-registered';
tC.querySelector('.name').innerHTML = '<span class="c-cos">COSMOS 2612</span>';
tC.querySelector('.meta').textContent = 'Russian military · launched Apr 2026';
const tagA = fade(s, 0.8, 41.2, 0.5);
let lines = '';
const place2 = (el, p, dx, dy, color) => {
if (!p || p.x < -50 || p.y < -50 || p.x > W + 50 || p.y > H + 50) {
el.style.opacity = 0;
return;
}
const a = tagA * (p.hidden ? 0.35 : 1);
el.style.opacity = a;
const lx = p.x + dx;
const ly = p.y + dy;
el.style.left = `${dx >= 0 ? lx + 8 * U : lx - 8 * U}px`;
el.style.top = `${ly}px`;
el.style.transform = dx >= 0 ? 'translate(0,-50%)' : 'translate(-100%,-50%)';
el.style.textAlign = dx >= 0 ? 'left' : 'right';
lines += `<polyline points="${p.x + Math.sign(dx) * 9 * U},${p.y + Math.sign(dy) * 9 * U} ${lx},${ly}" fill="none" stroke="${color}" stroke-opacity="${a * 0.7}" stroke-width="${1.2 * U}"/>`;
};
let dX = [46 * U, -40 * U];
let dC = [-46 * U, 40 * U];
if (pX && pC) {
// Put each label on the side away from the other satellite.
const vx = pC.x - pX.x;
const vy = pC.y - pX.y;
const sx = vx >= 0 ? -1 : 1;
dX = [sx * 46 * U, (vy >= 0 ? -1 : 1) * 40 * U];
dC = [-sx * 46 * U, (vy >= 0 ? 1 : -1) * 40 * U];
}
place2(tX, pX, dX[0], dX[1], '#5ce1e6');
place2(tC, pC, dC[0], dC[1], '#ff5a3c');
leaders.innerHTML = lines;
// Event log under the caption: never collides with the scene labels.
let html = '';
for (const e of EVENTS) {
const hold = e.kind === 'ca' ? 4.4 : e.kind === 'vantor' ? 2.8 : 1.9;
const a = fade(s, e.s, e.s + hold, 0.25);
if (a <= 0) continue;
const color = e.kind === 'burn' ? '#ffb547' : e.kind === 'ca' ? '#ffffff' : e.kind === 'vantor' ? '#c9b8ff' : '#eef2f6';
html += `<div class="event" style="opacity:${a};color:${color}"><i style="background:${color}"></i>${e.title}<div class="sub">${e.sub}</div></div>`;
}
callEl.innerHTML = html;
// Short dip through black at hard 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.78 * endA})`;
if (!ec.dataset.built) {
ec.dataset.built = '1';
ec.innerHTML = `<div class="panel">
<h2>22 days, 8 burns, and a pass of about <span class="c-cos">${Math.round(D.ca.m / 5) * 5} m</span></h2>
<div class="stats">
<div class="stat"><div class="n mono">~${Math.round(D.total_dv)} m/s</div><div class="l">total Δv spent by Cosmos 2612 (est.)</div></div>
<div class="stat"><div class="n mono">35+ h</div><div class="l">within ~3 km of ICEYE-X36</div></div>
<div class="stat"><div class="n mono">0.002°</div><div class="l">final difference in orbit planes</div></div>
<div class="stat"><div class="n mono">480 km</div><div class="l">behind X36 today, matched orbit</div></div>
</div>
<div class="src"><b>Reconstruction, not tracking data.</b> ICEYE-X36: SGP4 on public CelesTrak element sets (NORAD 59103). Cosmos 2612 (68762): relative-orbit model with burns timed to published analyses by KeepTrack, LeoLabs and Marco Langbroek, pinned to today's elements. Closest-approach estimates range 150–650 m.
Earth: Esri World Imagery; the Earth below is slowed during time-lapse. Vantor image: Axios, Oct 22. Spacecraft models: ICEYE to published dimensions; Cosmos 2612 illustrative.</div>
</div>`;
}
}
await document.fonts.ready;
window.rpo = { frame, tau, bclock, duration: DUR, ready: true, CAM };