tools(rpo-replay): v5 — critic fixes: relative-view inset, corrected copy, laps readout, hook into shadow, portrait layout

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-11-08 05:58:35 +00:00
1 parent fff5d5a479
commit a93f2bb4d7
5 files changed
+368 -118

No files matched your search

+62 -39
View File
@@ -4,10 +4,13 @@
// except label placement, which carries state between consecutive frames so // except label placement, which carries state between consecutive frames so
// labels hold their side (hysteresis) and ease instead of jumping. // labels hold their side (hysteresis) and ease instead of jumping.
import { createInset } from './inset.js';
export function createHud(ctx) { export function createHud(ctx) {
const { D, W, H, U, PORTRAIT, sec, rate, sOfTau, CA_T, VANTOR_T, BURNS, CUTS, SHOTS, shotAt, DUR, project } = ctx; const { D, W, H, U, PORTRAIT, sec, rate, sOfTau, CA_T, VANTOR_T, BURNS, CUTS, SHOTS, shotAt, DUR, project, relAt } = ctx;
const $ = (q) => document.querySelector(q); const $ = (q) => document.querySelector(q);
const CA_M = Math.round(D.ca.m / 5) * 5; // Element sets can't resolve tens of metres: quote the pass to the nearest 50 m.
const CA_M = Math.round(D.ca.m / 50) * 50;
const END = SHOTS.find((x) => x.id === 'end').s0; const END = SHOTS.find((x) => x.id === 'end').s0;
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
@@ -26,33 +29,33 @@ export function createHud(ctx) {
]; ];
const cap = (id, title, body, extra = {}) => ({ s0: span(id)[0], s1: span(id)[1], title, body, ...extra }); const cap = (id, title, body, extra = {}) => ({ s0: span(id)[0], s1: span(id)[1], title, body, ...extra });
const CAPTIONS = [ const CAPTIONS = [
cap('hook', `A Russian military satellite came within <span class="c-cos nw">~${CA_M} m</span> of a U.S.-registered one`, cap('hook', 'A Russian military satellite came within about half a kilometre of a U.S. radar satellite',
'This is how it got there, rebuilt from public tracking data.', { hook: true, s0: -1 }), 'Rebuilt from public tracking data.', { hook: true, s0: -1 }),
cap('climb', 'Climb higher to fall behind', cap('climb', 'Climb higher to fall behind',
'Same orbit, a quarter-orbit apart. Two burns lift <b class="c-cos">Cosmos 2612</b> <b>47 km</b>, and a higher orbit is a slower one.'), 'Two burns lift <b class="c-cos">Cosmos 2612</b> <b>47 km</b>. A higher orbit is a longer lap, so it falls behind.'),
cap('lap', 'Fall behind, and lap it', cap('lap', 'Lapped, twice',
'Drifting back <b>~6,700 km a day</b>, it laps almost twice, passing over <b class="c-x36">X36</b> on Oct 5 and Oct 12, as its orbit plane swings into line.'), 'Losing <b>~6,700 km a day</b>, it is lapped by <b class="c-x36">X36</b> on Oct 5 and Oct 12, while Earth\'s bulge swings the two orbits into line.'),
cap('drop', 'Drop into the same orbit', cap('drop', 'Drop into the same orbit',
'Oct 12: from <b>47 km</b> directly overhead it dives into X36\'s orbit, and lands about <b>480 km behind</b>.'), 'Oct 12: from <b>54 km</b> directly overhead it dives into X36\'s orbit, landing <b>~500 km behind</b>.'),
cap('close', 'Speed up, then brake', cap('close', 'Speed up, then brake',
'A 2 km dip makes it faster, closing <b>~350 km a day</b>. Then it brakes 3 km behind.'), 'A 2 km dip makes it faster, closing <b>~350 km a day</b>. Then it brakes <b>~10 km</b> behind.'),
cap('dance', 'The dance', cap('dance', 'The dance',
'Every orbit is a day and a night. Each one, Cosmos loops <b>~1.5 km</b> around X36, 3 km behind it.'), 'Every orbit is a day and a night. Each one, Cosmos traces a <b>~1.5 km</b> loop as it edges in from 8 to 3 km behind.'),
cap('dance-ff', 'Flatten the loops', cap('dance-ff', 'Flatten the loops',
'A tiny burn puts Cosmos almost exactly in X36\'s path. It creeps in <b>~3 km a day</b>.'), 'A tiny burn puts Cosmos almost exactly in X36\'s path. It creeps in <b>~3 km a day</b>.'),
cap('approach', 'Closing in, slowly', cap('approach', 'Closing in, slowly',
'Creeping in at <b>a few centimetres per second</b>, while both fly at <b>27,000 km/h</b>.'), 'Creeping in at <b>a few centimetres a second</b>, while both fly at <b>27,000 km/h</b>.'),
cap('ca', `Closest pass: <span class="c-cos nw">~${CA_M} m</span>`, cap('ca', `Closest pass: <span class="c-cos nw">~${CA_M} m</span>`,
'True size. LeoLabs measured <b>~500 m</b>; other estimates run <b>150 to 650 m</b>.'), 'In Earth\'s shadow, ~01:17 UTC. LeoLabs radar: <b>~500 m</b>. Public estimates: <b>150–650 m</b>.'),
cap('sunrise', 'Then the sun comes up', cap('sunrise', 'Then the sun comes up',
'Twenty minutes later both leave Earth\'s shadow. Within hours, Cosmos backs away.'), 'Twenty minutes later both leave Earth\'s shadow. Within the hour, Cosmos backs away.'),
cap('leave', 'Back off, and wait', cap('leave', 'Back off, and wait',
'It drifts back <b>~350 km a day</b>. By Oct 25 it sat <b>~480 km behind</b>, in the same orbit.'), 'It drifts back <b>~350 km a day</b>, then matches orbits again. By Oct 25 it sat <b>~480 km behind</b>.'),
]; ];
const BURN_NOTES = { const BURN_NOTES = {
'Phasing raise': 'raises its orbit 46 km', 'Phasing raise': 'two burns raise its orbit 46 km',
'Plane trim': 'tilts its orbit into line', 'Plane trim': 'tilts its orbit into line',
'Descent to X36 orbit': 'drops 47 km into X36\'s orbit', 'Descent to X36 orbit': 'drops 48 km into X36\'s orbit',
'Closing burn': 'dips 2.3 km to speed up', 'Closing burn': 'dips 2.3 km to speed up',
'Braking burn': 'rises 2.5 km to stop', 'Braking burn': 'rises 2.5 km to stop',
'Back-off': 'rises 140 m and starts to drift back', 'Back-off': 'rises 140 m and starts to drift back',
@@ -68,7 +71,7 @@ export function createHud(ctx) {
{ t: sec('2026-10-05T12:28:00Z'), kind: 'note', title: 'Passes overhead', sub: '55 km above its target' }, { t: sec('2026-10-05T12:28:00Z'), kind: 'note', title: 'Passes overhead', sub: '55 km above its target' },
{ t: sec('2026-10-12T00:30:00Z'), kind: 'note', title: 'Directly overhead', sub: '54 km above its target' }, { t: sec('2026-10-12T00:30:00Z'), kind: 'note', title: 'Directly overhead', sub: '54 km above its target' },
{ t: VANTOR_T, kind: 'vantor', title: 'Vantor photographs the pair', sub: `from its own satellite · ~${D.vantor.km.toFixed(0)} km apart · Oct 17` }, { t: VANTOR_T, kind: 'vantor', title: 'Vantor photographs the pair', sub: `from its own satellite · ~${D.vantor.km.toFixed(0)} km apart · Oct 17` },
{ t: CA_T, kind: 'ca', title: 'Closest approach', sub: `${D.ca.t.slice(11, 16)} UTC, Oct 18 · in Earth's shadow` }, { t: CA_T, kind: 'ca', title: 'Closest approach', sub: '~01:17 UTC, Oct 18 · in Earth\'s shadow' },
].map((e) => ({ ...e, s: sOfTau(e.t) })).filter((e) => e.s !== null).sort((a, b) => a.s - b.s); ].map((e) => ({ ...e, s: sOfTau(e.t) })).filter((e) => e.s !== null).sort((a, b) => a.s - b.s);
const caS = EVENTS.find((e) => e.kind === 'ca')?.s ?? 1e9; const caS = EVENTS.find((e) => e.kind === 'ca')?.s ?? 1e9;
@@ -135,6 +138,13 @@ export function createHud(ctx) {
return `${(r / 86400).toFixed(1)} days`.replace('.0 days', ' days'); return `${(r / 86400).toFixed(1)} days`.replace('.0 days', ' days');
} }
// "Seen from X36" inset (relative frame).
const insetEl = $('#inset');
insetEl.width = Math.round(insetEl.clientWidth * 2);
insetEl.height = Math.round(insetEl.clientHeight * 2);
const inset = createInset({ D, U, relAt, canvas: insetEl });
if (PORTRAIT) $('#readout').prepend($('#clock .light'));
// Label placement memory (hysteresis). // Label placement memory (hysteresis).
const memo = { tagX: null, tagC: null, lastS: -1 }; const memo = { tagX: null, tagC: null, lastS: -1 };
@@ -143,7 +153,7 @@ export function createHud(ctx) {
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
function update(s, t, b, st) { function update(s, t, b, st) {
const sh = shotAt(s); const sh = shotAt(s);
const endA = fade(s, END, 99, 0.5); const endA = fade(s, END - 0.15, 99, 0.25);
const live = 1 - endA; const live = 1 - endA;
if (Math.abs(s - memo.lastS) > 0.2 || CUTS.some((c) => memo.lastS < c && s >= c)) { if (Math.abs(s - memo.lastS) > 0.2 || CUTS.some((c) => memo.lastS < c && s >= c)) {
memo.tagX = null; memo.tagX = null;
@@ -191,27 +201,32 @@ export function createHud(ctx) {
$('#clock .rate').innerHTML = `1 sec = ${perSecond(rate(s))}`; $('#clock .rate').innerHTML = `1 sec = ${perSecond(rate(s))}`;
$('#clock .ff').style.display = sh.mode === 'ff' ? 'inline-block' : 'none'; $('#clock .ff').style.display = sh.mode === 'ff' ? 'inline-block' : 'none';
const lit = st.lx > 0.5; const lit = st.lx > 0.5;
const light = $('#clock .light'); const light = $('.light');
light.className = `light ${lit ? 'day' : 'night'}`; light.className = `light ${lit ? 'day' : 'night'}`;
light.innerHTML = lit ? '<i></i>In sunlight' : '<i></i>In Earth\'s shadow'; const orbitN = sh.id === 'dance' ? Math.floor((t - sh.tauF(sh.s0)) / 5730) + 1 : 0;
light.innerHTML = (lit ? '<i></i>Day' : '<i></i>Night · Earth\'s shadow') + (orbitN ? ` · orbit ${orbitN}` : '');
light.style.display = sh.mode === 'true' && sh.id !== 'climb' && sh.id !== 'end' ? 'inline-flex' : 'none'; light.style.display = sh.mode === 'true' && sh.id !== 'climb' && sh.id !== 'end' ? 'inline-flex' : 'none';
$('#clock').style.opacity = live; $('#clock').style.opacity = live;
// Distance readout // Distance readout
const ro = $('#readout'); const ro = $('#readout');
ro.style.opacity = fade(s, climbS + 0.6, END, 0.4); ro.style.opacity = fade(s, climbS + 0.6, END, 0.4);
ro.querySelector('.val').innerHTML = fmtSep(st.sep); const lapMode = sh.id === 'climb' || sh.id === 'lap';
ro.querySelector('.label').textContent = lapMode ? 'GAP ALONG THE ORBIT' : 'DISTANCE APART';
ro.querySelector('.val').innerHTML = lapMode
? `${(-st.rel[1] / (2 * Math.PI * D.a_ref_km * 1000)).toFixed(2)}<small>laps</small>`
: fmtSep(st.sep);
const [r, tt0] = st.rel; const [r, tt0] = st.rel;
const circ = 2 * Math.PI * D.a_ref_km * 1000; const circ = 2 * Math.PI * D.a_ref_km * 1000;
const tt = ((tt0 % circ) + circ * 1.5) % circ - circ / 2; const tt = ((tt0 % circ) + circ * 1.5) % circ - circ / 2;
let ctxLine; let ctxLine;
if (st.sep < 1500) ctxLine = `≈ ${Math.max(1, Math.round(st.sep / 100))} football fields`; 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 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 · ` : ''}a quarter-orbit or more apart`; else ctxLine = `${Math.abs(r) > 20e3 ? `${fmtNum(Math.abs(r) / 1000)} km higher · ` : ''}${fmtNum(st.sep / 1000)} km apart in a straight line`;
if (s > caS && s < span('leave')[0] + 0.5) ctxLine += ` · closest ${CA_M} m`; if (s > caS && s < span('leave')[0] + 0.5) ctxLine += ` · closest ${CA_M} m`;
ro.querySelector('.ctx').textContent = ctxLine; ro.querySelector('.ctx').textContent = lapMode ? `${Math.abs(r) > 20e3 ? `${fmtNum(Math.abs(r) / 1000)} km higher · ` : ''}behind X36` : ctxLine;
const relLine = ro.querySelector('.rel'); const relLine = ro.querySelector('.rel');
relLine.textContent = st.sep < 2e4 && sh.mode === 'true' ? (st.relSpeed < 0.2 ? `relative speed ${Math.round(st.relSpeed * 100)} cm/s` : `relative speed ${st.relSpeed.toFixed(1)} m/s`) : ''; relLine.textContent = st.sep < 2e4 && sh.mode === 'true' ? (st.relSpeed < 0.2 ? `relative speed ~${Math.max(1, Math.round(st.relSpeed * 100))} cm/s (modelled)` : `relative speed ~${st.relSpeed.toFixed(1)} m/s (modelled)`) : '';
// Scale chip // Scale chip
const magEl = $('#mag'); const magEl = $('#mag');
@@ -219,7 +234,13 @@ export function createHud(ctx) {
const trueSize = !enlarged && st.camDistX < 200; const trueSize = !enlarged && st.camDistX < 200;
magEl.style.opacity = (enlarged || trueSize) ? fade(s, climbS, END, 0.3) : 0; magEl.style.opacity = (enlarged || trueSize) ? fade(s, climbS, END, 0.3) : 0;
magEl.classList.toggle('enlarged', enlarged); magEl.classList.toggle('enlarged', enlarged);
magEl.textContent = enlarged ? `Satellites enlarged ${Math.round(st.mag)}×` : 'True size'; magEl.textContent = enlarged ? `Satellites enlarged ${Math.round(st.mag)}×` : sh.id === 'ca' ? 'True size · faint bar: ±1.5 km tracking uncertainty' : 'True size';
const relChip = $('#relchip');
relChip.style.opacity = ['drop', 'close', 'dance', 'dance-ff', 'approach', 'leave'].includes(sh.id) ? fade(s, sh.s0, sh.s1, 0.3) : 0;
// Inset
const insA = sh.inset && s < END - 0.1 ? fade(s, Math.max(sh.s0, climbS + 0.8), END, 0.3) : 0;
inset.draw(s, t, (st.sepG || st.sep) / 1000, { alpha: insA * live, trailHours: sh.inset || 1, cut: CUTS.some((c) => Math.abs(s - c) < 0.05) });
// Timeline // Timeline
const px = timeline.x(Math.min(Math.max(t, TL.t0), TL.t1)); const px = timeline.x(Math.min(Math.max(t, TL.t0), TL.t1));
@@ -244,16 +265,19 @@ export function createHud(ctx) {
const pC = project(st.cosW); const pC = project(st.cosW);
const tX = tag('tagX', 'ICEYE-X36', 'c-x36'); const tX = tag('tagX', 'ICEYE-X36', 'c-x36');
const tC = tag('tagC', 'COSMOS 2612', 'c-cos'); const tC = tag('tagC', 'COSMOS 2612', 'c-cos');
const tagA = fade(s, span('climb')[0] + 0.6, END, 0.4); const tagA = fade(s, 0.3, END, 0.3);
const onScreen = (p) => p && p.x > -40 && p.y > -40 && p.x < W + 40 && p.y < H + 40; const onScreen = (p) => p && p.x > -40 && p.y > -40 && p.x < W + 40 && p.y < H + 40;
const speedX = `${(st.vX / 1000).toFixed(2)} km/s`; const speedX = `${(st.vX / 1000).toFixed(2)} km/s`;
const speedC = `${(st.vC / 1000).toFixed(2)} km/s`; const speedC = `${(st.vC / 1000).toFixed(2)} km/s`;
tX.querySelector('.meta').textContent = pX?.hidden ? 'far side of Earth' : `U.S.-registered radar sat · ${speedX}`; const speedShot = ['climb', 'lap', 'drop'].includes(sh.id);
tC.querySelector('.meta').textContent = pC?.hidden ? 'far side of Earth' : `Russian military · ${speedC}`; tX.querySelector('.meta').textContent = speedShot ? `U.S. radar satellite · ${speedX}` : sh.id === 'hook' ? 'U.S. radar satellite' : '';
tC.querySelector('.meta').textContent = speedShot ? `Russian military · ${speedC}` : sh.id === 'hook' ? 'Russian military' : '';
tX.querySelector('.meta').style.display = tX.querySelector('.meta').textContent ? '' : 'none';
tC.querySelector('.meta').style.display = tC.querySelector('.meta').textContent ? '' : 'none';
let svg = ''; let svg = '';
const M = 24 * U; const M = 24 * U;
const obstacles = []; const obstacles = [];
for (const q of ['#logo', '#clock', '#caption', '#readout', '#tlwrap', '#chapters', '#mag']) { for (const q of ['#logo', '#clock', '#caption', '#readout', '#tlwrap', '#chapters', '#mag', '#inset', '#relchip']) {
const el = $(q); const el = $(q);
if (Number(el.style.opacity || 1) > 0.05) obstacles.push(el.getBoundingClientRect()); if (Number(el.style.opacity || 1) > 0.05) obstacles.push(el.getBoundingClientRect());
} }
@@ -290,9 +314,8 @@ export function createHud(ctx) {
// Tags: keep the previous quadrant unless it has been blocked for a while; // Tags: keep the previous quadrant unless it has been blocked for a while;
// ease the label toward its target so it never jumps. // ease the label toward its target so it never jumps.
const place = (key, el, p, prefer, color) => { const place = (key, el, p, prefer, color) => {
if (!onScreen(p)) { if (!onScreen(p) || p.hidden) {
el.style.opacity = 0; el.style.opacity = 0;
memo[key] = null;
return; return;
} }
el.style.opacity = tagA; el.style.opacity = tagA;
@@ -310,9 +333,9 @@ export function createHud(ctx) {
let q = m ? [m.sx, m.sy] : prefer; let q = m ? [m.sx, m.sy] : prefer;
let cand = rectFor(q); let cand = rectFor(q);
if (m) { if (m) {
m.blocked = cand.cost > 2000 * U * U ? m.blocked + 1 : 0; m.blocked = cand.cost > 4000 * U * U ? m.blocked + 1 : 0;
} }
if (!m || m.blocked > 8) { if (!m || m.blocked > 20) {
const quads = [q, prefer, [prefer[0], -prefer[1]], [-prefer[0], prefer[1]], [-prefer[0], -prefer[1]]]; const quads = [q, prefer, [prefer[0], -prefer[1]], [-prefer[0], prefer[1]], [-prefer[0], -prefer[1]]];
for (const qq of quads) { for (const qq of quads) {
const c = rectFor(qq); const c = rectFor(qq);
@@ -353,7 +376,7 @@ export function createHud(ctx) {
// Dip at cuts // Dip at cuts
let dip = 0; let dip = 0;
for (const c of CUTS) dip = Math.max(dip, 1 - Math.abs(s - c) / 0.12);
$('#dip').style.opacity = Math.max(0, dip); $('#dip').style.opacity = Math.max(0, dip);
// End card // End card
@@ -364,13 +387,13 @@ export function createHud(ctx) {
ec.dataset.built = '1'; ec.dataset.built = '1';
ec.innerHTML = `<div class="panel"> ec.innerHTML = `<div class="panel">
<div class="big">~${CA_M} m</div> <div class="big">~${CA_M} m</div>
<div class="range">rebuilt from public element sets · LeoLabs ~500 m · published range 150–650 m</div> <div class="range">closest pass, Oct 18, in Earth's shadow · LeoLabs ~500 m · estimates 150–650 m</div>
<div class="lead">Closest pass between Cosmos 2612 and ICEYE-X36 · Oct 18, in Earth's shadow</div>
<div class="stats"> <div class="stats">
<div><b>22</b><span>days in pursuit</span></div> <div><b>18</b><span>days from first burn to closest pass</span></div>
<div><b>${BURNS.length}</b><span>engine burns</span></div> <div><b>${BURNS.length}</b><span>manoeuvres</span></div>
<div><b>${Math.max(1, Math.round((D.ca.v || 0.05) * 100))} cm/s</b><span>relative speed at closest pass</span></div> <div><b>~${Math.round(D.total_dv)} m/s</b><span>of thrust spent</span></div>
</div> </div>
<div class="why">U.S. officials called it <b>"consistent with on-orbit weapons operations."</b><br>By Oct 25 it was still parked ~480 km behind, in the same orbit.</div>
</div>`; </div>`;
} }
$('#endlogo').style.opacity = endA; $('#endlogo').style.opacity = endA;
+202
View File
@@ -0,0 +1,202 @@
// "Seen from ICEYE-X36": a 2D inset in X36's rotating frame. X36 stays fixed
// at the top of its orbit ring; Cosmos 2612's path is drawn around it.
// Zoomed out it is the whole orbit (heights exaggerated, so "higher = slower,
// falls behind, laps" is visible); zoomed in it becomes a true-scale
// radial / along-track plot of the loops and the approach.
export function createInset({ D, U, relAt, canvas }) {
const ctx = canvas.getContext('2d');
const A = D.a_km; // km
const AREF = D.a_ref_km;
const RE = 6371;
const state = { hw: null, lastS: -1 };
const smooth = (x) => {
const t = Math.min(1, Math.max(0, x));
return t * t * (3 - 2 * t);
};
function niceScale(km) {
const p = 10 ** Math.floor(Math.log10(km));
for (const m of [1, 2, 5, 10]) if (m * p >= km) return m * p;
return 10 * p;
}
const fmt = (km) => (km >= 1 ? `${km.toLocaleString('en-US')} km` : `${Math.round(km * 1000)} m`);
function draw(s, t, sepGuideKm, opts) {
const W = canvas.width;
const H = canvas.height;
const dpr = W / canvas.clientWidth || 1;
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.clearRect(0, 0, W, H);
if (opts.alpha <= 0.01) return;
ctx.globalAlpha = opts.alpha;
// Zoom: half-width (km) follows the guiding separation; eased frame to frame.
const target = Math.min(9200, Math.max(1.4, 1.6 * sepGuideKm));
if (state.hw === null || Math.abs(s - state.lastS) > 0.2 || opts.cut) state.hw = target;
else state.hw = Math.exp(Math.log(state.hw) + (Math.log(target) - Math.log(state.hw)) * 0.12);
state.lastS = s;
const hw = state.hw;
const wOut = smooth((hw - 150) / 2500); // 0 = close-up, 1 = whole-orbit view
const exag = 1 + 19 * wOut; // radial exaggeration when zoomed out
const wCenter = smooth((hw - 2500) / 5500);
// Panel
const pad = 18 * U * dpr;
const r = 14 * U * dpr;
ctx.fillStyle = 'rgba(5,9,16,0.78)';
ctx.strokeStyle = 'rgba(255,255,255,0.14)';
ctx.lineWidth = 1.5 * dpr;
ctx.beginPath();
ctx.roundRect(0, 0, W, H, r);
ctx.fill();
ctx.stroke();
const head = 44 * U * dpr;
ctx.fillStyle = 'rgba(244,247,250,0.92)';
ctx.font = `800 ${22 * U * dpr}px Inter, sans-serif`;
ctx.textBaseline = 'middle';
ctx.fillText('SEEN FROM X36', pad, head / 2 + 4 * dpr);
ctx.fillStyle = 'rgba(244,247,250,0.6)';
ctx.font = `600 ${20 * U * dpr}px Inter, sans-serif`;
ctx.textAlign = 'right';
ctx.fillText('flight →', W - pad, head / 2 + 4 * dpr);
ctx.textAlign = 'left';
// Plot area
const px0 = pad;
const py0 = head;
const pw = W - 2 * pad;
const ph = H - head - pad;
ctx.save();
ctx.beginPath();
ctx.rect(px0, py0, pw, ph);
ctx.clip();
const scale = pw / 2 / hw; // px per km
// Centre: X36 when close; drifts toward Earth's centre when zoomed out.
const cx = 0;
const cy = A * (1 - wCenter) + 0 * wCenter;
const ox = px0 + pw / 2;
const oy = py0 + ph * (0.5 + 0.12 * (1 - wCenter));
const P = (x, y) => [ox + (x - cx) * scale, oy - (y - cy) * scale];
const ring = (rad) => {
// Polyline of the visible part of a circle about Earth's centre.
const pts = [];
const span = Math.min(Math.PI, (hw * 1.6) / rad + 0.05);
const n = 180;
for (let k = 0; k <= n; k++) {
const a = -span + (2 * span * k) / n;
pts.push(P(rad * Math.sin(a), rad * Math.cos(a)));
}
return pts;
};
const path = (pts) => {
ctx.beginPath();
pts.forEach(([x, y], i) => (i ? ctx.lineTo(x, y) : ctx.moveTo(x, y)));
};
// Earth (exaggeration shrinks it so the altitude gap reads)
const earthR = Math.max(A * 0.5, A - (A - RE) * exag); // schematic when heights are exaggerated
if (earthR > 0) {
const [ex, ey] = P(0, 0);
const g = ctx.createRadialGradient(ex, ey - earthR * scale * 0.3, earthR * scale * 0.2, ex, ey, earthR * scale);
g.addColorStop(0, '#1d3a5c');
g.addColorStop(1, '#0b1a2c');
ctx.fillStyle = g;
ctx.beginPath();
ctx.arc(ex, ey, Math.max(0, earthR * scale), 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = 'rgba(120,190,255,0.5)';
ctx.lineWidth = 2 * dpr;
ctx.stroke();
}
// X36's orbit
ctx.setLineDash([8 * dpr, 7 * dpr]);
ctx.strokeStyle = 'rgba(92,225,230,0.55)';
ctx.lineWidth = 2 * dpr;
path(ring(A));
ctx.stroke();
ctx.setLineDash([]);
// Cosmos path (relative), with a fading tail
const toXY = (rel) => {
const phi = rel[1] / 1000 / AREF;
const rad = A + (rel[0] / 1000) * exag;
return [rad * Math.sin(phi), rad * Math.cos(phi)];
};
const spanH = opts.trailHours;
const n = 260;
let prev = null;
for (let k = 0; k <= n; k++) {
const tt = t - spanH * 3600 + (k / n) * spanH * 3600;
if (tt < 0) continue;
const [x, y] = P(...toXY(relAt(tt)));
if (prev) {
ctx.strokeStyle = `rgba(255,90,60,${0.08 + 0.85 * (k / n) ** 1.5})`;
ctx.lineWidth = (1.5 + 2.5 * (k / n)) * dpr;
ctx.beginPath();
ctx.moveTo(prev[0], prev[1]);
ctx.lineTo(x, y);
ctx.stroke();
}
prev = [x, y];
}
const rel = relAt(t);
const [cxp, cyp] = P(...toXY(rel));
const [xxp, xyp] = P(0, A);
// Gap line
ctx.strokeStyle = 'rgba(255,255,255,0.5)';
ctx.setLineDash([5 * dpr, 6 * dpr]);
ctx.lineWidth = 1.5 * dpr;
ctx.beginPath();
ctx.moveTo(xxp, xyp);
ctx.lineTo(cxp, cyp);
ctx.stroke();
ctx.setLineDash([]);
const dot = (x, y, col, rad) => {
ctx.fillStyle = col;
ctx.shadowColor = col;
ctx.shadowBlur = 14 * dpr;
ctx.beginPath();
ctx.arc(x, y, rad * dpr, 0, Math.PI * 2);
ctx.fill();
ctx.shadowBlur = 0;
};
dot(xxp, xyp, '#5ce1e6', 7 * U);
dot(cxp, cyp, '#ff5a3c', 7 * U);
ctx.font = `800 ${20 * U * dpr}px Inter, sans-serif`;
ctx.fillStyle = '#5ce1e6';
ctx.textAlign = 'center';
ctx.fillText('X36', xxp, xyp - 20 * U * dpr);
ctx.fillStyle = '#ff5a3c';
const below = cyp > xyp + 10 * dpr || Math.abs(cxp - xxp) < 40 * U * dpr;
ctx.fillText('COSMOS', Math.min(Math.max(cxp, px0 + 50 * U * dpr), px0 + pw - 50 * U * dpr), cyp + (below ? 24 : -20) * U * dpr);
ctx.textAlign = 'left';
ctx.restore();
// Scale bar + exaggeration note
const sb = niceScale(hw * 0.4);
const sbpx = sb * scale;
const by = H - pad * 0.55;
ctx.strokeStyle = 'rgba(244,247,250,0.8)';
ctx.lineWidth = 2.5 * dpr;
ctx.beginPath();
ctx.moveTo(pad, by);
ctx.lineTo(pad + sbpx, by);
ctx.stroke();
ctx.fillStyle = 'rgba(244,247,250,0.85)';
ctx.font = `700 ${19 * U * dpr}px 'JetBrains Mono', monospace`;
ctx.textBaseline = 'bottom';
ctx.fillText(fmt(sb), pad, by - 6 * dpr);
if (exag > 1.5) {
ctx.textAlign = 'right';
ctx.fillStyle = 'rgba(255,181,71,0.95)';
ctx.font = `700 ${19 * U * dpr}px Inter, sans-serif`;
ctx.fillText(`heights ×${Math.round(exag)}`, W - pad, by - 6 * dpr);
ctx.textAlign = 'left';
}
ctx.globalAlpha = 1;
}
return { draw };
}
+24 -13
View File
@@ -46,7 +46,7 @@
display: grid; grid-template-columns: repeat(5, 1fr); gap: calc(12 * var(--u)); } display: grid; grid-template-columns: repeat(5, 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 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 b { display: block; height: 100%; width: 0; background: #fff; }
.ch span { display: block; margin-top: calc(10 * var(--u)); white-space: nowrap; font: 700 calc(24 * var(--u))/1 Inter, sans-serif; .ch span { display: block; margin-top: calc(10 * var(--u)); white-space: nowrap; font: 800 calc(26 * var(--u))/1 Inter, sans-serif;
letter-spacing: 0.06em; text-transform: uppercase; color: var(--ink-3); } letter-spacing: 0.06em; text-transform: uppercase; color: var(--ink-3); }
.ch.on span { color: var(--ink); } .ch.on span { color: var(--ink); }
@@ -90,7 +90,7 @@
#readout .val small { font-size: 0.42em; color: var(--ink-2); margin-left: 0.12em; letter-spacing: 0; } #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); } #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(196 * var(--u)); padding: calc(7 * var(--u)) calc(14 * var(--u)); border-radius: calc(6 * var(--u)); #mag { position: absolute; left: calc(56 * var(--u) + 536 * var(--u)); bottom: calc(246 * var(--u)); padding: calc(7 * var(--u)) calc(14 * var(--u)); border-radius: calc(6 * var(--u));
font: 800 calc(28 * var(--u))/1 Inter, sans-serif; letter-spacing: 0.02em; color: #000; background: #fff; } font: 800 calc(28 * var(--u))/1 Inter, sans-serif; letter-spacing: 0.02em; color: #000; background: #fff; }
#mag.enlarged { background: var(--burn); } #mag.enlarged { background: var(--burn); }
@@ -105,11 +105,16 @@
#clock .light.day { color: #ffe08a; } #clock .light.day i { background: #ffd34d; box-shadow: 0 0 calc(14 * var(--u)) #ffd34d; } #clock .light.day { color: #ffe08a; } #clock .light.day i { background: #ffd34d; box-shadow: 0 0 calc(14 * var(--u)) #ffd34d; }
#clock .light.night { color: #b9c8ff; } #clock .light.night i { background: #20263a; border: calc(3 * var(--u)) solid #b9c8ff; } #clock .light.night { color: #b9c8ff; } #clock .light.night i { background: #20263a; border: calc(3 * var(--u)) solid #b9c8ff; }
#readout .rel { margin-top: calc(6 * var(--u)); font: 600 calc(28 * var(--u))/1.2 Inter, sans-serif; color: var(--ink-2); } #readout .rel { margin-top: calc(6 * var(--u)); font: 600 calc(28 * var(--u))/1.2 Inter, sans-serif; color: var(--ink-2); }
/* Inset + chips */
#inset { position: absolute; left: var(--pad); bottom: calc(196 * var(--u)); width: calc(520 * var(--u)); height: calc(330 * var(--u)); }
#relchip { position: absolute; left: calc(56 * var(--u) + 536 * var(--u)); bottom: calc(196 * var(--u)); padding: calc(7 * var(--u)) calc(14 * var(--u));
border-radius: calc(6 * var(--u)); font: 800 calc(26 * var(--u))/1 Inter, sans-serif; color: #fff; background: rgba(255,90,60,0.85); opacity: 0; }
.event { background: rgba(4,8,14,0.62); padding: calc(10 * var(--u)) calc(16 * var(--u)) calc(10 * var(--u)) calc(14 * var(--u)); border-radius: calc(8 * var(--u)); width: fit-content; }
/* Timeline */ /* Timeline */
#tlwrap { position: absolute; left: var(--pad); right: var(--pad); bottom: calc(52 * var(--u)); } #tlwrap { position: absolute; left: var(--pad); right: var(--pad); bottom: calc(52 * var(--u)); }
#tlwrap .lab { font: 700 calc(24 * var(--u))/1 Inter, sans-serif; letter-spacing: 0.1em; color: var(--ink-3); margin-bottom: calc(8 * var(--u)); } #tlwrap .lab { font: 700 calc(24 * var(--u))/1 Inter, sans-serif; letter-spacing: 0.1em; color: var(--ink-3); margin-bottom: calc(8 * var(--u)); }
#tlwrap .lab span { color: var(--burn); } #tlwrap .lab b { color: var(--ink-2); font-weight: 700; } #tlwrap .lab span { color: var(--burn); } #tlwrap .lab b { color: var(--ink-2); font-weight: 700; }
#timeline { height: calc(76 * var(--u)); } #timeline { height: calc(70 * var(--u)); }
#timeline svg { width: 100%; height: 100%; overflow: visible; font-family: 'JetBrains Mono', monospace; font-weight: 600; } #timeline svg { width: 100%; height: 100%; overflow: visible; font-family: 'JetBrains Mono', monospace; font-weight: 600; }
#interstitial { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; opacity: 0; #interstitial { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; opacity: 0;
background: radial-gradient(ellipse at center, rgba(0,0,0,0.55), rgba(0,0,0,0.15) 70%); } background: radial-gradient(ellipse at center, rgba(0,0,0,0.55), rgba(0,0,0,0.15) 70%); }
@@ -159,15 +164,19 @@
body.portrait #caption.hook h1 { font-size: calc(76 * var(--u)); } body.portrait #caption.hook h1 { font-size: calc(76 * var(--u)); }
body.portrait #caption p, body.portrait #caption.hook p { font-size: calc(38 * var(--u)); max-width: none; } body.portrait #caption p, body.portrait #caption.hook p { font-size: calc(38 * var(--u)); max-width: none; }
body.portrait #events { top: calc(700 * var(--u)); width: auto; right: var(--pad); } body.portrait #events { top: calc(700 * var(--u)); width: auto; right: var(--pad); }
body.portrait #readout { right: auto; left: var(--pad); bottom: calc(580 * var(--u)); text-align: left; } body.portrait #readout { right: auto; left: var(--pad); bottom: calc(420 * var(--u)); text-align: left; }
body.portrait #readout .val { font-size: calc(132 * var(--u)); } body.portrait #readout .val { font-size: calc(104 * var(--u)); }
body.portrait #mag { left: auto; right: var(--pad); bottom: calc(590 * var(--u)); } body.portrait #mag { left: auto; right: var(--pad); bottom: calc(912 * var(--u)); }
body.portrait #tlwrap { bottom: calc(440 * var(--u)); } body.portrait #tlwrap { display: none; }
body.portrait #timeline { height: calc(80 * var(--u)); } body.portrait #inset { left: auto; right: var(--pad); bottom: calc(420 * var(--u)); width: calc(560 * var(--u)); height: calc(430 * var(--u)); }
body.portrait #relchip { left: auto; right: var(--pad); bottom: calc(866 * var(--u)); }
body.portrait #readout { max-width: calc(400 * var(--u)); }
body.portrait #readout .light { margin: 0 0 calc(18 * var(--u)); display: inline-flex; }
body.portrait .ch span { font-size: calc(24 * var(--u)); }
body.portrait #interstitial .d { font-size: calc(120 * var(--u)); } body.portrait #interstitial .d { font-size: calc(120 * var(--u)); }
body.portrait #topshade { height: calc(1000 * var(--u)); } body.portrait #topshade { height: calc(1000 * var(--u)); }
body.portrait #botshade { height: calc(1200 * var(--u)); background: linear-gradient(transparent, rgba(0,0,0,0.78) 45%, rgba(0,0,0,0.85)); } body.portrait #botshade { height: calc(1200 * 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(400 * var(--u)); font-size: calc(17 * var(--u)); } body.portrait #credits { white-space: normal; bottom: calc(330 * var(--u)); font-size: calc(17 * var(--u)); }
body.portrait #endcard .panel { width: calc(960 * var(--u)); } body.portrait #endcard .panel { width: calc(960 * var(--u)); }
body.portrait #endcard .big { font-size: calc(210 * var(--u)); } body.portrait #endcard .big { font-size: calc(210 * var(--u)); }
body.portrait #endcard .lead { font-size: calc(46 * var(--u)); } body.portrait #endcard .lead { font-size: calc(46 * var(--u)); }
@@ -188,14 +197,16 @@
<div id="chapters" class="shadow-text"></div> <div id="chapters" class="shadow-text"></div>
<div id="caption" class="shadow-text"><h1></h1><p></p></div> <div id="caption" class="shadow-text"><h1></h1><p></p></div>
<div id="events" class="shadow-text"></div> <div id="events" class="shadow-text"></div>
<div id="clock" class="shadow-text"><div class="date"></div><div class="time"></div><div class="pills"><div class="ff">Fast-forward</div><div class="rate"></div></div><div class="light"></div></div> <div id="clock" class="shadow-text"><div class="date"></div><div class="time"></div><div class="pills"><div class="ff">Fast-forward · Earth slowed</div><div class="rate"></div></div><div class="light"></div></div>
<div id="readout" class="shadow-text"><div class="label">DISTANCE APART</div><div class="val"></div><div class="ctx"></div><div class="rel"></div></div> <div id="readout" class="shadow-text"><div class="label">DISTANCE APART</div><div class="val"></div><div class="ctx"></div><div class="rel"></div></div>
<div id="mag" class="shadow-text"></div> <div id="mag" class="shadow-text"></div>
<div id="tlwrap"><div class="lab">22 DAYS · <span>● ENGINE BURN</span> · <b>○ CLOSEST PASS</b></div><div id="timeline"></div></div> <div id="tlwrap"><div id="timeline"></div></div>
<div id="interstitial"><div class="k">22 days earlier</div><div class="d">SEP 30, 2026</div></div> <canvas id="inset"></canvas>
<div id="relchip">Red path: Cosmos as seen from X36</div>
<div id="interstitial"><div class="k">18 days earlier</div><div class="d">SEP 30, 2026</div></div>
<div id="endcard"></div> <div id="endcard"></div>
<div id="endlogo"><img src="logo.svg" alt=""><span>GOD'S EYE VIEW</span></div> <div id="endlogo"><img src="logo.svg" alt=""><span>GOD'S EYE VIEW</span></div>
<div id="credits">Reconstruction, not tracking data · Orbits: CelesTrak, KeepTrack, LeoLabs, M. Langbroek · Imagery: Esri, Maxar, Earthstar Geographics, NASA · Sky: ESO/S. Brunier (CC BY 4.0), Yale BSC</div> <div id="credits">Reconstruction from public Space-Track element sets (41 Cosmos 2612, 54 ICEYE-X36), not tracking data · Analyses: LeoLabs, KeepTrack, M. Langbroek, COMSPOC, Integrity ISR · Imagery: Esri, Maxar, Earthstar Geographics, NASA · Sky: ESO/S. Brunier (CC BY 4.0), Yale BSC</div>
<div id="dip" style="position:absolute;inset:0;background:#000;opacity:0"></div> <div id="dip" style="position:absolute;inset:0;background:#000;opacity:0"></div>
</div> </div>
<script src="/cesium/Cesium.js"></script> <script src="/cesium/Cesium.js"></script>
+19 -7
View File
@@ -195,6 +195,7 @@ function camAt(s) {
sun: lerp(a.sun || 0, b.sun || 0), sun: lerp(a.sun || 0, b.sun || 0),
vup: lerp(a.vup || 0, b.vup || 0), vup: lerp(a.vup || 0, b.vup || 0),
comp: lerp(a.comp ?? 1, b.comp ?? 1), comp: lerp(a.comp ?? 1, b.comp ?? 1),
compY: lerp(a.compY ?? 0, b.compY ?? 0),
pairD: lerp(a.pairD || 0, b.pairD || 0), pairD: lerp(a.pairD || 0, b.pairD || 0),
pairTh: lerp(a.pairTh || 0, b.pairTh || 0), pairTh: lerp(a.pairTh || 0, b.pairTh || 0),
aimRel: [0, 1, 2].map((k) => lerp(ar[k], br[k])), aimRel: [0, 1, 2].map((k) => lerp(ar[k], br[k])),
@@ -356,7 +357,7 @@ function project(world) {
return p; return p;
} }
const hud = createHud({ const hud = createHud({
D, W, H, U, PORTRAIT, sec, rate, sOfTau, sepAt, CA_T, VANTOR_T, BURNS, CUTS, SHOTS, shotAt, DUR, project, D, W, H, U, PORTRAIT, sec, rate, sOfTau, sepAt, CA_T, VANTOR_T, BURNS, CUTS, SHOTS, shotAt, DUR, project, relAt,
}); });
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -468,7 +469,7 @@ function setState(s) {
// (sideways, in the orbit's horizontal), aimed at the bisector of the two. // (sideways, in the orbit's horizontal), aimed at the bisector of the two.
const u = v3.normalize(v3.subtract(cosW, x36W, new v3()), new v3()); const u = v3.normalize(v3.subtract(cosW, x36W, new v3()), new v3());
const perp = v3.normalize(v3.subtract(NW, v3.multiplyByScalar(u, v3.dot(NW, u), new v3()), new v3()), new v3()); const perp = v3.normalize(v3.subtract(NW, v3.multiplyByScalar(u, v3.dot(NW, u), new v3()), new v3()), new v3());
const th = Cesium.Math.toRadians(cam.pairTh); const th = Cesium.Math.toRadians(cam.pairTh * (PORTRAIT ? 1.3 : 1));
v3.add(x36W, v3.multiplyByScalar(u, -cam.pairD * Math.cos(th), new v3()), camPos); v3.add(x36W, v3.multiplyByScalar(u, -cam.pairD * Math.cos(th), new v3()), camPos);
v3.add(camPos, v3.multiplyByScalar(perp, cam.pairD * Math.sin(th), new v3()), camPos); v3.add(camPos, v3.multiplyByScalar(perp, cam.pairD * Math.sin(th), new v3()), camPos);
const a1 = v3.normalize(v3.subtract(x36W, camPos, new v3()), new v3()); const a1 = v3.normalize(v3.subtract(x36W, camPos, new v3()), new v3());
@@ -481,7 +482,11 @@ function setState(s) {
// Up: local zenith; in portrait chase shots, the direction of flight (so the // Up: local zenith; in portrait chase shots, the direction of flight (so the
// pair travels up the tall frame and Earth's limb runs down one side). // pair travels up the tall frame and Earth's limb runs down one side).
const vup = PORTRAIT ? cam.vup : 0; const vup = PORTRAIT ? cam.vup : 0;
const upHint = v3.normalize(v3.lerp(RW, TW, vup, new v3()), new v3()); let upHint = v3.normalize(v3.lerp(RW, TW, vup, new v3()), new v3());
if (PORTRAIT && cam.pairD > 0) {
// Portrait true-size shot: run the pair up the tall frame.
upHint = v3.normalize(v3.subtract(cosW, x36W, new v3()), new v3());
}
let right = v3.normalize(v3.cross(dir, upHint, new v3()), new v3()); let right = v3.normalize(v3.cross(dir, upHint, new v3()), new v3());
let up = v3.cross(right, dir, new v3()); let up = v3.cross(right, dir, new v3());
{ {
@@ -491,13 +496,14 @@ function setState(s) {
const tanH = PORTRAIT ? Math.tan(fovR / 2) * aspect : Math.tan(fovR / 2); 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 tanV = PORTRAIT ? Math.tan(fovR / 2) : Math.tan(fovR / 2) / aspect;
const ndcX = (PORTRAIT ? 0 : 0.18) * cam.comp; const ndcX = (PORTRAIT ? 0 : 0.18) * cam.comp;
const ndcY = (PORTRAIT ? -0.04 : 0.06) * cam.comp; const ndcY = (PORTRAIT ? -0.04 : 0.06) * cam.comp + cam.compY;
v3.normalize(v3.add(v3.add(dir, v3.multiplyByScalar(right, -ndcX * tanH, new v3()), new v3()), 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); v3.multiplyByScalar(up, -ndcY * tanV, new v3()), new v3()), dir);
right = v3.normalize(v3.cross(dir, upHint, new v3()), new v3()); right = v3.normalize(v3.cross(dir, upHint, new v3()), new v3());
up = v3.cross(right, dir, new v3()); up = v3.cross(right, dir, new v3());
} }
scene.camera.setView({ destination: camPos, orientation: { direction: dir, up } }); scene.camera.setView({ destination: camPos, orientation: { direction: dir, up } });
scene.skyBox.show = !sh.noSky;
scene.camera.frustum.fov = Cesium.Math.toRadians(PORTRAIT ? cam.fov * 1.55 : cam.fov); scene.camera.frustum.fov = Cesium.Math.toRadians(PORTRAIT ? cam.fov * 1.55 : cam.fov);
{ {
const icrf = Cesium.Transforms.computeIcrfToFixedMatrix(jd) || M; const icrf = Cesium.Transforms.computeIcrfToFixedMatrix(jd) || M;
@@ -535,8 +541,8 @@ function setState(s) {
bCos.position = cosW; bCos.position = cosW;
bX36.scale = (pxX > 24 ? 0.16 : 0.5) * U; bX36.scale = (pxX > 24 ? 0.16 : 0.5) * U;
bCos.scale = (pxC > 24 ? 0.16 : 0.5) * U; bCos.scale = (pxC > 24 ? 0.16 : 0.5) * U;
bX36.color = Cesium.Color.WHITE.withAlpha(pxX > 60 ? 0.35 : 1); bX36.color = Cesium.Color.WHITE.withAlpha(pxX > 60 ? 0.6 : 1);
bCos.color = Cesium.Color.WHITE.withAlpha(pxC > 60 ? 0.35 : 1); bCos.color = Cesium.Color.WHITE.withAlpha(pxC > 60 ? 0.6 : 1);
// Burn flare // Burn flare
let flare = 0; let flare = 0;
@@ -602,6 +608,12 @@ function setState(s) {
} }
polyline(pts, cols, 1.4); polyline(pts, cols, 1.4);
} }
// Element-set uncertainty at closest approach: ±1.5 km along-track.
if (sh.id === 'ca') {
const a0 = v3.add(cosW, v3.multiplyByScalar(TW, -1500, new v3()), new v3());
const a1 = v3.add(cosW, v3.multiplyByScalar(TW, 1500, new v3()), new v3());
polyline([a0, cosW, a1], [COL_COS.withAlpha(0), COL_COS.withAlpha(0.45), COL_COS.withAlpha(0)], 10);
}
// RIC reference rings (orbital plane) during proximity // RIC reference rings (orbital plane) during proximity
const gridA = Math.min(1, Math.max(0, (8e3 - Math.max(sep, camDistX)) / 3e3)) * (cam.mag > 0.4 ? 1 : 0.35); const gridA = Math.min(1, Math.max(0, (8e3 - Math.max(sep, camDistX)) / 3e3)) * (cam.mag > 0.4 ? 1 : 0.35);
if (gridA > 0.01) { if (gridA > 0.01) {
@@ -628,7 +640,7 @@ function setState(s) {
const r0 = relAt(t - 30); const r0 = relAt(t - 30);
const relSpeed = Math.hypot(r1[0] - r0[0], r1[1] - r0[1], r1[2] - r0[2]) / 60; const relSpeed = Math.hypot(r1[0] - r0[0], r1[1] - r0[1], r1[2] - r0[2]) / 60;
return { sh, t, b, jd, x36W, cosW, RW, TW, NW, rel, sep, mag, gridA, lx, camDistX, vX: vXv, vC, relSpeed }; return { sh, t, b, jd, x36W, cosW, RW, TW, NW, rel, sep, sepG, mag, gridA, lx, camDistX, vX: vXv, vC, relSpeed };
} }
async function frame(s) { async function frame(s) {
+61 -59
View File
@@ -1,4 +1,4 @@
// v4 storyboard: a list of shots separated by hard cuts. Each shot owns its // Storyboard (v5): a list of shots separated by hard cuts. Each shot owns its
// clock. `true` shots run Earth, sun, both satellites and the camera on one // clock. `true` shots run Earth, sun, both satellites and the camera on one
// physical clock (a real time-lapse: day and night cycle, the ground rushes // physical clock (a real time-lapse: day and night cycle, the ground rushes
// by). `ff` (fast-forward) shots are too fast for that to read, so the Earth // by). `ff` (fast-forward) shots are too fast for that to read, so the Earth
@@ -6,118 +6,120 @@
// relative motion runs at the stated rate; they carry a FAST-FORWARD badge. // relative motion runs at the stated rate; they carry a FAST-FORWARD badge.
// //
// Camera keys use X36's local frame (R up, T velocity, N orbit normal): // Camera keys use X36's local frame (R up, T velocity, N orbit normal):
// dist (m) or f × separation; az 0 = behind X36 looking forward; el up; // dist (m) or f × guiding separation; az 0 = behind X36 looking forward;
// look 0 = X36 … 1 = Cosmos; earth pulls the aim toward Earth's centre; // el up; look 0 = X36 … 1 = Cosmos's guiding centre; earth pulls the aim
// frame 'inertial' freezes the basis at the shot start (wide shots); // toward Earth's centre; frame 'inertial' freezes the basis at the shot
// vup (portrait) rolls the camera so the direction of flight points up; // start (wide shots); vup (portrait) rolls the camera so the direction of
// rtrail = Cosmos relative-path trail (hours); atrail = orbit trails (min); // flight points up; rtrail = Cosmos relative-path trail (hours);
// mag/mx = fixed model enlargement; pairD/pairTh = pair-locked true-size cam. // atrail = orbit trails (min); mag/mx = fixed model enlargement;
// pairD/pairTh = pair-locked true-size camera; inset = hours of path shown
// in the "seen from X36" inset.
export function buildStory({ sec, D }) { export function buildStory({ sec, D }) {
const CA = sec(D.ca.t); const CA = sec(D.ca.t);
const M = 60; const M = 60;
const iso = (s) => sec(s); const iso = (s) => sec(s);
const WIDE = { frame: 'inertial', look: 0, earth: 0.86, f: 0, fov: 40, mag: 0, sun: 0.5 }; const WIDE = { frame: 'inertial', look: 0, earth: 0.86, f: 0, fov: 40, mag: 0, sun: 0.35 };
const shots = [ const shots = [
{ {
id: 'hook', mode: 'true', s0: 0, s1: 3.6, blur: 2, id: 'hook', mode: 'true', s0: 0, s1: 3.6, blur: 1, inset: 1,
tau: [[0, CA - 7 * M], [3.6, CA - 2 * M]], tau: [[0, CA - 13 * M], [3.6, CA - 6 * M]],
cam: [ cam: [
{ s: 0, dist: 1500, f: 2.6, az: 118, el: 18, look: 0.5, fov: 34, mag: 1, mx: 25, rtrail: 1.2, atrail: 4, vup: 1 }, { s: 0, dist: 1300, f: 2.4, az: 120, el: 20, look: 0.5, fov: 34, mag: 1, mx: 25, rtrail: 1.0, atrail: 2, vup: 1, compY: -0.16 },
{ s: 3.6, dist: 1300, f: 2.4, az: 104, el: 14, look: 0.5, fov: 34, mag: 1, mx: 25, rtrail: 1.2, atrail: 4, vup: 1 }, { s: 3.6, dist: 1100, f: 2.2, az: 108, el: 16, look: 0.5, fov: 34, mag: 1, mx: 25, rtrail: 1.0, atrail: 2, vup: 1, compY: -0.16 },
], ],
}, },
{ {
id: 'climb', mode: 'true', s0: 3.6, s1: 10.6, blur: 3, id: 'climb', mode: 'true', s0: 3.6, s1: 9.6, blur: 1, inset: 6,
tau: [[3.6, iso('2026-09-30T07:20:00Z')], [10.6, iso('2026-09-30T12:40:00Z')]], tau: [[3.6, iso('2026-09-30T07:30:00Z')], [9.6, iso('2026-09-30T12:30:00Z')]],
cam: [ cam: [
{ s: 3.6, ...WIDE, dist: 3.1e7, az: 86, el: 38, atrail: 70, rtrail: 0 }, { s: 3.6, ...WIDE, dist: 3.3e7, az: 92, el: 28, atrail: 80, rtrail: 0 },
{ s: 10.6, ...WIDE, dist: 2.6e7, az: 104, el: 34, atrail: 70, rtrail: 0 }, { s: 9.6, ...WIDE, dist: 2.9e7, az: 100, el: 24, atrail: 80, rtrail: 0 },
], ],
}, },
{ {
id: 'lap', mode: 'ff', s0: 10.6, s1: 14.6, blur: 1, bgRate: 260, id: 'lap', mode: 'ff', s0: 9.6, s1: 13.6, blur: 1, bgRate: 260, inset: 30,
tau: [[10.6, iso('2026-09-30T13:00:00Z')], [14.6, iso('2026-10-11T23:40:00Z')]], tau: [[9.6, iso('2026-09-30T13:00:00Z')], [13.6, iso('2026-10-11T23:40:00Z')]],
cam: [ cam: [
{ s: 10.6, ...WIDE, dist: 2.7e7, az: 100, el: 36, atrail: 0, rtrail: 30 }, { s: 9.6, ...WIDE, dist: 2.9e7, az: 100, el: 30, atrail: 0, rtrail: 30 },
{ s: 14.6, ...WIDE, dist: 2.3e7, az: 124, el: 30, atrail: 0, rtrail: 30 }, { s: 13.6, ...WIDE, dist: 2.5e7, az: 116, el: 28, atrail: 0, rtrail: 30 },
], ],
}, },
{ {
id: 'drop', mode: 'true', s0: 14.6, s1: 19.6, blur: 4, id: 'drop', mode: 'true', s0: 13.6, s1: 18.6, blur: 3, inset: 4,
tau: [[14.6, iso('2026-10-12T00:00:00Z')], [19.6, iso('2026-10-12T03:30:00Z')]], tau: [[13.6, iso('2026-10-12T00:00:00Z')], [18.6, iso('2026-10-12T03:30:00Z')]],
cam: [ cam: [
{ s: 14.6, dist: 2.4e5, f: 2.6, az: 62, el: 28, look: 0.5, fov: 36, mag: 0, rtrail: 3, atrail: 20, vup: 1 }, { s: 13.6, dist: 2.4e5, f: 2.6, az: 62, el: 28, look: 0.5, fov: 36, mag: 0, rtrail: 3, atrail: 20, vup: 1 },
{ s: 17.0, dist: 3.0e5, f: 2.6, az: 48, el: 30, look: 0.5, fov: 36, mag: 0, rtrail: 4, atrail: 20, vup: 1 }, { s: 16.0, dist: 3.0e5, f: 2.6, az: 48, el: 30, look: 0.5, fov: 36, mag: 0, rtrail: 4, atrail: 20, vup: 1 },
{ s: 19.6, dist: 4.0e5, f: 2.6, az: 34, el: 32, look: 0.5, fov: 36, mag: 0, rtrail: 4, atrail: 20, vup: 1 }, { s: 18.6, dist: 4.0e5, f: 2.6, az: 34, el: 32, look: 0.5, fov: 36, mag: 0, rtrail: 4, atrail: 20, vup: 1 },
], ],
}, },
{ {
id: 'close', mode: 'ff', s0: 19.6, s1: 23.2, blur: 1, bgRate: 140, id: 'close', mode: 'ff', s0: 18.6, s1: 22.0, blur: 1, bgRate: 140, inset: 30,
tau: [[19.6, iso('2026-10-12T03:40:00Z')], [21.4, iso('2026-10-15T06:00:00Z')], [23.2, iso('2026-10-16T07:00:00Z')]], tau: [[18.6, iso('2026-10-12T03:40:00Z')], [20.3, iso('2026-10-15T06:00:00Z')], [22.0, iso('2026-10-16T07:00:00Z')]],
cam: [ cam: [
{ s: 19.6, dist: 1.0e6, f: 2.8, az: 30, el: 34, look: 0.5, fov: 34, mag: 0, rtrail: 30, atrail: 0, vup: 1 }, { s: 18.6, dist: 1.0e6, f: 2.8, az: 30, el: 34, look: 0.5, fov: 34, mag: 0, rtrail: 30, atrail: 0, vup: 1 },
{ s: 23.2, dist: 2.0e4, f: 2.8, az: 46, el: 34, look: 0.5, fov: 34, mag: 0, rtrail: 30, atrail: 0, vup: 1 }, { s: 22.0, dist: 2.0e4, f: 2.8, az: 46, el: 34, look: 0.5, fov: 34, mag: 0, rtrail: 30, atrail: 0, vup: 1 },
], ],
}, },
{ {
id: 'dance', mode: 'true', s0: 23.2, s1: 29.2, blur: 5, id: 'dance', mode: 'true', s0: 22.0, s1: 28.0, blur: 5, inset: 3.2,
tau: [[23.2, iso('2026-10-16T07:10:00Z')], [29.2, iso('2026-10-16T12:10:00Z')]], tau: [[22.0, iso('2026-10-16T07:10:00Z')], [28.0, iso('2026-10-16T12:10:00Z')]],
cam: [ cam: [
{ s: 23.2, dist: 5.5e3, f: 1.7, az: 62, el: 30, look: 0.5, fov: 38, mag: 1, mx: 40, rtrail: 3, atrail: 1.2, vup: 1 }, { s: 22.0, dist: 5.5e3, f: 1.7, az: 62, el: 30, look: 0.5, fov: 38, mag: 1, mx: 40, rtrail: 3, atrail: 1.2, vup: 1 },
{ s: 29.2, dist: 5.0e3, f: 1.7, az: 84, el: 24, look: 0.5, fov: 38, mag: 1, mx: 40, rtrail: 3, atrail: 1.2, vup: 1 }, { s: 28.0, dist: 5.0e3, f: 1.7, az: 80, el: 26, look: 0.5, fov: 38, mag: 1, mx: 40, rtrail: 3, atrail: 1.2, vup: 1 },
], ],
}, },
{ {
id: 'dance-ff', mode: 'ff', s0: 29.2, s1: 31.2, blur: 1, bgRate: 140, id: 'dance-ff', mode: 'ff', s0: 28.0, s1: 30.0, blur: 1, bgRate: 140, inset: 10,
tau: [[29.2, iso('2026-10-16T12:20:00Z')], [31.2, iso('2026-10-17T22:20:00Z')]], tau: [[28.0, iso('2026-10-16T12:20:00Z')], [30.0, iso('2026-10-17T22:20:00Z')]],
cam: [ cam: [
{ s: 29.2, dist: 4.5e3, f: 1.7, az: 70, el: 40, look: 0.5, fov: 38, mag: 1, mx: 40, rtrail: 12, atrail: 0, vup: 1 }, { s: 28.0, dist: 4.5e3, f: 1.7, az: 72, el: 38, look: 0.5, fov: 38, mag: 1, mx: 40, rtrail: 12, atrail: 0, vup: 1 },
{ s: 31.2, dist: 3.4e3, f: 1.7, az: 92, el: 34, look: 0.5, fov: 38, mag: 1, mx: 40, rtrail: 12, atrail: 0, vup: 1 }, { s: 30.0, dist: 3.4e3, f: 1.7, az: 90, el: 32, look: 0.5, fov: 38, mag: 1, mx: 40, rtrail: 12, atrail: 0, vup: 1 },
], ],
}, },
{ {
id: 'approach', mode: 'true', s0: 31.2, s1: 35.4, blur: 4, id: 'approach', mode: 'true', s0: 30.0, s1: 33.6, blur: 3, inset: 3,
tau: [[31.2, iso('2026-10-17T22:30:00Z')], [33.4, CA - 40 * M], [35.4, CA - 4 * M]], tau: [[30.0, iso('2026-10-17T22:30:00Z')], [32.0, CA - 40 * M], [33.6, CA - 5 * M]],
cam: [ cam: [
{ s: 31.2, dist: 3.0e3, f: 1.7, az: 96, el: 26, look: 0.5, fov: 38, mag: 1, mx: 30, rtrail: 3, atrail: 1.5, vup: 1 }, { s: 30.0, dist: 3.0e3, f: 1.7, az: 96, el: 26, look: 0.5, fov: 38, mag: 1, mx: 30, rtrail: 3, atrail: 1.5, vup: 1 },
{ s: 35.4, dist: 1.2e3, f: 2.2, az: 112, el: 18, look: 0.5, fov: 36, mag: 1, mx: 25, rtrail: 1.6, atrail: 1.5, vup: 1 }, { s: 33.6, dist: 1.4e3, f: 1.7, az: 112, el: 18, look: 0.5, fov: 36, mag: 1, mx: 25, rtrail: 1.6, atrail: 1.5, vup: 1 },
], ],
}, },
{ {
id: 'ca', mode: 'true', s0: 35.4, s1: 37.4, blur: 2, id: 'ca', mode: 'true', s0: 33.6, s1: 36.6, blur: 1, inset: 1, noSky: true,
tau: [[35.4, CA - 4 * M], [37.4, CA + 1.5 * M]], tau: [[33.6, CA - 4 * M], [36.6, CA + 2 * M]],
cam: [ cam: [
{ s: 35.4, dist: 26, az: 160, el: 0, look: 0, fov: 34, mag: 0, rtrail: 0.4, atrail: 0.6, comp: 0, pairD: 26, pairTh: 22 }, { s: 33.6, dist: 26, az: 160, el: 0, look: 0, fov: 34, mag: 0, rtrail: 0.4, atrail: 0.6, comp: 0, pairD: 26, pairTh: 24 },
{ s: 37.4, dist: 22, az: 163, el: 0, look: 0, fov: 34, mag: 0, rtrail: 0.4, atrail: 0.6, comp: 0, pairD: 22, pairTh: 18 }, { s: 36.6, dist: 20, az: 163, el: 0, look: 0, fov: 34, mag: 0, rtrail: 0.4, atrail: 0.6, comp: 0, pairD: 20, pairTh: 14 },
], ],
}, },
{ {
id: 'sunrise', mode: 'true', s0: 37.4, s1: 40.0, blur: 2, id: 'sunrise', mode: 'true', s0: 36.6, s1: 39.0, blur: 1, inset: 1.2,
tau: [[37.4, CA + 2 * M], [40.0, CA + 27 * M]], tau: [[36.6, CA + 6 * M], [39.0, CA + 27 * M]],
cam: [ cam: [
{ s: 37.4, dist: 1.3e3, f: 2.2, az: 96, el: 16, look: 0.5, fov: 36, mag: 1, mx: 25, rtrail: 1.6, atrail: 1.5, vup: 1 }, { s: 36.6, dist: 1.3e3, f: 2.2, az: 96, el: 16, look: 0.5, fov: 36, mag: 1, mx: 25, rtrail: 1.6, atrail: 1.5, vup: 1 },
{ s: 40.0, dist: 2.2e3, f: 2.4, az: 104, el: 22, look: 0.5, fov: 36, mag: 1, mx: 25, rtrail: 2, atrail: 1.5, vup: 1 }, { s: 39.0, dist: 2.2e3, f: 2.4, az: 104, el: 22, look: 0.5, fov: 36, mag: 1, mx: 25, rtrail: 2, atrail: 1.5, vup: 1 },
], ],
}, },
{ {
id: 'leave', mode: 'ff', s0: 40.0, s1: 44.0, blur: 1, bgRate: 140, id: 'leave', mode: 'ff', s0: 39.0, s1: 42.4, blur: 1, bgRate: 140, inset: 40,
tau: [[40.0, CA + 30 * M], [42.0, iso('2026-10-19T08:00:00Z')], [44.0, iso('2026-10-21T12:00:00Z')]], tau: [[39.0, CA + 30 * M], [40.8, iso('2026-10-19T08:00:00Z')], [42.4, iso('2026-10-21T12:00:00Z')]],
cam: [ cam: [
{ s: 40.0, dist: 1.2e4, f: 2.8, az: 110, el: 30, look: 0.4, fov: 36, mag: 1, mx: 40, rtrail: 8, atrail: 0, vup: 1 }, { s: 39.0, dist: 1.2e4, f: 2.8, az: 110, el: 30, look: 0.4, fov: 36, mag: 1, mx: 40, rtrail: 8, atrail: 0, vup: 1 },
{ s: 42.0, dist: 4.0e5, f: 2.8, az: 70, el: 30, look: 0.5, fov: 34, mag: 0, rtrail: 30, atrail: 0, vup: 1 }, { s: 40.8, dist: 4.0e5, f: 2.8, az: 70, el: 30, look: 0.5, fov: 34, mag: 0, rtrail: 30, atrail: 0, vup: 1 },
{ s: 44.0, dist: 1.6e6, f: 2.8, az: 44, el: 30, look: 0.5, fov: 34, mag: 0, rtrail: 60, atrail: 0, vup: 1 }, { s: 42.4, dist: 1.6e6, f: 2.8, az: 44, el: 30, look: 0.5, fov: 34, mag: 0, rtrail: 60, atrail: 0, vup: 1 },
], ],
}, },
{ {
id: 'end', mode: 'true', s0: 44.0, s1: 48.5, blur: 1, id: 'end', mode: 'true', s0: 42.4, s1: 45.4, blur: 1, inset: 0,
tau: [[44.0, iso('2026-10-21T12:00:00Z')], [48.5, iso('2026-10-21T14:00:00Z')]], tau: [[42.4, iso('2026-10-21T12:00:00Z')], [45.4, iso('2026-10-21T13:30:00Z')]],
cam: [ cam: [
{ s: 44.0, ...WIDE, dist: 4.6e7, az: 82, el: 34, atrail: 60, rtrail: 0 }, { s: 42.4, ...WIDE, dist: 3.4e7, az: 92, el: 28, atrail: 80, rtrail: 0 },
{ s: 48.5, ...WIDE, dist: 4.2e7, az: 96, el: 32, atrail: 60, rtrail: 0 }, { s: 45.4, ...WIDE, dist: 3.1e7, az: 100, el: 26, atrail: 80, rtrail: 0 },
], ],
}, },
]; ];
return { shots, duration: 48.5 }; return { shots, duration: 45.4 };
} }