From 9d3d541b666c2f7d48eba21ba33e6c5279ad5529 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 05:38:35 +0000 Subject: [PATCH] =?UTF-8?q?tools(rpo-replay):=20v2=20=E2=80=94=20phone-leg?= =?UTF-8?q?ible=20HUD,=20logo,=20chapters,=20dimension=20line,=20faster=20?= =?UTF-8?q?tiles?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0187rSCa4AKJPGw6uiuPSGyX --- tools/rpo-replay/hud.js | 344 +++++++++++++++++++++++++++++++++ tools/rpo-replay/render.mjs | 10 +- tools/rpo-replay/scene.html | 218 ++++++++++++--------- tools/rpo-replay/scene.js | 366 ++++-------------------------------- 4 files changed, 518 insertions(+), 420 deletions(-) create mode 100644 tools/rpo-replay/hud.js diff --git a/tools/rpo-replay/hud.js b/tools/rpo-replay/hud.js new file mode 100644 index 000000000..4f1698e67 --- /dev/null +++ b/tools/rpo-replay/hud.js @@ -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 ~${CA_M} m`, + body: 'How Cosmos 2612 hunted ICEYE-X36 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 47 km, 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 480 km behind — 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 1.6 km every orbit, inching in for two days.', + }, + { + s0: 29.7, s1: 35.6, + title: `Closest pass: ~${CA_M} meters`, + body: 'In Earth\'s shadow, at walking pace — while both fly at 27,000 km/h.', + }, + { + 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 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) => `
${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 = 24 * 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.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))}`; + 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 += `
${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. 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 += ``; + 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'); + + // 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 += ``; + 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 = `
+
~${CA_M} m
+
Russia's Cosmos 2612 got within about ${CA_M} meters of the U.S. radar satellite ICEYE-X36.
+
+
22days of stalking
+
8engine burns
+
0.9 m/sclosest-pass speed
+
+
Tracking firm LeoLabs says maneuvers like these could support spying, inspection, interference — or preparing future attacks.
+
`; + } + $('#endlogo').style.opacity = endA; + $('#logo').style.opacity = live; + } + + return { update }; +} diff --git a/tools/rpo-replay/render.mjs b/tools/rpo-replay/render.mjs index 8fc4b0cd6..1352c2604 100644 --- a/tools/rpo-replay/render.mjs +++ b/tools/rpo-replay/render.mjs @@ -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`); diff --git a/tools/rpo-replay/scene.html b/tools/rpo-replay/scene.html index d16b5f5b2..7c6e1f0ef 100644 --- a/tools/rpo-replay/scene.html +++ b/tools/rpo-replay/scene.html @@ -4,124 +4,170 @@ Cosmos 2612 / ICEYE-X36 replay - +
-
-
-
-

-
-
SEPARATION
-
-
+
+
+ +
+

+
+
● IN EARTH'S SHADOW
+
DISTANCE APART
+
+
DISTANCE OVER 22 DAYS · LOG SCALE
+ +
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
diff --git a/tools/rpo-replay/scene.js b/tools/rpo-replay/scene.js index 8e2fb17de..f25f4a6c5 100644 --- a/tools/rpo-replay/scene.js +++ b/tools/rpo-replay/scene.js @@ -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 · Sep 30 – Oct 21, 2026', - title: 'Hunting a radar satellite', - body: 'Over three weeks, Russia\'s military satellite Cosmos 2612 stalked ICEYE-X36, a U.S.-registered radar imager whose pictures help Ukraine — and closed to a few hundred meters.', - }, - { - s0: 4.5, s1: 12.3, - kicker: '1 · Phasing', - title: 'Climb high, fall behind', - body: 'Two burns lift Cosmos 2612 47 km above its target. A higher orbit is slower, so it drifts back ~6,500 km a day and laps X36 — while Earth\'s equatorial bulge slowly swings its orbit plane into line.', - }, - { - s0: 12.6, s1: 21.9, - kicker: '2 · Descent', - title: 'Drop into the same orbit', - body: 'Directly overhead on Oct 12, it drops 47 km into X36\'s orbit, 480 km behind. On Oct 15 a 2 km dip makes it slightly faster: it closes ~350 km a day — then brakes 3 km behind.', - }, - { - s0: 22.1, s1: 29.6, - kicker: '3 · Proximity', - title: 'Close enough to inspect', - body: 'For two days it holds station behind X36, its path looping 1.6 km 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: '3 · Closest approach', - title: 'In Earth\'s shadow', - body: 'Early on Oct 18, during orbital night, it passes about %CA% m behind X36 at under 1 m/s. Published estimates range from 150 to 650 m. Nine minutes later, the sun comes up.', - }, - { - s0: 35.8, s1: 41.0, - kicker: '4 · Departure', - title: 'Back off, and wait', - body: 'A 1.3 m/s burn lifts it 2.4 km and it falls away at ~350 km a day. On Oct 19 it matches orbits again — and today it is still parked 480 km behind.', - }, -]; - 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 `×${rr.toLocaleString('en-US')} · 1 s = ${per}`; -} - -// 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 ` - ${lab}`; - }) - .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(``); - if (major) { - days.push(`${d === 0 ? 'SEP 30' : `OCT ${d}`}`); - } - } - const burns = BURNS.map( - (b) => ` - `, - ).join(''); - const caX = x(CA_T); - const caY = y(D.ca.m / 1000); - chartBox.innerHTML = ` - - DISTANCE BETWEEN THE SATELLITES · LOG SCALE - ▼ BURN - ${grid}${days.join('')}${burns} - - - - - - - - ~${Math.round(D.ca.m / 5) * 5} m - - - `; - 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 = '
'; - 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)}km`; - if (m >= 1000) return `${(m / 1000).toFixed(2)}km`; - return `${Math.round(m)}m`; -} -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 = - `${fmtComp(tt)} ${tt < 0 ? 'behind' : 'ahead'} · ${fmtComp(r)} ${r >= 0 ? 'above' : 'below'} · ${fmtComp(n)} aside`; - } else { - const deg = (Math.abs(((tt / Cesium.Cartesian3.magnitude(x36Teme(b).r)) * 180) / Math.PI) % 360); - ro.querySelector('.ric').innerHTML = `${(deg > 180 ? 360 - deg : deg).toFixed(0)}° apart along the orbit · ${fmtComp(r)} ${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
Rings: 500 m · 1 km · 2 km in X36's orbit plane` - : 'Spacecraft at true scale
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 = 'ICEYE-X36'; - tX.querySelector('.meta').textContent = 'SAR imager · U.S.-registered'; - tC.querySelector('.name').innerHTML = 'COSMOS 2612'; - 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 += ``; - }; - 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 += `
${e.title}
${e.sub}
`; - } - 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 = `
-

22 days, 8 burns, and a pass of about ${Math.round(D.ca.m / 5) * 5} m

-
-
~${Math.round(D.total_dv)} m/s
total Δv spent by Cosmos 2612 (est.)
-
35+ h
within ~3 km of ICEYE-X36
-
0.002°
final difference in orbit planes
-
480 km
behind X36 today, matched orbit
-
-
Reconstruction, not tracking data. 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.
-
`; - } -} - await document.fonts.ready; window.rpo = { frame, tau, bclock, duration: DUR, ready: true, CAM };