mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 11:24:00 +00:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
11dd283712 | ||
|
|
cbca6a228d | ||
|
|
445af0875b | ||
|
|
d167cf0ccb | ||
|
|
3685c416f3 | ||
|
|
586ed4f8ea |
No files matched your search
@@ -0,0 +1 @@
|
|||||||
|
- Bound untrusted display text at output boundaries (asturksever, #1280).
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
- Pause periodic network updates in hidden tabs; the overdue update on return restarts the polling schedule, so the next poll follows one interval later even when that update fails (asturksever, #1282).
|
|
||||||
- Report and guard browser visibility during Transit QA so hidden-page polling pauses cannot masquerade as playback regressions (asturksever, #1282).
|
|
||||||
- Require each live city to supply its own feed vehicles in Transit QA; retained vehicles cannot qualify another city (asturksever, #1282).
|
|
||||||
- Bound the Transit card-anchor probe by observed poll coverage instead of a fixed 20-second window (asturksever, #1282).
|
|
||||||
- Keep visible-page Transit probes focused through the browser testing API (asturksever, #1282).
|
|
||||||
- Add a real-browser regression gate for hidden-tab periodic work, explicit refresh, resume and teardown (asturksever, #1282).
|
|
||||||
- Restore minimized test windows before screenshots so native compositor pauses cannot stall browser acceptance (asturksever, #1282).
|
|
||||||
+10
-3
@@ -9,7 +9,16 @@ broader suffix variants. Follow-ups retain boundary provenance, and cancelled
|
|||||||
lookups preserve the previous analyst cohort and boundary source. Navigation
|
lookups preserve the previous analyst cohort and boundary source. Navigation
|
||||||
search keeps a successful geocode, with no search or view-based region recovery.
|
search keeps a successful geocode, with no search or view-based region recovery.
|
||||||
|
|
||||||
Manager-owned periodic network updates pause while the document is hidden. Returning to a visible tab runs one overdue update per enabled layer without overlapping an active refresh and restarts that layer's polling schedule from the return, so the next poll follows one interval later whether or not the catch-up update succeeds; explicit refresh requests remain available. Transit also defers camera-driven feed admission while hidden and rechecks coverage on return. Visibility listeners are released with their polling intervals.
|
External ALPR tags, surveillance-tool display fields, voice context text and
|
||||||
|
compact analyst strings collapse controls and whitespace before a final
|
||||||
|
180-character cap (scene labels retain their 120-character cap). Empty ALPR
|
||||||
|
values remain null and Unicode text is preserved without splitting a surrogate
|
||||||
|
pair. Only display fields are normalized: canonical analyst identifiers
|
||||||
|
(`id`, `layerKey`, `icao24`, `mmsi`, `noradId`, `registration`, `callsign`)
|
||||||
|
pass through unchanged, because bikeshare uses the raw station name as its ID
|
||||||
|
and numbered voice references resolve by that key. These fields remain
|
||||||
|
untrusted source data in structured tool output; text normalization does not
|
||||||
|
establish trust or eliminate prompt injection.
|
||||||
|
|
||||||
Terrain height requests accept at most 64 points, matching the browser service's
|
Terrain height requests accept at most 64 points, matching the browser service's
|
||||||
existing batch size and one bounded upstream chunk. Larger requests return HTTP
|
existing batch size and one bounded upstream chunk. Larger requests return HTTP
|
||||||
@@ -4215,8 +4224,6 @@ animation. Polls, surface completions and trail fades request individual renders
|
|||||||
Disable clears vehicles, detection references, requests, timers and holds;
|
Disable clears vehicles, detection references, requests, timers and holds;
|
||||||
destruction also releases collections and registrations.
|
destruction also releases collections and registrations.
|
||||||
|
|
||||||
The `scripts/qa-layer-visibility.mjs` gate uses the development-only registration seam and real browser tab visibility to prove periodic work pauses while hidden, explicit refresh still works, resume performs one overdue refresh, and disabled layers stay stopped.
|
|
||||||
|
|
||||||
**Acceptance harness.** Run against an already running development server:
|
**Acceptance harness.** Run against an already running development server:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
|||||||
@@ -6,7 +6,6 @@
|
|||||||
"scripts/qa-form-fields.mjs",
|
"scripts/qa-form-fields.mjs",
|
||||||
"scripts/package-boundaries.json",
|
"scripts/package-boundaries.json",
|
||||||
"scripts/format.mjs",
|
"scripts/format.mjs",
|
||||||
"scripts/qa-layer-visibility.mjs",
|
|
||||||
"scripts/next-layer-state-token.mjs",
|
"scripts/next-layer-state-token.mjs",
|
||||||
"scripts/check-layer-state-tokens.mjs",
|
"scripts/check-layer-state-tokens.mjs",
|
||||||
"scripts/qa-layer-token-twochar.mjs",
|
"scripts/qa-layer-token-twochar.mjs",
|
||||||
|
|||||||
@@ -1500,6 +1500,7 @@
|
|||||||
"src/layers/alpr/overlay.js",
|
"src/layers/alpr/overlay.js",
|
||||||
"src/layers/alpr/source.js",
|
"src/layers/alpr/source.js",
|
||||||
"src/data/inputOwnership.js",
|
"src/data/inputOwnership.js",
|
||||||
|
"src/data/displayText.js",
|
||||||
"src/layers/alpr/records.js",
|
"src/layers/alpr/records.js",
|
||||||
"src/data/tomtomTiles.js",
|
"src/data/tomtomTiles.js",
|
||||||
"src/layers/alpr/tileRecords.js",
|
"src/layers/alpr/tileRecords.js",
|
||||||
@@ -2290,6 +2291,7 @@
|
|||||||
"src/ui/visualSettings.js",
|
"src/ui/visualSettings.js",
|
||||||
"src/voice/commands.js",
|
"src/voice/commands.js",
|
||||||
"src/voice/control.js",
|
"src/voice/control.js",
|
||||||
|
"src/data/displayText.js",
|
||||||
"src/voice/gevActions.js",
|
"src/voice/gevActions.js",
|
||||||
"src/agent/worldPacket.js",
|
"src/agent/worldPacket.js",
|
||||||
"src/voice/speech.js",
|
"src/voice/speech.js",
|
||||||
@@ -2974,6 +2976,7 @@
|
|||||||
"src/search/coordinateParser.js",
|
"src/search/coordinateParser.js",
|
||||||
"src/search/presetGeocoder.js",
|
"src/search/presetGeocoder.js",
|
||||||
"src/app/localGeojsonServices.js",
|
"src/app/localGeojsonServices.js",
|
||||||
|
"src/data/displayText.js",
|
||||||
"src/layers/alpr/records.js",
|
"src/layers/alpr/records.js",
|
||||||
"src/layers/earthquakes/records.js",
|
"src/layers/earthquakes/records.js",
|
||||||
"src/app/layers/perimeters.js",
|
"src/app/layers/perimeters.js",
|
||||||
@@ -3471,6 +3474,7 @@
|
|||||||
"exports": ["./layers/alpr/source"],
|
"exports": ["./layers/alpr/source"],
|
||||||
"modules": [
|
"modules": [
|
||||||
"src/layers/alpr/policy.js",
|
"src/layers/alpr/policy.js",
|
||||||
|
"src/data/displayText.js",
|
||||||
"src/layers/alpr/records.js",
|
"src/layers/alpr/records.js",
|
||||||
"src/layers/alpr/source.js",
|
"src/layers/alpr/source.js",
|
||||||
"src/data/tomtomTiles.js",
|
"src/data/tomtomTiles.js",
|
||||||
@@ -3886,6 +3890,7 @@
|
|||||||
"src/i18n/marker.js",
|
"src/i18n/marker.js",
|
||||||
"src/tools/queries/imagery.js",
|
"src/tools/queries/imagery.js",
|
||||||
"src/tools/queries/cables.js",
|
"src/tools/queries/cables.js",
|
||||||
|
"src/data/displayText.js",
|
||||||
"src/tools/queries/surveillance.js",
|
"src/tools/queries/surveillance.js",
|
||||||
"src/tools/queries/infrastructure.js",
|
"src/tools/queries/infrastructure.js",
|
||||||
"src/tools/queries/events.js",
|
"src/tools/queries/events.js",
|
||||||
@@ -3992,6 +3997,7 @@
|
|||||||
"src/layers/submarineCables/bundledSource.js",
|
"src/layers/submarineCables/bundledSource.js",
|
||||||
"src/layers/alpr/source.js",
|
"src/layers/alpr/source.js",
|
||||||
"src/layers/alpr/policy.js",
|
"src/layers/alpr/policy.js",
|
||||||
|
"src/data/displayText.js",
|
||||||
"src/layers/alpr/records.js",
|
"src/layers/alpr/records.js",
|
||||||
"src/layers/alpr/tileRecords.js",
|
"src/layers/alpr/tileRecords.js",
|
||||||
"src/sources/infrastructureData.js",
|
"src/sources/infrastructureData.js",
|
||||||
|
|||||||
@@ -1,124 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
/**
|
|
||||||
* Real-browser proof for manager-owned periodic work and document visibility.
|
|
||||||
* Uses the app's development-only layer registration seam; no provider keys.
|
|
||||||
* Run: node scripts/qa-layer-visibility.mjs --url http://localhost:4173
|
|
||||||
* Hiding uses a real background tab, never a patched document.hidden getter.
|
|
||||||
*/
|
|
||||||
import assert from 'node:assert/strict';
|
|
||||||
import puppeteer from 'puppeteer';
|
|
||||||
import { chromeExecutablePath } from './qa-chrome.mjs';
|
|
||||||
|
|
||||||
const args = process.argv.slice(2);
|
|
||||||
const urlIndex = args.indexOf('--url');
|
|
||||||
const url = new URL(
|
|
||||||
(urlIndex >= 0 ? args[urlIndex + 1] : undefined) ||
|
|
||||||
process.env.QA_BASE_URL ||
|
|
||||||
'http://localhost:4173',
|
|
||||||
);
|
|
||||||
url.searchParams.set('welcome', '0');
|
|
||||||
const browser = await puppeteer.launch({
|
|
||||||
executablePath: chromeExecutablePath(),
|
|
||||||
headless: true,
|
|
||||||
args: [
|
|
||||||
'--no-sandbox',
|
|
||||||
'--disable-background-timer-throttling',
|
|
||||||
'--disable-renderer-backgrounding',
|
|
||||||
],
|
|
||||||
});
|
|
||||||
const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
||||||
const check = (name, actual, expected) => {
|
|
||||||
assert.equal(actual, expected, name);
|
|
||||||
console.log(`PASS ${name}: ${actual}`);
|
|
||||||
};
|
|
||||||
try {
|
|
||||||
const page = await browser.newPage();
|
|
||||||
const focus = await page.createCDPSession();
|
|
||||||
await focus.send('Emulation.setFocusEmulationEnabled', { enabled: true });
|
|
||||||
await page.goto(url.href, { waitUntil: 'domcontentloaded' });
|
|
||||||
await page.waitForFunction(
|
|
||||||
() => window.__gevQaRegisterLayer && window.__godsEyeView?.dataManager,
|
|
||||||
);
|
|
||||||
await page.evaluate(async () => {
|
|
||||||
const manager = window.__godsEyeView.dataManager;
|
|
||||||
window.__qaVisibilityUpdates = 0;
|
|
||||||
window.__gevQaRegisterLayer(manager, {
|
|
||||||
id: 'qa-document-visibility',
|
|
||||||
name: 'Visibility QA',
|
|
||||||
updateInterval: 750,
|
|
||||||
init() {},
|
|
||||||
enable() {},
|
|
||||||
disable() {},
|
|
||||||
destroy() {},
|
|
||||||
async update() {
|
|
||||||
window.__qaVisibilityUpdates++;
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
getStats() {
|
|
||||||
return { count: window.__qaVisibilityUpdates };
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await manager.setEnabled('qa-document-visibility', true, { origin: 'qa' });
|
|
||||||
});
|
|
||||||
const count = () => page.evaluate(() => window.__qaVisibilityUpdates);
|
|
||||||
await page.waitForFunction(() => window.__qaVisibilityUpdates >= 3);
|
|
||||||
check('periodic updates run while visible', (await count()) >= 3, true);
|
|
||||||
await focus.send('Emulation.setFocusEmulationEnabled', { enabled: false });
|
|
||||||
const cover = await browser.newPage();
|
|
||||||
await cover.bringToFront();
|
|
||||||
await page.waitForFunction(() => document.visibilityState === 'hidden');
|
|
||||||
const hiddenCount = await count();
|
|
||||||
await pause(2200);
|
|
||||||
check('hidden tab makes no periodic updates', await count(), hiddenCount);
|
|
||||||
check(
|
|
||||||
'explicit refresh remains available while hidden',
|
|
||||||
await page.evaluate(() =>
|
|
||||||
window.__godsEyeView.dataManager.refreshLayer('qa-document-visibility'),
|
|
||||||
),
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
check('explicit hidden refresh runs once', await count(), hiddenCount + 1);
|
|
||||||
await focus.send('Emulation.setFocusEmulationEnabled', { enabled: true });
|
|
||||||
await page.waitForFunction(
|
|
||||||
() =>
|
|
||||||
document.visibilityState === 'visible' &&
|
|
||||||
window.__qaVisibilityUpdates > 0,
|
|
||||||
);
|
|
||||||
await pause(200);
|
|
||||||
check(
|
|
||||||
'resume performs one overdue refresh without a catch-up burst',
|
|
||||||
await count(),
|
|
||||||
hiddenCount + 2,
|
|
||||||
);
|
|
||||||
await page.evaluate(() =>
|
|
||||||
window.__godsEyeView.dataManager.setEnabled(
|
|
||||||
'qa-document-visibility',
|
|
||||||
false,
|
|
||||||
{ origin: 'qa' },
|
|
||||||
),
|
|
||||||
);
|
|
||||||
const stopped = await count();
|
|
||||||
await focus.send('Emulation.setFocusEmulationEnabled', { enabled: false });
|
|
||||||
await cover.bringToFront();
|
|
||||||
await page.waitForFunction(() => document.visibilityState === 'hidden');
|
|
||||||
await focus.send('Emulation.setFocusEmulationEnabled', { enabled: true });
|
|
||||||
await pause(1100);
|
|
||||||
check(
|
|
||||||
'disabled layer does not resume on visibility change',
|
|
||||||
await count(),
|
|
||||||
stopped,
|
|
||||||
);
|
|
||||||
check(
|
|
||||||
'QA layer destroys cleanly',
|
|
||||||
await page.evaluate(() =>
|
|
||||||
window.__gevQaUnregisterLayer(
|
|
||||||
window.__godsEyeView.dataManager,
|
|
||||||
'qa-document-visibility',
|
|
||||||
),
|
|
||||||
),
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
console.log('RESULT: 7 passed');
|
|
||||||
} finally {
|
|
||||||
await browser.close();
|
|
||||||
}
|
|
||||||
@@ -47,7 +47,6 @@ export async function reportTransitVisibility(page, label) {
|
|||||||
const layer = app.dataManager.layers.get('transit').module;
|
const layer = app.dataManager.layers.get('transit').module;
|
||||||
const state = layer._transitStateForTest();
|
const state = layer._transitStateForTest();
|
||||||
return {
|
return {
|
||||||
pageVisibility: document.visibilityState,
|
|
||||||
total: state._vehicles.size,
|
total: state._vehicles.size,
|
||||||
visible: state._visible.size,
|
visible: state._visible.size,
|
||||||
moving: state._moving.size,
|
moving: state._moving.size,
|
||||||
@@ -60,10 +59,6 @@ export async function reportTransitVisibility(page, label) {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
console.log(` ${label}: ${JSON.stringify(result)}`);
|
console.log(` ${label}: ${JSON.stringify(result)}`);
|
||||||
if (result.pageVisibility !== 'visible')
|
|
||||||
throw new Error(
|
|
||||||
`${label}: Transit QA requires a visible page; hidden pages deliberately pause polling`,
|
|
||||||
);
|
|
||||||
if (result.renderLoop === false || result.renderErrors.length)
|
if (result.renderLoop === false || result.renderErrors.length)
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`${label}: Cesium render loop stopped or raised an error: ${JSON.stringify(result.renderErrors)}`,
|
`${label}: Cesium render loop stopped or raised an error: ${JSON.stringify(result.renderErrors)}`,
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
export const BOSTON = {
|
export const BOSTON = {
|
||||||
id: 'boston',
|
id: 'boston',
|
||||||
feedId: 'mbta',
|
|
||||||
label: 'Boston (MBTA)',
|
label: 'Boston (MBTA)',
|
||||||
lat: 42.3554,
|
lat: 42.3554,
|
||||||
lon: -71.0605,
|
lon: -71.0605,
|
||||||
@@ -12,7 +11,6 @@ const LIVE_CITIES = [
|
|||||||
BOSTON,
|
BOSTON,
|
||||||
{
|
{
|
||||||
id: 'austin',
|
id: 'austin',
|
||||||
feedId: 'capmetro-austin',
|
|
||||||
label: 'Austin (CapMetro)',
|
label: 'Austin (CapMetro)',
|
||||||
lat: 30.2672,
|
lat: 30.2672,
|
||||||
lon: -97.7431,
|
lon: -97.7431,
|
||||||
@@ -21,7 +19,6 @@ const LIVE_CITIES = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'minneapolis',
|
id: 'minneapolis',
|
||||||
feedId: 'metrotransit-msp',
|
|
||||||
label: 'Minneapolis (Metro Transit)',
|
label: 'Minneapolis (Metro Transit)',
|
||||||
lat: 44.9778,
|
lat: 44.9778,
|
||||||
lon: -93.265,
|
lon: -93.265,
|
||||||
@@ -30,7 +27,6 @@ const LIVE_CITIES = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'helsinki',
|
id: 'helsinki',
|
||||||
feedId: 'hsl-helsinki',
|
|
||||||
label: 'Helsinki (HSL)',
|
label: 'Helsinki (HSL)',
|
||||||
lat: 60.1699,
|
lat: 60.1699,
|
||||||
lon: 24.9384,
|
lon: 24.9384,
|
||||||
@@ -39,7 +35,6 @@ const LIVE_CITIES = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'oslo',
|
id: 'oslo',
|
||||||
feedId: 'entur-norway',
|
|
||||||
label: 'Oslo (Entur)',
|
label: 'Oslo (Entur)',
|
||||||
lat: 59.9139,
|
lat: 59.9139,
|
||||||
lon: 10.7522,
|
lon: 10.7522,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/** Camera-jump regression against an existing server; never starts a server. */
|
/** Camera-jump regression against an existing server; never starts a server. */
|
||||||
import puppeteer from 'puppeteer';
|
import puppeteer from 'puppeteer';
|
||||||
import { chromeExecutablePath, captureQaScreenshot } from './qa-chrome.mjs';
|
import { chromeExecutablePath } from './qa-chrome.mjs';
|
||||||
import { mkdir, writeFile } from 'node:fs/promises';
|
import { mkdir, writeFile } from 'node:fs/promises';
|
||||||
import { reduceTrailPixels } from '../src/layers/transit/qaMetrics.js';
|
import { reduceTrailPixels } from '../src/layers/transit/qaMetrics.js';
|
||||||
|
|
||||||
@@ -307,7 +307,7 @@ try {
|
|||||||
result.pixelPass = pixels.pass;
|
result.pixelPass = pixels.pass;
|
||||||
result.pixelDetails = pixels;
|
result.pixelDetails = pixels;
|
||||||
result.errors = errors;
|
result.errors = errors;
|
||||||
await captureQaScreenshot(page, { path: `${shots}/recovery.png` });
|
await page.screenshot({ path: `${shots}/recovery.png` });
|
||||||
await writeFile(`${shots}/recovery.json`, JSON.stringify(result, null, 2));
|
await writeFile(`${shots}/recovery.json`, JSON.stringify(result, null, 2));
|
||||||
for (const key of ['on', 'off', 'onAgain', 'offAgain']) delete result[key];
|
for (const key of ['on', 'off', 'onAgain', 'offAgain']) delete result[key];
|
||||||
const pass =
|
const pass =
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { captureQaScreenshot } from './qa-chrome.mjs';
|
|
||||||
import { getRegisteredTransitFeed } from '../src/data/transitFeeds.js';
|
import { getRegisteredTransitFeed } from '../src/data/transitFeeds.js';
|
||||||
|
|
||||||
// Not registered or active: camera discovery and live polls never own this feed.
|
// Not registered or active: camera discovery and live polls never own this feed.
|
||||||
@@ -220,7 +219,7 @@ export async function runFleetBudgets({
|
|||||||
allocations.layerFrameBytes / allocationTrace.rows.length <= 8192,
|
allocations.layerFrameBytes / allocationTrace.rows.length <= 8192,
|
||||||
`${allocations.layerFrameBytes / allocationTrace.rows.length} transit-authored bytes/frame; whole frame ${allocations.frameBytes / allocationTrace.rows.length}; top three ${JSON.stringify(details.topAllocatorsPerFrame)}; collected objects included. Full profile saved.`,
|
`${allocations.layerFrameBytes / allocationTrace.rows.length} transit-authored bytes/frame; whole frame ${allocations.frameBytes / allocationTrace.rows.length}; top three ${JSON.stringify(details.topAllocatorsPerFrame)}; collected objects included. Full profile saved.`,
|
||||||
);
|
);
|
||||||
await captureQaScreenshot(page, {
|
await page.screenshot({
|
||||||
path: `${shots}/${tag}-fleet-${count}.jpg`,
|
path: `${shots}/${tag}-fleet-${count}.jpg`,
|
||||||
type: 'jpeg',
|
type: 'jpeg',
|
||||||
quality: 85,
|
quality: 85,
|
||||||
@@ -393,7 +392,7 @@ export async function runBostonMatrix({
|
|||||||
JSON.stringify({ pixels, contrast }),
|
JSON.stringify({ pixels, contrast }),
|
||||||
{ unexercised: pixels.length < 6 },
|
{ unexercised: pixels.length < 6 },
|
||||||
);
|
);
|
||||||
await captureQaScreenshot(page, {
|
await page.screenshot({
|
||||||
path: `${shots}/${tag}-matrix-${background.name}-${look.name}-fleet.jpg`,
|
path: `${shots}/${tag}-matrix-${background.name}-${look.name}-fleet.jpg`,
|
||||||
type: 'jpeg',
|
type: 'jpeg',
|
||||||
quality: 90,
|
quality: 90,
|
||||||
@@ -408,7 +407,7 @@ export async function runBostonMatrix({
|
|||||||
target.key,
|
target.key,
|
||||||
);
|
);
|
||||||
await wait(500);
|
await wait(500);
|
||||||
await captureQaScreenshot(page, {
|
await page.screenshot({
|
||||||
path: `${shots}/${tag}-matrix-${background.name}-${look.name}-${target.route}-selected.jpg`,
|
path: `${shots}/${tag}-matrix-${background.name}-${look.name}-${target.route}-selected.jpg`,
|
||||||
type: 'jpeg',
|
type: 'jpeg',
|
||||||
quality: 90,
|
quality: 90,
|
||||||
@@ -432,7 +431,7 @@ export async function runBostonMatrix({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
await wait(300);
|
await wait(300);
|
||||||
await captureQaScreenshot(page, {
|
await page.screenshot({
|
||||||
path: `${shots}/${tag}-matrix-${background.name}-${look.name}-restored.jpg`,
|
path: `${shots}/${tag}-matrix-${background.name}-${look.name}-restored.jpg`,
|
||||||
type: 'jpeg',
|
type: 'jpeg',
|
||||||
quality: 90,
|
quality: 90,
|
||||||
@@ -580,7 +579,7 @@ export async function runBostonLive({
|
|||||||
JSON.stringify(selection),
|
JSON.stringify(selection),
|
||||||
{ unexercised: !target },
|
{ unexercised: !target },
|
||||||
);
|
);
|
||||||
await captureQaScreenshot(page, {
|
await page.screenshot({
|
||||||
path: `${shots}/${tag}-boston-live-${view.name}.jpg`,
|
path: `${shots}/${tag}-boston-live-${view.name}.jpg`,
|
||||||
type: 'jpeg',
|
type: 'jpeg',
|
||||||
quality: 90,
|
quality: 90,
|
||||||
@@ -1011,7 +1010,7 @@ export async function runTrailVisibility({
|
|||||||
stable: pixels.stable[i],
|
stable: pixels.stable[i],
|
||||||
eligible: pixels.eligible[i],
|
eligible: pixels.eligible[i],
|
||||||
}));
|
}));
|
||||||
await captureQaScreenshot(page, {
|
await page.screenshot({
|
||||||
path: `${shots}/${tag}-${altitude}m-trail-visible.jpg`,
|
path: `${shots}/${tag}-${altitude}m-trail-visible.jpg`,
|
||||||
type: 'jpeg',
|
type: 'jpeg',
|
||||||
quality: 95,
|
quality: 95,
|
||||||
|
|||||||
+20
-67
@@ -36,11 +36,10 @@ import {
|
|||||||
*/
|
*/
|
||||||
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||||
import puppeteer from 'puppeteer';
|
import puppeteer from 'puppeteer';
|
||||||
import { chromeExecutablePath, captureQaScreenshot } from './qa-chrome.mjs';
|
import { chromeExecutablePath } from './qa-chrome.mjs';
|
||||||
import { createTransitHistory } from '../server/providers/transitHistory.js';
|
import { createTransitHistory } from '../server/providers/transitHistory.js';
|
||||||
import { DETECTION_THEME_MAP } from '../src/overlays/worldOverlayTokens.js';
|
import { DETECTION_THEME_MAP } from '../src/overlays/worldOverlayTokens.js';
|
||||||
import { transitModeTier } from '../src/data/transitPresetStyle.js';
|
import { transitModeTier } from '../src/data/transitPresetStyle.js';
|
||||||
import { TRANSIT_POLL_MS } from '../src/layers/transit/policy.js';
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
reduceAnchor,
|
reduceAnchor,
|
||||||
@@ -265,7 +264,6 @@ const browser = await puppeteer.launch({
|
|||||||
executablePath: chromeExecutablePath(),
|
executablePath: chromeExecutablePath(),
|
||||||
args: [
|
args: [
|
||||||
'--no-sandbox',
|
'--no-sandbox',
|
||||||
'--disable-backgrounding-occluded-windows',
|
|
||||||
'--disable-background-timer-throttling',
|
'--disable-background-timer-throttling',
|
||||||
'--disable-renderer-backgrounding',
|
'--disable-renderer-backgrounding',
|
||||||
'--window-size=1920,1080',
|
'--window-size=1920,1080',
|
||||||
@@ -273,10 +271,6 @@ const browser = await puppeteer.launch({
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
const page = await browser.newPage();
|
const page = await browser.newPage();
|
||||||
// This gate measures visible-page motion. OS minimization must not silently
|
|
||||||
// switch it into the app's intentional hidden-tab polling pause.
|
|
||||||
const focus = await page.createCDPSession();
|
|
||||||
await focus.send('Emulation.setFocusEmulationEnabled', { enabled: true });
|
|
||||||
boundPageEvaluations(page);
|
boundPageEvaluations(page);
|
||||||
await page.setViewport({ width: 1920, height: 1080 });
|
await page.setViewport({ width: 1920, height: 1080 });
|
||||||
const pageErrors = [];
|
const pageErrors = [];
|
||||||
@@ -292,13 +286,11 @@ page.on('request', (request) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const shot = (name, caption) =>
|
const shot = (name, caption) =>
|
||||||
captureQaScreenshot(page, {
|
page
|
||||||
path: `${SHOTS}/${name}`,
|
.screenshot({ path: `${SHOTS}/${name}`, type: 'jpeg', quality: 82 })
|
||||||
type: 'jpeg',
|
.then(() => {
|
||||||
quality: 82,
|
console.log(` shot ${name} — ${caption}`);
|
||||||
}).then(() => {
|
});
|
||||||
console.log(` shot ${name} — ${caption}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
const view = (city) =>
|
const view = (city) =>
|
||||||
`${BASE}/?welcome=0#lat=${city.lat}&lon=${city.lon}&alt=600&heading=${city.heading}&pitch=-45`;
|
`${BASE}/?welcome=0#lat=${city.lat}&lon=${city.lon}&alt=600&heading=${city.heading}&pitch=-45`;
|
||||||
@@ -397,42 +389,22 @@ try {
|
|||||||
orientation: { heading: 0, pitch: (-40 * Math.PI) / 180, roll: 0 },
|
orientation: { heading: 0, pitch: (-40 * Math.PI) / 180, roll: 0 },
|
||||||
});
|
});
|
||||||
}, city);
|
}, city);
|
||||||
const arrived = await page
|
await page.waitForFunction(
|
||||||
.waitForFunction(
|
() =>
|
||||||
(feedId) =>
|
(window.__godsEyeView.dataManager.layers
|
||||||
[
|
.get('transit')
|
||||||
...window.__godsEyeView.dataManager.layers
|
.module.getStats().count || 0) > 0,
|
||||||
.get('transit')
|
{ timeout: 90_000 },
|
||||||
.module._transitStateForTest()
|
);
|
||||||
._vehicles.keys(),
|
|
||||||
].some((key) => key.startsWith(`${feedId}:`)),
|
|
||||||
{ timeout: 90_000 },
|
|
||||||
city.feedId,
|
|
||||||
)
|
|
||||||
.then(
|
|
||||||
() => true,
|
|
||||||
() => false,
|
|
||||||
);
|
|
||||||
if (!arrived) {
|
|
||||||
check(
|
|
||||||
`${city.label}: live feed arrived`,
|
|
||||||
false,
|
|
||||||
`No vehicles from ${city.feedId}; retained vehicles from other cities cannot qualify this view`,
|
|
||||||
{ unexercised: true },
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
await wait(9_000);
|
await wait(9_000);
|
||||||
const fleet = await page.evaluate((feedId) => {
|
const fleet = await page.evaluate(() => {
|
||||||
const layer =
|
const layer =
|
||||||
window.__godsEyeView.dataManager.layers.get('transit').module;
|
window.__godsEyeView.dataManager.layers.get('transit').module;
|
||||||
return {
|
return {
|
||||||
count: [...layer._transitStateForTest()._vehicles.keys()].filter(
|
count: layer._transitStateForTest()._vehicles.size,
|
||||||
(key) => key.startsWith(`${feedId}:`),
|
|
||||||
).length,
|
|
||||||
stats: layer.getStats(),
|
stats: layer.getStats(),
|
||||||
};
|
};
|
||||||
}, city.feedId);
|
});
|
||||||
check(
|
check(
|
||||||
`${city.label}: vehicles are rendered`,
|
`${city.label}: vehicles are rendered`,
|
||||||
fleet.count >= 20,
|
fleet.count >= 20,
|
||||||
@@ -454,7 +426,6 @@ try {
|
|||||||
.module._transitStateForTest();
|
.module._transitStateForTest();
|
||||||
let best = null;
|
let best = null;
|
||||||
for (const entry of state._vehicles.values()) {
|
for (const entry of state._vehicles.values()) {
|
||||||
if (!entry.key.startsWith(`${c.feedId}:`)) continue;
|
|
||||||
const d = Math.hypot(entry.to.lat - c.lat, entry.to.lon - c.lon);
|
const d = Math.hypot(entry.to.lat - c.lat, entry.to.lon - c.lon);
|
||||||
if (!best || d < best.d)
|
if (!best || d < best.d)
|
||||||
best = { d, lat: entry.to.lat, lon: entry.to.lon, key: entry.key };
|
best = { d, lat: entry.to.lat, lon: entry.to.lon, key: entry.key };
|
||||||
@@ -1636,7 +1607,6 @@ try {
|
|||||||
m[3] * v[0] + m[7] * v[1] + m[11] * v[2] + m[15] * v[3],
|
m[3] * v[0] + m[7] * v[1] + m[11] * v[2] + m[15] * v[3],
|
||||||
];
|
];
|
||||||
const frames = [];
|
const frames = [];
|
||||||
const observedPolls = new Set();
|
|
||||||
const publicationsBefore = state._cardPublications || 0;
|
const publicationsBefore = state._cardPublications || 0;
|
||||||
let headSamples = 0,
|
let headSamples = 0,
|
||||||
headErrorPx = 0,
|
headErrorPx = 0,
|
||||||
@@ -1653,13 +1623,6 @@ try {
|
|||||||
const proj = scene.camera.frustum.projectionMatrix;
|
const proj = scene.camera.frustum.projectionMatrix;
|
||||||
const p = entry.marker.position;
|
const p = entry.marker.position;
|
||||||
const clip = mul(proj, mul(view, [p.x, p.y, p.z, 1]));
|
const clip = mul(proj, mul(view, [p.x, p.y, p.z, 1]));
|
||||||
if (
|
|
||||||
entry.marker.show !== false &&
|
|
||||||
rect &&
|
|
||||||
Number.isFinite(entry.pollSeq)
|
|
||||||
) {
|
|
||||||
observedPolls.add(entry.pollSeq);
|
|
||||||
}
|
|
||||||
frames.push({
|
frames.push({
|
||||||
t: performance.now() - t0,
|
t: performance.now() - t0,
|
||||||
mx: ((clip[0] / clip[3]) * 0.5 + 0.5) * canvas.clientWidth,
|
mx: ((clip[0] / clip[3]) * 0.5 + 0.5) * canvas.clientWidth,
|
||||||
@@ -1705,15 +1668,7 @@ try {
|
|||||||
headSamples++;
|
headSamples++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const elapsed = performance.now() - t0;
|
if (performance.now() - t0 >= durationMs) finish();
|
||||||
// Keep at least 20 seconds of motion and require an observed poll
|
|
||||||
// transition. A fixed 20s wall-clock window can miss a 15s poll
|
|
||||||
// when an earlier refresh is still settling.
|
|
||||||
if (
|
|
||||||
(elapsed >= 20_000 && observedPolls.size >= 2) ||
|
|
||||||
elapsed >= durationMs
|
|
||||||
)
|
|
||||||
finish();
|
|
||||||
};
|
};
|
||||||
let remove;
|
let remove;
|
||||||
const finish = () => {
|
const finish = () => {
|
||||||
@@ -1721,7 +1676,6 @@ try {
|
|||||||
remove?.();
|
remove?.();
|
||||||
resolve({
|
resolve({
|
||||||
selected: true,
|
selected: true,
|
||||||
elapsedMs: performance.now() - t0,
|
|
||||||
frames,
|
frames,
|
||||||
textPublishes:
|
textPublishes:
|
||||||
(state._cardPublications || 0) - publicationsBefore,
|
(state._cardPublications || 0) - publicationsBefore,
|
||||||
@@ -1736,7 +1690,7 @@ try {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
target.key,
|
target.key,
|
||||||
3 * TRANSIT_POLL_MS,
|
20_000,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
check(
|
check(
|
||||||
@@ -1757,9 +1711,8 @@ try {
|
|||||||
);
|
);
|
||||||
check(
|
check(
|
||||||
'card text publication stays throttled while the anchor runs at frame rate',
|
'card text publication stays throttled while the anchor runs at frame rate',
|
||||||
anchor.textPublishes > 0 &&
|
anchor.textPublishes > 0 && anchor.textPublishes <= 20_000 / 250 + 3,
|
||||||
anchor.textPublishes <= anchor.elapsedMs / 250 + 3,
|
`${anchor.textPublishes} text publications in 20 s`,
|
||||||
`${anchor.textPublishes} text publications in ${(anchor.elapsedMs / 1000).toFixed(1)} s`,
|
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
check(
|
check(
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ export function createApplicationData({
|
|||||||
// Initialize data layer manager
|
// Initialize data layer manager
|
||||||
const dataManager = new LayerLifecycle(viewer, {
|
const dataManager = new LayerLifecycle(viewer, {
|
||||||
allowQaRegistration,
|
allowQaRegistration,
|
||||||
visibilityTarget: document,
|
|
||||||
getSourceAvailability: catalog?.getSourceAvailability,
|
getSourceAvailability: catalog?.getSourceAvailability,
|
||||||
});
|
});
|
||||||
defer(async () => {
|
defer(async () => {
|
||||||
|
|||||||
@@ -1493,3 +1493,24 @@ test('a repeated move inside an outstanding query keeps the displayed marker set
|
|||||||
h.restore();
|
h.restore();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('ALPR display tags collapse controls and cap final text without erasing Unicode', () => {
|
||||||
|
const record = normalizeAlprNode({
|
||||||
|
type: 'node',
|
||||||
|
id: 1,
|
||||||
|
lat: 30,
|
||||||
|
lon: -97,
|
||||||
|
tags: {
|
||||||
|
'surveillance:type': 'ALPR',
|
||||||
|
operator: ' Şehir\n\u0000 PD ',
|
||||||
|
manufacturer: '😀'.repeat(200),
|
||||||
|
'camera:type': null,
|
||||||
|
'surveillance:zone': '\t traffic zone ',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.equal(record.operator, 'Şehir PD');
|
||||||
|
assert.equal(record.manufacturer.length, 180);
|
||||||
|
assert.equal(record.manufacturer, '😀'.repeat(90));
|
||||||
|
assert.equal(record.cameraType, null);
|
||||||
|
assert.equal(record.zone, 'traffic zone');
|
||||||
|
});
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
/** Bounded, single-line external display text; it remains untrusted source data. */
|
||||||
|
export function normalizeDisplayText(value, maxLength = 180) {
|
||||||
|
if (value == null || typeof value === 'object' || typeof value === 'function')
|
||||||
|
return '';
|
||||||
|
return String(value)
|
||||||
|
.replace(/[\u0000-\u001f\u007f-\u009f]/g, ' ')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim()
|
||||||
|
.slice(0, maxLength)
|
||||||
|
.replace(/[\uD800-\uDBFF]$/, '')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
+17
-60
@@ -72,20 +72,13 @@ function refreshFailureFromStats(stats, label) {
|
|||||||
/**
|
/**
|
||||||
* LayerLifecycle — Manages registration, toggling, and update loops
|
* LayerLifecycle — Manages registration, toggling, and update loops
|
||||||
* for real-time data overlays on the CesiumJS globe.
|
* for real-time data overlays on the CesiumJS globe.
|
||||||
* Hosts may inject a visibilityTarget with hidden/visibilityState and event
|
|
||||||
* listener methods. Without one, periodic work retains its headless behavior.
|
|
||||||
*/
|
*/
|
||||||
export class LayerLifecycle {
|
export class LayerLifecycle {
|
||||||
constructor(
|
constructor(
|
||||||
viewer,
|
viewer,
|
||||||
{
|
{ allowQaRegistration = false, getSourceAvailability } = {},
|
||||||
allowQaRegistration = false,
|
|
||||||
getSourceAvailability,
|
|
||||||
visibilityTarget = null,
|
|
||||||
} = {},
|
|
||||||
) {
|
) {
|
||||||
this._catalogSourceAvailability = getSourceAvailability;
|
this._catalogSourceAvailability = getSourceAvailability;
|
||||||
this._visibilityTarget = visibilityTarget;
|
|
||||||
this.viewer = viewer;
|
this.viewer = viewer;
|
||||||
this._activityListeners = new Set();
|
this._activityListeners = new Set();
|
||||||
this.layers = new Map(); // id → { module, enabled, initialized, intervalId, lifecycleState, lifecycleUncertain }
|
this.layers = new Map(); // id → { module, enabled, initialized, intervalId, lifecycleState, lifecycleUncertain }
|
||||||
@@ -535,13 +528,6 @@ export class LayerLifecycle {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_stopUpdateLoop(entry) {
|
|
||||||
clearInterval(entry.intervalId);
|
|
||||||
entry.intervalId = null;
|
|
||||||
entry.removeVisibilityListener?.();
|
|
||||||
entry.removeVisibilityListener = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
_armUpdateLoop(layerId, entry) {
|
_armUpdateLoop(layerId, entry) {
|
||||||
const configuredRefreshInterval = Number(entry.module.refreshInterval);
|
const configuredRefreshInterval = Number(entry.module.refreshInterval);
|
||||||
const updateInterval = Number(entry.module.updateInterval);
|
const updateInterval = Number(entry.module.updateInterval);
|
||||||
@@ -552,45 +538,9 @@ export class LayerLifecycle {
|
|||||||
? updateInterval
|
? updateInterval
|
||||||
: 0;
|
: 0;
|
||||||
if (refreshInterval > 0) {
|
if (refreshInterval > 0) {
|
||||||
const visibilityDocument = this._visibilityTarget;
|
entry.intervalId = setInterval(() => {
|
||||||
let intervalId = null;
|
|
||||||
let dueAt = 0;
|
|
||||||
const canPoll = () =>
|
|
||||||
!visibilityDocument?.hidden &&
|
|
||||||
visibilityDocument?.visibilityState !== 'hidden' &&
|
|
||||||
entry.enabled &&
|
|
||||||
!entry.destroying &&
|
|
||||||
!entry.refreshing &&
|
|
||||||
entry.intervalId === intervalId;
|
|
||||||
// dueAt and the interval phase share one origin, so every interval tick
|
|
||||||
// is a due poll. A visibility catch-up re-arms the interval at the new
|
|
||||||
// deadline; otherwise the old phase would skip its next tick.
|
|
||||||
const schedule = () => {
|
|
||||||
clearInterval(intervalId);
|
|
||||||
dueAt = Date.now() + refreshInterval;
|
|
||||||
intervalId = setInterval(onInterval, refreshInterval);
|
|
||||||
entry.intervalId = intervalId;
|
|
||||||
};
|
|
||||||
const onInterval = () => {
|
|
||||||
if (!canPoll()) return;
|
|
||||||
dueAt = Date.now() + refreshInterval;
|
|
||||||
void this._runPeriodicUpdate(layerId, entry);
|
void this._runPeriodicUpdate(layerId, entry);
|
||||||
};
|
}, refreshInterval);
|
||||||
const onVisibilityChange = () => {
|
|
||||||
if (!canPoll() || Date.now() < dueAt) return;
|
|
||||||
schedule();
|
|
||||||
void this._runPeriodicUpdate(layerId, entry);
|
|
||||||
};
|
|
||||||
schedule();
|
|
||||||
visibilityDocument?.addEventListener?.(
|
|
||||||
'visibilitychange',
|
|
||||||
onVisibilityChange,
|
|
||||||
);
|
|
||||||
entry.removeVisibilityListener = () =>
|
|
||||||
visibilityDocument?.removeEventListener?.(
|
|
||||||
'visibilitychange',
|
|
||||||
onVisibilityChange,
|
|
||||||
);
|
|
||||||
} else if (updateInterval === 0) {
|
} else if (updateInterval === 0) {
|
||||||
entry.intervalId = setInterval(() => {
|
entry.intervalId = setInterval(() => {
|
||||||
if (!entry.enabled) return;
|
if (!entry.enabled) return;
|
||||||
@@ -822,7 +772,8 @@ export class LayerLifecycle {
|
|||||||
entry.lifecycleUncertain = !compensated && !cleanupConfirmed;
|
entry.lifecycleUncertain = !compensated && !cleanupConfirmed;
|
||||||
settleLifecycle();
|
settleLifecycle();
|
||||||
if (!entry.enabled && entry.intervalId) {
|
if (!entry.enabled && entry.intervalId) {
|
||||||
this._stopUpdateLoop(entry);
|
clearInterval(entry.intervalId);
|
||||||
|
entry.intervalId = null;
|
||||||
}
|
}
|
||||||
this._publishActivity({ type: 'status' });
|
this._publishActivity({ type: 'status' });
|
||||||
if (!compensated) {
|
if (!compensated) {
|
||||||
@@ -882,7 +833,8 @@ export class LayerLifecycle {
|
|||||||
}
|
}
|
||||||
if (signal?.aborted) return finishCancelledDisable('disable');
|
if (signal?.aborted) return finishCancelledDisable('disable');
|
||||||
if (entry.intervalId) {
|
if (entry.intervalId) {
|
||||||
this._stopUpdateLoop(entry);
|
clearInterval(entry.intervalId);
|
||||||
|
entry.intervalId = null;
|
||||||
}
|
}
|
||||||
entry.enabled = false;
|
entry.enabled = false;
|
||||||
entry.lifecycleUncertain = false;
|
entry.lifecycleUncertain = false;
|
||||||
@@ -893,7 +845,8 @@ export class LayerLifecycle {
|
|||||||
let abortCleanup = null;
|
let abortCleanup = null;
|
||||||
const cancelEnable = () => {
|
const cancelEnable = () => {
|
||||||
if (entry.intervalId) {
|
if (entry.intervalId) {
|
||||||
this._stopUpdateLoop(entry);
|
clearInterval(entry.intervalId);
|
||||||
|
entry.intervalId = null;
|
||||||
}
|
}
|
||||||
// Disable immediately so modules with their own AbortController (Radio)
|
// Disable immediately so modules with their own AbortController (Radio)
|
||||||
// cancel pending update work at the same turn boundary. A second
|
// cancel pending update work at the same turn boundary. A second
|
||||||
@@ -923,7 +876,8 @@ export class LayerLifecycle {
|
|||||||
// a later abort cannot revoke a successful retry.
|
// a later abort cannot revoke a successful retry.
|
||||||
signal?.removeEventListener('abort', cancelEnable);
|
signal?.removeEventListener('abort', cancelEnable);
|
||||||
if (entry.intervalId) {
|
if (entry.intervalId) {
|
||||||
this._stopUpdateLoop(entry);
|
clearInterval(entry.intervalId);
|
||||||
|
entry.intervalId = null;
|
||||||
}
|
}
|
||||||
await abortCleanup;
|
await abortCleanup;
|
||||||
let cleanupConfirmed = false;
|
let cleanupConfirmed = false;
|
||||||
@@ -964,7 +918,8 @@ export class LayerLifecycle {
|
|||||||
};
|
};
|
||||||
const finishFailedEnable = async (phase, error) => {
|
const finishFailedEnable = async (phase, error) => {
|
||||||
if (entry.intervalId) {
|
if (entry.intervalId) {
|
||||||
this._stopUpdateLoop(entry);
|
clearInterval(entry.intervalId);
|
||||||
|
entry.intervalId = null;
|
||||||
}
|
}
|
||||||
let cleanupConfirmed = false;
|
let cleanupConfirmed = false;
|
||||||
try {
|
try {
|
||||||
@@ -1074,7 +1029,8 @@ export class LayerLifecycle {
|
|||||||
// Always clear any stale interval before assigning a new one, so we never
|
// Always clear any stale interval before assigning a new one, so we never
|
||||||
// orphan a running timer and end up double-polling.
|
// orphan a running timer and end up double-polling.
|
||||||
if (entry.intervalId) {
|
if (entry.intervalId) {
|
||||||
this._stopUpdateLoop(entry);
|
clearInterval(entry.intervalId);
|
||||||
|
entry.intervalId = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Manager-owned periodic refresh work has one normalized loading/error
|
// Manager-owned periodic refresh work has one normalized loading/error
|
||||||
@@ -2202,7 +2158,8 @@ export class LayerLifecycle {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (entry.intervalId) {
|
if (entry.intervalId) {
|
||||||
this._stopUpdateLoop(entry);
|
clearInterval(entry.intervalId);
|
||||||
|
entry.intervalId = null;
|
||||||
}
|
}
|
||||||
entry.enabled = false;
|
entry.enabled = false;
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -5,7 +5,7 @@ export { layerFeedState } from '../ui/layers.js';
|
|||||||
/** Compatibility facade for callers that construct a manager with its panel. */
|
/** Compatibility facade for callers that construct a manager with its panel. */
|
||||||
export class DataLayerManager extends LayerLifecycle {
|
export class DataLayerManager extends LayerLifecycle {
|
||||||
constructor(viewer, options) {
|
constructor(viewer, options) {
|
||||||
super(viewer, { visibilityTarget: globalThis.document, ...options });
|
super(viewer, options);
|
||||||
this._presentation = new LayerPresentation(this);
|
this._presentation = new LayerPresentation(this);
|
||||||
}
|
}
|
||||||
buildTogglePanel(container) {
|
buildTogglePanel(container) {
|
||||||
|
|||||||
@@ -3492,92 +3492,3 @@ test('refreshLayerStats reaches presentation through the bare lifecycle', async
|
|||||||
lifecycle.refreshLayerStats();
|
lifecycle.refreshLayerStats();
|
||||||
assert.deepEqual(changes, ['status']);
|
assert.deepEqual(changes, ['status']);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('periodic polls pause hidden, catch up once when due, and dispose visibility listeners', async (t) => {
|
|
||||||
const listeners = new Set();
|
|
||||||
const doc = {
|
|
||||||
hidden: false,
|
|
||||||
addEventListener: (_name, fn) => listeners.add(fn),
|
|
||||||
removeEventListener: (_name, fn) => listeners.delete(fn),
|
|
||||||
};
|
|
||||||
t.mock.timers.enable({ apis: ['setInterval', 'Date'], now: 1000 });
|
|
||||||
const { LayerLifecycle } = await import('./lifecycle.js');
|
|
||||||
const manager = new LayerLifecycle({}, { visibilityTarget: doc });
|
|
||||||
const { module, calls } = makeSlowLayer('hidden-poll');
|
|
||||||
manager.register(module);
|
|
||||||
await manager.setEnabled('hidden-poll', true);
|
|
||||||
assert.equal(calls.update, 1);
|
|
||||||
doc.hidden = true;
|
|
||||||
t.mock.timers.tick(10000);
|
|
||||||
assert.equal(calls.update, 1);
|
|
||||||
assert.equal(await manager.refreshLayer('hidden-poll'), true);
|
|
||||||
assert.equal(calls.update, 2, 'explicit refresh remains allowed');
|
|
||||||
doc.hidden = false;
|
|
||||||
for (const fn of listeners) fn();
|
|
||||||
for (const fn of listeners) fn();
|
|
||||||
assert.equal(calls.update, 3);
|
|
||||||
await Promise.resolve();
|
|
||||||
await Promise.resolve();
|
|
||||||
await manager.destroyAll();
|
|
||||||
assert.equal(listeners.size, 0);
|
|
||||||
t.mock.timers.tick(10000);
|
|
||||||
assert.equal(calls.update, 3);
|
|
||||||
});
|
|
||||||
|
|
||||||
async function resumeBetweenTicks(t, { failResume = false } = {}) {
|
|
||||||
const listeners = new Set();
|
|
||||||
const doc = {
|
|
||||||
hidden: false,
|
|
||||||
addEventListener: (_name, fn) => listeners.add(fn),
|
|
||||||
removeEventListener: (_name, fn) => listeners.delete(fn),
|
|
||||||
};
|
|
||||||
t.mock.timers.enable({ apis: ['setInterval', 'Date'], now: 1000 });
|
|
||||||
const { LayerLifecycle } = await import('./lifecycle.js');
|
|
||||||
const manager = new LayerLifecycle({}, { visibilityTarget: doc });
|
|
||||||
const { module, calls } = makeSlowLayer('resume-poll');
|
|
||||||
const updates = [];
|
|
||||||
module.update = async () => {
|
|
||||||
calls.update++;
|
|
||||||
updates.push(Date.now());
|
|
||||||
await Promise.resolve();
|
|
||||||
if (failResume && updates.length === 2) throw new Error('resume failed');
|
|
||||||
};
|
|
||||||
manager.register(module);
|
|
||||||
await manager.setEnabled('resume-poll', true);
|
|
||||||
assert.deepEqual(updates, [1000]);
|
|
||||||
doc.hidden = true;
|
|
||||||
t.mock.timers.tick(1000); // 2000: due tick skipped while hidden
|
|
||||||
t.mock.timers.tick(500); // 2500: return between ticks
|
|
||||||
doc.hidden = false;
|
|
||||||
for (const fn of listeners) fn();
|
|
||||||
assert.deepEqual(updates, [1000, 2500], 'overdue poll runs once on return');
|
|
||||||
await Promise.resolve();
|
|
||||||
await Promise.resolve();
|
|
||||||
await Promise.resolve();
|
|
||||||
const managerRefreshError = manager.layers.get('resume-poll').managerRefreshError;
|
|
||||||
if (failResume) assert.equal(managerRefreshError, 'resume failed');
|
|
||||||
else assert.equal(managerRefreshError, null);
|
|
||||||
t.mock.timers.tick(999); // 3499: the old 3000 phase must not fire
|
|
||||||
assert.deepEqual(updates, [1000, 2500]);
|
|
||||||
t.mock.timers.tick(1); // 3500: one interval after the resume poll
|
|
||||||
assert.deepEqual(updates, [1000, 2500, 3500]);
|
|
||||||
await Promise.resolve();
|
|
||||||
await Promise.resolve();
|
|
||||||
await Promise.resolve();
|
|
||||||
t.mock.timers.tick(1000);
|
|
||||||
assert.deepEqual(updates, [1000, 2500, 3500, 4500], 'new phase keeps one interval');
|
|
||||||
await Promise.resolve();
|
|
||||||
await Promise.resolve();
|
|
||||||
await manager.destroyAll();
|
|
||||||
assert.equal(listeners.size, 0);
|
|
||||||
t.mock.timers.tick(5000);
|
|
||||||
assert.equal(updates.length, 4);
|
|
||||||
}
|
|
||||||
|
|
||||||
test('periodic polls realign to the deadline after a between-ticks resume', async (t) => {
|
|
||||||
await resumeBetweenTicks(t);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('periodic polls realign to the deadline after a failed resume refresh', async (t) => {
|
|
||||||
await resumeBetweenTicks(t, { failResume: true });
|
|
||||||
});
|
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
|
import { normalizeDisplayText } from '../../data/displayText.js';
|
||||||
import { QUERY_LIMIT, QUERY_SNAP_DEGREES } from './policy.js';
|
import { QUERY_LIMIT, QUERY_SNAP_DEGREES } from './policy.js';
|
||||||
|
|
||||||
function textTag(value) {
|
function textTag(value) {
|
||||||
const t = String(value ?? '').trim();
|
const t = normalizeDisplayText(value);
|
||||||
return t || null;
|
return t || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -97,7 +97,6 @@ export function createLifecycle({ state, services, parts }) {
|
|||||||
else if (hidden) {
|
else if (hidden) {
|
||||||
hidden = false;
|
hidden = false;
|
||||||
parts.rendering.resumePlayback();
|
parts.rendering.resumePlayback();
|
||||||
parts.viewport.runProximityCheck();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
function bindVisibility() {
|
function bindVisibility() {
|
||||||
|
|||||||
@@ -75,11 +75,6 @@ export function createViewport({ state, services, parts }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function runProximityCheck() {
|
function runProximityCheck() {
|
||||||
if (
|
|
||||||
globalThis.document?.hidden ||
|
|
||||||
globalThis.document?.visibilityState === 'hidden'
|
|
||||||
)
|
|
||||||
return;
|
|
||||||
if (!state._enabled || !state._viewer) return;
|
if (!state._enabled || !state._viewer) return;
|
||||||
refreshViewBounds();
|
refreshViewBounds();
|
||||||
const altitude = getCameraAltitude();
|
const altitude = getCameraAltitude();
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { readFileSync } from 'node:fs';
|
import { readFileSync } from 'node:fs';
|
||||||
import { getTransitFeed } from '../data/transitFeeds.js';
|
|
||||||
import {
|
import {
|
||||||
chooseLiveCities,
|
chooseLiveCities,
|
||||||
selectStyle,
|
selectStyle,
|
||||||
@@ -102,18 +101,6 @@ test('live visual cities follow local operating hours in summer and winter', ()
|
|||||||
const cities = chooseLiveCities(now);
|
const cities = chooseLiveCities(now);
|
||||||
assert.ok(cities.length >= 1);
|
assert.ok(cities.length >= 1);
|
||||||
for (const city of cities) {
|
for (const city of cities) {
|
||||||
const feed = getTransitFeed(city.feedId);
|
|
||||||
assert.ok(feed, `${city.id} has a registered feed`);
|
|
||||||
const distanceKm =
|
|
||||||
111.32 *
|
|
||||||
Math.hypot(
|
|
||||||
feed.center.lat - city.lat,
|
|
||||||
(feed.center.lon - city.lon) * Math.cos((city.lat * Math.PI) / 180),
|
|
||||||
);
|
|
||||||
assert.ok(
|
|
||||||
distanceKm < feed.loadRadiusKm,
|
|
||||||
`${city.id} is inside its feed's admission region`,
|
|
||||||
);
|
|
||||||
const local = Number(
|
const local = Number(
|
||||||
new Intl.DateTimeFormat('en', {
|
new Intl.DateTimeFormat('en', {
|
||||||
timeZone: city.zone,
|
timeZone: city.zone,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
/** Automated license plate reader cameras mapped in OpenStreetMap. */
|
/** Automated license plate reader cameras mapped in OpenStreetMap. */
|
||||||
|
|
||||||
|
import { normalizeDisplayText } from '../../data/displayText.js';
|
||||||
import { suggestView } from '../views.js';
|
import { suggestView } from '../views.js';
|
||||||
import { defineTool, ToolError } from '../catalog.js';
|
import { defineTool, ToolError } from '../catalog.js';
|
||||||
import {
|
import {
|
||||||
@@ -69,12 +70,12 @@ export const findAlprCameras = defineTool({
|
|||||||
id: camera.id,
|
id: camera.id,
|
||||||
lat: round(point.lat, 6),
|
lat: round(point.lat, 6),
|
||||||
lon: round(point.lon, 6),
|
lon: round(point.lon, 6),
|
||||||
operator: camera.operator || null,
|
operator: normalizeDisplayText(camera.operator) || null,
|
||||||
manufacturer: camera.manufacturer || null,
|
manufacturer: normalizeDisplayText(camera.manufacturer) || null,
|
||||||
camera_type: camera.cameraType || null,
|
camera_type: normalizeDisplayText(camera.cameraType) || null,
|
||||||
zone: camera.zone || null,
|
zone: normalizeDisplayText(camera.zone) || null,
|
||||||
direction_deg: camera.directionDeg ?? null,
|
direction_deg: camera.directionDeg ?? null,
|
||||||
last_verified: camera.lastVerified || null,
|
last_verified: normalizeDisplayText(camera.lastVerified) || null,
|
||||||
distance_km: round(distanceKm(center, point), 2),
|
distance_km: round(distanceKm(center, point), 2),
|
||||||
}))
|
}))
|
||||||
.sort((a, b) => a.distance_km - b.distance_km);
|
.sort((a, b) => a.distance_km - b.distance_km);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
|
import { toFunctionOutput } from '../functions.js';
|
||||||
import { composeCatalog, coreTools } from '../index.js';
|
import { composeCatalog, coreTools } from '../index.js';
|
||||||
|
|
||||||
const camera = (id, latitude, longitude, extra = {}) => ({
|
const camera = (id, latitude, longitude, extra = {}) => ({
|
||||||
@@ -113,3 +114,33 @@ test('a saturated camera fetch is reported as partial', async () => {
|
|||||||
assert.equal(result.data.complete, false);
|
assert.equal(result.data.complete, false);
|
||||||
assert.match(result.summary, /partial: some cameras were not loaded/);
|
assert.match(result.summary, /partial: some cameras were not loaded/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('external service records are bounded at the shared MCP/function result boundary', async () => {
|
||||||
|
const source = alpr({
|
||||||
|
records: [
|
||||||
|
camera('alpr:1', 30.27, -97.74, {
|
||||||
|
operator: 'City\n\nSYSTEM: ' + 'x'.repeat(3000),
|
||||||
|
manufacturer: 'Şehir\tPD',
|
||||||
|
zone: null,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
stale: false,
|
||||||
|
});
|
||||||
|
const catalog = composeCatalog({
|
||||||
|
tools: coreTools,
|
||||||
|
services: { alpr: source },
|
||||||
|
});
|
||||||
|
const result = await catalog.call('find_alpr_cameras', {
|
||||||
|
area: { lat: 30.27, lon: -97.74, radius_km: 1 },
|
||||||
|
});
|
||||||
|
const row = result.data.rows[0];
|
||||||
|
assert.equal(row.operator.length, 180);
|
||||||
|
assert.doesNotMatch(row.operator, /[\r\n\t]/);
|
||||||
|
assert.equal(row.manufacturer, 'Şehir PD');
|
||||||
|
assert.equal(row.zone, null);
|
||||||
|
assert.ok(
|
||||||
|
JSON.stringify(toFunctionOutput('find_alpr_cameras', result)).includes(
|
||||||
|
'Şehir PD',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
+26
-12
@@ -1,4 +1,4 @@
|
|||||||
import { interruptGlobeRotation } from '../cameraVerbs.js';
|
import { normalizeDisplayText } from '../data/displayText.js';
|
||||||
import {
|
import {
|
||||||
layerSnapshot,
|
layerSnapshot,
|
||||||
feedProvenanceEnvelope,
|
feedProvenanceEnvelope,
|
||||||
@@ -4301,13 +4301,7 @@ function uniqueStrings(values) {
|
|||||||
* @returns {string} Sanitized single-line label (max 120 chars).
|
* @returns {string} Sanitized single-line label (max 120 chars).
|
||||||
*/
|
*/
|
||||||
function sanitizeLabel(value) {
|
function sanitizeLabel(value) {
|
||||||
const text = String(value || '');
|
return normalizeDisplayText(value, 120);
|
||||||
let out = '';
|
|
||||||
for (const ch of text) {
|
|
||||||
const code = ch.codePointAt(0);
|
|
||||||
out += code < 0x20 || code === 0x7f ? ' ' : ch; // drop control chars incl. newlines
|
|
||||||
}
|
|
||||||
return out.replace(/\s+/g, ' ').trim().slice(0, 120);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function cachePrecision(service) {
|
function cachePrecision(service) {
|
||||||
@@ -4424,10 +4418,8 @@ function compactProperties(props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function cleanText(value) {
|
function cleanText(value) {
|
||||||
if (value == null || typeof value === 'object') return '';
|
const text = normalizeDisplayText(value);
|
||||||
const text = String(value).trim();
|
return text === 'undefined' || text === 'null' ? '' : text;
|
||||||
if (!text || text === 'undefined' || text === 'null') return '';
|
|
||||||
return text.length > 180 ? `${text.slice(0, 177)}...` : text;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function layerTitle(layerId) {
|
function layerTitle(layerId) {
|
||||||
@@ -4581,6 +4573,21 @@ const ANALYST_IDENTITY_FIELDS = Object.freeze([
|
|||||||
'noradId',
|
'noradId',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Analyst item keys that identify a record rather than describe it: the
|
||||||
|
* referent registry, track_entity and locateLayerEntity match on them, so
|
||||||
|
* display-text normalization must leave them byte-for-byte unchanged.
|
||||||
|
*/
|
||||||
|
const ANALYST_CANONICAL_FIELDS = new Set([
|
||||||
|
'layerKey',
|
||||||
|
'id',
|
||||||
|
'icao24',
|
||||||
|
'mmsi',
|
||||||
|
'noradId',
|
||||||
|
'registration',
|
||||||
|
'callsign',
|
||||||
|
]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Compact one analyst record for the voice payload: identity, position (so a
|
* Compact one analyst record for the voice payload: identity, position (so a
|
||||||
* follow-up can fly to it), distance, and that layer's manifest fields with
|
* follow-up can fly to it), distance, and that layer's manifest fields with
|
||||||
@@ -4618,6 +4625,13 @@ function compactAnalystItem(r) {
|
|||||||
} else compact[key] = r[key];
|
} else compact[key] = r[key];
|
||||||
}
|
}
|
||||||
if (present(r.ageHours)) compact.ageHours = r.ageHours;
|
if (present(r.ageHours)) compact.ageHours = r.ageHours;
|
||||||
|
// Display text only: canonical keys are lookup identities (bikeshare uses
|
||||||
|
// the raw station name as its id), so the referent registry and
|
||||||
|
// locateLayerEntity must see them exactly as the layer reports them.
|
||||||
|
for (const key of Object.keys(compact)) {
|
||||||
|
if (typeof compact[key] === 'string' && !ANALYST_CANONICAL_FIELDS.has(key))
|
||||||
|
compact[key] = normalizeDisplayText(compact[key]);
|
||||||
|
}
|
||||||
return compact;
|
return compact;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3931,3 +3931,56 @@ test('analyst-only lookup distinguishes duplicate names and uses canonical aircr
|
|||||||
assert.equal(plane.ok, true);
|
assert.equal(plane.ok, true);
|
||||||
assert.equal(plane.latitude, 36, 'canonical ID resolves the current position, not old coordinates');
|
assert.equal(plane.latitude, 36, 'canonical ID resolves the current position, not old coordinates');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
test('analyst item display strings are single-line and bounded for voice', async () => {
|
||||||
|
globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null };
|
||||||
|
const rows = [{ id: 'stable-id', name: 'Şehir\n' + 'x'.repeat(3000), label: 'Town\tStation', operator: 'City\nPD', lat: 30, lon: -97 }];
|
||||||
|
const { runner } = manifestRunner({ bikeshare: { getStats: () => ({ count: 1, lastUpdate: Date.now() }), getAnalystRecords: () => rows } });
|
||||||
|
const result = await runner('analyst_query', { layers: ['bikeshare'], scope: { kind: 'anywhere' }, limit: 1 });
|
||||||
|
assert.equal(result.ok, true);
|
||||||
|
assert.equal(result.items[0].name.length, 180);
|
||||||
|
assert.equal(result.items[0].label, 'Town Station');
|
||||||
|
assert.equal(result.items[0].id, 'stable-id');
|
||||||
|
for (const value of Object.values(result.items[0])) if (typeof value === 'string') assert.doesNotMatch(value, /[\r\n\t]/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('analyst_query keeps canonical IDs raw so numbered references still fly to the station', async () => {
|
||||||
|
globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null };
|
||||||
|
const { viewer, styleManager } = createVoiceNavigationHarness();
|
||||||
|
const referents = createReferentRegistry();
|
||||||
|
const longName = 'Station ' + 'é'.repeat(200);
|
||||||
|
const spaced = 'Congress Ave & 2nd';
|
||||||
|
const stations = [
|
||||||
|
{ id: spaced, name: spaced, lat: 30.2672, lon: -97.7431 },
|
||||||
|
{ id: longName, name: longName, lat: 30.2711, lon: -97.7437 },
|
||||||
|
];
|
||||||
|
const bikeshare = { getStats: () => ({ count: stations.length, lastUpdate: Date.now() }), getAnalystRecords: () => stations };
|
||||||
|
const runner = createActionRunner({
|
||||||
|
viewer, styleManager,
|
||||||
|
dataManager: {
|
||||||
|
layers: new Map([['bikeshare', { module: bikeshare }]]),
|
||||||
|
isEnabled: () => true,
|
||||||
|
getAll: () => [{ id: 'bikeshare', name: 'bikeshare', enabled: true, stats: bikeshare.getStats() }],
|
||||||
|
},
|
||||||
|
deixis: { referents },
|
||||||
|
});
|
||||||
|
const listed = await runner('analyst_query', { layers: ['bikeshare'], scope: { kind: 'anywhere' }, limit: 2 });
|
||||||
|
assert.equal(listed.ok, true);
|
||||||
|
const bySpaced = listed.items.find((item) => item.id === spaced);
|
||||||
|
const byLong = listed.items.find((item) => item.id === longName);
|
||||||
|
assert.ok(bySpaced && byLong, 'compact items keep the raw station-name IDs');
|
||||||
|
assert.equal(bySpaced.name, 'Congress Ave & 2nd', 'display name is still normalized');
|
||||||
|
assert.equal(byLong.name.length, 180, 'display name is still bounded');
|
||||||
|
assert.equal(byLong.id.length, longName.length);
|
||||||
|
const registered = referents.list();
|
||||||
|
assert.equal(registered.length, 2, 'the analyst result became the numbered list');
|
||||||
|
for (const ref of registered) {
|
||||||
|
const station = stations.find((row) => row.id === ref.id);
|
||||||
|
assert.ok(station, `referent ${ref.n} keeps the canonical ID`);
|
||||||
|
const flown = await runner('fly_to_location', { referent: ref.n });
|
||||||
|
assert.equal(flown.ok, true, `referent ${ref.n} resolves instead of "no longer loaded"`);
|
||||||
|
assert.equal(flown.latitude, station.lat);
|
||||||
|
assert.equal(flown.longitude, station.lon);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
# RPO replay: Cosmos 2612 → ICEYE-X36 (Sep 30 – Oct 21, 2026)
|
|
||||||
|
|
||||||
A 45-second, physics-grounded reconstruction of the Russian military satellite
|
|
||||||
Cosmos 2612 (NORAD 68762) maneuvering to within a few hundred meters of the
|
|
||||||
U.S.-registered ICEYE-X36 SAR satellite (NORAD 59103), rendered in CesiumJS
|
|
||||||
over Esri World Imagery and exported as 16:9 and 9:16 video.
|
|
||||||
|
|
||||||
```sh
|
|
||||||
python3 tools/rpo-replay/model.py # orbit reconstruction -> data.json
|
|
||||||
python3 tools/rpo-replay/build_models.py # procedural GLBs
|
|
||||||
node tools/rpo-replay/render.mjs --format 16x9 --stills 2,24,32 # spot checks
|
|
||||||
node tools/rpo-replay/render.mjs --format 16x9 # all frames (resumable)
|
|
||||||
node tools/rpo-replay/render.mjs --format 16x9 --encode # -> MP4
|
|
||||||
```
|
|
||||||
|
|
||||||
Formats: `16x9` (1920×1080), `9x16` (1080×1920), and `preview` / `preview-v`
|
|
||||||
at half size. Output goes to `output/rpo-replay/<format>/`. Requires
|
|
||||||
`pip install sgp4 numpy scipy pillow`, Playwright's Chromium (SwiftShader, no
|
|
||||||
GPU), and ffmpeg.
|
|
||||||
|
|
||||||
## What is measured and what is modeled
|
|
||||||
|
|
||||||
**ICEYE-X36** is propagated with SGP4 from its public CelesTrak element set
|
|
||||||
(`tles.txt`). X36 did not maneuver in this window.
|
|
||||||
|
|
||||||
**Cosmos 2612** has no public element-set history without an account, so its
|
|
||||||
path is a relative-orbit reconstruction about X36:
|
|
||||||
|
|
||||||
- Quasi-nonsingular relative orbital elements (δa, δλ, δe, δi), advanced
|
|
||||||
with Keplerian along-track drift and the differential J2 node drift. The
|
|
||||||
47 km "high park" is what swings Cosmos 2612's orbit plane into X36's.
|
|
||||||
The model needs a 0.27° initial plane offset; published analysis gives
|
|
||||||
~0.22°.
|
|
||||||
- Burns are impulses through Gauss's variational equations. SMA changes are
|
|
||||||
two half-orbit-apart tangential impulses. Each burn is timed inside the
|
|
||||||
element-set window that KeepTrack, LeoLabs, and Marco Langbroek published.
|
|
||||||
- Solved checkpoints:
|
|
||||||
- Oct 12 overhead pass, then 480 km behind.
|
|
||||||
- ~400 km on Oct 15.
|
|
||||||
- 3 km behind after the Oct 16 braking burn.
|
|
||||||
- 2.0 / 0.99 / 0.72 / 0.60 km on Oct 17.
|
|
||||||
- 5.1 km behind and 140 m higher at 07:38 Oct 18.
|
|
||||||
- 293 km on Oct 19.
|
|
||||||
- Matched SMA, 477–479 km behind, from Oct 21. This matches today's Cosmos
|
|
||||||
2612 element set.
|
|
||||||
- The model gives ~60 m/s total Δv (KeepTrack estimates ~67 m/s). The
|
|
||||||
closest approach comes out at **~325 m, 01:28 UTC Oct 18**, in Earth's
|
|
||||||
shadow. Published estimates range 150–650 m. Langbroek gives ~01:35 UTC.
|
|
||||||
|
|
||||||
The 26 m and 25 m figures some outlets quoted come from element sets taken
|
|
||||||
after the departure burn, and KeepTrack rejects them. This replay does not
|
|
||||||
use them.
|
|
||||||
|
|
||||||
## Rendering conventions
|
|
||||||
|
|
||||||
- Camera and Earth ride with X36 on a slower "backdrop" clock during
|
|
||||||
time-lapse to avoid strobing. Relative motion runs at the on-screen rate.
|
|
||||||
Around closest approach the two clocks are identical, so lighting, shadow,
|
|
||||||
and the following orbital sunrise are true to time.
|
|
||||||
- Up close, the spacecraft are drawn enlarged (the factor is on screen, capped
|
|
||||||
at 150×). At closest approach they are at true scale.
|
|
||||||
- In Earth's shadow the models get a dim blue fill so their shapes stay
|
|
||||||
readable.
|
|
||||||
- ICEYE-X36 is modeled to ICEYE's published Gen-3 dimensions: 3.2 × 0.4 m
|
|
||||||
antenna, ~3.25 m span. No public imagery of Cosmos 2612's design exists, so
|
|
||||||
its model is illustrative.
|
|
||||||
|
|
||||||
## Sources
|
|
||||||
|
|
||||||
- CelesTrak GP element sets for 59103 and 68762.
|
|
||||||
- KeepTrack, "Cosmos 2612 / ICEYE-X36 rendezvous" deep dive: burn windows,
|
|
||||||
separations, SMA offsets.
|
|
||||||
- LeoLabs event report via Via Satellite, Oct 19, 2026.
|
|
||||||
- Marco Langbroek (SatTrackCam Leiden), quoted by Space.com, Aerospace Global
|
|
||||||
News, and TWZ.
|
|
||||||
- Vantor image of the pair, Oct 17 (~1.02 km), published by Axios Oct 22.
|
|
||||||
- Imagery: Esri World Imagery; NASA GIBS VIIRS Black Marble (night side).
|
|
||||||
- Sky: Yale Bright Star Catalogue and ESO/S. Brunier Milky Way (see
|
|
||||||
`src/data/local_data/sky/README.md`).
|
|
||||||
@@ -1,399 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Procedural GLB models for the RPO replay.
|
|
||||||
|
|
||||||
ICEYE-X36: ICEYE Gen-3 class SAR microsatellite. Dimensions follow ICEYE's
|
|
||||||
fleet documentation (3.2 m x 0.4 m phased-array antenna, ~3.25 m span,
|
|
||||||
two deployable solar arrays, ~0.7 x 0.5 m bus).
|
|
||||||
|
|
||||||
Cosmos 2612: no public imagery of the design exists. The model is an
|
|
||||||
illustrative small military bus (box body, two three-panel wings, a forward
|
|
||||||
optical sensor and a rear propulsion module) sized like its launch cohort.
|
|
||||||
|
|
||||||
Axes (glTF, Y-up): +X along-track (velocity), +Y zenith, +Z = -orbit normal.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import io
|
|
||||||
import json
|
|
||||||
import math
|
|
||||||
import struct
|
|
||||||
|
|
||||||
import numpy as np
|
|
||||||
from PIL import Image, ImageDraw, ImageFilter
|
|
||||||
|
|
||||||
HERE = __file__.rsplit('/', 1)[0]
|
|
||||||
RNG = np.random.default_rng(7)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Textures
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
def png_bytes(img):
|
|
||||||
b = io.BytesIO()
|
|
||||||
img.save(b, 'PNG', optimize=True)
|
|
||||||
return b.getvalue()
|
|
||||||
|
|
||||||
|
|
||||||
def solar_texture(cols=8, rows=6, size=(512, 384)):
|
|
||||||
w, h = size
|
|
||||||
img = Image.new('RGB', size, (18, 22, 40))
|
|
||||||
d = ImageDraw.Draw(img)
|
|
||||||
cw, ch = w / cols, h / rows
|
|
||||||
for i in range(cols):
|
|
||||||
for j in range(rows):
|
|
||||||
x0, y0 = i * cw + 3, j * ch + 3
|
|
||||||
x1, y1 = (i + 1) * cw - 3, (j + 1) * ch - 3
|
|
||||||
shade = int(RNG.integers(-6, 6))
|
|
||||||
d.rectangle([x0, y0, x1, y1], fill=(28 + shade, 40 + shade, 92 + shade))
|
|
||||||
# clipped corners like triple-junction cells
|
|
||||||
c = 6
|
|
||||||
for cx, cy in [(x0, y0), (x1, y0), (x0, y1), (x1, y1)]:
|
|
||||||
d.polygon([(cx, cy), (cx + (c if cx == x0 else -c), cy), (cx, cy + (c if cy == y0 else -c))],
|
|
||||||
fill=(18, 22, 40))
|
|
||||||
for k in range(1, 6):
|
|
||||||
yy = y0 + (y1 - y0) * k / 6
|
|
||||||
d.line([x0 + 2, yy, x1 - 2, yy], fill=(70, 82, 130), width=1)
|
|
||||||
d.line([x0, (y0 + y1) / 2, x1, (y0 + y1) / 2], fill=(150, 155, 170), width=2)
|
|
||||||
return img
|
|
||||||
|
|
||||||
|
|
||||||
def mli_texture(base, size=256):
|
|
||||||
n = RNG.normal(0, 1, (size // 8, size // 8))
|
|
||||||
n = np.kron(n, np.ones((8, 8)))
|
|
||||||
img = Image.fromarray(np.uint8(np.clip(128 + n * 30, 0, 255))).filter(ImageFilter.GaussianBlur(3))
|
|
||||||
a = np.asarray(img, dtype=float) / 255
|
|
||||||
rgb = np.stack([np.clip(base[c] * (0.78 + 0.45 * a), 0, 255) for c in range(3)], -1)
|
|
||||||
# seams / tape
|
|
||||||
out = Image.fromarray(np.uint8(rgb))
|
|
||||||
d = ImageDraw.Draw(out)
|
|
||||||
for x in (0, size // 2):
|
|
||||||
d.line([x, 0, x, size], fill=tuple(int(c * 0.65) for c in base), width=2)
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def mli_normal(size=256):
|
|
||||||
n = RNG.normal(0, 1, (size // 6 + 1, size // 6 + 1))
|
|
||||||
n = np.kron(n, np.ones((6, 6)))[:size, :size]
|
|
||||||
h = np.asarray(Image.fromarray(np.uint8(np.clip(128 + n * 40, 0, 255))).filter(ImageFilter.GaussianBlur(2)),
|
|
||||||
dtype=float) / 255
|
|
||||||
gx = np.roll(h, -1, 1) - np.roll(h, 1, 1)
|
|
||||||
gy = np.roll(h, -1, 0) - np.roll(h, 1, 0)
|
|
||||||
s = 3.0
|
|
||||||
nx, ny, nz = -gx * s, -gy * s, np.ones_like(h)
|
|
||||||
l = np.sqrt(nx ** 2 + ny ** 2 + nz ** 2)
|
|
||||||
rgb = np.stack([(nx / l + 1) / 2, (ny / l + 1) / 2, (nz / l + 1) / 2], -1)
|
|
||||||
return Image.fromarray(np.uint8(rgb * 255))
|
|
||||||
|
|
||||||
|
|
||||||
def sar_texture(size=(1024, 128)):
|
|
||||||
w, h = size
|
|
||||||
img = Image.new('RGB', size, (34, 36, 40))
|
|
||||||
d = ImageDraw.Draw(img)
|
|
||||||
nx, ny = 64, 8
|
|
||||||
for i in range(nx):
|
|
||||||
for j in range(ny):
|
|
||||||
cx, cy = (i + 0.5) * w / nx, (j + 0.5) * h / ny
|
|
||||||
r = 4.5
|
|
||||||
d.rectangle([cx - r, cy - r, cx + r, cy + r], fill=(150, 112, 70))
|
|
||||||
d.rectangle([cx - r + 2, cy - r + 2, cx + r - 2, cy + r - 2], fill=(118, 88, 56))
|
|
||||||
for k in range(1, 5):
|
|
||||||
x = w * k / 5
|
|
||||||
d.line([x, 0, x, h], fill=(10, 10, 12), width=3)
|
|
||||||
return img
|
|
||||||
|
|
||||||
|
|
||||||
def panel_texture(color, size=128, lines=4):
|
|
||||||
img = Image.new('RGB', (size, size), color)
|
|
||||||
d = ImageDraw.Draw(img)
|
|
||||||
for k in range(1, lines):
|
|
||||||
p = size * k / lines
|
|
||||||
d.line([p, 0, p, size], fill=tuple(int(c * 0.8) for c in color), width=1)
|
|
||||||
return img
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Minimal glTF builder
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class Gltf:
|
|
||||||
def __init__(self):
|
|
||||||
self.bin = bytearray()
|
|
||||||
self.j = dict(asset=dict(version='2.0', generator='gods-eye-view rpo-replay'),
|
|
||||||
scenes=[dict(nodes=[0])], scene=0, nodes=[dict(name='root', children=[])],
|
|
||||||
meshes=[], materials=[], accessors=[], bufferViews=[], buffers=[],
|
|
||||||
images=[], textures=[], samplers=[dict(magFilter=9729, minFilter=9987, wrapS=10497, wrapT=10497)])
|
|
||||||
self.prims = []
|
|
||||||
|
|
||||||
def _view(self, data, target=None):
|
|
||||||
while len(self.bin) % 4:
|
|
||||||
self.bin.append(0)
|
|
||||||
off = len(self.bin)
|
|
||||||
self.bin += data
|
|
||||||
v = dict(buffer=0, byteOffset=off, byteLength=len(data))
|
|
||||||
if target:
|
|
||||||
v['target'] = target
|
|
||||||
self.j['bufferViews'].append(v)
|
|
||||||
return len(self.j['bufferViews']) - 1
|
|
||||||
|
|
||||||
def _acc(self, arr, typ, target):
|
|
||||||
arr = np.ascontiguousarray(arr)
|
|
||||||
comp = 5126 if arr.dtype == np.float32 else 5125
|
|
||||||
view = self._view(arr.tobytes(), target)
|
|
||||||
a = dict(bufferView=view, componentType=comp, count=int(arr.shape[0]), type=typ)
|
|
||||||
if typ == 'VEC3' and comp == 5126:
|
|
||||||
a['min'] = arr.min(0).tolist()
|
|
||||||
a['max'] = arr.max(0).tolist()
|
|
||||||
self.j['accessors'].append(a)
|
|
||||||
return len(self.j['accessors']) - 1
|
|
||||||
|
|
||||||
def texture(self, img):
|
|
||||||
view = self._view(png_bytes(img))
|
|
||||||
self.j['images'].append(dict(bufferView=view, mimeType='image/png'))
|
|
||||||
self.j['textures'].append(dict(sampler=0, source=len(self.j['images']) - 1))
|
|
||||||
return len(self.j['textures']) - 1
|
|
||||||
|
|
||||||
def material(self, name, color=(1, 1, 1), metal=0.0, rough=0.6, tex=None, normal=None, emissive=None,
|
|
||||||
double=False):
|
|
||||||
m = dict(name=name, pbrMetallicRoughness=dict(baseColorFactor=[*color, 1.0], metallicFactor=metal,
|
|
||||||
roughnessFactor=rough))
|
|
||||||
if tex is not None:
|
|
||||||
m['pbrMetallicRoughness']['baseColorTexture'] = dict(index=tex)
|
|
||||||
if normal is not None:
|
|
||||||
m['normalTexture'] = dict(index=normal, scale=0.6)
|
|
||||||
if emissive:
|
|
||||||
m['emissiveFactor'] = list(emissive)
|
|
||||||
if double:
|
|
||||||
m['doubleSided'] = True
|
|
||||||
self.j['materials'].append(m)
|
|
||||||
return len(self.j['materials']) - 1
|
|
||||||
|
|
||||||
def add(self, name, pos, nrm, uv, idx, mat, translation=(0, 0, 0), rotation=None):
|
|
||||||
p = dict(attributes=dict(POSITION=self._acc(np.float32(pos), 'VEC3', 34962),
|
|
||||||
NORMAL=self._acc(np.float32(nrm), 'VEC3', 34962),
|
|
||||||
TEXCOORD_0=self._acc(np.float32(uv), 'VEC2', 34962)),
|
|
||||||
indices=self._acc(np.uint32(idx).ravel(), 'SCALAR', 34963), material=mat)
|
|
||||||
self.j['meshes'].append(dict(name=name, primitives=[p]))
|
|
||||||
node = dict(name=name, mesh=len(self.j['meshes']) - 1, translation=list(map(float, translation)))
|
|
||||||
if rotation is not None:
|
|
||||||
node['rotation'] = list(map(float, rotation))
|
|
||||||
self.j['nodes'].append(node)
|
|
||||||
self.j['nodes'][0]['children'].append(len(self.j['nodes']) - 1)
|
|
||||||
|
|
||||||
def save(self, path):
|
|
||||||
while len(self.bin) % 4:
|
|
||||||
self.bin.append(0)
|
|
||||||
self.j['buffers'] = [dict(byteLength=len(self.bin))]
|
|
||||||
js = json.dumps(self.j, separators=(',', ':')).encode()
|
|
||||||
while len(js) % 4:
|
|
||||||
js += b' '
|
|
||||||
out = struct.pack('<III', 0x46546C67, 2, 12 + 8 + len(js) + 8 + len(self.bin))
|
|
||||||
out += struct.pack('<II', len(js), 0x4E4F534A) + js
|
|
||||||
out += struct.pack('<II', len(self.bin), 0x004E4942) + bytes(self.bin)
|
|
||||||
open(path, 'wb').write(out)
|
|
||||||
print(path, f'{len(out)/1024:.0f} KB')
|
|
||||||
|
|
||||||
|
|
||||||
def box(sx, sy, sz, uv_scale=(1, 1)):
|
|
||||||
"""Axis-aligned box centred at origin with per-face UVs."""
|
|
||||||
hx, hy, hz = sx / 2, sy / 2, sz / 2
|
|
||||||
faces = [
|
|
||||||
((1, 0, 0), [(hx, -hy, hz), (hx, -hy, -hz), (hx, hy, -hz), (hx, hy, hz)]),
|
|
||||||
((-1, 0, 0), [(-hx, -hy, -hz), (-hx, -hy, hz), (-hx, hy, hz), (-hx, hy, -hz)]),
|
|
||||||
((0, 1, 0), [(-hx, hy, hz), (hx, hy, hz), (hx, hy, -hz), (-hx, hy, -hz)]),
|
|
||||||
((0, -1, 0), [(-hx, -hy, -hz), (hx, -hy, -hz), (hx, -hy, hz), (-hx, -hy, hz)]),
|
|
||||||
((0, 0, 1), [(-hx, -hy, hz), (hx, -hy, hz), (hx, hy, hz), (-hx, hy, hz)]),
|
|
||||||
((0, 0, -1), [(hx, -hy, -hz), (-hx, -hy, -hz), (-hx, hy, -hz), (hx, hy, -hz)]),
|
|
||||||
]
|
|
||||||
P, N, U, I = [], [], [], []
|
|
||||||
for n, quad in faces:
|
|
||||||
b = len(P)
|
|
||||||
P += quad
|
|
||||||
N += [n] * 4
|
|
||||||
U += [(0, uv_scale[1]), (uv_scale[0], uv_scale[1]), (uv_scale[0], 0), (0, 0)]
|
|
||||||
I += [(b, b + 1, b + 2), (b, b + 2, b + 3)]
|
|
||||||
return P, N, U, I
|
|
||||||
|
|
||||||
|
|
||||||
def cylinder(r0, r1, length, seg=32, axis='x', caps=True):
|
|
||||||
P, N, U, I = [], [], [], []
|
|
||||||
for k in range(seg + 1):
|
|
||||||
a = 2 * math.pi * k / seg
|
|
||||||
c, s = math.cos(a), math.sin(a)
|
|
||||||
slope = (r0 - r1) / length
|
|
||||||
nl = math.sqrt(1 + slope ** 2)
|
|
||||||
P += [(0, r0 * c, r0 * s), (length, r1 * c, r1 * s)]
|
|
||||||
N += [(slope / nl, c / nl, s / nl)] * 2
|
|
||||||
U += [(k / seg, 0), (k / seg, 1)]
|
|
||||||
for k in range(seg):
|
|
||||||
a, b = 2 * k, 2 * k + 2
|
|
||||||
I += [(a, b, a + 1), (a + 1, b, b + 1)]
|
|
||||||
if caps:
|
|
||||||
for x, r, nx in ((0, r0, -1), (length, r1, 1)):
|
|
||||||
if r <= 0:
|
|
||||||
continue
|
|
||||||
c0 = len(P)
|
|
||||||
P.append((x, 0, 0)); N.append((nx, 0, 0)); U.append((0.5, 0.5))
|
|
||||||
for k in range(seg + 1):
|
|
||||||
a = 2 * math.pi * k / seg
|
|
||||||
P.append((x, r * math.cos(a), r * math.sin(a))); N.append((nx, 0, 0))
|
|
||||||
U.append((0.5 + 0.5 * math.cos(a), 0.5 + 0.5 * math.sin(a)))
|
|
||||||
for k in range(seg):
|
|
||||||
tri = (c0, c0 + 1 + k, c0 + 2 + k)
|
|
||||||
I.append(tri if nx > 0 else tri[::-1])
|
|
||||||
P = np.array(P, float)
|
|
||||||
N = np.array(N, float)
|
|
||||||
if axis == 'y':
|
|
||||||
P, N = P[:, [1, 0, 2]] * [1, 1, 1], N[:, [1, 0, 2]]
|
|
||||||
I = [t[::-1] for t in I]
|
|
||||||
elif axis == 'z':
|
|
||||||
P, N = P[:, [1, 2, 0]], N[:, [1, 2, 0]]
|
|
||||||
return P.tolist(), N.tolist(), U, I
|
|
||||||
|
|
||||||
|
|
||||||
def dish(r, depth, seg=32, rings=6):
|
|
||||||
"""Paraboloid dish opening toward +X."""
|
|
||||||
P, N, U, I = [], [], [], []
|
|
||||||
for i in range(rings + 1):
|
|
||||||
rr = r * i / rings
|
|
||||||
x = depth * (rr / r) ** 2
|
|
||||||
for k in range(seg + 1):
|
|
||||||
a = 2 * math.pi * k / seg
|
|
||||||
P.append((x, rr * math.cos(a), rr * math.sin(a)))
|
|
||||||
n = np.array([-1.0, 2 * depth * rr / r ** 2 * math.cos(a), 2 * depth * rr / r ** 2 * math.sin(a)])
|
|
||||||
n = -n / np.linalg.norm(n)
|
|
||||||
N.append(tuple(n))
|
|
||||||
U.append((k / seg, i / rings))
|
|
||||||
for i in range(rings):
|
|
||||||
for k in range(seg):
|
|
||||||
a = i * (seg + 1) + k
|
|
||||||
b = a + seg + 1
|
|
||||||
I += [(a, a + 1, b), (a + 1, b + 1, b)]
|
|
||||||
return P, N, U, I
|
|
||||||
|
|
||||||
|
|
||||||
def quat(axis, deg):
|
|
||||||
axis = np.array(axis, float) / np.linalg.norm(axis)
|
|
||||||
h = math.radians(deg) / 2
|
|
||||||
return [*(axis * math.sin(h)), math.cos(h)]
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# ICEYE-X36
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
def build_iceye():
|
|
||||||
g = Gltf()
|
|
||||||
t_sar = g.texture(sar_texture())
|
|
||||||
t_sol = g.texture(solar_texture(cols=6, rows=4, size=(384, 256)))
|
|
||||||
t_gold = g.texture(mli_texture((214, 168, 72)))
|
|
||||||
t_n = g.texture(mli_normal())
|
|
||||||
t_rad = g.texture(panel_texture((226, 228, 232)))
|
|
||||||
|
|
||||||
m_sar = g.material('sar-face', tex=t_sar, metal=0.55, rough=0.42)
|
|
||||||
m_back = g.material('antenna-back', color=(0.82, 0.83, 0.86), metal=0.2, rough=0.55, tex=t_rad)
|
|
||||||
m_gold = g.material('mli-gold', tex=t_gold, normal=t_n, metal=0.85, rough=0.32)
|
|
||||||
m_sol = g.material('solar', tex=t_sol, metal=0.4, rough=0.25)
|
|
||||||
m_frame = g.material('frame', color=(0.55, 0.56, 0.6), metal=0.9, rough=0.35)
|
|
||||||
m_black = g.material('black', color=(0.05, 0.05, 0.06), metal=0.1, rough=0.6)
|
|
||||||
m_white = g.material('white', color=(0.9, 0.9, 0.92), metal=0.0, rough=0.5)
|
|
||||||
|
|
||||||
# SAR antenna: five hinged segments, 3.25 m along-track x 0.40 m, face tilted
|
|
||||||
# 25 deg off nadir toward -Z (right-looking).
|
|
||||||
seg_len, gap = 0.638, 0.015
|
|
||||||
tilt = quat((1, 0, 0), -25)
|
|
||||||
for k in range(5):
|
|
||||||
x = (k - 2) * (seg_len + gap)
|
|
||||||
P, N, U, I = box(seg_len, 0.035, 0.40)
|
|
||||||
# face (-Y) UVs map into 1/5 of the antenna texture
|
|
||||||
U = [(u * 0.2 + 0.2 * k, v) for (u, v) in U]
|
|
||||||
P = np.array(P) + [x, 0, 0]
|
|
||||||
g.add(f'sar-seg-{k}', P, N, U, I, m_sar if True else m_back, rotation=tilt)
|
|
||||||
Pb, Nb, Ub, Ib = box(seg_len - 0.04, 0.012, 0.36)
|
|
||||||
g.add(f'sar-back-{k}', np.array(Pb) + [x, 0.022, 0], Nb, Ub, Ib, m_back, rotation=tilt)
|
|
||||||
# Bus behind the antenna
|
|
||||||
P, N, U, I = box(0.62, 0.42, 0.46)
|
|
||||||
g.add('bus', P, N, U, I, m_gold, translation=(0, 0.25, 0.06))
|
|
||||||
P, N, U, I = box(0.6, 0.02, 0.44)
|
|
||||||
g.add('bus-top-radiator', P, N, U, I, m_white, translation=(0, 0.47, 0.06))
|
|
||||||
# Solar wings off the bus, canted toward the sun-side
|
|
||||||
for side in (1, -1):
|
|
||||||
P, N, U, I = box(0.58, 0.02, 0.78)
|
|
||||||
g.add(f'solar-{side}', P, N, U, I, m_sol, translation=(0, 0.42, 0.06 + side * 0.66),
|
|
||||||
rotation=quat((1, 0, 0), side * 14))
|
|
||||||
P, N, U, I = cylinder(0.012, 0.012, 0.16, seg=8, axis='z')
|
|
||||||
g.add(f'hinge-{side}', P, N, U, I, m_frame, translation=(0, 0.42, 0.06 + side * 0.20 - (0.16 if side < 0 else 0)))
|
|
||||||
# Star trackers, X-band downlink horn, GNSS patch
|
|
||||||
for k, z in enumerate((-0.12, 0.12)):
|
|
||||||
P, N, U, I = cylinder(0.035, 0.05, 0.08, seg=16, axis='y')
|
|
||||||
g.add(f'star-tracker-{k}', P, N, U, I, m_black, translation=(-0.2, 0.48, 0.06 + z))
|
|
||||||
P, N, U, I = cylinder(0.05, 0.02, 0.09, seg=16, axis='y')
|
|
||||||
g.add('x-band', P, N, U, I, m_frame, translation=(0.2, 0.04, 0.2), rotation=quat((1, 0, 0), 180))
|
|
||||||
P, N, U, I = box(0.08, 0.012, 0.08)
|
|
||||||
g.add('gnss', P, N, U, I, m_white, translation=(0.18, 0.485, 0.06))
|
|
||||||
g.save(f'{HERE}/iceye-x36.glb')
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Cosmos 2612 (illustrative)
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
def build_cosmos():
|
|
||||||
g = Gltf()
|
|
||||||
t_sol = g.texture(solar_texture(cols=8, rows=6))
|
|
||||||
t_mli = g.texture(mli_texture((178, 182, 186)))
|
|
||||||
t_dark = g.texture(mli_texture((64, 62, 58)))
|
|
||||||
t_n = g.texture(mli_normal())
|
|
||||||
t_rad = g.texture(panel_texture((210, 212, 205)))
|
|
||||||
|
|
||||||
m_mli = g.material('mli-silver', tex=t_mli, normal=t_n, metal=0.9, rough=0.28)
|
|
||||||
m_dark = g.material('mli-dark', tex=t_dark, normal=t_n, metal=0.6, rough=0.4)
|
|
||||||
m_sol = g.material('solar', tex=t_sol, metal=0.4, rough=0.25)
|
|
||||||
m_rad = g.material('radiator', tex=t_rad, metal=0.0, rough=0.55)
|
|
||||||
m_frame = g.material('frame', color=(0.6, 0.6, 0.62), metal=0.9, rough=0.3)
|
|
||||||
m_black = g.material('black', color=(0.03, 0.03, 0.035), metal=0.2, rough=0.4)
|
|
||||||
m_lens = g.material('lens', color=(0.05, 0.08, 0.12), metal=1.0, rough=0.05, emissive=(0.02, 0.05, 0.08))
|
|
||||||
m_nozzle = g.material('nozzle', color=(0.35, 0.3, 0.28), metal=0.95, rough=0.4)
|
|
||||||
|
|
||||||
P, N, U, I = box(0.95, 0.72, 0.72)
|
|
||||||
g.add('bus', P, N, U, I, m_mli)
|
|
||||||
P, N, U, I = box(0.5, 0.74, 0.74)
|
|
||||||
g.add('bus-band', P, N, U, I, m_dark, translation=(-0.12, 0, 0))
|
|
||||||
P, N, U, I = box(0.8, 0.02, 0.6)
|
|
||||||
g.add('radiator-top', P, N, U, I, m_rad, translation=(0.02, 0.375, 0))
|
|
||||||
# Three-panel wings along +/-Z
|
|
||||||
for side in (1, -1):
|
|
||||||
P, N, U, I = cylinder(0.018, 0.018, 0.32, seg=10, axis='z')
|
|
||||||
g.add(f'yoke-{side}', P, N, U, I, m_frame, translation=(0, 0, side * 0.36 - (0.32 if side < 0 else 0)))
|
|
||||||
for k in range(3):
|
|
||||||
P, N, U, I = box(0.62, 0.02, 0.5)
|
|
||||||
z = side * (0.36 + 0.32 + 0.26 + k * 0.52)
|
|
||||||
g.add(f'wing-{side}-{k}', P, N, U, I, m_sol, translation=(0, 0, z), rotation=quat((0, 0, 1), 10))
|
|
||||||
# Forward optical sensor (faces +X: toward a target ahead along-track)
|
|
||||||
P, N, U, I = cylinder(0.13, 0.13, 0.24, seg=32, axis='x')
|
|
||||||
g.add('sensor-barrel', P, N, U, I, m_black, translation=(0.475, 0.12, 0.0))
|
|
||||||
P, N, U, I = cylinder(0.145, 0.16, 0.06, seg=32, axis='x', caps=False)
|
|
||||||
g.add('sensor-hood', P, N, U, I, m_frame, translation=(0.70, 0.12, 0.0))
|
|
||||||
P, N, U, I = cylinder(0.1, 0.1, 0.01, seg=32, axis='x')
|
|
||||||
g.add('sensor-lens', P, N, U, I, m_lens, translation=(0.715, 0.12, 0.0))
|
|
||||||
# Proximity radar / laser rangefinder block
|
|
||||||
P, N, U, I = box(0.12, 0.12, 0.16)
|
|
||||||
g.add('ranging', P, N, U, I, m_dark, translation=(0.53, -0.18, 0.18))
|
|
||||||
# Rear propulsion
|
|
||||||
P, N, U, I = cylinder(0.16, 0.16, 0.12, seg=24, axis='x')
|
|
||||||
g.add('prop-module', P, N, U, I, m_dark, translation=(-0.595, 0, 0))
|
|
||||||
P, N, U, I = cylinder(0.035, 0.09, 0.16, seg=24, axis='x', caps=False)
|
|
||||||
g.add('nozzle', P, N, U, I, m_nozzle, translation=(-0.76, 0, 0), rotation=quat((0, 0, 1), 180))
|
|
||||||
for k, (y, z) in enumerate(((0.3, 0.3), (0.3, -0.3), (-0.3, 0.3), (-0.3, -0.3))):
|
|
||||||
P, N, U, I = cylinder(0.012, 0.025, 0.05, seg=10, axis='x', caps=False)
|
|
||||||
g.add(f'rcs-{k}', P, N, U, I, m_nozzle, translation=(-0.48, y, z), rotation=quat((0, 0, 1), 180))
|
|
||||||
# S-band dish on a short mast (zenith)
|
|
||||||
P, N, U, I = cylinder(0.012, 0.012, 0.22, seg=8, axis='y')
|
|
||||||
g.add('mast', P, N, U, I, m_frame, translation=(-0.25, 0.37, 0.18))
|
|
||||||
P, N, U, I = dish(0.14, 0.05)
|
|
||||||
g.add('dish', P, N, U, I, m_rad, translation=(-0.25, 0.6, 0.18), rotation=quat((0, 0, 1), 90))
|
|
||||||
g.save(f'{HERE}/cosmos-2612.glb')
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
build_iceye()
|
|
||||||
build_cosmos()
|
|
||||||
Binary file not shown.
@@ -1 +0,0 @@
|
|||||||
{"t0":"2026-09-29T12:00:00Z","dt":60,"a_km":6920.86656753336,"x36":[-3625.5189,-5318.3928,2553.1804,-3766.6429,-5406.7449,2129.5637,-3891.4855,-5471.7261,1696.7165,-3999.5064,-5513.0548,1256.5148,-4090.2389,-5530.5527,810.8666,-4163.2919,-5524.1455,361.704,-4218.3513,-5493.863,-89.0263,-4255.1815,-5439.8394,-539.3709,-4273.6267,-5362.3124,-987.3787,-4273.6112,-5261.6219,-1431.1097,-4255.1399,-5138.2088,-1868.6435,-4218.2979,-4992.6125,-2298.0876,-4163.2501,-4825.4685,-2717.5863,-4090.2405,-4637.5056,-3125.3281,-3999.5906,-4429.5422,-3519.5541,-3891.6983,-4202.4827,-3898.5655,-3767.0355,-3957.3135,-4260.7306,-3626.1461,-3695.098,-4604.4922,-3469.6435,-3416.9724,-4928.3736,-3298.2077,-3124.1403,-5230.9851,-3112.5823,-2817.8673,-5511.0297,-2913.5708,-2499.4756,-5767.3082,-2702.0336,-2170.3385,-5998.7243,-2478.884,-1831.874,-6204.2888,-2245.084,-1485.5388,-6383.1236,-2001.6406,-1132.8228,-6534.465,-1749.6012,-775.2418,-6657.667,-1490.0494,-414.3321,-6752.2034,-1224.1004,-51.6435,-6817.6704,-952.8961,311.2668,-6853.7876,-677.601,672.8408,-6860.3993,-399.3967,1031.526,-6837.4753,-119.4774,1385.782,-6785.1109,160.9549,1734.0865,-6703.5265,440.6958,2074.9421,-6593.067,718.5431,2406.882,-6454.2004,993.3019,2728.4768,-6287.5159,1263.7898,3038.3397,-6093.7221,1528.8414,3335.1331,-5873.6438,1787.3137,3617.5742,-5628.2193,2038.0909,3884.4401,-5358.4963,2280.089,4134.5737,-5065.628,2512.2606,4366.8886,-4750.8684,2733.5996,4580.374,-4415.5673,2943.1456,4774.0993,-4061.1646,3139.9881,4947.2182,-3689.1846,3323.2708,5098.9729,-3301.2294,3492.1953,5228.6976,-2898.9722,3646.0251,5335.8216,-2484.1499,3784.0885,5419.8722,-2058.5561,3905.7822,5480.4768,-1624.0326,4010.5739,5517.3649,-1182.4621,4098.0049,5530.3699,-735.7589,4167.6921,5519.4291,-285.8613,4219.3301,5484.5851,165.2776,4252.6923,5425.985,615.6986,4267.6325,5343.8803,1063.4451,4264.0848,5238.6252,1506.572,4242.0646,5110.6759,1943.1542,4201.6682,4960.5876,2371.2956,4143.0722,4789.0125,2789.1375,4066.533,4596.6966,3194.8666,3972.3851,4384.4763,3586.7233,3861.0399,4153.2748,3963.0095,3732.9835,3904.0971,4322.0959,3588.7745,3638.0265,4662.4289,3429.0413,3356.2186,4982.5378,3254.4793,3059.8969,5281.0405,3065.8479,2750.347,5556.6496,2863.9667,2428.9108,5808.1779,2649.7119,2096.9807,6034.543,2424.0126,1755.9934,6234.7722,2187.8466,1407.424,6408.0057,1942.2361,1052.779,6553.5008,1688.2435,693.5905,6670.6345,1426.9663,331.4093,6758.906,1159.5332,-32.2016,6817.9385,887.0984,-395.6736,6847.4811,610.8377,-757.4391,6847.4092,331.9425,-1115.938,6817.7252,51.6159,-1469.6244,6758.5586,-228.9334,-1816.9732,6670.1653,-508.4954,-2156.4859,6552.9263,-785.8643,-2486.6979,6407.3469,-1059.8435,-2806.1839,6234.0537,-1329.2506,-3113.5641,6033.7929,-1592.9226,-3407.5106,5807.4269,-1849.7206,-3686.7526,5555.9306,-2098.535,-3950.0821,5280.3881,-2338.2901,-4196.3592,4981.9873,-2567.9488,-4424.5171,4662.0157,-2786.5173,-4633.5668,4321.8548,-2993.0494,-4822.6018,3962.9741,-3186.6507,-4990.8017,3586.925,-3366.4824,-5137.4364,3195.3341,-3531.7656,-5261.8692,2789.8963,-3681.7841,-5363.5598,2372.3671,-3815.8884,-5442.0668,1944.5552,-3933.498,-5497.0496,1508.3147,-4034.1043,-5528.2705,1065.5362,-4117.273,-5535.5952,618.1391,-4182.6458,-5518.9936,168.063,-4229.9422,-5478.5403,-282.7416,-4258.9606,-5414.4137,-732.321,-4269.5793,-5326.8954,-1178.7281,-4261.7566,-5216.369,-1620.03,-4235.5314,-5083.318,-2054.3173,-4191.0225,-4928.3236,-2479.7125,-4128.4281,-4752.0621,-2894.3776,-4048.0249,-4555.3015,-3296.5229,-3950.1663,-4338.8981,-3684.4144,-3835.2812,-4103.7925,-4056.3812,-3703.8716,-3851.0055,-4410.8229,-3556.5102,-3581.633,-4746.216,-3393.8381,-3296.8416,-5061.1204,-3216.5615,-2997.8631,-5354.1857,-3025.4486,-2685.989,-5624.1563,-2821.3264,-2362.5652,-5869.8766,-2605.0768,-2028.9858,-6090.2958,-2377.6327,-1686.6876,-6284.472,-2139.9745,-1337.1435,-6451.5758,-1893.1251,-981.8563,-6590.8939,-1638.1463,-622.3528,-6701.8315,-1376.134,-260.1771,-6783.9148,-1108.2137,103.1156,-6836.7927,-835.5356,465.9655,-6860.2385,-559.2705,826.815,-6854.1503,-280.6041,1184.1145,-6818.552,-0.7327,1536.3291,-6753.5926,279.1418,1881.9451,-6659.5462,557.817,2219.4761,-6536.8107,834.095,2547.4691,-6385.9067,1106.7874,2864.5109,-6207.4749,1374.7199,3169.2339,-6002.2742,1636.738,3460.3218,-5771.1787,1891.7111,3736.5156,-5515.1741,2138.5378,3996.6187,-5235.3541,2376.1508,4239.5027,-4932.9158,2603.521,4464.1117,-4609.155,2819.6626,4669.4677,-4265.461,3023.6373,4854.6748,-3903.3108,3214.5582,5018.9232,-3524.2629,3391.5945,5161.4933,-3129.9508,3553.9745,5281.7589,-2722.076,3700.9899,5379.1903,-2302.4007,3831.9985,5453.3567,-1872.7403,3946.4277,5503.9286,-1434.9551,4043.7766,5530.6795,-990.9423,4123.6191,5533.4868,-542.6278,4185.6053,5512.3327,-91.9572,4229.4634,5467.3044,359.1127,4255.0011,5398.5938,808.6228,4262.1061,5306.4965,1254.6202,4250.7472,5191.4108,1695.1675,4220.9739,5053.8358,2128.3511,4172.9164,4894.3688,2552.2897,4106.785,4713.7032,2965.1433,4022.8689,4512.6245,3365.1207,3921.5348,4292.0072,3750.4879,3803.2253,4052.811,4Line truncated
|
|
||||||
@@ -1,376 +0,0 @@
|
|||||||
// 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: 'close', label: 'Close in', s0: 22.0, s1: 29.7 },
|
|
||||||
{ key: 'closest', label: 'Closest', s0: 29.7, s1: 35.6 },
|
|
||||||
{ key: 'leave', label: 'Leave', s0: 35.6, s1: 41.2 },
|
|
||||||
];
|
|
||||||
const CAPTIONS = [
|
|
||||||
{
|
|
||||||
s0: -1, s1: 4.4, hook: true,
|
|
||||||
title: `A Russian military satellite shadowed a U.S.-registered one — to <span class="c-cos" style="white-space:nowrap">~${CA_M} m</span>`,
|
|
||||||
body: 'How <b class="c-cos">Cosmos 2612</b> matched <b class="c-x36">ICEYE-X36</b>\'s orbit over 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: 17.2,
|
|
||||||
title: 'Drop into the same orbit',
|
|
||||||
body: 'Oct 12: it dives 47 km and lands about <b>480 km behind</b>.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
s0: 17.2, s1: 22.0,
|
|
||||||
title: 'Speed up, then brake',
|
|
||||||
body: 'Oct 15: a 2 km dip makes it faster — closing <b>~350 km a day</b> — then it brakes 3 km behind.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
s0: 22.0, s1: 29.7,
|
|
||||||
title: 'Park behind it. Creep closer.',
|
|
||||||
body: 'Each orbit its path loops <b>1.6 km</b>, inching in for two days.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
s0: 29.7, s1: 32.3,
|
|
||||||
title: 'Into Earth\'s shadow',
|
|
||||||
body: 'Closing at strolling pace (<b>0.9 m/s</b>) — while both fly at 27,000 km/h.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
s0: 32.3, s1: 35.6,
|
|
||||||
title: `Closest pass: <span class="c-cos">~${CA_M} m</span>`,
|
|
||||||
body: 'Shown at true size. Published estimates: <b>150–650 m</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 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` },
|
|
||||||
].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 = (PORTRAIT ? 24 : 26) * 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.dataset.meta = meta;
|
|
||||||
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))}`;
|
|
||||||
$('#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;
|
|
||||||
const caS = EVENTS.find((e) => e.kind === 'ca').s;
|
|
||||||
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`;
|
|
||||||
if (s > caS && s < 36.5) ctxLine += ` · closest ${CA_M} m`;
|
|
||||||
ro.querySelector('.ctx').textContent = ctxLine;
|
|
||||||
|
|
||||||
// Scale chip: amber when enlarged, white at true size.
|
|
||||||
const magEl = $('#mag');
|
|
||||||
const enlarged = st.mag > 1.5;
|
|
||||||
const trueSize = !enlarged && st.camDistX < 200;
|
|
||||||
magEl.style.opacity = (enlarged || trueSize) ? fade(s, 4.4, 41.2, 0.3) : 0;
|
|
||||||
magEl.classList.toggle('enlarged', enlarged);
|
|
||||||
magEl.textContent = enlarged ? `Satellites enlarged ${Math.round(st.mag)}×` : 'True size';
|
|
||||||
|
|
||||||
// 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 quiet = s > 31.85 && s < 33.95; // true-size shot: headline + pulse carry it
|
|
||||||
if (!quiet && st.lx < 0.5 && st.sep < 5e3 && s < 41.2) html += '<div class="event shadow"><i></i><div><div class="t">In Earth\'s shadow</div><div class="sub">orbital night · sunrise in minutes</div></div></div>';
|
|
||||||
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 quiet ? [] : 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.-registered 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;
|
|
||||||
el.style.opacity = a;
|
|
||||||
el.querySelector('.meta').textContent = p.hidden ? 'far side of Earth' : el.dataset.meta;
|
|
||||||
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}"/>`;
|
|
||||||
};
|
|
||||||
// Dimension line + pill (offset perpendicular so it clears the line's ends).
|
|
||||||
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}"/>`;
|
|
||||||
const nx = -(pC.y - pX.y) / gap;
|
|
||||||
const ny = (pC.x - pX.x) / gap;
|
|
||||||
const side = ny > 0 ? -1 : 1; // put the pill above the line
|
|
||||||
dim.innerHTML = fmtSep(st.sep).replace(/<\/?small>/g, ' ').trim();
|
|
||||||
const mx = (pX.x + pC.x) / 2 + side * nx * 40 * U;
|
|
||||||
const my = (pX.y + pC.y) / 2 + side * ny * 40 * U;
|
|
||||||
dim.style.left = `${mx}px`;
|
|
||||||
dim.style.top = `${my}px`;
|
|
||||||
const w = dim.offsetWidth;
|
|
||||||
const h = dim.offsetHeight;
|
|
||||||
obstacles.push({ left: mx - w / 2, right: mx + w / 2, top: my - h / 2, bottom: my + h / 2 });
|
|
||||||
}
|
|
||||||
dim.style.opacity = dimA;
|
|
||||||
// Pulse rings on both satellites at the closest moment.
|
|
||||||
const caS2 = EVENTS.find((e) => e.kind === 'ca').s;
|
|
||||||
const k = (s - caS2) / 1.2;
|
|
||||||
if (k > 0 && k < 1) {
|
|
||||||
for (const p of [pX, pC]) {
|
|
||||||
if (!onScreen(p)) continue;
|
|
||||||
svg += `<circle cx="${p.x}" cy="${p.y}" r="${(18 + 90 * k) * U}" fill="none" stroke="#fff" stroke-opacity="${(1 - k) * 0.9}" stroke-width="${4 * 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');
|
|
||||||
|
|
||||||
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="range">estimated · 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="stats">
|
|
||||||
<div><b>22</b><span>days in pursuit</span></div>
|
|
||||||
<div><b>8</b><span>engine burns</span></div>
|
|
||||||
<div><b>0.9 m/s</b><span>speed at closest pass</span></div>
|
|
||||||
</div>
|
|
||||||
</div>`;
|
|
||||||
}
|
|
||||||
$('#endlogo').style.opacity = endA;
|
|
||||||
$('#logo').style.opacity = live;
|
|
||||||
}
|
|
||||||
|
|
||||||
return { update };
|
|
||||||
}
|
|
||||||
Binary file not shown.
@@ -1,43 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="180 120 775 520" role="img" aria-labelledby="logo-title">
|
|
||||||
<title id="logo-title">God's Eye View</title>
|
|
||||||
<defs>
|
|
||||||
<style>
|
|
||||||
.cls-4 {
|
|
||||||
fill: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cls-2, .cls-4 {
|
|
||||||
stroke: #000;
|
|
||||||
stroke-width: 1px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cls-1 {
|
|
||||||
fill: #4454727a;
|
|
||||||
stroke: #48b;
|
|
||||||
stroke-width: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cls-1, .cls-2, .cls-3, .cls-4 {
|
|
||||||
fill-rule: evenodd;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cls-2 {
|
|
||||||
fill: #00f6ff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cls-3 {
|
|
||||||
stroke: #00f6ff;
|
|
||||||
stroke-linecap: round;
|
|
||||||
stroke-width: 6.99px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cls-4 {
|
|
||||||
opacity: 0.63;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</defs>
|
|
||||||
<path id="outer_eye" class="cls-1" d="M567.5,623c-163.183,0-303.026-99.084-361.823-239.922C257.508,238.76,401.037,135,570,135c166.451,0,308.222,100.7,361.954,241.667C874.771,520.905,733.152,623,567.5,623ZM567,156c124.816,0,226,101.184,226,226S691.816,608,567,608,341,506.816,341,382,442.184,156,567,156ZM816,256.213A272.836,272.836,0,0,1,846,381M793.574,488.677a249.545,249.545,0,0,0,3.239-208.3A303.384,303.384,0,0,0,816,256.213a274.468,274.468,0,0,1-1.516,252.475q-0.927,1.758-1.88,3.5-1.632-2.238-3.306-4.446Q801.826,497.884,793.574,488.677ZM345.5,490.85q-8.055,9.164-15.37,18.966a274.353,274.353,0,0,1-3.748-250.343q7.347,10.012,15.469,19.393A249.512,249.512,0,0,0,345.5,490.85Z"/>
|
|
||||||
<path id="inner_eye" class="cls-2" d="M568,179.985c107.678,0,194.968,88.655,194.968,198.015S675.678,576.015,568,576.015,373.032,487.36,373.032,378,460.322,179.985,568,179.985Z"/>
|
|
||||||
<path id="globe" class="cls-3" d="M569.5,499.831A120.331,120.331,0,1,1,689.831,379.5,120.33,120.33,0,0,1,569.5,499.831Zm65.1-50.585c1.225-.4,3.268-2.858,4.492-3.677s3.268-4.083,3.675-5.717,1.227-4.495,1.634-6.126a36.015,36.015,0,0,0,.818-6.135,14.639,14.639,0,0,0-.407-2.855l-3.266,6.127s-4.9,2.04-5.315,3.674-0.817,4.088-.817,4.088a61.248,61.248,0,0,0-2.45,6.133C632.554,446.388,633.372,449.66,634.6,449.246ZM461.011,377.072c0.72-1.105,1.928-6.276,1.6-7.894-0.411-2.037,1.225-6.94,1.225-9.392s4.083-7.358,4.9-9.4,4.491-7.349,6.535-10.623-0.4-2.038-2.449-.809,1.225-3.274,2.449-4.086,8.172-5.72,8.172-5.72,2.449-2.454-.409-7.762c-2.211-4.1,0-4.5,0-6.128a2.43,2.43,0,0,0-1.28-.223c2.129-2.952,4.408-5.788,6.811-8.512A108.871,108.871,0,0,0,494.612,458.6a0.454,0.454,0,0,1,.683-0.363c2.043,0.819,2.449-.4,4.085-0.4s4.086-2.043,4.086-2.043l-1.641-4.9,1.641-6.126,2.041-5.717s0-4.095-2.041-4.5-5.722-4.081-6.946-4.081a10.018,10.018,0,0,1-4.491-1.636c-1.227-.813-3.677-1.634-4.5-2.857a31.144,31.144,0,0,1-2.858-5.314c-0.816-2.039-.409-2.039-3.681-5.715s-2.452-2.863-4.9-4.093-3.265-2.443-5.308-3.263a13.378,13.378,0,0,1-4.5-2.857,4.884,4.884,0,0,0-2.711-1.519,110.041,110.041,0,0,1-2.595-23.808C460.98,378.618,461,377.84,461.011,377.072Zm35.743-78.7a13.8,13.8,0,0,0-1.051,2.589c-0.408,1.638,2.864.416,2.864,0.416L502.239,303l4.5-1.631a13.741,13.741,0,0,1,4.083-4.5c2.857-2.043,2.454-2.452,3.677-4.084s0-.006,5.314-5.732,8.167-5.718,8.167-5.718,1.915-2.1,3.244-3.895A108.986,108.986,0,0,0,496.754,298.368Zm41.024,56.1c-1.224.82-5.715,3.272-6.944,5.315a22.681,22.681,0,0,0-2.859,6.535,31.042,31.042,0,0,0-.408,6.538c0,2.854-.41,4.5,0,6.949s4.5,8.568,6.128,10.623c1.63,2.038,3.674,4.483,5.719,5.713a47.235,47.235,0,0,1,4.9,3.674,4.03,4.03,0,0,0,4.9,1.223c3.272-1.223,6.538-1.223,9.4-2.853s7.353-3.685,7.353-1.231,1.632,4.084,4.082,3.272,6.539-2.858,6.13,0S577,406.764,577,408.4s-0.4,3.265.818,4.495,4.5,3.265,4.5,4.49,0.409,5.311,1.229,6.946,1.639,4.08.823,5.724-2.434,6.123-2.434,6.123-0.779,3.676-.37,4.9a12.683,12.683,0,0,0,3.332,4.906c2.043,2.042,1.771,4.492,2.994,6.124s2.993,6.539,2.993,6.539l1.236,4.088v3.677s7.966-1.231,9.591-1.64a30.673,30.673,0,0,0,8.618-4.9c1.636-1.632,1.311-2.856,3.349-4.488s5.762-4.5,5.762-4.5l-0.387-6.126,4.914-6.544,3.684-3.677s3.267-4.484,2.865-6.128c-0.413-1.628-1.231-9.8-1.231-9.8a37.972,37.972,0,0,1,2.863-5.313c1.224-1.634,1.227-3.678,3.679-5.719a33.109,33.109,0,0,0,5.306-6.126c1.229-1.634,3.681-4.9,4.5-6.127a21.871,21.871,0,0,0,1.635-4.894l-7.355,1.216c-2.45-4.082-9.392-13.073-10.621-15.112a37.04,37.04,0,0,1-2.861-7.76c-0.4-1.634-2.445-6.131-2.856-7.356s-3.27-4.908-4.088-6.128-3.675-2.043-5.309-3.268-11.843-3.27-11.843-3.27-3.129-1.991-4.088-1.226c-4.083,3.269-13.48,1.226-15.523.415s-3.269-2.041-3.269-3.272-1.835-7.143-1.835-7.143-3.985.705-5.213,0.705a20.333,20.333,0,0,1-4.442-1.276s-4.055.379-5.693,1.2a61.205,61.205,0,0,1-6.115,2.438,36.488,36.488,0,0,1-4.488.814c-3.671-1.636-6.529,2.45-7.352,4.079s-2.445,2.454-2.445,4.084S539,353.659,537.778,354.471Zm18.191-54.739-3.128-4.5-3.727,2.045V304.5l-3.839-1.5-1.715,3.674,1.685,2.452L549.114,305v4.129l8.478-1.636,3.164-2.449Zm71.15-12.676c-1.669.869-4.808,3.094-6,1.894-1.227-1.218-4.086-3.423-4.086-3.423,2.448-1.636-4.9-5.212-4.9-5.212l-8.576-1.022h-6.125c-1.227,0-3.681.825-5.723,0.825s-6.943,3.583-8.577,3.994-5.314,2.5-6.944,2.5-4.088,3.086-4.088,3.086-2.856,5.218-.4,4.4,5.717-.858,6.122.367,1.227,3.242,1.636,4.471,6.131,1.618,6.131,1.618a18.551,18.551,0,0,1,2.038-3.683c1.227-1.634,2.452-3.675,2.452-3.675s2.044,4.082,2.452,5.3-0.818,4.908-.818,4.908-4.491,2.852-5.716,2.852-8.175-6.126-8.175-6.126a25.4,25.4,0,0,0-4.489,2.043c-2.856,2.861-5.717,5.31-5.717,5.31a17.057,17.057,0,0,0-4.081,2.046c-1.629,1.222-5.138,1.487-6.117,2.854-2.039,2.863.84,4.9,0.84,4.9l-2.408,3.271-1.548,1.225-5.553,1.221-2.751.616c0.4,1.227,1.258,5.105,1.258,5.105s-1.87,6.943,0,6.943c0,0,4.276,1.64,5.5,1.64s2.625,0.4,4.265-1.227a59.861,59.861,0,0,0,5.156-6.538,8.047,8.047,0,0,1,4.49-3.267c2.454-.82,3-2.047,4.231-2.452s6.47-1.229,6.47-1.229,3.709,2.043,4.932,2.861c0.573,0.386-1.393,1.658,6.086,2.907V331.6l3.7-.411-4.207-3.264-0.44-.1-4.778-4.8-0.419-3.672s4.861,2.862,6.493,3.672a43.671,43.671,0,0,1,4.81,3.272l1.783,5.719,2.4,4.5s-1.87-5.722,0-5.722c0,0,5.158.405,6.381,0a36.253,36.253,0,0,0,5.237-1.632c1.636-.815-0.648-3.68.986-5.721a10.385,10.385,0,0,1,4.981-3.27c1.23-.405,5.35-2.447,5.35-2.447l4.522,0.82c-2.041,1.627-4.486,4.077-2.85,3.676s5.313,1.63,6.949,2.855,0.413,4.087.413,4.087-4.5-.411-7.355-0.815a29.263,29.263,0,0,0-7.351.4l-3.677,2.043s-2.454.816-2.045,2.861,2.045,4.5,3.679,6.535,2.858,4.5,4.085,3.675,5.309-3.265,5.309-3.265a12.784,12.784,0,0,0-.409,4.9,30.6,30.6,0,0,1,.409,4.9s-4.082,0-2.861.822,2.967,3.905,3.681,4.9c4.083,5.717,12.664,18.383,13.482,20.015s0.44,4.126,1.631,5.31c2.454,2.45,7.352,0,8.578-.817s5.311-2.863,6.536-4.082,0.818-.822,3.272-2.454,4.079-2.856,2.859-4.085-3.68-5.725-3.68-5.725c-3.676.825-7.351-1.222-7.351-2.445s-3.269-6.124-3.269-6.124,0.407-.82,3.269.809,5.306,4.908Line truncated
|
|
||||||
<path id="globe_cage" class="cls-4" d="M569.5,576.491c-106.968,0-193.988-89.044-193.988-198.491S462.532,179.509,569.5,179.509,763.488,268.553,763.488,378,676.468,576.491,569.5,576.491ZM578.4,368.9h76.237a314.2,314.2,0,0,0-11.945-78.026H578.4V368.9ZM654.634,387.1H578.4V464.94h64.345A314.251,314.251,0,0,0,654.634,387.1Zm90.813,0H671.861a333.4,333.4,0,0,1-11.206,77.836h63.112A182.117,182.117,0,0,0,745.447,387.1Zm-32.528,95.444H655.363a288.421,288.421,0,0,1-32.3,67.191A177.214,177.214,0,0,0,712.919,482.548Zm-115.67,73.48a263.416,263.416,0,0,0,39.828-73.48H578.4v75.476A173.846,173.846,0,0,0,597.249,556.028ZM560.6,558.036V482.548H497.674a263.837,263.837,0,0,0,39.135,72.575A174.279,174.279,0,0,0,560.6,558.036ZM510.434,547.83a289.038,289.038,0,0,1-31.078-65.282H426.1A177.5,177.5,0,0,0,510.434,547.83Zm-95.208-82.89H474.1A333.353,333.353,0,0,1,462.878,387.1H393.555A182.127,182.127,0,0,0,415.226,464.94ZM393.555,368.9h69.323a333.4,333.4,0,0,1,11.268-78.026H415.332A182.13,182.13,0,0,0,393.555,368.9Zm32.676-95.633h53.185a289.064,289.064,0,0,1,31-65.087A177.487,177.487,0,0,0,426.231,273.263Zm134.372-75.3a174.068,174.068,0,0,0-23.8,2.914,263.8,263.8,0,0,0-39.06,72.384H560.6v-75.3Zm0,92.905H492.051A314.088,314.088,0,0,0,480.106,368.9h80.5V290.87Zm-80.5,96.234A314.1,314.1,0,0,0,492,464.94h68.6V387.1h-80.5ZM578.4,197.977v75.286h58.612a263.412,263.412,0,0,0-39.751-73.289A174.011,174.011,0,0,0,578.4,197.977Zm44.672,8.286a288.6,288.6,0,0,1,32.231,67h57.491A177.213,177.213,0,0,0,623.069,206.263Zm100.6,84.607H660.6A333.3,333.3,0,0,1,671.861,368.9h73.586A182.129,182.129,0,0,0,723.667,290.87Z"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 8.2 KiB |
@@ -1,445 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Reconstruct the Cosmos 2612 / ICEYE-X36 rendezvous (Sep 30 - Oct 22, 2026).
|
|
||||||
|
|
||||||
ICEYE-X36 is propagated with SGP4 from its public CelesTrak element set.
|
|
||||||
Cosmos 2612 is carried as quasi-nonsingular relative orbital elements (ROE)
|
|
||||||
about X36 and advanced with linearised Keplerian drift plus the differential
|
|
||||||
J2 node drift. Manoeuvres are tangential / normal impulses applied through
|
|
||||||
Gauss's variational equations, timed inside the element-set windows that
|
|
||||||
KeepTrack, LeoLabs and Marco Langbroek published. The final state is pinned
|
|
||||||
to Cosmos 2612's own current element set (479 km behind, matched SMA).
|
|
||||||
|
|
||||||
Output: data.json (60 s samples) consumed by scene.html.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import json
|
|
||||||
import math
|
|
||||||
from datetime import datetime, timedelta, timezone
|
|
||||||
|
|
||||||
import numpy as np
|
|
||||||
from scipy.optimize import least_squares
|
|
||||||
from sgp4.api import Satrec, jday
|
|
||||||
|
|
||||||
HERE = __file__.rsplit('/', 1)[0]
|
|
||||||
MU = 398600.4418
|
|
||||||
RE = 6378.137
|
|
||||||
J2 = 1.08262668e-3
|
|
||||||
|
|
||||||
lines = open(f'{HERE}/tles.txt').read().split('\n')
|
|
||||||
cosmos_tle = Satrec.twoline2rv(lines[1], lines[2])
|
|
||||||
x36 = Satrec.twoline2rv(lines[4], lines[5])
|
|
||||||
|
|
||||||
A = (MU / (x36.no_kozai / 60) ** 2) ** (1 / 3) # km
|
|
||||||
N_RAD_S = math.sqrt(MU / A ** 3)
|
|
||||||
N_DAY = N_RAD_S * 86400
|
|
||||||
INC = x36.inclo
|
|
||||||
V = N_RAD_S * A * 1000 # m/s orbital speed
|
|
||||||
# Differential node rate per unit da/a: dOmegadot = -3.5 * Omegadot * da/a
|
|
||||||
OMEGA_DOT = -1.5 * N_RAD_S * J2 * (RE / A) ** 2 * math.cos(INC) # rad/s
|
|
||||||
|
|
||||||
T0 = datetime(2026, 9, 29, 12, 0, tzinfo=timezone.utc)
|
|
||||||
T1 = datetime(2026, 10, 22, 12, 0, tzinfo=timezone.utc)
|
|
||||||
DT = 60
|
|
||||||
|
|
||||||
|
|
||||||
def utc(s):
|
|
||||||
return datetime.fromisoformat(s.replace('Z', '+00:00'))
|
|
||||||
|
|
||||||
|
|
||||||
def secs(d):
|
|
||||||
return (d - T0).total_seconds()
|
|
||||||
|
|
||||||
|
|
||||||
def x36_state(t):
|
|
||||||
d = T0 + timedelta(seconds=t)
|
|
||||||
jd, fr = jday(d.year, d.month, d.day, d.hour, d.minute, d.second + d.microsecond / 1e6)
|
|
||||||
e, r, v = x36.sgp4(jd, fr)
|
|
||||||
assert e == 0
|
|
||||||
return np.array(r), np.array(v)
|
|
||||||
|
|
||||||
|
|
||||||
def frame(r, v):
|
|
||||||
R = r / np.linalg.norm(r)
|
|
||||||
Nn = np.cross(r, v)
|
|
||||||
Nn /= np.linalg.norm(Nn)
|
|
||||||
return R, np.cross(Nn, R), Nn
|
|
||||||
|
|
||||||
|
|
||||||
def arg_lat(r, v):
|
|
||||||
R, T, Nn = frame(r, v)
|
|
||||||
node = np.cross([0, 0, 1], Nn)
|
|
||||||
node /= np.linalg.norm(node)
|
|
||||||
return math.atan2(np.dot(np.cross(node, R), Nn), np.dot(node, R))
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# ROE dynamics. roe = [da, dl, dex, dey, dix, diy] (dimensionless, / a)
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
def drift(roe, dt):
|
|
||||||
out = roe.copy()
|
|
||||||
out[1] += -1.5 * N_RAD_S * roe[0] * dt
|
|
||||||
out[5] += -3.5 * OMEGA_DOT * roe[0] * math.sin(INC) * dt
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def impulse(roe, u, dvr=0.0, dvt=0.0, dvn=0.0):
|
|
||||||
k = 1 / (N_RAD_S * A * 1000)
|
|
||||||
out = roe.copy()
|
|
||||||
out[0] += 2 * k * dvt
|
|
||||||
out[1] += -2 * k * dvr
|
|
||||||
out[2] += k * (math.sin(u) * dvr + 2 * math.cos(u) * dvt)
|
|
||||||
out[3] += k * (-math.cos(u) * dvr + 2 * math.sin(u) * dvt)
|
|
||||||
out[4] += k * math.cos(u) * dvn
|
|
||||||
out[5] += k * math.sin(u) * dvn
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def rtn(roe, u):
|
|
||||||
da, dl, dex, dey, dix, diy = roe
|
|
||||||
return A * 1000 * np.array([
|
|
||||||
da - dex * math.cos(u) - dey * math.sin(u),
|
|
||||||
dl + 2 * dex * math.sin(u) - 2 * dey * math.cos(u),
|
|
||||||
dix * math.sin(u) - diy * math.cos(u),
|
|
||||||
])
|
|
||||||
|
|
||||||
|
|
||||||
def dv_for_da(da_km):
|
|
||||||
return V * da_km / A / 2 # m/s total, split into two half-orbit impulses
|
|
||||||
|
|
||||||
|
|
||||||
PERIOD = 2 * math.pi / N_RAD_S
|
|
||||||
U_CACHE = {}
|
|
||||||
|
|
||||||
|
|
||||||
def u_at(t):
|
|
||||||
key = round(t)
|
|
||||||
if key not in U_CACHE:
|
|
||||||
U_CACHE[key] = arg_lat(*x36_state(t))
|
|
||||||
return U_CACHE[key]
|
|
||||||
|
|
||||||
|
|
||||||
def build(params):
|
|
||||||
"""Return (burns, simulate) from a parameter dict."""
|
|
||||||
p = params
|
|
||||||
burns = []
|
|
||||||
|
|
||||||
def hohmann(t, da_km, label, de_vec=None):
|
|
||||||
"""Two tangential impulses half an orbit apart -> pure SMA change.
|
|
||||||
|
|
||||||
de_vec optionally re-targets the relative eccentricity vector by
|
|
||||||
splitting the impulses unevenly (a small radial-tangential trim)."""
|
|
||||||
dv = dv_for_da(da_km)
|
|
||||||
burns.append(dict(t=t, dvt=dv / 2, label=label))
|
|
||||||
burns.append(dict(t=t + PERIOD / 2, dvt=dv / 2, label=label, second=True, de=de_vec))
|
|
||||||
|
|
||||||
hohmann(p['t_sep30'], p['da_sep30'], 'Phasing raise')
|
|
||||||
burns.append(dict(t=p['t_oct7'], dvn=p['dvn_oct7'], label='Plane trim'))
|
|
||||||
hohmann(p['t_oct12'], p['da_oct12'], 'Descent to X36 orbit')
|
|
||||||
hohmann(p['t_oct15'], p['da_oct15'], 'Closing burn')
|
|
||||||
hohmann(p['t_oct16'], p['da_oct16'], 'Braking burn', de_vec=p['de_prox'])
|
|
||||||
burns.append(dict(t=p['t_oct18a'], dvt=dv_for_da(p['da_oct18a']), label='Back-off'))
|
|
||||||
hohmann(p['t_oct18'], p['da_oct18'], 'Departure burn')
|
|
||||||
hohmann(p['t_oct19'], p['da_oct19'], 'Re-match orbit')
|
|
||||||
burns.sort(key=lambda b: b['t'])
|
|
||||||
return burns
|
|
||||||
|
|
||||||
|
|
||||||
def simulate(params, times):
|
|
||||||
burns = build(params)
|
|
||||||
roe = np.array(params['roe0'], dtype=float)
|
|
||||||
t = 0.0
|
|
||||||
bi = 0
|
|
||||||
out = []
|
|
||||||
roe_out = []
|
|
||||||
for tt in times:
|
|
||||||
while bi < len(burns) and burns[bi]['t'] <= tt:
|
|
||||||
b = burns[bi]
|
|
||||||
roe = drift(roe, b['t'] - t)
|
|
||||||
t = b['t']
|
|
||||||
u = u_at(t)
|
|
||||||
roe = impulse(roe, u, dvt=b.get('dvt', 0), dvn=b.get('dvn', 0))
|
|
||||||
if b.get('de') is not None:
|
|
||||||
roe[2], roe[3] = b['de'][0] / (A * 1000), b['de'][1] / (A * 1000)
|
|
||||||
bi += 1
|
|
||||||
roe = drift(roe, tt - t)
|
|
||||||
t = tt
|
|
||||||
out.append(rtn(roe, u_at(tt)))
|
|
||||||
roe_out.append(roe.copy())
|
|
||||||
return np.array(out), np.array(roe_out), burns
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Timeline (UTC). Windows are the published element-set gaps.
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
OCT12_OVERHEAD = secs(utc('2026-10-12T00:10:00Z'))
|
|
||||||
LAP_DA = 47.0 # km, KeepTrack: "raised orbit by 47 km in total"
|
|
||||||
lap_rate = 1.5 * N_RAD_S * LAP_DA / A # rad/s
|
|
||||||
t_sep30 = secs(utc('2026-09-30T11:00:00Z'))
|
|
||||||
|
|
||||||
params = dict(
|
|
||||||
t_sep30=t_sep30, da_sep30=LAP_DA - 1.0,
|
|
||||||
t_oct7=secs(utc('2026-10-07T10:30:00Z')), dvn_oct7=4.0,
|
|
||||||
t_oct12=secs(utc('2026-10-12T01:30:00Z')), da_oct12=-(LAP_DA + 0.2),
|
|
||||||
t_oct15=secs(utc('2026-10-15T03:00:00Z')), da_oct15=-2.3,
|
|
||||||
t_oct16=secs(utc('2026-10-16T05:30:00Z')), da_oct16=2.48,
|
|
||||||
de_prox=(0.0, 800.0),
|
|
||||||
t_oct18a=secs(utc('2026-10-18T02:00:00Z')), da_oct18a=0.13,
|
|
||||||
t_oct18=secs(utc('2026-10-18T12:26:00Z')), da_oct18=2.40,
|
|
||||||
t_oct19=secs(utc('2026-10-19T20:00:00Z')), da_oct19=-2.45,
|
|
||||||
roe0=[1.0 / A, 0.0, 0.0, 0.0, -0.032 * math.pi / 180, 0.0],
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def sep_at(p, iso_list):
|
|
||||||
ts = [secs(utc(s)) for s in iso_list]
|
|
||||||
r, roe, _ = simulate(p, ts)
|
|
||||||
return np.linalg.norm(r, axis=1) / 1000, r, roe
|
|
||||||
|
|
||||||
|
|
||||||
def dl_at(p, iso):
|
|
||||||
_, _, roe = sep_at(p, [iso])
|
|
||||||
return roe[0][1]
|
|
||||||
|
|
||||||
|
|
||||||
# 1) Initial along-track phase so the Oct 12 overhead pass (dl = -2 pi) is hit.
|
|
||||||
def f_phase(x):
|
|
||||||
p = dict(params, roe0=[params['roe0'][0], x[0]] + params['roe0'][2:])
|
|
||||||
return [dl_at(p, '2026-10-12T00:10:00Z') + 2 * math.pi]
|
|
||||||
|
|
||||||
|
|
||||||
params['roe0'][1] = least_squares(f_phase, [-0.5]).x[0]
|
|
||||||
# Undo the lap so dl is near zero afterwards (wrap): handled by subtracting 2 pi.
|
|
||||||
params['roe0'][1] += 2 * math.pi
|
|
||||||
# Wrap: overhead at Oct 12 is now dl=0.
|
|
||||||
|
|
||||||
# 1b) Initial relative inclination vector: the 47 km "high park" lets
|
|
||||||
# differential J2 node drift close the plane; the Oct 7 normal burn
|
|
||||||
# trims the rest. Fit so the plane angle is ~0.002 deg by Oct 16.
|
|
||||||
def f_plane(x):
|
|
||||||
p = dict(params, roe0=params['roe0'][:4] + [x[0], x[1]])
|
|
||||||
_, _, roe = sep_at(p, ['2026-10-16T12:00:00Z'])
|
|
||||||
target = 0.002 * math.pi / 180 / math.sqrt(2)
|
|
||||||
return [(roe[0][4] - target) * 1e5, (roe[0][5] - target) * 1e5]
|
|
||||||
|
|
||||||
|
|
||||||
params['roe0'][4:6] = list(least_squares(f_plane, [0.0, -0.002]).x)
|
|
||||||
plane0 = math.degrees(math.hypot(*params['roe0'][4:6]))
|
|
||||||
|
|
||||||
# 2) Oct 12 descent timing -> ~480 km behind once the drift stops.
|
|
||||||
def f_desc(x):
|
|
||||||
p = dict(params, t_oct12=x[0])
|
|
||||||
return [dl_at(p, '2026-10-12T06:00:00Z') * A + 480.0]
|
|
||||||
|
|
||||||
|
|
||||||
params['t_oct12'] = least_squares(f_desc, [params['t_oct12']]).x[0]
|
|
||||||
|
|
||||||
# 3) Oct 15 closing burn timing so drift is arrested ~3 km behind by the Oct 16 braking burn.
|
|
||||||
def f_close(x):
|
|
||||||
p = dict(params, t_oct15=x[0])
|
|
||||||
t16 = params['t_oct16'] + PERIOD
|
|
||||||
d = (T0 + timedelta(seconds=t16)).isoformat()
|
|
||||||
return [dl_at(p, d) * A + 3.0]
|
|
||||||
|
|
||||||
|
|
||||||
params['t_oct15'] = least_squares(f_close, [params['t_oct15']]).x[0]
|
|
||||||
|
|
||||||
# 4) Proximity phase: fit the residual SMA offset, relative e / i vectors to
|
|
||||||
# published separations (Oct 17) and a sub-km closest approach early Oct 18.
|
|
||||||
CHECK = [
|
|
||||||
('2026-10-17T07:45:00Z', 2.1),
|
|
||||||
('2026-10-17T17:18:00Z', 0.85),
|
|
||||||
('2026-10-17T20:29:00Z', 0.64),
|
|
||||||
('2026-10-17T23:40:00Z', 0.64),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
DE_PROX_M = 800.0 # KeepTrack: relative loops ~1.6 km tall after the braking burn
|
|
||||||
|
|
||||||
|
|
||||||
def prox_params(x):
|
|
||||||
t16, da16, phi = x
|
|
||||||
return dict(params, t_oct16=t16, da_oct16=da16,
|
|
||||||
de_prox=(DE_PROX_M * math.cos(phi), DE_PROX_M * math.sin(phi)))
|
|
||||||
|
|
||||||
|
|
||||||
def window(start, end, step_min):
|
|
||||||
t, out = utc(start), []
|
|
||||||
while t < utc(end):
|
|
||||||
out.append(t.isoformat())
|
|
||||||
t += timedelta(minutes=step_min)
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
ca_window = window('2026-10-17T23:30:00Z', '2026-10-18T02:00:00Z', 1)
|
|
||||||
pre_window = window('2026-10-16T08:00:00Z', '2026-10-17T23:30:00Z', 4)
|
|
||||||
T_CA_TARGET = 75 # minutes after 00:00 -> 01:15 UTC (Langbroek ~01:35, KeepTrack ~01:10)
|
|
||||||
|
|
||||||
|
|
||||||
def f_prox(x):
|
|
||||||
p = prox_params(x)
|
|
||||||
d, _, roe = sep_at(p, [c[0] for c in CHECK] + ['2026-10-16T12:00:00Z'])
|
|
||||||
res = [(d[i] - c[1]) / 0.5 for i, c in enumerate(CHECK)]
|
|
||||||
res.append((roe[-1][1] * A + 3.0) / 0.3)
|
|
||||||
dw, _, _ = sep_at(p, ca_window)
|
|
||||||
k = int(np.argmin(dw))
|
|
||||||
res.append((dw[k] - 0.20) / 0.02)
|
|
||||||
res.append((k - 30 - T_CA_TARGET) / 15)
|
|
||||||
dp, _, _ = sep_at(p, pre_window)
|
|
||||||
res.append(max(0.0, 0.55 - dp.min()) / 0.02)
|
|
||||||
return res
|
|
||||||
|
|
||||||
|
|
||||||
best = None
|
|
||||||
for phi0 in np.linspace(0, 2 * math.pi, 8, endpoint=False):
|
|
||||||
f = least_squares(f_prox, [params['t_oct16'] - 1800, 2.49, phi0], x_scale=[600, 0.01, 0.3],
|
|
||||||
diff_step=[1e-4, 1e-4, 1e-3])
|
|
||||||
if best is None or f.cost < best.cost:
|
|
||||||
best = f
|
|
||||||
fit = best
|
|
||||||
params.update(prox_params(fit.x))
|
|
||||||
print('prox fit residuals', np.round(fit.fun, 2), 'x', fit.x)
|
|
||||||
|
|
||||||
# 5) Post-CA back-off so Oct 18 07:38 is ~5.1 km behind.
|
|
||||||
def f_back(x):
|
|
||||||
p = dict(params, da_oct18a=x[0])
|
|
||||||
d, r, _ = sep_at(p, ['2026-10-18T07:38:00Z'])
|
|
||||||
return [d[0] - 5.1]
|
|
||||||
|
|
||||||
|
|
||||||
params['da_oct18a'] = least_squares(f_back, [0.13]).x[0]
|
|
||||||
|
|
||||||
# 6) Re-match burn so the state lands on the current element sets
|
|
||||||
# (Oct 24: 479 km behind, SMA offset +0.017 km).
|
|
||||||
def pair_phase(iso):
|
|
||||||
d = utc(iso)
|
|
||||||
jd, fr = jday(d.year, d.month, d.day, d.hour, d.minute, d.second)
|
|
||||||
_, rc, _ = cosmos_tle.sgp4(jd, fr)
|
|
||||||
r, v = x36_state(secs(d))
|
|
||||||
R, T, Nn = frame(r, v)
|
|
||||||
return math.atan2(np.dot(np.cross(r, rc), Nn), np.dot(r, rc))
|
|
||||||
|
|
||||||
|
|
||||||
A_COSMOS = (MU / (cosmos_tle.no_kozai / 60) ** 2) ** (1 / 3)
|
|
||||||
END_DA = A_COSMOS - A
|
|
||||||
target_dl = pair_phase('2026-10-21T12:00:00Z')
|
|
||||||
|
|
||||||
|
|
||||||
def f_rematch(x):
|
|
||||||
p = dict(params, t_oct19=x[0], da_oct19=END_DA - (params['da_oct18a'] + params['da_oct18'] + params['roe0'][0] * 0 + 0))
|
|
||||||
return [(dl_at(p, '2026-10-21T12:00:00Z') - target_dl) * A]
|
|
||||||
|
|
||||||
|
|
||||||
# SMA bookkeeping: total offset after Oct 18 burns.
|
|
||||||
def sma_after(p, iso):
|
|
||||||
_, _, roe = sep_at(p, [iso])
|
|
||||||
return roe[0][0] * A
|
|
||||||
|
|
||||||
|
|
||||||
da_before_rematch = sma_after(params, '2026-10-19T12:00:00Z')
|
|
||||||
params['da_oct19'] = END_DA - da_before_rematch
|
|
||||||
params['t_oct19'] = least_squares(
|
|
||||||
lambda x: [(dl_at(dict(params, t_oct19=x[0]), '2026-10-21T12:00:00Z') - target_dl) * A],
|
|
||||||
[params['t_oct19']],
|
|
||||||
).x[0]
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Sample and export
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
times = np.arange(0, secs(T1) + 1, DT)
|
|
||||||
rel, roe, burns = simulate(params, list(times))
|
|
||||||
x36_pos = np.array([x36_state(t)[0] for t in times])
|
|
||||||
|
|
||||||
dist_km = np.linalg.norm(rel, axis=1) / 1000
|
|
||||||
# Chord distance for large phases: the ROE map is linear, so use the
|
|
||||||
# curvilinear placement for the true separation.
|
|
||||||
def true_sep(i):
|
|
||||||
r, v = x36_state(times[i])
|
|
||||||
R, T, Nn = frame(r, v)
|
|
||||||
th = rel[i][1] / 1000 / np.linalg.norm(r)
|
|
||||||
Rp = R * math.cos(th) + T * math.sin(th)
|
|
||||||
pc = (np.linalg.norm(r) + rel[i][0] / 1000) * Rp + rel[i][2] / 1000 * Nn
|
|
||||||
return np.linalg.norm(pc - r)
|
|
||||||
|
|
||||||
|
|
||||||
sep = np.array([true_sep(i) for i in range(len(times))])
|
|
||||||
ica = int(np.argmin(np.where(times > secs(utc('2026-10-17T12:00:00Z')), sep, 1e9)))
|
|
||||||
|
|
||||||
events = [
|
|
||||||
dict(t='2026-09-30T11:00:00Z', key='raise'),
|
|
||||||
dict(t='2026-10-12T00:10:00Z', key='overhead'),
|
|
||||||
]
|
|
||||||
|
|
||||||
burn_list = []
|
|
||||||
for b in burns:
|
|
||||||
if b.get('second'):
|
|
||||||
continue
|
|
||||||
dv = abs(b.get('dvt', 0)) * (1 if b['label'] == 'Back-off' else 2) + abs(b.get('dvn', 0))
|
|
||||||
burn_list.append(dict(t=(T0 + timedelta(seconds=b['t'])).isoformat().replace('+00:00', 'Z'),
|
|
||||||
label=b['label'], dv=round(dv, 2)))
|
|
||||||
|
|
||||||
total_dv = sum(b['dv'] for b in burn_list)
|
|
||||||
ca_t = (T0 + timedelta(seconds=float(times[ica]))).isoformat().replace('+00:00', 'Z')
|
|
||||||
# Refine CA with 2 s sampling
|
|
||||||
fine = list(np.arange(times[ica] - 120, times[ica] + 120, 2.0))
|
|
||||||
relf, _, _ = simulate(params, fine)
|
|
||||||
dfine = np.linalg.norm(relf, axis=1)
|
|
||||||
j = int(np.argmin(dfine))
|
|
||||||
ca_t = (T0 + timedelta(seconds=float(fine[j]))).isoformat().replace('+00:00', 'Z')
|
|
||||||
ca_m = float(dfine[j])
|
|
||||||
ca_rtn = relf[j].tolist()
|
|
||||||
|
|
||||||
# Vantor image: Oct 17, ~1.02 km. Pick the Oct 17 sample nearest 1.02 km in the afternoon.
|
|
||||||
# Vantor imaged optically, so the pair must be sunlit at capture.
|
|
||||||
def sun_dir(t):
|
|
||||||
n = (T0 + timedelta(seconds=float(t)) - datetime(2000, 1, 1, 12, tzinfo=timezone.utc)).total_seconds() / 86400
|
|
||||||
L = math.radians((280.46 + 0.9856474 * n) % 360)
|
|
||||||
g = math.radians((357.528 + 0.9856003 * n) % 360)
|
|
||||||
lam = L + math.radians(1.915) * math.sin(g) + math.radians(0.02) * math.sin(2 * g)
|
|
||||||
eps = math.radians(23.439)
|
|
||||||
return np.array([math.cos(lam), math.cos(eps) * math.sin(lam), math.sin(eps) * math.sin(lam)])
|
|
||||||
|
|
||||||
|
|
||||||
def sunlit(t):
|
|
||||||
r = x36_state(t)[0]
|
|
||||||
s = sun_dir(t)
|
|
||||||
p = np.dot(r, s)
|
|
||||||
return p > 0 or np.linalg.norm(r - p * s) > RE
|
|
||||||
|
|
||||||
|
|
||||||
lit = np.array([sunlit(t) for t in times])
|
|
||||||
mask = (times > secs(utc('2026-10-17T06:00:00Z'))) & (times < secs(utc('2026-10-17T23:59:00Z'))) & lit
|
|
||||||
iv = int(np.argmin(np.where(mask, np.abs(sep - 1.02), 1e9)))
|
|
||||||
|
|
||||||
out = dict(
|
|
||||||
t0=T0.isoformat().replace('+00:00', 'Z'),
|
|
||||||
dt=DT,
|
|
||||||
a_km=A,
|
|
||||||
x36=np.round(x36_pos, 4).ravel().tolist(),
|
|
||||||
rel=np.round(rel, 2).ravel().tolist(),
|
|
||||||
sep_km=np.round(sep, 4).tolist(),
|
|
||||||
burns=burn_list,
|
|
||||||
total_dv=round(total_dv, 1),
|
|
||||||
ca=dict(t=ca_t, m=round(ca_m, 1), rtn=[round(v, 1) for v in ca_rtn]),
|
|
||||||
vantor=dict(t=(T0 + timedelta(seconds=float(times[iv]))).isoformat().replace('+00:00', 'Z'),
|
|
||||||
km=round(float(sep[iv]), 2)),
|
|
||||||
lit=''.join('1' if v else '0' for v in lit),
|
|
||||||
sma_offset_km=np.round(roe[:, 0] * A, 3).tolist()[::10],
|
|
||||||
)
|
|
||||||
json.dump(out, open(f'{HERE}/data.json', 'w'), separators=(',', ':'))
|
|
||||||
|
|
||||||
print(f'a = {A:.3f} km, period = {PERIOD/60:.2f} min')
|
|
||||||
print('burns:')
|
|
||||||
for b in burn_list:
|
|
||||||
print(' ', b)
|
|
||||||
print('total dv', total_dv)
|
|
||||||
print('CA', ca_t, f'{ca_m:.0f} m', 'RTN', np.round(ca_rtn, 0))
|
|
||||||
print('Vantor', out['vantor'])
|
|
||||||
for iso in ['2026-10-05T12:00:00Z', '2026-10-12T00:10:00Z', '2026-10-12T06:00:00Z', '2026-10-15T00:00:00Z',
|
|
||||||
'2026-10-15T12:00:00Z', '2026-10-16T06:00:00Z', '2026-10-16T12:00:00Z', '2026-10-17T07:45:00Z',
|
|
||||||
'2026-10-17T17:18:00Z', '2026-10-17T20:29:00Z', '2026-10-17T23:40:00Z', '2026-10-18T07:38:00Z',
|
|
||||||
'2026-10-19T07:32:00Z', '2026-10-21T12:00:00Z', '2026-10-22T12:00:00Z']:
|
|
||||||
i = int(secs(utc(iso)) // DT)
|
|
||||||
print(f' {iso} sep {sep[i]:9.2f} km rtn(km) {np.round(rel[i]/1000, 2)} dSMA {roe[i][0]*A:7.3f}')
|
|
||||||
print('plane angle at T0 deg', plane0)
|
|
||||||
print('target dl (Oct 21 from TLEs) km', target_dl * A, 'END_DA', END_DA)
|
|
||||||
@@ -1,144 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
/**
|
|
||||||
* render.mjs — deterministic frame capture for the Cosmos 2612 / ICEYE-X36 replay.
|
|
||||||
*
|
|
||||||
* node tools/rpo-replay/render.mjs --format 16x9 --stills 2,9,24,32
|
|
||||||
* node tools/rpo-replay/render.mjs --format 9x16 --from 0 --to 675
|
|
||||||
* node tools/rpo-replay/render.mjs --format 16x9 --encode
|
|
||||||
*
|
|
||||||
* Frames are written to output/rpo-replay/<format>/frames/NNNNN.jpg; existing
|
|
||||||
* frames are skipped, so interrupted renders resume. --encode muxes them to MP4.
|
|
||||||
* Uses Playwright's Chromium with SwiftShader (no GPU needed).
|
|
||||||
*/
|
|
||||||
import { createServer } from 'node:http';
|
|
||||||
import { readFile, mkdir, access } from 'node:fs/promises';
|
|
||||||
import { extname, join, normalize } from 'node:path';
|
|
||||||
import { fileURLToPath } from 'node:url';
|
|
||||||
import { spawnSync } from 'node:child_process';
|
|
||||||
import { createRequire } from 'node:module';
|
|
||||||
|
|
||||||
const require = createRequire(import.meta.url);
|
|
||||||
const { chromium } = require('playwright');
|
|
||||||
|
|
||||||
const HERE = fileURLToPath(new URL('.', import.meta.url));
|
|
||||||
const ROOT = process.env.GEV_PROJECT_ROOT || join(HERE, '../..');
|
|
||||||
const CESIUM = join(ROOT, 'node_modules/cesium/Build/Cesium');
|
|
||||||
const SKY = join(ROOT, 'src/data/local_data/sky');
|
|
||||||
|
|
||||||
const args = Object.fromEntries(
|
|
||||||
process.argv.slice(2).reduce((acc, a, i, all) => {
|
|
||||||
if (a.startsWith('--')) acc.push([a.slice(2), all[i + 1] && !all[i + 1].startsWith('--') ? all[i + 1] : true]);
|
|
||||||
return acc;
|
|
||||||
}, []),
|
|
||||||
);
|
|
||||||
const FORMATS = { '16x9': [1920, 1080], '9x16': [1080, 1920], preview: [960, 540], 'preview-v': [540, 960] };
|
|
||||||
const format = args.format || '16x9';
|
|
||||||
const [W, H] = FORMATS[format];
|
|
||||||
const FPS = Number(args.fps || 30);
|
|
||||||
const DUR = 45;
|
|
||||||
const total = Math.round(DUR * FPS);
|
|
||||||
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 = {
|
|
||||||
'.html': 'text/html', '.js': 'application/javascript', '.css': 'text/css', '.json': 'application/json',
|
|
||||||
'.png': 'image/png', '.jpg': 'image/jpeg', '.svg': 'image/svg+xml', '.wasm': 'application/wasm',
|
|
||||||
'.glb': 'model/gltf-binary', '.xml': 'application/xml', '.ktx2': 'image/ktx2', '.webp': 'image/webp',
|
|
||||||
};
|
|
||||||
|
|
||||||
function serve() {
|
|
||||||
const server = createServer(async (req, res) => {
|
|
||||||
const url = new URL(req.url, 'http://localhost');
|
|
||||||
const p = decodeURIComponent(url.pathname);
|
|
||||||
const file = p.startsWith('/cesium/')
|
|
||||||
? join(CESIUM, normalize(p.slice('/cesium/'.length)))
|
|
||||||
: p.startsWith('/sky/')
|
|
||||||
? join(SKY, normalize(p.slice('/sky/'.length)))
|
|
||||||
: join(HERE, normalize(p === '/' ? 'scene.html' : p.slice(1)));
|
|
||||||
if (!file.startsWith(CESIUM) && !file.startsWith(SKY) && !file.startsWith(HERE)) {
|
|
||||||
res.writeHead(403).end();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const body = await readFile(file);
|
|
||||||
res.writeHead(200, { 'Content-Type': MIME[extname(file)] || 'application/octet-stream' });
|
|
||||||
res.end(body);
|
|
||||||
} catch {
|
|
||||||
res.writeHead(404).end();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return new Promise((r) => server.listen(0, '127.0.0.1', () => r(server)));
|
|
||||||
}
|
|
||||||
|
|
||||||
async function exists(p) {
|
|
||||||
try {
|
|
||||||
await access(p);
|
|
||||||
return true;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function encode() {
|
|
||||||
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',
|
|
||||||
'-vf', 'format=yuv420p', out,
|
|
||||||
], { stdio: 'inherit' });
|
|
||||||
if (r.status !== 0) process.exit(r.status || 1);
|
|
||||||
console.log(out);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (args.encode) {
|
|
||||||
encode();
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
const server = await serve();
|
|
||||||
const port = server.address().port;
|
|
||||||
const browser = await chromium.launch({
|
|
||||||
args: ['--use-gl=angle', '--use-angle=swiftshader', '--enable-unsafe-swiftshader', '--ignore-gpu-blocklist',
|
|
||||||
'--disable-gpu-sandbox', '--enable-webgl'],
|
|
||||||
});
|
|
||||||
const page = await browser.newPage({ viewport: { width: W, height: H }, deviceScaleFactor: 1 });
|
|
||||||
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}&rs=${RS}&settle=${args.stills ? 60 : Number(args.settle || 4)}`);
|
|
||||||
await page.waitForFunction(() => window.rpo?.ready, null, { timeout: 180000 });
|
|
||||||
|
|
||||||
if (args.stills) {
|
|
||||||
const dir = join(outDir, 'stills');
|
|
||||||
await mkdir(dir, { recursive: true });
|
|
||||||
for (const s of String(args.stills).split(',').map(Number)) {
|
|
||||||
const t0 = Date.now();
|
|
||||||
const info = await page.evaluate((x) => window.rpo.frame(x), s);
|
|
||||||
await page.screenshot({ path: join(dir, `s${s.toFixed(2).padStart(5, '0')}.jpg`), type: 'jpeg', quality: 92 });
|
|
||||||
console.log(`still ${s}s sep=${(info.sep / 1000).toFixed(3)} km mag=${info.mag.toFixed(1)} ${Date.now() - t0} ms`);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
await mkdir(frameDir, { recursive: true });
|
|
||||||
const from = Number(args.from || 0);
|
|
||||||
const to = Math.min(Number(args.to || total), total);
|
|
||||||
const t0 = Date.now();
|
|
||||||
let done = 0;
|
|
||||||
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`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await browser.close();
|
|
||||||
server.close();
|
|
||||||
@@ -1,181 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<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;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: #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; }
|
|
||||||
.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; }
|
|
||||||
.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); }
|
|
||||||
|
|
||||||
#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)); }
|
|
||||||
|
|
||||||
/* 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 { white-space: nowrap; font: 800 calc(26 * var(--u))/1 Inter, sans-serif; letter-spacing: 0.16em; }
|
|
||||||
|
|
||||||
/* 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(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;
|
|
||||||
letter-spacing: 0.06em; text-transform: uppercase; color: var(--ink-3); }
|
|
||||||
.ch.on span { color: var(--ink); }
|
|
||||||
|
|
||||||
/* 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(70 * var(--u))/1.04 Inter, sans-serif; letter-spacing: -0.025em; }
|
|
||||||
#caption p { margin: 0; font: 500 calc(40 * var(--u))/1.26 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(42 * var(--u)); max-width: calc(1100 * var(--u)); }
|
|
||||||
|
|
||||||
/* Events */
|
|
||||||
#events { position: absolute; left: var(--pad); top: calc(440 * 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(34 * var(--u))/1.1 Inter, sans-serif; letter-spacing: 0.02em; text-transform: uppercase; color: var(--burn); }
|
|
||||||
.event .sub { font: 500 calc(30 * 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; }
|
|
||||||
.event.shadow i { background: #9fb4ff; box-shadow: 0 0 calc(12 * var(--u)) #9fb4ff; }
|
|
||||||
.event.shadow .t { color: #b9c8ff; }
|
|
||||||
|
|
||||||
/* 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(34 * var(--u))/1 Inter, sans-serif; letter-spacing: 0.02em; color: #000; background: #fff; border-radius: calc(8 * var(--u)); }
|
|
||||||
#clock .shadow { display: none; 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(276 * 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); }
|
|
||||||
|
|
||||||
/* Chart */
|
|
||||||
#chartwrap { position: absolute; left: var(--pad); right: var(--pad); bottom: calc(58 * var(--u)); }
|
|
||||||
#chartwrap .lab { font: 700 calc(24 * var(--u))/1 Inter, sans-serif; letter-spacing: 0.12em; color: var(--ink-3); margin-bottom: calc(10 * var(--u)); }
|
|
||||||
#chart { height: calc(170 * 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(40 * var(--u))/1.05 Inter, sans-serif; letter-spacing: 0.01em; }
|
|
||||||
.tag .meta { font: 600 calc(28 * 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)); white-space: nowrap;
|
|
||||||
font: 700 calc(34 * var(--u))/1 'JetBrains Mono', monospace; color: #000; background: #fff; border-radius: calc(6 * var(--u)); }
|
|
||||||
|
|
||||||
/* Attribution: present, deliberately quiet */
|
|
||||||
#credits { position: absolute; left: var(--pad); right: var(--pad); bottom: calc(16 * var(--u));
|
|
||||||
font: 400 calc(17 * var(--u))/1.3 Inter, sans-serif; color: rgba(244,247,250,0.45); white-space: nowrap; overflow: hidden; }
|
|
||||||
|
|
||||||
/* End card */
|
|
||||||
#endcard { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; }
|
|
||||||
#endcard .panel { width: calc(1300 * var(--u)); text-align: center; }
|
|
||||||
#endcard .big { font: 800 calc(200 * var(--u))/0.9 Inter, sans-serif; letter-spacing: -0.04em; color: var(--cosmos); }
|
|
||||||
#endcard .range { margin-top: calc(18 * var(--u)); font: 600 calc(32 * var(--u))/1.2 Inter, sans-serif; color: var(--ink-2); }
|
|
||||||
#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(96 * var(--u)); }
|
|
||||||
#endlogo span { font-size: calc(40 * var(--u)); }
|
|
||||||
|
|
||||||
/* ---------------- Portrait (Reels/Shorts/TikTok safe zones: top ~170, bottom ~400) ---------------- */
|
|
||||||
body.portrait { --pad: calc(52 * var(--u)); }
|
|
||||||
body.portrait #logo { top: calc(168 * var(--u)); }
|
|
||||||
body.portrait #clock { top: calc(160 * var(--u)); }
|
|
||||||
body.portrait #clock .date { font-size: calc(44 * var(--u)); }
|
|
||||||
body.portrait #chapters { left: var(--pad); right: var(--pad); top: calc(318 * var(--u)); }
|
|
||||||
body.portrait .ch span { font-size: calc(22 * var(--u)); letter-spacing: 0.02em; }
|
|
||||||
body.portrait #caption, body.portrait #caption.hook { top: calc(392 * var(--u)); width: auto; right: var(--pad); }
|
|
||||||
body.portrait #caption h1 { font-size: calc(68 * var(--u)); }
|
|
||||||
body.portrait #caption.hook h1 { font-size: calc(76 * var(--u)); }
|
|
||||||
body.portrait #caption p, body.portrait #caption.hook p { font-size: calc(38 * var(--u)); max-width: none; }
|
|
||||||
body.portrait #events { top: calc(700 * var(--u)); width: auto; right: var(--pad); }
|
|
||||||
body.portrait #readout { right: auto; left: var(--pad); bottom: calc(690 * 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(700 * var(--u)); }
|
|
||||||
body.portrait #chartwrap { bottom: calc(450 * var(--u)); }
|
|
||||||
body.portrait #chart { height: calc(170 * 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 #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)); }
|
|
||||||
body.portrait #endcard .stats { gap: calc(40 * var(--u)); }
|
|
||||||
body.portrait #endcard .stats b { font-size: calc(58 * var(--u)); }
|
|
||||||
body.portrait #endlogo { top: calc(260 * var(--u)); }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="globe"></div>
|
|
||||||
<div id="hud">
|
|
||||||
<div id="topshade"></div>
|
|
||||||
<div id="botshade"></div>
|
|
||||||
<svg id="leaders"></svg>
|
|
||||||
<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, 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="dip" style="position:absolute;inset:0;background:#000;opacity:0"></div>
|
|
||||||
</div>
|
|
||||||
<script src="/cesium/Cesium.js"></script>
|
|
||||||
<script type="module" src="scene.js"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,634 +0,0 @@
|
|||||||
/* global Cesium */
|
|
||||||
// Deterministic frame renderer for the Cosmos 2612 / ICEYE-X36 replay.
|
|
||||||
// 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);
|
|
||||||
const PORTRAIT = H > W;
|
|
||||||
const U = PORTRAIT ? W / 1080 : W / 1920;
|
|
||||||
document.documentElement.style.setProperty('--u', `${U}px`);
|
|
||||||
if (PORTRAIT) document.body.classList.add('portrait');
|
|
||||||
|
|
||||||
const D = await (await fetch('data.json')).json();
|
|
||||||
const T0 = Date.parse(D.t0);
|
|
||||||
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
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
function cr(arr, t, out, scale = 1) {
|
|
||||||
const x = Math.min(Math.max(t / DT, 1), NS - 3);
|
|
||||||
const i = Math.floor(x);
|
|
||||||
const u = x - i;
|
|
||||||
const u2 = u * u;
|
|
||||||
const u3 = u2 * u;
|
|
||||||
for (let k = 0; k < 3; k++) {
|
|
||||||
const p0 = arr[(i - 1) * 3 + k];
|
|
||||||
const p1 = arr[i * 3 + k];
|
|
||||||
const p2 = arr[(i + 1) * 3 + k];
|
|
||||||
const p3 = arr[(i + 2) * 3 + k];
|
|
||||||
out[k] =
|
|
||||||
0.5 * scale *
|
|
||||||
(2 * p1 + (-p0 + p2) * u + (2 * p0 - 5 * p1 + 4 * p2 - p3) * u2 + (-p0 + 3 * p1 - 3 * p2 + p3) * u3);
|
|
||||||
if (out.length > 3) {
|
|
||||||
out[3 + k] = (0.5 * scale * ((-p0 + p2) + 2 * (2 * p0 - 5 * p1 + 4 * p2 - p3) * u + 3 * (-p0 + 3 * p1 - 3 * p2 + p3) * u2)) / DT;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
const v3 = Cesium.Cartesian3;
|
|
||||||
const tmp6 = new Float64Array(6);
|
|
||||||
function x36Teme(t) {
|
|
||||||
cr(D.x36, t, tmp6, 1000);
|
|
||||||
return { r: new v3(tmp6[0], tmp6[1], tmp6[2]), v: new v3(tmp6[3], tmp6[4], tmp6[5]) };
|
|
||||||
}
|
|
||||||
const tmp3 = new Float64Array(3);
|
|
||||||
function relAt(t) {
|
|
||||||
cr(D.rel, t, tmp3, 1);
|
|
||||||
return [tmp3[0], tmp3[1], tmp3[2]];
|
|
||||||
}
|
|
||||||
function sepAt(t) {
|
|
||||||
const x = Math.min(Math.max(t / DT, 0), NS - 2);
|
|
||||||
const i = Math.floor(x);
|
|
||||||
return D.sep_km[i] + (D.sep_km[i + 1] - D.sep_km[i]) * (x - i);
|
|
||||||
}
|
|
||||||
function litAt(t) {
|
|
||||||
return D.lit[Math.round(Math.min(Math.max(t / DT, 0), NS - 1))] === '1';
|
|
||||||
}
|
|
||||||
function basis(r, v) {
|
|
||||||
const R = v3.normalize(r, new v3());
|
|
||||||
const N = v3.normalize(v3.cross(r, v, new v3()), new v3());
|
|
||||||
const T = v3.cross(N, R, new v3());
|
|
||||||
return { R, T, N };
|
|
||||||
}
|
|
||||||
// Curvilinear placement: along-track offset is an arc on the anchor orbit.
|
|
||||||
function place(anchor, B, rel) {
|
|
||||||
const rm = v3.magnitude(anchor);
|
|
||||||
const th = rel[1] / rm;
|
|
||||||
const c = Math.cos(th);
|
|
||||||
const s = Math.sin(th);
|
|
||||||
const Rp = new v3(B.R.x * c + B.T.x * s, B.R.y * c + B.T.y * s, B.R.z * c + B.T.z * s);
|
|
||||||
const out = v3.multiplyByScalar(Rp, rm + rel[0], new v3());
|
|
||||||
return v3.add(out, v3.multiplyByScalar(B.N, rel[2], new v3()), out);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Time maps. tau(s): simulation clock. b(s): backdrop/anchor clock (Earth and
|
|
||||||
// lighting), equal to tau around closest approach.
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
const CA_T = sec(D.ca.t);
|
|
||||||
const VANTOR_T = sec(D.vantor.t);
|
|
||||||
const TAU_KEYS = [
|
|
||||||
[0.0, '2026-09-30T07:30:00Z'],
|
|
||||||
[4.0, '2026-09-30T10:20:00Z'],
|
|
||||||
[7.0, '2026-09-30T13:00:00Z'],
|
|
||||||
[12.5, '2026-10-11T23:20:00Z'],
|
|
||||||
[15.0, '2026-10-12T03:20:00Z'],
|
|
||||||
[17.0, '2026-10-15T01:30:00Z'],
|
|
||||||
[18.6, '2026-10-15T04:40:00Z'],
|
|
||||||
[20.6, '2026-10-16T04:40:00Z'],
|
|
||||||
[22.2, '2026-10-16T07:40:00Z'],
|
|
||||||
[25.8, '2026-10-17T15:40:00Z'],
|
|
||||||
[27.6, '2026-10-17T18:10:00Z'],
|
|
||||||
[29.8, D.ca.t, -14 * 60],
|
|
||||||
[35.0, D.ca.t, 11 * 60],
|
|
||||||
[36.4, '2026-10-18T07:00:00Z'],
|
|
||||||
[40.6, '2026-10-21T10:00:00Z'],
|
|
||||||
[45.0, '2026-10-21T13:00:00Z'],
|
|
||||||
].map(([s, iso, off = 0]) => [s, sec(iso) + off]);
|
|
||||||
|
|
||||||
function monotone(keys) {
|
|
||||||
const n = keys.length;
|
|
||||||
const xs = keys.map((k) => k[0]);
|
|
||||||
const ys = keys.map((k) => k[1]);
|
|
||||||
const d = [];
|
|
||||||
for (let i = 0; i < n - 1; i++) d.push((ys[i + 1] - ys[i]) / (xs[i + 1] - xs[i]));
|
|
||||||
// Geometric-mean slopes keep huge rate swings smooth in log-space.
|
|
||||||
const m = [d[0]];
|
|
||||||
for (let i = 1; i < n - 1; i++) m.push(Math.sqrt(d[i - 1] * d[i]));
|
|
||||||
m.push(d[n - 2]);
|
|
||||||
for (let i = 0; i < n - 1; i++) {
|
|
||||||
const a = m[i] / d[i];
|
|
||||||
const b = m[i + 1] / d[i];
|
|
||||||
const h = a * a + b * b;
|
|
||||||
if (h > 9) {
|
|
||||||
const t = 3 / Math.sqrt(h);
|
|
||||||
m[i] = t * a * d[i];
|
|
||||||
m[i + 1] = t * b * d[i];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const f = (s) => {
|
|
||||||
let i = 0;
|
|
||||||
while (i < n - 2 && s > xs[i + 1]) i++;
|
|
||||||
const hh = xs[i + 1] - xs[i];
|
|
||||||
const t = Math.min(Math.max((s - xs[i]) / hh, 0), 1);
|
|
||||||
const t2 = t * t;
|
|
||||||
const t3 = t2 * t;
|
|
||||||
return (
|
|
||||||
(2 * t3 - 3 * t2 + 1) * ys[i] + (t3 - 2 * t2 + t) * hh * m[i] + (-2 * t3 + 3 * t2) * ys[i + 1] + (t3 - t2) * hh * m[i + 1]
|
|
||||||
);
|
|
||||||
};
|
|
||||||
return f;
|
|
||||||
}
|
|
||||||
const tau = monotone(TAU_KEYS);
|
|
||||||
const rate = (s) => (tau(s + 0.01) - tau(s - 0.01)) / 0.02;
|
|
||||||
|
|
||||||
const S_SYNC0 = 29.8;
|
|
||||||
const S_SYNC1 = 35.0;
|
|
||||||
function hermite(s, s0, y0, m0, s1, y1, m1) {
|
|
||||||
const h = s1 - s0;
|
|
||||||
const t = (s - s0) / h;
|
|
||||||
const t2 = t * t;
|
|
||||||
const t3 = t2 * t;
|
|
||||||
return (2 * t3 - 3 * t2 + 1) * y0 + (t3 - 2 * t2 + t) * h * m0 + (-2 * t3 + 3 * t2) * y1 + (t3 - t2) * h * m1;
|
|
||||||
}
|
|
||||||
const BG_RATE = 110;
|
|
||||||
function bclock(s) {
|
|
||||||
if (s >= S_SYNC0 && s <= S_SYNC1) return tau(s);
|
|
||||||
if (s < S_SYNC0) {
|
|
||||||
const y1 = tau(S_SYNC0);
|
|
||||||
return hermite(s, 0, y1 - S_SYNC0 * BG_RATE * 1.15, BG_RATE, S_SYNC0, y1, rate(S_SYNC0));
|
|
||||||
}
|
|
||||||
const y0 = tau(S_SYNC1);
|
|
||||||
return hermite(s, S_SYNC1, y0, rate(S_SYNC1), DUR, y0 + (DUR - S_SYNC1) * BG_RATE * 1.2, BG_RATE);
|
|
||||||
}
|
|
||||||
function sOfTau(t) {
|
|
||||||
let lo = 0;
|
|
||||||
let hi = DUR;
|
|
||||||
for (let k = 0; k < 50; k++) {
|
|
||||||
const mid = (lo + hi) / 2;
|
|
||||||
if (tau(mid) < t) lo = mid;
|
|
||||||
else hi = mid;
|
|
||||||
}
|
|
||||||
return lo;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Story: captions, events, camera
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
const BURNS = D.burns.map((b) => ({ ...b, t: sec(b.t) }));
|
|
||||||
|
|
||||||
// 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
|
|
||||||
// looking forward. look: 0 = X36, 1 = Cosmos. earth: pull aim to Earth centre.
|
|
||||||
const WIDE = { f: 0, look: 0, earth: 0.86, fov: 40, mag: 0, sun: 0.55 };
|
|
||||||
const CAM = [
|
|
||||||
{ s: 0.0, ...WIDE, dist: 5.4e7, az: 82, el: 36, trail: 2 },
|
|
||||||
{ s: 6.0, ...WIDE, dist: 4.6e7, az: 96, el: 34, trail: 20 },
|
|
||||||
{ s: 12.5, ...WIDE, dist: 4.4e7, az: 118, el: 30, trail: 30 },
|
|
||||||
{ s: 12.5, cut: true, dist: 6.0e5, f: 2.8, az: 50, el: 38, look: 0.5, earth: 0, fov: 34, mag: 0, trail: 3 },
|
|
||||||
{ 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: 2.4, az: 58, el: 34, look: 0.45, earth: 0, fov: 36, mag: 1, mx: 40, trail: 9 },
|
|
||||||
{ s: 26.0, dist: 4.6e3, f: 2.4, az: 76, el: 30, look: 0.5, earth: 0, fov: 36, mag: 1, mx: 40, trail: 9 },
|
|
||||||
{ s: 29.7, dist: 2.0e3, f: 2.2, az: 104, el: 24, look: 0.5, earth: 0, fov: 36, mag: 1, mx: 25, trail: 4 },
|
|
||||||
{ s: 31.9, dist: 1.0e3, f: 2.2, az: 116, el: 22, look: 0.5, earth: 0, fov: 36, mag: 1, mx: 25, trail: 3 },
|
|
||||||
{ s: 31.9, cut: true, dist: 26, f: 0, az: 160, el: 0, look: 0.0, earth: 0, fov: 34, mag: 0, trail: 0.5, comp: 0, pairD: 26, pairTh: 22 },
|
|
||||||
{ s: 33.9, dist: 22, f: 0, az: 163, el: 0, look: 0.0, earth: 0, fov: 34, mag: 0, trail: 0.5, comp: 0, pairD: 22, pairTh: 18 },
|
|
||||||
{ s: 33.9, cut: true, dist: 1.3e3, f: 2.2, az: 96, el: 18, look: 0.5, earth: 0, fov: 36, mag: 1, mx: 25, trail: 1.6 },
|
|
||||||
{ s: 35.6, dist: 1.6e3, f: 2.4, az: 100, el: 22, look: 0.5, earth: 0, fov: 36, mag: 1, mx: 25, trail: 2 },
|
|
||||||
{ s: 37.0, dist: 1.2e4, f: 2.8, az: 110, el: 30, look: 0.4, earth: 0, fov: 36, mag: 1, mx: 40, trail: 4 },
|
|
||||||
{ s: 39.0, dist: 9.0e5, f: 2.8, az: 60, el: 30, look: 0.5, earth: 0, fov: 34, mag: 0, trail: 30 },
|
|
||||||
{ s: 41.2, dist: 2.0e6, f: 2.8, az: 44, el: 30, look: 0.5, earth: 0, fov: 34, mag: 0, trail: 60 },
|
|
||||||
{ s: 41.2, cut: true, ...WIDE, dist: 4.8e7, az: 80, el: 34, trail: 60 },
|
|
||||||
{ s: 45.0, ...WIDE, dist: 4.2e7, az: 96, el: 32, trail: 60 },
|
|
||||||
];
|
|
||||||
const CUTS = CAM.filter((k) => k.cut).map((k) => k.s);
|
|
||||||
const smooth = (x) => x * x * x * (x * (x * 6 - 15) + 10);
|
|
||||||
function camAt(s) {
|
|
||||||
// Keys are grouped into shots at cut keys; interpolation never crosses a cut.
|
|
||||||
let start = 0;
|
|
||||||
for (let k = 1; k < CAM.length; k++) if (CAM[k].cut && s >= CAM[k].s) start = k;
|
|
||||||
let end = CAM.length - 1;
|
|
||||||
for (let k = start + 1; k < CAM.length; k++) if (CAM[k].cut) { end = k - 1; break; }
|
|
||||||
let i = start;
|
|
||||||
while (i < end - 1 && s > CAM[i + 1].s) i++;
|
|
||||||
const a = CAM[i];
|
|
||||||
const b = CAM[Math.min(i + 1, end)];
|
|
||||||
const u = b === a ? 0 : smooth(Math.min(Math.max((s - a.s) / (b.s - a.s), 0), 1));
|
|
||||||
const lerp = (x, y) => x + (y - x) * u;
|
|
||||||
const ar = a.aimRel || [0, 0, 0];
|
|
||||||
const br = b.aimRel || [0, 0, 0];
|
|
||||||
return {
|
|
||||||
dist: Math.exp(lerp(Math.log(a.dist), Math.log(b.dist))),
|
|
||||||
f: lerp(a.f, b.f),
|
|
||||||
az: lerp(a.az, b.az),
|
|
||||||
el: lerp(a.el, b.el),
|
|
||||||
look: lerp(a.look, b.look),
|
|
||||||
earth: lerp(a.earth, b.earth),
|
|
||||||
fov: lerp(a.fov, b.fov),
|
|
||||||
mag: lerp(a.mag, b.mag),
|
|
||||||
comp: lerp(a.comp ?? 1, b.comp ?? 1),
|
|
||||||
pairD: lerp(a.pairD || 0, b.pairD || 0),
|
|
||||||
pairTh: lerp(a.pairTh || 0, b.pairTh || 0),
|
|
||||||
mx: Math.exp(lerp(Math.log(a.mx || 1), Math.log(b.mx || 1))),
|
|
||||||
trail: Math.exp(lerp(Math.log(a.trail), Math.log(b.trail))),
|
|
||||||
aimRel: [0, 1, 2].map((k) => lerp(ar[k], br[k])),
|
|
||||||
sun: lerp(a.sun || 0, b.sun || 0),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Cesium scene
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
const esri = await Cesium.ArcGisMapServerImageryProvider.fromUrl(
|
|
||||||
'https://services.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer',
|
|
||||||
{ enablePickFeatures: false, maximumLevel: 9 },
|
|
||||||
);
|
|
||||||
const viewer = new Cesium.Viewer('globe', {
|
|
||||||
baseLayer: new Cesium.ImageryLayer(esri, { brightness: 0.92, contrast: 1.08, gamma: 1.05 }),
|
|
||||||
animation: false, timeline: false, baseLayerPicker: false, geocoder: false, homeButton: false,
|
|
||||||
sceneModePicker: false, navigationHelpButton: false, fullscreenButton: false, infoBox: false,
|
|
||||||
selectionIndicator: false, useDefaultRenderLoop: false, shouldAnimate: false,
|
|
||||||
contextOptions: { webgl: { preserveDrawingBuffer: true, alpha: false } },
|
|
||||||
msaaSamples: 1,
|
|
||||||
});
|
|
||||||
const scene = viewer.scene;
|
|
||||||
// Night side: NASA Black Marble city lights (keyless GIBS tiles).
|
|
||||||
const nightLayer = viewer.imageryLayers.addImageryProvider(new Cesium.UrlTemplateImageryProvider({
|
|
||||||
url: 'https://gibs.earthdata.nasa.gov/wmts/epsg3857/best/VIIRS_Black_Marble/default/2016-01-01/GoogleMapsCompatible_Level8/{z}/{y}/{x}.png',
|
|
||||||
maximumLevel: 8, credit: 'NASA Black Marble',
|
|
||||||
}));
|
|
||||||
nightLayer.dayAlpha = 0.0;
|
|
||||||
nightLayer.nightAlpha = 1.0;
|
|
||||||
nightLayer.brightness = 1.6;
|
|
||||||
viewer.imageryLayers.get(0).nightAlpha = 0.0;
|
|
||||||
// Sky: ESO Milky Way cube (stars removed) + Yale Bright Star Catalogue points.
|
|
||||||
scene.skyBox = new Cesium.SkyBox({ sources: Object.fromEntries(
|
|
||||||
[['positiveX', 'px'], ['negativeX', 'nx'], ['positiveY', 'py'], ['negativeY', 'ny'], ['positiveZ', 'pz'], ['negativeZ', 'nz']]
|
|
||||||
.map(([k, f]) => [k, `/sky/milkyway_${f}.webp`])) });
|
|
||||||
const starData = await (await fetch('/sky/bright_stars.json')).json();
|
|
||||||
const starArr = Array.isArray(starData) ? starData : starData.stars || starData.data;
|
|
||||||
const stars = scene.primitives.add(new Cesium.PointPrimitiveCollection({ blendOption: Cesium.BlendOption.TRANSLUCENT }));
|
|
||||||
const starDirs = [];
|
|
||||||
for (let i = 0; i + 3 < starArr.length; i += 4) {
|
|
||||||
const [ra, dec, vm, bv] = [starArr[i], starArr[i + 1], starArr[i + 2], starArr[i + 3]];
|
|
||||||
const r = Cesium.Math.toRadians(ra);
|
|
||||||
const d = Cesium.Math.toRadians(dec);
|
|
||||||
const dir = new Cesium.Cartesian3(Math.cos(d) * Math.cos(r), Math.cos(d) * Math.sin(r), Math.sin(d));
|
|
||||||
const b = Math.min(1, Math.sqrt(10 ** (-0.4 * ((Number.isFinite(vm) ? vm : 6.5) - 6.5))) / 9);
|
|
||||||
const t = Math.min(Math.max(((Number.isFinite(bv) ? bv : 0.6) + 0.3) / 2.0, 0), 1);
|
|
||||||
const col = new Cesium.Color(0.78 + 0.22 * t, 0.84 + 0.06 * (1 - Math.abs(t - 0.4)), 1.0 - 0.3 * t, 0.25 + 0.75 * b);
|
|
||||||
starDirs.push(dir);
|
|
||||||
stars.add({ position: dir, color: col, pixelSize: 0.9 + 2.6 * b });
|
|
||||||
}
|
|
||||||
|
|
||||||
const globe = scene.globe;
|
|
||||||
globe.enableLighting = true;
|
|
||||||
globe.dynamicAtmosphereLighting = true;
|
|
||||||
globe.dynamicAtmosphereLightingFromSun = true;
|
|
||||||
globe.showGroundAtmosphere = true;
|
|
||||||
globe.atmosphereLightIntensity = 9;
|
|
||||||
globe.lightingFadeOutDistance = 1;
|
|
||||||
globe.lightingFadeInDistance = 2;
|
|
||||||
globe.nightFadeOutDistance = 1e10;
|
|
||||||
globe.nightFadeInDistance = 1e10;
|
|
||||||
globe.maximumScreenSpaceError = 2.2;
|
|
||||||
globe.tileCacheSize = 1500;
|
|
||||||
globe.preloadSiblings = false;
|
|
||||||
globe.baseColor = Cesium.Color.fromCssColorString('#04070d');
|
|
||||||
scene.fog.enabled = false;
|
|
||||||
scene.skyAtmosphere.show = true;
|
|
||||||
scene.skyAtmosphere.atmosphereLightIntensity = 10;
|
|
||||||
scene.highDynamicRange = false;
|
|
||||||
scene.sun.glowFactor = 1.6;
|
|
||||||
scene.moon.show = true;
|
|
||||||
scene.postProcessStages.fxaa.enabled = true;
|
|
||||||
scene.backgroundColor = Cesium.Color.BLACK;
|
|
||||||
scene.screenSpaceCameraController.enableInputs = false;
|
|
||||||
scene.camera.frustum.near = 0.5;
|
|
||||||
scene.camera.frustum.far = 1e9;
|
|
||||||
viewer.resolutionScale = Number(params.get('rs') || 1);
|
|
||||||
const bloom = scene.postProcessStages.bloom;
|
|
||||||
bloom.enabled = false;
|
|
||||||
bloom.uniforms.contrast = 119;
|
|
||||||
bloom.uniforms.brightness = -0.42;
|
|
||||||
bloom.uniforms.glowOnly = false;
|
|
||||||
bloom.uniforms.delta = 1.0;
|
|
||||||
bloom.uniforms.sigma = 3.2;
|
|
||||||
bloom.uniforms.stepSize = 1.0;
|
|
||||||
|
|
||||||
const COL_X36 = Cesium.Color.fromCssColorString('#5ce1e6');
|
|
||||||
const COL_COS = Cesium.Color.fromCssColorString('#ff5a3c');
|
|
||||||
const COL_BURN = Cesium.Color.fromCssColorString('#ffb547');
|
|
||||||
|
|
||||||
const modelOpts = { upAxis: Cesium.Axis.Z, forwardAxis: Cesium.Axis.X, shadows: Cesium.ShadowMode.DISABLED };
|
|
||||||
const mX36 = await Cesium.Model.fromGltfAsync({ url: 'iceye-x36.glb', ...modelOpts });
|
|
||||||
const mCos = await Cesium.Model.fromGltfAsync({ url: 'cosmos-2612.glb', ...modelOpts });
|
|
||||||
scene.primitives.add(mX36);
|
|
||||||
scene.primitives.add(mCos);
|
|
||||||
for (const m of [mX36, mCos]) {
|
|
||||||
m.environmentMapOptions = { enabled: false };
|
|
||||||
m.imageBasedLighting.sphericalHarmonicCoefficients = undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
function glowCanvas(color, core = 0.18) {
|
|
||||||
const c = document.createElement('canvas');
|
|
||||||
c.width = c.height = 128;
|
|
||||||
const g = c.getContext('2d');
|
|
||||||
const grd = g.createRadialGradient(64, 64, 0, 64, 64, 64);
|
|
||||||
grd.addColorStop(0, 'rgba(255,255,255,1)');
|
|
||||||
grd.addColorStop(core, color.withAlpha(1).toCssColorString());
|
|
||||||
grd.addColorStop(0.45, color.withAlpha(0.28).toCssColorString());
|
|
||||||
grd.addColorStop(1, color.withAlpha(0).toCssColorString());
|
|
||||||
g.fillStyle = grd;
|
|
||||||
g.fillRect(0, 0, 128, 128);
|
|
||||||
return c;
|
|
||||||
}
|
|
||||||
const billboards = scene.primitives.add(new Cesium.BillboardCollection({ scene }));
|
|
||||||
const bX36 = billboards.add({ image: glowCanvas(COL_X36), scale: 0.3 * U, disableDepthTestDistance: 0 });
|
|
||||||
const bCos = billboards.add({ image: glowCanvas(COL_COS), scale: 0.3 * U, disableDepthTestDistance: 0 });
|
|
||||||
const bBurn = billboards.add({ image: glowCanvas(COL_BURN, 0.08), scale: 0, disableDepthTestDistance: 0 });
|
|
||||||
|
|
||||||
let dynamic = [];
|
|
||||||
function clearDynamic() {
|
|
||||||
for (const p of dynamic) scene.primitives.remove(p);
|
|
||||||
dynamic = [];
|
|
||||||
}
|
|
||||||
function polyline(positions, colors, width) {
|
|
||||||
if (positions.length < 2) return;
|
|
||||||
const p = new Cesium.Primitive({
|
|
||||||
geometryInstances: new Cesium.GeometryInstance({
|
|
||||||
geometry: new Cesium.PolylineGeometry({
|
|
||||||
positions, colors, colorsPerVertex: true, width: width * U, arcType: Cesium.ArcType.NONE,
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
appearance: new Cesium.PolylineColorAppearance({ translucent: true }),
|
|
||||||
asynchronous: false,
|
|
||||||
});
|
|
||||||
scene.primitives.add(p);
|
|
||||||
dynamic.push(p);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// HUD
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
function project(world) {
|
|
||||||
const p = Cesium.SceneTransforms.worldToWindowCoordinates(scene, world);
|
|
||||||
if (!p) return null;
|
|
||||||
// Behind-camera / occluded test
|
|
||||||
const cam = scene.camera;
|
|
||||||
const d = v3.subtract(world, cam.positionWC, new v3());
|
|
||||||
if (v3.dot(d, cam.directionWC) <= 0) return null;
|
|
||||||
const ray = v3.normalize(d, new v3());
|
|
||||||
const hit = Cesium.IntersectionTests.rayEllipsoid(new Cesium.Ray(cam.positionWC, ray), Cesium.Ellipsoid.WGS84);
|
|
||||||
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
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
const M = new Cesium.Matrix3();
|
|
||||||
function toWorld(teme, jd) {
|
|
||||||
return Cesium.Matrix3.multiplyByVector(M, teme, new v3());
|
|
||||||
}
|
|
||||||
|
|
||||||
function modelMatrix(pos, X, Y, scale) {
|
|
||||||
const Z = v3.cross(X, Y, new v3());
|
|
||||||
const m = new Cesium.Matrix4(
|
|
||||||
X.x * scale, Y.x * scale, Z.x * scale, pos.x,
|
|
||||||
X.y * scale, Y.y * scale, Z.y * scale, pos.y,
|
|
||||||
X.z * scale, Y.z * scale, Z.z * scale, pos.z,
|
|
||||||
0, 0, 0, 1,
|
|
||||||
);
|
|
||||||
return m;
|
|
||||||
}
|
|
||||||
|
|
||||||
function eclipseLight(worldPos, sunW) {
|
|
||||||
// Cylindrical shadow with a soft edge.
|
|
||||||
const p = v3.dot(worldPos, sunW);
|
|
||||||
if (p > 0) return 1;
|
|
||||||
const perp = v3.magnitude(v3.subtract(worldPos, v3.multiplyByScalar(sunW, p, new v3()), new v3()));
|
|
||||||
const e = (perp - 6.371e6) / 6e4;
|
|
||||||
return Math.min(1, Math.max(0, 0.5 + e));
|
|
||||||
}
|
|
||||||
|
|
||||||
async function frame(s) {
|
|
||||||
const t = tau(s);
|
|
||||||
const b = bclock(s);
|
|
||||||
const jd = Cesium.JulianDate.addSeconds(Cesium.JulianDate.fromIso8601(D.t0), b, new Cesium.JulianDate());
|
|
||||||
Cesium.Transforms.computeTemeToPseudoFixedMatrix(jd, M);
|
|
||||||
const cam = camAt(s);
|
|
||||||
|
|
||||||
// Anchor (X36 at backdrop time) and relative state at simulation time.
|
|
||||||
const A = x36Teme(b);
|
|
||||||
const B = basis(A.r, A.v);
|
|
||||||
const rel = relAt(t);
|
|
||||||
const x36W = toWorld(A.r);
|
|
||||||
const cosW = toWorld(place(A.r, B, rel));
|
|
||||||
const RW = toWorld(B.R);
|
|
||||||
const TW = toWorld(B.T);
|
|
||||||
const NW = toWorld(B.N);
|
|
||||||
const sep = v3.distance(x36W, cosW);
|
|
||||||
|
|
||||||
// Camera
|
|
||||||
const sunW = v3.normalize(Cesium.Simon1994PlanetaryPositions.computeSunPositionInEarthInertialFrame(jd, new v3()), new v3());
|
|
||||||
Cesium.Matrix3.multiplyByVector(Cesium.Transforms.computeIcrfToFixedMatrix(jd) || M, sunW, sunW);
|
|
||||||
v3.normalize(sunW, sunW);
|
|
||||||
// Camera orbits a pivot on the X36 -> Cosmos line; aimRel only tilts the gaze.
|
|
||||||
const pivot = v3.lerp(x36W, cosW, cam.look, new v3());
|
|
||||||
v3.lerp(pivot, v3.ZERO, cam.earth, pivot);
|
|
||||||
const aim = v3.clone(pivot);
|
|
||||||
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) * (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();
|
|
||||||
v3.add(off, v3.multiplyByScalar(TW, -Math.cos(el) * Math.cos(az), new v3()), off);
|
|
||||||
v3.add(off, v3.multiplyByScalar(NW, Math.cos(el) * Math.sin(az), new v3()), off);
|
|
||||||
v3.add(off, v3.multiplyByScalar(RW, Math.sin(el), new v3()), off);
|
|
||||||
if (cam.sun > 0) {
|
|
||||||
v3.normalize(v3.add(v3.multiplyByScalar(off, 1 - cam.sun, off), v3.multiplyByScalar(sunW, cam.sun, new v3()), off), off);
|
|
||||||
}
|
|
||||||
const camPos = v3.add(pivot, v3.multiplyByScalar(off, dist, new v3()), new v3());
|
|
||||||
if (cam.pairD > 0) {
|
|
||||||
// Pair-locked: camera just off X36, angled pairTh from the X36->Cosmos line
|
|
||||||
// (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);
|
|
||||||
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());
|
|
||||||
const a2 = v3.normalize(v3.subtract(cosW, camPos, new v3()), new v3());
|
|
||||||
v3.add(camPos, v3.add(a1, a2, new v3()), aim);
|
|
||||||
}
|
|
||||||
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());
|
|
||||||
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) * cam.comp;
|
|
||||||
const ndcY = (PORTRAIT ? -0.02 : -0.06) * cam.comp;
|
|
||||||
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);
|
|
||||||
{
|
|
||||||
const icrf = Cesium.Transforms.computeIcrfToFixedMatrix(jd) || M;
|
|
||||||
const tmp = new v3();
|
|
||||||
for (let i = 0; i < starDirs.length; i++) {
|
|
||||||
Cesium.Matrix3.multiplyByVector(icrf, starDirs[i], tmp);
|
|
||||||
stars.get(i).position = v3.add(camPos, v3.multiplyByScalar(tmp, 3e8, tmp), new v3());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
scene.camera.frustum.near = Math.max(0.2, Math.min(dist * 0.02, 1000));
|
|
||||||
|
|
||||||
// Models: attitude, magnification, lighting
|
|
||||||
const camDistX = v3.distance(camPos, x36W);
|
|
||||||
const camDistC = v3.distance(camPos, cosW);
|
|
||||||
const pxPerRad = (PORTRAIT ? W : W) / (2 * Math.tan(scene.camera.frustum.fov / 2));
|
|
||||||
const targetPx = 110 * U; // magnified apparent size
|
|
||||||
const magFor = (d, size) => Math.max(1, (targetPx * d) / (pxPerRad * size));
|
|
||||||
// One fixed enlargement per beat (keyed), so the on-screen factor holds still.
|
|
||||||
const rawMag = cam.mag > 0.5 ? cam.mx : 1;
|
|
||||||
const MAG_MAX = 150;
|
|
||||||
const mag = rawMag;
|
|
||||||
void magFor;
|
|
||||||
const nearScale = Math.min(1, Math.max(0, cam.mag * 2));
|
|
||||||
mX36.show = (cam.mag > 0.01 && rawMag <= MAG_MAX) || camDistX < 3000;
|
|
||||||
mCos.show = (cam.mag > 0.01 && rawMag <= MAG_MAX) || camDistC < 6000;
|
|
||||||
mX36.modelMatrix = modelMatrix(x36W, TW, RW, mag);
|
|
||||||
// Cosmos points its sensor at X36 when close, otherwise flies prograde.
|
|
||||||
const toX = v3.normalize(v3.subtract(x36W, cosW, new v3()), new v3());
|
|
||||||
const w = Math.min(1, Math.max(0, (60e3 - sep) / 40e3));
|
|
||||||
const cx = v3.normalize(v3.lerp(TW, toX, w, new v3()), new v3());
|
|
||||||
const cy = v3.normalize(v3.subtract(RW, v3.multiplyByScalar(cx, v3.dot(RW, cx), new v3()), new v3()), new v3());
|
|
||||||
mCos.modelMatrix = modelMatrix(cosW, cx, cy, mag);
|
|
||||||
const lx = eclipseLight(x36W, sunW);
|
|
||||||
const lc = eclipseLight(cosW, sunW);
|
|
||||||
// In Earth's shadow a dim blue fill keeps the shapes legible.
|
|
||||||
const lightFor = (l) => new v3(0.6 + (2.3 - 0.6) * l, 0.66 + (2.3 - 0.66) * l, 0.92 + (2.3 - 0.92) * l);
|
|
||||||
mX36.lightColor = lightFor(lx);
|
|
||||||
mCos.lightColor = lightFor(lc);
|
|
||||||
|
|
||||||
// Glows: shown when the model is too small to read.
|
|
||||||
const pxX = (3.25 * mag * pxPerRad) / camDistX;
|
|
||||||
const pxC = (3.6 * mag * pxPerRad) / camDistC;
|
|
||||||
bX36.position = x36W;
|
|
||||||
bCos.position = cosW;
|
|
||||||
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);
|
|
||||||
|
|
||||||
// Burn flare
|
|
||||||
let flare = 0;
|
|
||||||
for (const bn of BURNS) {
|
|
||||||
const sb = sOfTauCached(bn.t);
|
|
||||||
const k = (s - sb) / 0.9;
|
|
||||||
if (k > -0.08 && k < 1) flare = Math.max(flare, k < 0 ? 1 + k / 0.08 : (1 - k) ** 1.6);
|
|
||||||
}
|
|
||||||
bBurn.position = cosW;
|
|
||||||
bBurn.scale = flare * 0.9 * U;
|
|
||||||
bBurn.color = Cesium.Color.WHITE.withAlpha(flare);
|
|
||||||
|
|
||||||
// Trails and reference geometry
|
|
||||||
clearDynamic();
|
|
||||||
// X36 orbit ring (inertial, at the anchor epoch); fades out up close.
|
|
||||||
const ringA = Math.min(1, Math.max(0, (camDistX - 1.5e4) / 6e4));
|
|
||||||
if (ringA > 0.01) {
|
|
||||||
const P = 5730;
|
|
||||||
const pts = [];
|
|
||||||
const cols = [];
|
|
||||||
const n = 240;
|
|
||||||
for (let k = 0; k <= n; k++) {
|
|
||||||
const tt = b - P + (k / n) * P;
|
|
||||||
pts.push(toWorld(x36Teme(tt).r));
|
|
||||||
const a = 0.06 + 0.5 * (k / n) ** 3;
|
|
||||||
cols.push(COL_X36.withAlpha(a * ringA));
|
|
||||||
}
|
|
||||||
polyline(pts, cols, 1.6);
|
|
||||||
}
|
|
||||||
// Cosmos relative trail, expressed in the current anchor frame
|
|
||||||
{
|
|
||||||
const span = cam.trail * 3600;
|
|
||||||
const n = Math.min(900, Math.max(60, Math.round(span / 45)));
|
|
||||||
const pts = [];
|
|
||||||
const cols = [];
|
|
||||||
for (let k = 0; k <= n; k++) {
|
|
||||||
const tt = t - span + (k / n) * span;
|
|
||||||
if (tt < 0) continue;
|
|
||||||
pts.push(toWorld(place(A.r, B, relAt(tt))));
|
|
||||||
cols.push(COL_COS.withAlpha(0.04 + 0.92 * (k / n) ** 1.8));
|
|
||||||
}
|
|
||||||
pts.push(cosW);
|
|
||||||
cols.push(COL_COS.withAlpha(1));
|
|
||||||
polyline(pts, cols, 2.4);
|
|
||||||
}
|
|
||||||
// Separation line when the pair is within view range
|
|
||||||
if (sep < 900e3) {
|
|
||||||
const a = Math.min(1, (900e3 - sep) / 200e3) * 0.55;
|
|
||||||
polyline([x36W, cosW], [Cesium.Color.WHITE.withAlpha(a * 0.25), Cesium.Color.WHITE.withAlpha(a * 0.25)], 1);
|
|
||||||
}
|
|
||||||
// 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) {
|
|
||||||
for (const r of [500, 1000, 2000]) {
|
|
||||||
const pts = [];
|
|
||||||
const cols = [];
|
|
||||||
for (let k = 0; k <= 120; k++) {
|
|
||||||
const a = (k / 120) * Math.PI * 2;
|
|
||||||
const p = v3.add(x36W, v3.multiplyByScalar(RW, r * Math.sin(a), new v3()), new v3());
|
|
||||||
v3.add(p, v3.multiplyByScalar(TW, r * Math.cos(a), new v3()), p);
|
|
||||||
pts.push(p);
|
|
||||||
cols.push(Cesium.Color.WHITE.withAlpha(0.16 * gridA));
|
|
||||||
}
|
|
||||||
polyline(pts, cols, 1);
|
|
||||||
}
|
|
||||||
const ax = (vec, len, col) => polyline(
|
|
||||||
[v3.add(x36W, v3.multiplyByScalar(vec, -len, new v3()), new v3()), v3.add(x36W, v3.multiplyByScalar(vec, len, new v3()), new v3())],
|
|
||||||
[col.withAlpha(0), col.withAlpha(0)], 1,
|
|
||||||
);
|
|
||||||
polyline([x36W, v3.add(x36W, v3.multiplyByScalar(TW, 2600, new v3()), new v3())], [COL_X36.withAlpha(0.5 * gridA), COL_X36.withAlpha(0)], 1.5);
|
|
||||||
polyline([x36W, v3.add(x36W, v3.multiplyByScalar(RW, 1400, new v3()), new v3())], [Cesium.Color.WHITE.withAlpha(0.4 * gridA), Cesium.Color.WHITE.withAlpha(0)], 1.5);
|
|
||||||
void ax;
|
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
// 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;
|
|
||||||
gl.readPixels(0, 0, 1, 1, gl.RGBA, gl.UNSIGNED_BYTE, new Uint8Array(4));
|
|
||||||
const dbg = { pX: Cesium.SceneTransforms.worldToWindowCoordinates(scene, x36W), dX: v3.distance(scene.camera.positionWC, x36W), ang: Cesium.Math.toDegrees(v3.angleBetween(scene.camera.directionWC, v3.subtract(x36W, scene.camera.positionWC, new v3()))), mshow: mX36.show, mm: Cesium.Matrix4.getTranslation(mX36.modelMatrix, new v3()), x36W };
|
|
||||||
return { dbg, s, t, b, sep, mag, camR: v3.magnitude(camPos), dist, fov: scene.camera.frustum.fov, fovy: scene.camera.frustum.fovy, aspect: scene.camera.frustum.aspectRatio };
|
|
||||||
}
|
|
||||||
|
|
||||||
const sOfTauMemo = new Map();
|
|
||||||
function sOfTauCached(t) {
|
|
||||||
if (!sOfTauMemo.has(t)) sOfTauMemo.set(t, sOfTau(t));
|
|
||||||
return sOfTauMemo.get(t);
|
|
||||||
}
|
|
||||||
|
|
||||||
await document.fonts.ready;
|
|
||||||
window.rpo = { frame, tau, bclock, duration: DUR, ready: true, CAM };
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
COSMOS 2612
|
|
||||||
1 68762U 26083H 26297.29186266 .00002093 00000+0 14092-3 0 9996
|
|
||||||
2 68762 97.8545 77.8974 0006461 247.4186 112.6358 15.07858138 28631
|
|
||||||
ICEYE-X36
|
|
||||||
1 59103U 24043F 26297.22477300 .00003484 00000+0 23160-3 0 9995
|
|
||||||
2 59103 97.8537 77.8281 0004894 236.2883 123.7878 15.07863566144584
|
|
||||||
Reference in new issue
Block a user