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