Compare commits

..
Author SHA1 Message Date
German Khokhlov ee3466dff9 Keep the queued-card check off the per-frame allocation path
Writing a fractional alpha onto the overlay record for every paint item
boxed a heap number per item per frame. The allocation gate caught it:
the generic 60-entry workload went from 2,838 to 12,614 B/frame. The
record now keeps only the frame stamp and a pointer to its paint item,
and isOverlayEntryQueued multiplies the alphas when it is asked. The
gate is back to main's numbers on Node 24.
2026-11-05 13:14:00 +04:00
German Khokhlov 092e7e71a8 Add the changelog entry for #1577 2026-11-05 12:54:11 +04:00
German Khokhlov 652f3c8ea1 Hide a vessel's detection callout while its own card paints
With Vessels and Detection on, a vessel with an ambient card on the map
also got its detection callout next to the bracket. The callout lane
paints beneath the cards, so its text showed through the card's plate
and covered the speed and course.

The callout now gives way only to a card the shared overlay actually
paints in the same frame. The overlay paints part of the published
cards (shared budget, collisions, UI chrome), so a vessel whose card
was dropped keeps its callout.

The detection lane paints before the cards publish their paint rects,
so getOverlayPaintRect has nothing for the current frame there. The new
isOverlayEntryQueued answers from the paint queue that the overlay
solves before painting. Vessel detection objects name their card in
cardSourceId/cardEntryId.
2026-11-05 12:53:17 +04:00
26 changed files with 206 additions and 433 deletions

No files matched your search

@@ -0,0 +1,3 @@
- A vessel whose ambient card is on the map no longer gets a Detection callout
showing through the card and covering its speed and course. A vessel whose
card the overlay did not paint keeps its callout (German, @gekh, #1577).
@@ -1,5 +0,0 @@
- Space Missions no longer locks the workspace when its mission index fails to
load. A 429, timeout or bad payload on `/api/launches` now rolls the entry
back to the previous layers and camera view instead of leaving an empty,
isolating mode, and the roster reads `30-DAY MISSION INDEX UNAVAILABLE`
rather than loading forever (German, @gekh, #1578).
+2 -2
View File
@@ -5169,7 +5169,7 @@ silently demoting every later lookup for the session.
- Context is set for reading in every theme: mode, standby and action labels are 10 px, and the panel draws no glow halo. Outside Cyber, the two modes form one segmented control, the standby guidance is plain left-aligned text rather than a dashed placeholder, and the panel's section titles share the side panels' dock-label colour. Cyber keeps its separately framed mode buttons, its red panel titles, and in layout-focus a framed, centred standby card over its radar art.
- The expanded desktop header omits the redundant `ON` label; the active mode button carries state. Expanded Contact results also omit the duplicate `GLOBAL CONTEXT` / `CONTEXT ONLY` status row and begin with the selected subject and its 250 km scope. Global Context does not fabricate a selected-entity model preview: the provisional hand-authored aircraft wireframe was removed because it was not geometry extracted from the selected entity's actual asset.
- Dependency ownership is reversible: disabling Global Context releases only dependencies it enabled, while user-enabled layers remain on. This also removes the Military-layer suppression handoff when Global Context owned Military, allowing an already-enabled civilian Flights layer to resume its normal mixed rendering. If OpenSky is unavailable and has no last-good cache, Flights requests a capped 250 nm adsb.lol point snapshot around the current view anchor and labels that provenance explicitly; it never relabels military-feed rows as civilian data. If both inputs fail, Flights remains `UNKNOWN`.
- Space Missions is replay-isolated: Rocket Launches and Satellites are the only Data Layers permitted while the mode is active. Direct UI and voice entry capture the same pre-entry snapshot; internal dependency and restoration enables do not create a user-owned Context session. Entry waits for incompatible layers to shut down, direct incompatible enables are blocked before lifecycle work, and the entry gate remains active through the complete Rocket Launches enable. That enable includes the first mission-index load: an HTTP error, timeout, network failure or malformed payload before any index has loaded fails the enable, so Context rolls back to the pre-entry layers and the camera returns to its pre-entry view instead of committing an empty, isolating mode. A valid empty index still enters, and a refresh failure after an index loaded keeps its records and the mode. The roster reads `LOADING 30-DAY MISSION INDEX` while that first load is pending and `30-DAY MISSION INDEX UNAVAILABLE` once it failed. A newer same-target ON request takes ownership of the pending entry without releasing its isolation snapshot, including when it arrives while the prior request is awaiting the adoption guard. A caller abort, resource cancellation, newer OFF, or layer teardown waits for exact manager settlement and restores that snapshot without resurrecting Rocket Launches. If an abort lands after only part of a restore settles, Context completes the same exact target without the stale caller signal and then replays newer explicit layer intent. Dedicated Voice Context cancellation reports a stable cancelled result plus the current Context state; generic layer visibility additionally exposes manager phase, reason, successor, and lifecycle details. Once an exact voice visibility intent commits, a newer voice turn cannot relabel it as cancelled while Context settlement completes; pre-commit aborts remain cancellable and final lifecycle mismatches remain failures. Clear Selected Layers reserves its complete captured OFF set before sequential teardown; a newer absolute request of any origin supersedes only its layer reservation and remains authoritative. A rejected layer teardown retains the truthful enabled state, rolls already-stopped siblings back to the captured pre-entry set, and aborts replay; rapid exit/re-entry serializes the full Satellite enabled-state and parameter restore before a new snapshot is taken. Contacts remains additive and restores user-enabled layers normally.
- Space Missions is replay-isolated: Rocket Launches and Satellites are the only Data Layers permitted while the mode is active. Direct UI and voice entry capture the same pre-entry snapshot; internal dependency and restoration enables do not create a user-owned Context session. Entry waits for incompatible layers to shut down, direct incompatible enables are blocked before lifecycle work, and the entry gate remains active through the complete Rocket Launches enable. A newer same-target ON request takes ownership of the pending entry without releasing its isolation snapshot, including when it arrives while the prior request is awaiting the adoption guard. A caller abort, resource cancellation, newer OFF, or layer teardown waits for exact manager settlement and restores that snapshot without resurrecting Rocket Launches. If an abort lands after only part of a restore settles, Context completes the same exact target without the stale caller signal and then replays newer explicit layer intent. Dedicated Voice Context cancellation reports a stable cancelled result plus the current Context state; generic layer visibility additionally exposes manager phase, reason, successor, and lifecycle details. Once an exact voice visibility intent commits, a newer voice turn cannot relabel it as cancelled while Context settlement completes; pre-commit aborts remain cancellable and final lifecycle mismatches remain failures. Clear Selected Layers reserves its complete captured OFF set before sequential teardown; a newer absolute request of any origin supersedes only its layer reservation and remains authoritative. A rejected layer teardown retains the truthful enabled state, rolls already-stopped siblings back to the captured pre-entry set, and aborts replay; rapid exit/re-entry serializes the full Satellite enabled-state and parameter restore before a new snapshot is taken. Contacts remains additive and restores user-enabled layers normally.
- Enabled Data Layer controls report normalized feed health on the button (`LOADING`, `DEGRADED`, `STALE`, `FALLBACK`, or `UNAVAILABLE`) while the metadata line retains the source and reason. A partial CelesTrak group failure keeps the usable catalog and reports `DEGRADED`; a total outage keeps last-good catalog data visible but reports `UNAVAILABLE`.
- On activation it focuses the nearest currently observed aircraft across the civilian and military feeds, with military winning an exact distance tie; if none are available, it focuses an observed AIS vessel. The aircraft search is deliberately uncapped and one refresh-tick retry handles initially empty feeds. This is an attention-priority navigation shortcut, not a high-risk, affiliation, or threat classification.
- A selected aircraft, AIS vessel, or mapped installation gets a 250 km **context window** with nearby cohort counts, nearest examples, source labels, and stale/unavailable reasons. It emits `NEARBY` or `UNKNOWN`; no detection, engagement, affiliation, or sensor-activity conclusion is calculated.
@@ -5322,7 +5322,7 @@ silently demoting every later lookup for the session.
Mission world text has no native `LabelGraphics`: overview launch markers publish at most 48 ambient candidates for a 24-winner budget, while selecting a mission clears that overview source and publishes its launch-site callout, stage re-entry annotations, live/estimated payload-position readout, and orbit annotation as protected selected-lane entries. The source retains the exact former strings and colors. Static anchors reuse the Cartesian values used to build their mission geometry; the moving payload entry reads the layer's per-frame live-position cache; catalog-backed orbit annotation positions are cached in the same one-second matrix update that realigns the orbit primitive. Keyhole edge fade, horizon culling, final collision placement, and UI exclusion are owned by the shared host. Deselect restores the bounded overview, and refresh, disable, and destroy replace or clear both mission sources.
A selected mission renders its orbit as four repeating tactical sectors, each containing one prominent cyan dot followed by one hundred thin translucent dashes. The bright dots act as orbit anchors while the subdued dash field remains depth-tested against the globe and is shown only for the selected mission.
Close selected-pad views add one static 500 m-radius cyan launch-zone ring with a low-opacity translucent fill over the sampled photoreal launch-site surface. The single scene primitive is created only for the visible selected site and is otherwise dormant. It appears during Focus, sufficiently close manual zoom, and the replay countdown, but is suppressed above 120 km camera altitude, beyond 180 km direct camera-to-pad range, for unselected missions, and whenever Space Missions is inactive. Focus establishes a launch-site-centered camera transform once; subsequent manual heading and pitch changes remain centered on that site without an automated per-frame correction. Surface mission markers and labels use an additional conservative globe-limb margin before the exact ellipsoid occluder boundary, preventing near-horizon visibility from alternating between frames.
- **AIS vessels**: chevron symbology (naval cyan base, type tints), world-space headings, MMSI-keyed reconciliation (selection survives refreshes; pinned 3 refreshes with STALE marker when absent), detection-overlay integration (`type: 'SEA'`), contextStore registration for voice Q&A. Empty-space clicks, id-less photorealistic-tile picks, and Escape dismiss the vessel card/HUD/context and clear its trail; picks owned by another layer (including `gev-trail:*`) and raw vessel-record picks without a live MMSI key are no-ops for vessel selection. Click and key handlers detach while the layer is disabled and reinstall on enable. Selecting another vessel replaces the selection and trail, and reconciliation clears a trail if its owning vessel is evicted.
- **AIS vessels**: chevron symbology (naval cyan base, type tints), world-space headings, MMSI-keyed reconciliation (selection survives refreshes; pinned 3 refreshes with STALE marker when absent), detection-overlay integration (`type: 'SEA'`; a vessel whose ambient card the shared host paints in the current frame draws no detection callout, because the callout would show through the card's plate, while a vessel whose card was published but not painted (shared budget, collisions, UI chrome) keeps its callout — its detectable object names the card in `cardSourceId`/`cardEntryId`, and the callout lane asks `isOverlayEntryQueued` in `src/overlays/worldOverlay.js`, which answers from the solved paint queue because the detection lane paints before the cards publish their paint rects), contextStore registration for voice Q&A. Empty-space clicks, id-less photorealistic-tile picks, and Escape dismiss the vessel card/HUD/context and clear its trail; picks owned by another layer (including `gev-trail:*`) and raw vessel-record picks without a live MMSI key are no-ops for vessel selection. Click and key handlers detach while the layer is disabled and reinstall on enable. Selecting another vessel replaces the selection and trail, and reconciliation clears a trail if its owning vessel is evicted.
- **Track trails**: server accumulates per-MMSI ring buffers (`/api/vessels/track?mmsi=`, Float32+Uint32, 64 samples, 30s/25m thinning); aircraft backfill proxies `/api/flights/track` (OAuth, own credit bucket). Military aircraft keep only their locally accumulated trails: `/api/military/track` validates the hex, then answers `503 history_unavailable` without an upstream request.
- Shared `src/data/pickRegistry.js` stops the two flight layers' click handlers from fighting over the camera.
-1
View File
@@ -474,7 +474,6 @@
"src/layers/satellites/stale.test.mjs",
"src/layers/launches/source.test.mjs",
"src/layers/launches/ingestion.test.mjs",
"src/layers/launches/failedEntry.test.mjs",
"src/layers/installations/lifecycle.js",
"src/layers/installations/viewport.js",
"src/layers/installations/controls.js",
+39
View File
@@ -1786,3 +1786,42 @@ test('vessel focus wire skips hidden sprites at rest while a target is tracked',
assert.equal(result.activeCount, 1);
for (const billboard of hidden) assert.equal(billboard.color.alpha, 1);
});
test('detection objects name the vessel card their callout gives way to', () => {
const vessel = (mmsi, name) => ({
name,
mmsi,
type: 'Cargo',
destination: '',
speed: 9.5,
course: 80,
heading: 80,
lastPositionUtc: '',
missedRefreshes: 0,
position: POS,
billboard: null,
});
const keyed = vessel('200', 'GOLDEN BEAR');
const unkeyed = { ...vessel('', 'NO MMSI'), lat: 37.8, lon: -122.4 };
_setVesselStateForTest({
viewer: {},
records: [keyed, unkeyed],
billboardCollection: { show: true, remove() {} },
});
try {
const objects = Object.fromEntries(
aisLiveVesselsLayer
.getDetectableObjects()
.map((object) => [object.id, object]),
);
assert.equal(objects['GOLDEN BEAR'].cardSourceId, 'ais-live-vessels');
assert.equal(objects['GOLDEN BEAR'].cardEntryId, 'vessel:200');
assert.equal(
objects['NO MMSI'].cardEntryId,
'vessel:unkeyed:NO MMSI:37.80000:-122.40000',
'the same identity the vessel card is published under',
);
} finally {
_setVesselStateForTest({ enabled: false });
}
});
+22 -2
View File
@@ -21,7 +21,10 @@ import {
getKeyholeFadeTuning,
keyholeLabelAlphaFromGeometry,
} from '../celestialRing.js';
import { registerWorldOverlayPaintLane } from '../overlays/worldOverlay.js';
import {
isOverlayEntryQueued,
registerWorldOverlayPaintLane,
} from '../overlays/worldOverlay.js';
import {
DETECTION_STYLE,
DETECTION_THEME_MAP,
@@ -576,6 +579,7 @@ export function getDetectionDiagnostics() {
// sprites/strokes. Nothing is copied on the production paint path.
result.calloutRects = _calloutPool
.slice(0, _calloutCount)
.filter((row) => !row.yielded)
.map(({ x, y, w, h, alpha }) => ({ x, y, w, h, alpha }));
return result;
}
@@ -1077,6 +1081,9 @@ function _stashCallout(entry, acquireFade, keyhole) {
font: FONT,
microFont: MICRO_FONT,
alpha: 1,
cardSourceId: '',
cardEntryId: '',
yielded: false,
};
_calloutPool[_calloutCount] = row;
}
@@ -1103,6 +1110,9 @@ function _stashCallout(entry, acquireFade, keyhole) {
row.font = FONT;
row.microFont = MICRO_FONT;
row.alpha = alpha;
row.cardSourceId = candidate.cardSourceId;
row.cardEntryId = candidate.cardEntryId;
row.yielded = false;
_calloutCount++;
}
@@ -1134,7 +1144,15 @@ function _paintCalloutLane(frame) {
ctx.textBaseline = 'alphabetic';
for (let i = 0; i < _calloutCount; i++) {
const row = _calloutPool[i];
paintDetectionCallout(ctx, row, row.alpha);
// The contact's own card (an ambient vessel card) already names it, and
// this callout would show through the card's plate. Checked every frame
// against the cards queued for THIS frame: the host paints only part of
// the published cards, and a contact whose card did not paint keeps its
// callout.
row.yielded =
row.cardEntryId !== '' &&
isOverlayEntryQueued(row.cardSourceId, row.cardEntryId);
if (!row.yielded) paintDetectionCallout(ctx, row, row.alpha);
}
ctx.globalAlpha = 1;
}
@@ -1211,6 +1229,8 @@ function _materializeCandidate(
primary,
micro,
hasMicro: card.hasMicro,
cardSourceId: obj.cardSourceId ? String(obj.cardSourceId) : '',
cardEntryId: obj.cardEntryId ? String(obj.cardEntryId) : '',
};
}
+62
View File
@@ -19,9 +19,11 @@ import {
suspendDetection,
} from './detection.js';
import {
clearOverlaySource,
destroyWorldOverlay,
initWorldOverlay,
setOverlayEntries,
setOverlaySourceVisible,
} from '../overlays/worldOverlay.js';
import { DETECTION_THEME_MAP } from '../overlays/worldOverlayTokens.js';
import {
@@ -579,6 +581,66 @@ test('every style paints its OWN plate token through the production callout path
}
});
test('a callout gives way to its contact\'s own card only while that card paints', () => {
// A vessel's ambient card already shows its name, speed and course, and the
// callout drawn beneath it shows through the card's plate. The host paints
// only part of the published cards (shared budget, collisions, UI chrome),
// so a contact whose card was published but not painted keeps its callout.
const env = installEnvironment({ width: 900, height: 600, dpr: 1 });
try {
const ships = [
{ x: -0.3, sourceId: 'carded', id: 'SHIP-CARDED', card: 'vessel:carded' },
{ x: 0.3, sourceId: 'dropped', id: 'SHIP-DROPPED', card: 'vessel:dropped' },
].map(({ x, sourceId, id, card }) => ({
position: new Cesium.Cartesian3(x, 0, 6_356_752),
sourceId,
id,
metric: '6 kn',
type: 'SEA',
cardSourceId: card === 'vessel:carded' ? 'vessel-cards' : 'dropped-cards',
cardEntryId: card,
}));
initWorldOverlay(env.viewer);
initDetection(env.viewer, [{ id: 'ais-live-vessels', getDetectableObjects: () => ships }], () => {});
const card = (id, title, x) => ({
id,
position: new Cesium.Cartesian3(x, 0, 6_356_752),
variant: 'label',
title,
selected: true,
protected: true,
horizonCull: false,
edgeFade: 'none',
});
setOverlayEntries('vessel-cards', [card('vessel:carded', 'CARD-CARDED', -0.3)]);
// Published, but the host does not paint it this frame.
setOverlayEntries('dropped-cards', [card('vessel:dropped', 'CARD-DROPPED', 0.3)]);
setOverlaySourceVisible('dropped-cards', false);
setMode('DENSE');
settleFrame(env);
env.ctx.calls.length = 0;
settleFrame(env);
const painted = (text) => env.ctx.calls.some(([name, value]) => name === 'fillText' && value === text);
assert.ok(painted('CARD-CARDED'), 'the fixture card reached the frame');
assert.ok(!painted('CARD-DROPPED'), 'the hidden source paints no card');
assert.ok(!painted('SHIP-CARDED'), 'no callout under the painted card');
assert.ok(painted('SHIP-DROPPED'), 'the contact whose card did not paint keeps its callout');
assert.equal(getDetectionDiagnostics().calloutRects.length, 1,
'only the callout that painted is reported');
// The card leaves the host: the callout comes back on the next frame.
clearOverlaySource('vessel-cards');
env.ctx.calls.length = 0;
settleFrame(env);
assert.ok(painted('SHIP-CARDED'), 'the callout returns once its card stops painting');
} finally {
destroyDetection();
destroyWorldOverlay();
env.cleanup();
}
});
test('space-tier callouts take the heavier plate while air contacts keep the light one', () => {
// Satellites sit over the high-albedo lit Earth disc far more often than
// aircraft do, so the space tier carries the heavier backing. Losing the
-1
View File
@@ -301,7 +301,6 @@
"AVAILABLE MISSIONS": "المهام المتاحة",
"SELECT A MISSION TO INSPECT": "اختر مهمة لفحصها",
"LOADING 30-DAY MISSION INDEX": "جارٍ تحميل فهرس مهام 30 يومًا",
"30-DAY MISSION INDEX UNAVAILABLE": "فهرس مهام 30 يومًا غير متاح",
"RADIO": "الراديو",
"ENABLE": "تفعيل",
"Previous filtered radio station": "محطة الراديو السابقة في القائمة المصفّاة",
-1
View File
@@ -760,7 +760,6 @@
"AVAILABLE MISSIONS": "VERFÜGBARE MISSIONEN",
"SELECT A MISSION TO INSPECT": "MISSION ZUM ANSEHEN WÄHLEN",
"LOADING 30-DAY MISSION INDEX": "30-TAGE-MISSIONSINDEX WIRD GELADEN",
"30-DAY MISSION INDEX UNAVAILABLE": "30-TAGE-MISSIONSINDEX NICHT VERFÜGBAR",
"TAB PREVIEWS · ENTER / SPACE SELECTS": "TAB: VORSCHAU · ENTER / LEERTASTE: AUSWAHL",
"Internet radio and local SDR": "Internetradio und lokales SDR",
"STATION TAG": "KATEGORIE",
-1
View File
@@ -192,7 +192,6 @@
"AVAILABLE MISSIONS": "MISIONES DISPONIBLES",
"SELECT A MISSION TO INSPECT": "SELECCIONA UNA MISIÓN PARA INSPECCIONAR",
"LOADING 30-DAY MISSION INDEX": "CARGANDO ÍNDICE DE MISIONES DE 30 DÍAS",
"30-DAY MISSION INDEX UNAVAILABLE": "ÍNDICE DE MISIONES DE 30 DÍAS NO DISPONIBLE",
"RADIO": "RADIO",
"ENABLE": "ACTIVAR",
"Previous filtered radio station": "Emisora de radio filtrada anterior",
-1
View File
@@ -2799,7 +2799,6 @@
"AVAILABLE MISSIONS": "MISSIONS DISPONIBLES",
"SELECT A MISSION TO INSPECT": "CHOISIR UNE MISSION À EXAMINER",
"LOADING 30-DAY MISSION INDEX": "CHARGEMENT DE L'INDEX DES MISSIONS À 30 JOURS",
"30-DAY MISSION INDEX UNAVAILABLE": "INDEX DES MISSIONS À 30 JOURS INDISPONIBLE",
"TAB PREVIEWS · ENTER / SPACE SELECTS": "TAB APERÇU · ENTER / SPACE SÉLECTIONNE",
"Internet radio and local SDR": "Radio Internet et SDR local",
"STATION TAG": "TAG DE STATION",
-1
View File
@@ -2799,7 +2799,6 @@
"AVAILABLE MISSIONS": "MISSIONI DISPONIBILI",
"SELECT A MISSION TO INSPECT": "SELEZIONA UNA MISSIONE DA ESAMINARE",
"LOADING 30-DAY MISSION INDEX": "CARICAMENTO INDICE MISSIONI A 30 GIORNI",
"30-DAY MISSION INDEX UNAVAILABLE": "INDICE MISSIONI A 30 GIORNI NON DISPONIBILE",
"TAB PREVIEWS · ENTER / SPACE SELECTS": "TAB: ANTEPRIMA · ENTER / SPACE: SELEZIONA",
"Internet radio and local SDR": "Radio internet e SDR locale",
"STATION TAG": "TAG STAZIONE",
-1
View File
@@ -2657,7 +2657,6 @@
"AVAILABLE MISSIONS": "利用可能なミッション",
"SELECT A MISSION TO INSPECT": "ミッションを選択して詳細を表示",
"LOADING 30-DAY MISSION INDEX": "30日間のミッション一覧を読込中",
"30-DAY MISSION INDEX UNAVAILABLE": "30日間のミッション一覧を取得できません",
"TAB PREVIEWS · ENTER / SPACE SELECTS": "Tabでプレビュー · Enter / Spaceで選択",
"Internet radio and local SDR": "インターネットラジオとローカルSDR",
"STATION TAG": "局タグ",
-1
View File
@@ -2801,7 +2801,6 @@
"AVAILABLE MISSIONS": "MISSÕES DISPONÍVEIS",
"SELECT A MISSION TO INSPECT": "SELECIONE UMA MISSÃO PARA INSPECIONAR",
"LOADING 30-DAY MISSION INDEX": "CARREGANDO ÍNDICE DE MISSÕES DE 30 DIAS",
"30-DAY MISSION INDEX UNAVAILABLE": "ÍNDICE DE MISSÕES DE 30 DIAS INDISPONÍVEL",
"TAB PREVIEWS · ENTER / SPACE SELECTS": "TAB MOSTRA PRÉVIA · ENTER / SPACE SELECIONA",
"Internet radio and local SDR": "Rádio pela internet e SDR local",
"STATION TAG": "TAG DA ESTAÇÃO",
-1
View File
@@ -722,7 +722,6 @@
"Contact Context actions": "Действия контекста контакта",
"Available Space Missions": "Доступные космические миссии",
"LOADING 30-DAY MISSION INDEX": "ЗАГРУЗКА ИНДЕКСА МИССИЙ ЗА 30 ДНЕЙ",
"30-DAY MISSION INDEX UNAVAILABLE": "ИНДЕКС МИССИЙ ЗА 30 ДНЕЙ НЕДОСТУПЕН",
"Radio playback": "Воспроизведение радио",
"Radio volume": "Громкость радио",
"STATION TAG": "ТЕГ СТАНЦИИ",
-1
View File
@@ -2869,7 +2869,6 @@
"AVAILABLE MISSIONS": "ДОСТУПНІ МІСІЇ",
"SELECT A MISSION TO INSPECT": "ВИБЕРІТЬ МІСІЮ ДЛЯ ПЕРЕГЛЯДУ",
"LOADING 30-DAY MISSION INDEX": "ЗАВАНТАЖЕННЯ ІНДЕКСУ МІСІЙ ЗА 30 ДНІВ",
"30-DAY MISSION INDEX UNAVAILABLE": "ІНДЕКС МІСІЙ ЗА 30 ДНІВ НЕДОСТУПНИЙ",
"TAB PREVIEWS · ENTER / SPACE SELECTS": "TAB — ОГЛЯД · ENTER / SPACE — ВИБІР",
"Internet radio and local SDR": "Інтернет-радіо та локальний SDR",
"STATION TAG": "ТЕГ СТАНЦІЇ",
-1
View File
@@ -2556,7 +2556,6 @@
"AVAILABLE MISSIONS": "可用任务",
"SELECT A MISSION TO INSPECT": "选择一项任务以查看",
"LOADING 30-DAY MISSION INDEX": "正在加载 30 天任务索引",
"30-DAY MISSION INDEX UNAVAILABLE": "30 天任务索引不可用",
"TAB PREVIEWS · ENTER / SPACE SELECTS": "TAB 预览 · ENTER / 空格键选择",
"Internet radio and local SDR": "网络电台与本地 SDR",
"STATION TAG": "电台标签",
-1
View File
@@ -2544,7 +2544,6 @@
"AVAILABLE MISSIONS": "可用任務",
"SELECT A MISSION TO INSPECT": "選擇一項任務以查看",
"LOADING 30-DAY MISSION INDEX": "正在加載 30 天任務索引",
"30-DAY MISSION INDEX UNAVAILABLE": "30 天任務索引不可用",
"TAB PREVIEWS · ENTER / SPACE SELECTS": "TAB 預覽 · ENTER / 空格鍵選擇",
"Internet radio and local SDR": "網絡電臺與本地 SDR",
"STATION TAG": "電臺標籤",
-348
View File
@@ -1,348 +0,0 @@
// A Space Missions entry whose mission index never loads must not commit the
// mode. The real manager, Context coordinator and launches ingestion run
// against a local /api/launches that answers 429, so the test follows the
// same failure the browser sees: isolation clears the other layers, the first
// update fails, and the entry rolls back to the layers and view it replaced.
import test from 'node:test';
import assert from 'node:assert/strict';
import http from 'node:http';
import * as Cesium from 'cesium';
import { DataLayerManager } from '../../data/manager.js';
import { ContextControls } from '../../ui/contextControls.js';
import { createLaunchSource } from './source.js';
import { createState } from './state.js';
import { createModel } from './model.js';
import { createPolicyHelpers } from './policyHelpers.js';
import { createIngestion } from './ingestion.js';
import { createPanel } from './panel.js';
import { createControls } from './controls.js';
import { createLifecycle } from './lifecycle.js';
const noop = () => {};
const launchRecord = {
id: 'fixture-launch',
name: 'Fixture mission',
net: new Date(Date.now() - 3600000).toISOString(),
status: { name: 'Launch Successful' },
pad: { name: 'Fixture pad', latitude: 28.6, longitude: -80.6 },
rocket: { payloads: [] },
};
function plainLayer(id, name, params = {}) {
let current = { ...params };
return {
id,
name,
icon: '',
source: 'test',
updateInterval: -1,
init: noop,
enable: noop,
disable: noop,
update: noop,
getParams: () => ({ ...current }),
setParams(next) {
current = { ...current, ...next };
return true;
},
};
}
function fakeCamera() {
const camera = {
position: new Cesium.Cartesian3(1, 2, 3),
heading: 0.1,
pitch: -0.5,
roll: 0,
flights: 0,
cancelled: 0,
get positionWC() {
return camera.position;
},
flyTo({ destination }) {
camera.flights += 1;
camera.position = Cesium.Cartesian3.clone(destination);
camera.heading = 0;
camera.pitch = -Math.PI / 2;
},
cancelFlight() {
camera.cancelled += 1;
},
setView({ destination, orientation }) {
camera.position = Cesium.Cartesian3.clone(destination);
camera.heading = orientation.heading;
camera.pitch = orientation.pitch;
camera.roll = orientation.roll;
},
};
return camera;
}
async function fixture(t) {
const upstream = { status: 429, results: [launchRecord] };
const server = http.createServer((req, res) => {
if (req.url === '/api/celestrak/active') {
res.end('');
return;
}
if (upstream.status !== 200) {
res.writeHead(upstream.status, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Launch Library 2 unavailable' }));
return;
}
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ results: upstream.results }));
});
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
const origin = `http://127.0.0.1:${server.address().port}`;
const source = createLaunchSource({
fetchImpl: (url, options) => fetch(new URL(url, origin), options),
});
const services = {
overlays: {
setOverlayEntries: noop,
setOverlaySourceVisible: noop,
clearOverlaySource: noop,
},
render: { holdContinuousRender: noop, releaseContinuousRender: noop },
};
const state = createState({ services });
const camera = fakeCamera();
const canvas = Object.assign(new EventTarget(), {
disableRootEvents: true,
onwheel: null,
style: {},
clientWidth: 1200,
clientHeight: 800,
});
const viewer = {
camera,
dataSources: new Cesium.DataSourceCollection(),
scene: { canvas, preRender: new Cesium.Event(), drillPick: () => [] },
};
// Scene presentation is inert; source, ingestion, lifecycle, panel views
// and the satellites dependency run unchanged.
const parts = {
overlays: {
shortMissionLabel: (name) => name,
createReplayVehicleOverlay: noop,
clearMissionOverlaySources: noop,
hideReplayVehicleOverlay: noop,
destroyReplayVehicleOverlay: noop,
},
replay: { stopMissionReplay: noop, presentReplay: () => null },
selection: {
focusFullGlobe: (target) =>
target.camera.flyTo({ destination: new Cesium.Cartesian3(9, 9, 9) }),
setSelectedMission: noop,
focusMission: noop,
stopMissionZoomAnchor: noop,
entityLaunchId: () => null,
},
orbitRendering: {
removeMissionOrbitPrimitives: noop,
syncMissionOrbitPrimitiveVisibility: noop,
},
launchPad: {
initLaunchPadZonePrimitive: noop,
hideLaunchPadZone: noop,
destroyLaunchPadZonePrimitive: noop,
},
rendering: {
updateMissionFrame: noop,
addLaunchEntity(record) {
state._dataSource.entities.add({
id: record.id,
position: Cesium.Cartesian3.fromDegrees(record.lon, record.lat),
});
},
},
};
const context = { state, services, parts, source };
parts.policyHelpers = createPolicyHelpers(context);
parts.model = createModel(context);
parts.panel = createPanel(context);
parts.ingestion = createIngestion(context);
parts.controls = createControls(context);
parts.lifecycle = createLifecycle(context);
const missions = Object.assign(
{},
parts.controls.methods,
parts.lifecycle.methods,
parts.ingestion.methods,
);
const rosters = [];
parts.panel.subscribePanel((view) => rosters.push(view.roster));
const manager = new DataLayerManager(viewer);
manager.register(plainLayer('earthquakes', 'Earthquakes (24h)'));
manager.register(plainLayer('satellites', 'Satellites'));
manager.register(plainLayer('wind', 'Wind'));
manager.register(missions);
missions.attachDataManager(manager);
const toasts = [];
const controls = new ContextControls({
elements: {},
installations: {},
actions: {
getCockpit: () => null,
claimVisualAuthority: noop,
showToast: (message) => toasts.push(message),
setClearBusy: noop,
syncDetection: noop,
scheduleLayout: noop,
refreshRadio: noop,
setPanelCollapsed: noop,
},
});
controls.connect(manager);
t.after(async () => {
controls.stop();
controls.disconnect();
for (const id of ['rocket-launches', 'satellites', 'earthquakes', 'wind'])
await manager.setEnabled(id, false);
await missions.destroy(viewer);
server.closeAllConnections();
await new Promise((resolve) => server.close(resolve));
});
const enabledIds = () => [...manager.getEnabledLayerIds()].sort();
const view = () => ({
x: camera.position.x,
y: camera.position.y,
z: camera.position.z,
heading: camera.heading,
pitch: camera.pitch,
});
const settle = async () => {
await controls._waitForContextLayerSettlement();
for (const id of ['rocket-launches', 'satellites', 'earthquakes'])
await manager.waitForLayerSettled(id);
await controls._waitForContextLayerSettlement();
};
return {
upstream,
manager,
controls,
missions,
state,
camera,
rosters,
toasts,
enabledIds,
view,
settle,
};
}
test('a direct Space Missions entry that gets HTTP 429 rolls back instead of isolating', async (t) => {
const f = await fixture(t);
assert.equal(
await f.manager.setEnabled('earthquakes', true, { origin: 'user' }),
true,
);
const before = f.view();
const entered = await f.manager.setEnabled('rocket-launches', true, {
origin: 'user',
});
await f.settle();
assert.equal(entered, false, 'a failed mission index fails the enable');
assert.deepEqual(f.enabledIds(), ['earthquakes']);
assert.equal(f.controls.getContextModeState().mode, null);
assert.equal(f.controls.getContextModeState().active, false);
assert.equal(f.missions.getStats().error, 'HTTP 429');
assert.deepEqual(f.view(), before, 'the pre-entry view comes back');
assert.ok(f.camera.cancelled > 0, 'the full-globe flight is cancelled');
// Nothing is left isolating the workspace.
assert.equal(
await f.manager.setEnabled('wind', true, { origin: 'user' }),
true,
'an unrelated layer is not blocked after the failed entry',
);
});
test('a Context-panel Space Missions entry that gets HTTP 429 restores the previous layers', async (t) => {
const f = await fixture(t);
await f.manager.setEnabled('earthquakes', true, { origin: 'user' });
const before = f.view();
const result = await f.controls.setContextMode('space-missions', {
claimVisualAuthority: false,
});
await f.settle();
assert.equal(result.ok, false);
assert.deepEqual(f.enabledIds(), ['earthquakes']);
assert.equal(f.controls.getContextModeState().mode, null);
assert.deepEqual(f.view(), before);
});
test('the roster reports a failed mission index instead of loading forever', async (t) => {
const f = await fixture(t);
await f.manager.setEnabled('rocket-launches', true, { origin: 'user' });
await f.settle();
const roster = f.rosters.at(-1);
assert.ok(roster, 'a failed load publishes a roster view');
assert.deepEqual(roster.entries, []);
assert.equal(roster.empty, '30-DAY MISSION INDEX UNAVAILABLE');
// A new attempt starts from the loading state, then shows the index.
f.upstream.status = 200;
const loadingFrom = f.rosters.length;
assert.equal(
await f.manager.setEnabled('rocket-launches', true, { origin: 'user' }),
true,
);
await f.settle();
assert.equal(f.rosters.slice(loadingFrom).includes(null), true);
assert.equal(f.rosters.at(-1).entries.length, 1);
assert.equal(f.controls.getContextModeState().mode, 'space-missions');
});
test('a valid empty mission index is a successful entry', async (t) => {
const f = await fixture(t);
f.upstream.status = 200;
f.upstream.results = [];
assert.equal(
await f.manager.setEnabled('rocket-launches', true, { origin: 'user' }),
true,
);
await f.settle();
assert.equal(f.controls.getContextModeState().mode, 'space-missions');
assert.equal(
f.rosters.at(-1).empty,
'NO MISSIONS AVAILABLE IN THE CURRENT 30-DAY WINDOW',
);
assert.ok(f.camera.flights > 0, 'a successful entry frames the globe');
});
test('a refresh failure after a loaded index keeps the mode and its records', async (t) => {
const f = await fixture(t);
f.upstream.status = 200;
await f.manager.setEnabled('rocket-launches', true, { origin: 'user' });
await f.settle();
const framed = f.view();
f.upstream.status = 429;
await f.missions.update();
await f.settle();
assert.equal(f.manager.isEnabled('rocket-launches'), true);
assert.equal(f.controls.getContextModeState().mode, 'space-missions');
assert.equal(f.missions.getStats().count, 1);
assert.equal(f.missions.getStats().error, 'HTTP 429');
assert.equal(f.rosters.at(-1).entries.length, 1);
// Leaving a mode that loaded keeps today's camera behaviour.
await f.manager.setEnabled('rocket-launches', false, { origin: 'user' });
await f.settle();
assert.deepEqual(f.view(), framed);
});
+4 -15
View File
@@ -50,9 +50,8 @@ export function createIngestion({
layerState._postTleRetryCount++;
layerState._retryTimer = setTimeout(() => {
layerState._retryTimer = null;
// A failed retry is already recorded in the layer stats.
if (layerState._enabled && token === layerState._lifecycleToken)
requestMissionUpdate().catch(() => {});
requestMissionUpdate();
}, POST_TLE_RETRY_DELAY_MS);
}
@@ -202,10 +201,6 @@ export function createIngestion({
if (layerState._renderedTleText === layerState._activeTleText)
clearPostTleRetry();
layerState._count = launches.length;
// The roster reads these to tell a loaded empty index from loading.
layerState._lastUpdate = Date.now();
layerState._lastError = null;
layerState._stale = stale === true;
parts.panel.renderMissionRoster();
if (
!layerState._selectedLaunchId ||
@@ -224,19 +219,13 @@ export function createIngestion({
}
layerState._focusAfterActiveLookup = false;
parts.panel.renderMissionPanel();
layerState._entryCameraView = null;
layerState._lastUpdate = Date.now();
layerState._lastError = null;
layerState._stale = stale === true;
} catch (error) {
if (layerState._enabled && token === layerState._lifecycleToken) {
layerState._lastError = error.message;
console.warn('[Data:RocketLaunches] Fetch error:', error);
// Without one usable index there is no mode to enter: report the
// failure so the manager fails the enable closed and Context rolls
// back. A refresh after a loaded index keeps its records instead.
if (layerState._lastUpdate === null) {
parts.panel.renderMissionRoster();
throw error;
}
layerState._entryCameraView = null;
}
}
}
-34
View File
@@ -111,28 +111,6 @@ export function createLaunchHover({
};
}
/** The camera pose to give back, in world coordinates. */
function captureCameraView(viewer) {
const camera = viewer?.camera;
if (!camera?.positionWC) return null;
return {
destination: Cesium.Cartesian3.clone(camera.positionWC),
orientation: {
heading: camera.heading,
pitch: camera.pitch,
roll: camera.roll,
},
};
}
/** Stop the full-globe flight and return to a captured pose. */
function restoreCameraView(viewer, view) {
const camera = viewer?.camera;
if (!camera) return;
camera.cancelFlight?.();
camera.setView(view);
}
export function createLifecycle({
state: layerState,
services,
@@ -222,14 +200,6 @@ export function createLifecycle({
layerState._updateDirty = false;
if (layerState._dataSource) layerState._dataSource.show = true;
parts.orbitRendering.syncMissionOrbitPrimitiveVisibility();
if (layerState._lastUpdate === null) {
// A new attempt at an index that never loaded starts from loading.
layerState._lastError = null;
parts.panel.renderMissionRoster();
}
// Kept until the mission index loads, so an entry that never does
// gives the view back.
layerState._entryCameraView = captureCameraView(layerState._viewer);
parts.selection.focusFullGlobe(layerState._viewer);
if (layerState._selectedLaunchId)
parts.selection.setSelectedMission(
@@ -244,9 +214,6 @@ export function createLifecycle({
hover?.reset();
layerState._sourceController.abort();
layerState._enabled = false;
const entryView = layerState._entryCameraView;
layerState._entryCameraView = null;
if (entryView) restoreCameraView(layerState._viewer, entryView);
releaseContinuousRender('rocket-launches');
layerState._lifecycleToken++;
layerState._updateDirty = false;
@@ -272,7 +239,6 @@ export function createLifecycle({
layerState._sourceController.abort();
releaseContinuousRender('rocket-launches'); // direct-destroy path (perf wave 2 fix)
layerState._enabled = false;
layerState._entryCameraView = null;
layerState._lifecycleToken++;
layerState._updateDirty = false;
await parts.ingestion.restoreSatelliteDependency();
+1 -12
View File
@@ -263,19 +263,8 @@ export function createPanel({ state: layerState, services, parts, source }) {
publishPanel();
}
/**
* The roster entries as the list shows them; null while the first index
* is still loading, and an empty failed roster once that load failed.
*/
/** The roster entries as the list shows them. */
function presentRoster() {
if (layerState._lastUpdate === null && !layerState._launches.length) {
if (!layerState._lastError) return null;
return {
count: '—',
entries: [],
empty: t('30-DAY MISSION INDEX UNAVAILABLE'),
};
}
const entries = parts.policyHelpers
.missionRosterEntries(layerState._launches)
.map(({ launch, index }) => {
-2
View File
@@ -19,8 +19,6 @@ export function createState({ services }) {
state._lastError = null;
state._entryCameraView = null;
state._stale = false;
state._orbitMatches = 0;
+6 -1
View File
@@ -21,6 +21,7 @@ import {
REFRESH_MS,
FOCUS_EVIDENCE_DEV,
} from './policy.js';
import { VESSEL_OVERLAY_SOURCE_ID } from '../../data/vesselLabels.js';
export function createQueries({
vesselState,
@@ -469,7 +470,7 @@ export function createQueries({
* @param {Object} [options={}] - Options from the detection system.
* @param {number} [options.maxCount] - Maximum objects to return (defaults to all).
* @param {number} [options.seed] - Seed offset for stride sampling.
* @returns {Array<{position: Cesium.Cartesian3, id: string, type: string, skipLabel: boolean}>}
* @returns {Array<{position: Cesium.Cartesian3, id: string, type: string, skipLabel: boolean, cardSourceId: string, cardEntryId: string}>}
*/
getDetectableObjects(options = {}) {
if (
@@ -504,6 +505,10 @@ export function createQueries({
id: record.name || record.mmsi || 'VESSEL',
type: 'SEA',
skipLabel: record === selected,
// The vessel's ambient card: while the overlay paints it, detection
// draws no callout under it.
cardSourceId: VESSEL_OVERLAY_SOURCE_ID,
cardEntryId: components.cards.vesselOverlayEntryId(record),
klass: record.type
? String(record.type).toUpperCase().slice(0, 14)
: undefined,
+29
View File
@@ -1065,6 +1065,28 @@ export function getOverlayPaintRect(sourceId, entryId) {
return rect && rect.key === key && rect.stamp === _frameStamp ? rect : null;
}
/**
* Whether an entry is queued to paint in the frame being painted. Custom lanes
* run before the host's own entries, so `getOverlayPaintRect` has nothing for
* this frame yet while they paint; this answers from the solved paint queue.
* @param {string} sourceId
* @param {string} entryId
* @returns {boolean}
*/
export function isOverlayEntryQueued(sourceId, entryId) {
if (_destroyed) return false;
const record = _records.get(entryKey(String(sourceId), String(entryId)));
if (!record || record.queuedStamp !== _frameStamp) return false;
// Same channels as the paint, minus the per-frame keyhole and sonar fades.
return (
record.sourceAlpha *
record.queuedItem.temporalAlpha *
record.distanceAlpha *
record.altitudeAlpha >
0.001
);
}
/**
* Resolve the topmost interactive world-overlay entry at CSS-pixel coords.
* @param {number} x
@@ -1737,6 +1759,8 @@ function getProjectionRecord(entry) {
contentAlpha: 1,
altitudeAlpha: 1,
sourceAlpha: 1,
queuedStamp: 0,
queuedItem: null,
protectedPlacement: null,
distanceOptions: {},
altitudeOptions: {},
@@ -2254,6 +2278,11 @@ function addPaintItem(record, placement, temporalAlpha, selected) {
item.zIndex = record.entry.zIndex;
item.key = record.key;
_paintQueue[_paintCount++] = item;
// Read by `isOverlayEntryQueued` while the custom lanes paint, before this
// item publishes its paint rect. Only a stamp and a pointer: storing the
// item's alpha here would box a double per paint item per frame.
record.queuedStamp = _frameStamp;
record.queuedItem = item;
}
function sourceCanPaint(sourceId) {
+38
View File
@@ -24,6 +24,7 @@ import {
activateWorldOverlayAction,
initWorldOverlay,
subscribeWorldOverlayActions,
isOverlayEntryQueued,
isOverlayPointVisible,
normalizeOverlayEntry,
paintLaneForOverlayEntry,
@@ -1213,6 +1214,43 @@ test('custom detection lane receives the shared host frame and paints below ordi
env.cleanup();
});
test('a detection lane sees which cards this frame will paint before they publish paint rects', () => {
// The detection lane paints first, so a card's paint rectangle for this
// frame does not exist yet when it runs. The queued check is what lets a
// contact's callout give way to that contact's own card in the same frame.
const env = installMockEnvironment({ width: 400, height: 300, dpr: 1 });
initWorldOverlay(env.viewer);
const seen = [];
registerWorldOverlayPaintLane('detection', () => {
seen.push({
shown: isOverlayEntryQueued('cards', 'SHOWN'),
hidden: isOverlayEntryQueued('hidden-cards', 'HIDDEN'),
unknown: isOverlayEntryQueued('cards', 'NOT-PUBLISHED'),
rect: getOverlayPaintRect('cards', 'SHOWN'),
});
}, { id: 'detection', active: true, target: 'detection' });
setOverlayEntries('cards', [selectedEntry('SHOWN', {
position: positionAtScreen(200, 150),
})]);
setOverlayEntries('hidden-cards', [selectedEntry('HIDDEN', {
position: positionAtScreen(100, 100),
})]);
setOverlaySourceVisible('hidden-cards', false);
env.postRender.raise();
assert.deepEqual(seen[0], { shown: true, hidden: false, unknown: false, rect: null },
'the card is queued while its paint rect is still unpublished');
assert.ok(getOverlayPaintRect('cards', 'SHOWN'), 'the card did paint after the lane');
assert.equal(getOverlayPaintRect('hidden-cards', 'HIDDEN'), null);
clearOverlaySource('cards');
env.postRender.raise();
assert.equal(seen[1].shown, false, 'a card cleared since the last frame is no longer queued');
destroyWorldOverlay();
assert.equal(isOverlayEntryQueued('cards', 'SHOWN'), false);
env.cleanup();
});
test('custom-lane painter state is bracketed by host save/restore', () => {
// A painter that leaks a clip or alpha must not corrupt later frames: the
// host wraps every custom-lane painter in save/try/finally-restore. Pin the