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
// labels hold their side (hysteresis) and ease instead of jumping.
import { createInset } from './inset.js';
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 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;
// -------------------------------------------------------------------------
@@ -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 CAPTIONS = [
cap('hook', `A Russian military satellite came within <span class="c-cos nw">~${CA_M} m</span> of a U.S.-registered one`,
'This is how it got there, rebuilt from public tracking data.', { hook: true, s0: -1 }),
cap('hook', 'A Russian military satellite came within about half a kilometre of a U.S. radar satellite',
'Rebuilt from public tracking data.', { hook: true, s0: -1 }),
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.'),
cap('lap', 'Fall behind, and lap it',
'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.'),
'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', 'Lapped, twice',
'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',
'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',
'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',
'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',
'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',
'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>`,
'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',
'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',
'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 = {
'Phasing raise': 'raises its orbit 46 km',
'Phasing raise': 'two burns raise its orbit 46 km',
'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',
'Braking burn': 'rises 2.5 km to stop',
'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-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: 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);
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');
}
// "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).
const memo = { tagX: null, tagC: null, lastS: -1 };
@@ -143,7 +153,7 @@ export function createHud(ctx) {
// -------------------------------------------------------------------------
function update(s, t, b, st) {
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;
if (Math.abs(s - memo.lastS) > 0.2 || CUTS.some((c) => memo.lastS < c && s >= c)) {
memo.tagX = null;
@@ -191,27 +201,32 @@ export function createHud(ctx) {
$('#clock .rate').innerHTML = `1 sec = ${perSecond(rate(s))}`;
$('#clock .ff').style.display = sh.mode === 'ff' ? 'inline-block' : 'none';
const lit = st.lx > 0.5;
const light = $('#clock .light');
const light = $('.light');
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';
$('#clock').style.opacity = live;
// Distance readout
const ro = $('#readout');
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 circ = 2 * Math.PI * D.a_ref_km * 1000;
const tt = ((tt0 % circ) + circ * 1.5) % circ - circ / 2;
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 · ` : ''}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`;
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');
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
const magEl = $('#mag');
@@ -219,7 +234,13 @@ export function createHud(ctx) {
const trueSize = !enlarged && st.camDistX < 200;
magEl.style.opacity = (enlarged || trueSize) ? fade(s, climbS, END, 0.3) : 0;
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
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 tX = tag('tagX', 'ICEYE-X36', 'c-x36');
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 speedX = `${(st.vX / 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}`;
tC.querySelector('.meta').textContent = pC?.hidden ? 'far side of Earth' : `Russian military · ${speedC}`;
const speedShot = ['climb', 'lap', 'drop'].includes(sh.id);
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 = '';
const M = 24 * U;
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);
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;
// ease the label toward its target so it never jumps.
const place = (key, el, p, prefer, color) => {
if (!onScreen(p)) {
if (!onScreen(p) || p.hidden) {
el.style.opacity = 0;
memo[key] = null;
return;
}
el.style.opacity = tagA;
@@ -310,9 +333,9 @@ export function createHud(ctx) {
let q = m ? [m.sx, m.sy] : prefer;
let cand = rectFor(q);
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]]];
for (const qq of quads) {
const c = rectFor(qq);
@@ -353,7 +376,7 @@ export function createHud(ctx) {
// Dip at cuts
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);
// End card
@@ -364,13 +387,13 @@ export function createHud(ctx) {
ec.dataset.built = '1';
ec.innerHTML = `<div class="panel">
<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="lead">Closest pass between Cosmos 2612 and ICEYE-X36 · Oct 18, in Earth's shadow</div>
<div class="range">closest pass, Oct 18, in Earth's shadow · LeoLabs ~500 m · estimates 150–650 m</div>
<div class="stats">
<div><b>22</b><span>days in pursuit</span></div>
<div><b>${BURNS.length}</b><span>engine burns</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>18</b><span>days from first burn to closest pass</span></div>
<div><b>${BURNS.length}</b><span>manoeuvres</span></div>
<div><b>~${Math.round(D.total_dv)} m/s</b><span>of thrust spent</span></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>`;
}
$('#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)); }
.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)); 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); }
.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 .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; }
#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.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); }
/* 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 */
#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 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; }
#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%); }
@@ -159,15 +164,19 @@
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 #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 .val { font-size: calc(132 * var(--u)); }
body.portrait #mag { left: auto; right: var(--pad); bottom: calc(590 * var(--u)); }
body.portrait #tlwrap { bottom: calc(440 * var(--u)); }
body.portrait #timeline { height: calc(80 * var(--u)); }
body.portrait #readout { right: auto; left: var(--pad); bottom: calc(420 * var(--u)); text-align: left; }
body.portrait #readout .val { font-size: calc(104 * var(--u)); }
body.portrait #mag { left: auto; right: var(--pad); bottom: calc(912 * var(--u)); }
body.portrait #tlwrap { display: none; }
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 #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 #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 .big { font-size: calc(210 * 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="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="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="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="interstitial"><div class="k">22 days earlier</div><div class="d">SEP 30, 2026</div></div>
<div id="tlwrap"><div id="timeline"></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="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>
<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),
vup: lerp(a.vup || 0, b.vup || 0),
comp: lerp(a.comp ?? 1, b.comp ?? 1),
compY: lerp(a.compY ?? 0, b.compY ?? 0),
pairD: lerp(a.pairD || 0, b.pairD || 0),
pairTh: lerp(a.pairTh || 0, b.pairTh || 0),
aimRel: [0, 1, 2].map((k) => lerp(ar[k], br[k])),
@@ -356,7 +357,7 @@ function project(world) {
return p;
}
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.
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 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(camPos, v3.multiplyByScalar(perp, cam.pairD * Math.sin(th), new v3()), camPos);
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
// pair travels up the tall frame and Earth's limb runs down one side).
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 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 tanV = PORTRAIT ? Math.tan(fovR / 2) : Math.tan(fovR / 2) / aspect;
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.multiplyByScalar(up, -ndcY * tanV, new v3()), new v3()), dir);
right = v3.normalize(v3.cross(dir, upHint, new v3()), new v3());
up = v3.cross(right, dir, new v3());
}
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);
{
const icrf = Cesium.Transforms.computeIcrfToFixedMatrix(jd) || M;
@@ -535,8 +541,8 @@ function setState(s) {
bCos.position = cosW;
bX36.scale = (pxX > 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);
bCos.color = Cesium.Color.WHITE.withAlpha(pxC > 60 ? 0.35 : 1);
bX36.color = Cesium.Color.WHITE.withAlpha(pxX > 60 ? 0.6 : 1);
bCos.color = Cesium.Color.WHITE.withAlpha(pxC > 60 ? 0.6 : 1);
// Burn flare
let flare = 0;
@@ -602,6 +608,12 @@ function setState(s) {
}
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
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) {
@@ -628,7 +640,7 @@ function setState(s) {
const r0 = relAt(t - 30);
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) {
+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
// 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
@@ -6,118 +6,120 @@
// 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):
// dist (m) or f × separation; az 0 = behind X36 looking forward; el up;
// look 0 = X36 … 1 = Cosmos; earth pulls the aim toward Earth's centre;
// frame 'inertial' freezes the basis at the shot start (wide shots);
// vup (portrait) rolls the camera so the direction of flight points up;
// rtrail = Cosmos relative-path trail (hours); atrail = orbit trails (min);
// mag/mx = fixed model enlargement; pairD/pairTh = pair-locked true-size cam.
// dist (m) or f × guiding separation; az 0 = behind X36 looking forward;
// el up; look 0 = X36 … 1 = Cosmos's guiding centre; earth pulls the aim
// toward Earth's centre; frame 'inertial' freezes the basis at the shot
// start (wide shots); vup (portrait) rolls the camera so the direction of
// flight points up; rtrail = Cosmos relative-path trail (hours);
// 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 }) {
const CA = sec(D.ca.t);
const M = 60;
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 = [
{
id: 'hook', mode: 'true', s0: 0, s1: 3.6, blur: 2,
tau: [[0, CA - 7 * M], [3.6, CA - 2 * M]],
id: 'hook', mode: 'true', s0: 0, s1: 3.6, blur: 1, inset: 1,
tau: [[0, CA - 13 * M], [3.6, CA - 6 * M]],
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: 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: 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: 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,
tau: [[3.6, iso('2026-09-30T07:20:00Z')], [10.6, iso('2026-09-30T12:40:00Z')]],
id: 'climb', mode: 'true', s0: 3.6, s1: 9.6, blur: 1, inset: 6,
tau: [[3.6, iso('2026-09-30T07:30:00Z')], [9.6, iso('2026-09-30T12:30:00Z')]],
cam: [
{ s: 3.6, ...WIDE, dist: 3.1e7, az: 86, el: 38, atrail: 70, rtrail: 0 },
{ s: 10.6, ...WIDE, dist: 2.6e7, az: 104, el: 34, atrail: 70, rtrail: 0 },
{ s: 3.6, ...WIDE, dist: 3.3e7, az: 92, el: 28, atrail: 80, 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,
tau: [[10.6, iso('2026-09-30T13:00:00Z')], [14.6, iso('2026-10-11T23:40:00Z')]],
id: 'lap', mode: 'ff', s0: 9.6, s1: 13.6, blur: 1, bgRate: 260, inset: 30,
tau: [[9.6, iso('2026-09-30T13:00:00Z')], [13.6, iso('2026-10-11T23:40:00Z')]],
cam: [
{ s: 10.6, ...WIDE, dist: 2.7e7, az: 100, el: 36, atrail: 0, rtrail: 30 },
{ s: 14.6, ...WIDE, dist: 2.3e7, az: 124, el: 30, atrail: 0, rtrail: 30 },
{ s: 9.6, ...WIDE, dist: 2.9e7, az: 100, 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,
tau: [[14.6, iso('2026-10-12T00:00:00Z')], [19.6, iso('2026-10-12T03:30:00Z')]],
id: 'drop', mode: 'true', s0: 13.6, s1: 18.6, blur: 3, inset: 4,
tau: [[13.6, iso('2026-10-12T00:00:00Z')], [18.6, iso('2026-10-12T03:30:00Z')]],
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: 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: 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: 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: 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: 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,
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')]],
id: 'close', mode: 'ff', s0: 18.6, s1: 22.0, blur: 1, bgRate: 140, inset: 30,
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: [
{ 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: 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: 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: 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,
tau: [[23.2, iso('2026-10-16T07:10:00Z')], [29.2, iso('2026-10-16T12:10:00Z')]],
id: 'dance', mode: 'true', s0: 22.0, s1: 28.0, blur: 5, inset: 3.2,
tau: [[22.0, iso('2026-10-16T07:10:00Z')], [28.0, iso('2026-10-16T12:10:00Z')]],
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: 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: 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: 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,
tau: [[29.2, iso('2026-10-16T12:20:00Z')], [31.2, iso('2026-10-17T22:20:00Z')]],
id: 'dance-ff', mode: 'ff', s0: 28.0, s1: 30.0, blur: 1, bgRate: 140, inset: 10,
tau: [[28.0, iso('2026-10-16T12:20:00Z')], [30.0, iso('2026-10-17T22:20:00Z')]],
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: 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: 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: 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,
tau: [[31.2, iso('2026-10-17T22:30:00Z')], [33.4, CA - 40 * M], [35.4, CA - 4 * M]],
id: 'approach', mode: 'true', s0: 30.0, s1: 33.6, blur: 3, inset: 3,
tau: [[30.0, iso('2026-10-17T22:30:00Z')], [32.0, CA - 40 * M], [33.6, CA - 5 * M]],
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: 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: 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: 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,
tau: [[35.4, CA - 4 * M], [37.4, CA + 1.5 * M]],
id: 'ca', mode: 'true', s0: 33.6, s1: 36.6, blur: 1, inset: 1, noSky: true,
tau: [[33.6, CA - 4 * M], [36.6, CA + 2 * M]],
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: 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: 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: 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,
tau: [[37.4, CA + 2 * M], [40.0, CA + 27 * M]],
id: 'sunrise', mode: 'true', s0: 36.6, s1: 39.0, blur: 1, inset: 1.2,
tau: [[36.6, CA + 6 * M], [39.0, CA + 27 * M]],
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: 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: 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: 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,
tau: [[40.0, CA + 30 * M], [42.0, iso('2026-10-19T08:00:00Z')], [44.0, iso('2026-10-21T12:00:00Z')]],
id: 'leave', mode: 'ff', s0: 39.0, s1: 42.4, blur: 1, bgRate: 140, inset: 40,
tau: [[39.0, CA + 30 * M], [40.8, iso('2026-10-19T08:00:00Z')], [42.4, iso('2026-10-21T12:00:00Z')]],
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: 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: 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: 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: 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: 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,
tau: [[44.0, iso('2026-10-21T12:00:00Z')], [48.5, iso('2026-10-21T14:00:00Z')]],
id: 'end', mode: 'true', s0: 42.4, s1: 45.4, blur: 1, inset: 0,
tau: [[42.4, iso('2026-10-21T12:00:00Z')], [45.4, iso('2026-10-21T13:30:00Z')]],
cam: [
{ s: 44.0, ...WIDE, dist: 4.6e7, az: 82, el: 34, atrail: 60, rtrail: 0 },
{ s: 48.5, ...WIDE, dist: 4.2e7, az: 96, el: 32, atrail: 60, rtrail: 0 },
{ s: 42.4, ...WIDE, dist: 3.4e7, az: 92, el: 28, atrail: 80, 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 };
}