mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-07 03:44:00 +00:00
Compare commits
37
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bdd6081fa0 | ||
|
|
d496de0d40 | ||
|
|
055709c12b | ||
|
|
d61a9225eb | ||
|
|
d544613aae | ||
|
|
5bab5ec26d | ||
|
|
5e64e599e1 | ||
|
|
ea22d16c1a | ||
|
|
4e518821b5 | ||
|
|
3d6423c6f8 | ||
|
|
15fdf65a73 | ||
|
|
aca5317a2a | ||
|
|
141181f83a | ||
|
|
0dbbd9047e | ||
|
|
7f8556c691 | ||
|
|
2c936ae131 | ||
|
|
b68cd25e9e | ||
|
|
66a5c483c2 | ||
|
|
f45d4bcc93 | ||
|
|
3689d2e6ca | ||
|
|
6af7cef3a3 | ||
|
|
25340f735c | ||
|
|
2a6a3922af | ||
|
|
11602742a8 | ||
|
|
657277dbf9 | ||
|
|
92666fdbaf | ||
|
|
deb02f635e | ||
|
|
c6decfced4 | ||
|
|
b358b5dba1 | ||
|
|
1330605c18 | ||
|
|
9a5cafca27 | ||
|
|
0e2ae76f0a | ||
|
|
3a7c7b7c6a | ||
|
|
abaaf0c254 | ||
|
|
a803be1af2 | ||
|
|
81f49406b8 | ||
|
|
28f3243090 |
No files matched your search
@@ -0,0 +1,4 @@
|
||||
- The Minimal HUD layout no longer draws the centred MGRS / LAT / LON banner
|
||||
across the globe; the bottom-left readout still shows the same position. In
|
||||
Minimal, Tactical and Cockpit that readout now sits clear of the map
|
||||
attribution row instead of overlapping it (Said Turksever, #1523).
|
||||
@@ -0,0 +1,3 @@
|
||||
- Let the voice assistant read the storm you select in Cyclone advisories:
|
||||
its official advisory, position, intensity and movement. Clearing the
|
||||
selection and the storm leaving the feed are reported apart (kvnloo, #847).
|
||||
@@ -0,0 +1,4 @@
|
||||
- Reduce dead-reckoning work for flights already hidden behind the horizon.
|
||||
Hidden contacts update on a staggered one-in-13-ticks rotation (about once a
|
||||
second) while visible contacts and contacts with fleet models retain the full
|
||||
update path (Wilmer Alzate, #870).
|
||||
+17
-1
@@ -458,6 +458,16 @@ that names Operator restores the default Tactical layout. Operator was the
|
||||
only layout that showed the HUD's top banner bar, so its markup and styles are
|
||||
removed too.
|
||||
|
||||
Minimal no longer shows the centred MGRS / LAT / LON bar near the bottom of the
|
||||
globe (November 3, 2026, #1523): the bottom-left readout already carries the
|
||||
same position, and the bar sat across the globe. Cyber keeps its own bar. The
|
||||
attribution row's position is one variable, `--gev-credits-bottom` in
|
||||
`command-dock-trays.css`, and the lower-left readout of Minimal, Tactical, Cyber
|
||||
and Cockpit sits `--gev-credits-reserve` (2.75rem) above it, which leaves 1rem
|
||||
over the 28px logo row with Scope on or off. Phones hide that readout.
|
||||
`scripts/qa-scope-hud.mjs` checks the hidden banner and that clearance against
|
||||
the rendered attribution row.
|
||||
|
||||
## Cyber HUD — September 30, 2026
|
||||
|
||||
Display > HUD > Layout includes Cyber, also available through the HUD voice
|
||||
@@ -742,6 +752,12 @@ geographic seams and releases on disable; forecast animation is not implied.
|
||||
NHC movement speed is converted from miles per hour to rounded knots by the
|
||||
proxy; advisory wind intensity is already in knots and is passed through.
|
||||
Cyclone markers, labels, tracks and cones are hidden beyond the horizon on every map source.
|
||||
Explicitly selecting a storm publishes one shared `weather-cyclones` record:
|
||||
advisory provenance, source position, the English classification name and,
|
||||
while the proxy serves its cache, the cache fetch time. The automatic row
|
||||
selection does not claim shared context; refreshes do not re-announce it.
|
||||
Clear or disable reports `deliberate`; a storm leaving the feed reports
|
||||
`evicted`, with no fallback.
|
||||
Consult the linked official advisory for safety decisions.
|
||||
|
||||
Voice and HUD snapshots reuse the existing feedState classifier. Analyst follow-ups retain their original data provenance; current-view results append provenance without replacing legacy fields. HUD context and deterministic telemetry include non-nominal feed state.
|
||||
@@ -5350,7 +5366,7 @@ silently demoting every later lookup for the session.
|
||||
|
||||
### Motion & Symbology Correctness (June 10, 2026)
|
||||
|
||||
- **Flights (commercial + military)** render one poll interval behind real time (30s/15s) and interpolate between two known feed-stamped fixes (OpenSky `time_position`; adsb.lol `receipt − seen_pos`). The display latency is an intentional product decision — do not "fix" it away. The whole fleet dead-reckons at ~12Hz (1m² write gating); aircraft get a 3-poll grace period (faded icon) before removal. When a position epoch pauses, both layers coast for at least 60 seconds of contact grace with an absolute five-minute ceiling. Source backoff marks each contact and the cockpit `STALE`; the cockpit then holds the exact layer position instead of continuing inertial flight. Repeated-position kinematic changes create a forward-only synthetic fix rather than mutating history, and grounded history is lifted only when no owned 3D model already controls its datum. A nominally successful worldwide OpenSky response whose own snapshot epoch is more than two minutes old prefers the existing 250-nm viewport-scoped adsb.lol fallback and labels the source/coverage accordingly when that upstream is available. If the fallback is also unavailable, the layer's freshness/error fields use the source epoch—never the cache receipt time—so the UI reports an old snapshot rather than “just now.”
|
||||
- **Flights (commercial + military)** render one poll interval behind real time (30s/15s) and interpolate between two known feed-stamped fixes (OpenSky `time_position`; adsb.lol `receipt − seen_pos`). The display latency is an intentional product decision — do not "fix" it away. The fleet dead-reckons at ~12Hz (1m² write gating); a contact already hidden behind the horizon whose last position still is re-reckons on a staggered one-in-13-ticks rotation (about once a second) instead, so the worldwide feed's ~13k contacts no longer cost a full pass when well under 1% are on screen. Anything that position puts in view, and any contact with a fleet model, takes the full pass immediately, and hidden-inclusive proximity queries read positions at most about a second old; aircraft get a 3-poll grace period (faded icon) before removal. When a position epoch pauses, both layers coast for at least 60 seconds of contact grace with an absolute five-minute ceiling. Source backoff marks each contact and the cockpit `STALE`; the cockpit then holds the exact layer position instead of continuing inertial flight. Repeated-position kinematic changes create a forward-only synthetic fix rather than mutating history, and grounded history is lifted only when no owned 3D model already controls its datum. A nominally successful worldwide OpenSky response whose own snapshot epoch is more than two minutes old prefers the existing 250-nm viewport-scoped adsb.lol fallback and labels the source/coverage accordingly when that upstream is available. If the fallback is also unavailable, the layer's freshness/error fields use the source epoch—never the cache receipt time—so the UI reports an old snapshot rather than “just now.”
|
||||
- **World-space headings at every angle** (`src/data/iconOrientation.js`): aircraft/vessel icon rotation uses the camera right/up basis per tick (alignedAxis always ZERO), which is exact at screen center and for orthographic/nadir views and remains stable through >180° tracked orbits. Perspective rays vary across the viewport, so off-center contacts at oblique pitch can diverge from an exact finite-difference window projection; a regression test pins that known regime, and field evidence decides whether to adopt exact projection with the basis method as fallback. Fleet rotations refresh on camera-pose change; tracked entities per frame. Billboards are horizon-culled via a shared EllipsoidalOccluder.
|
||||
- **Military/OpenSky reconciliation** (`src/data/militaryRegistry.js`): known-military ICAOs render amber in the flights layer (60s self-poll of the cached mil endpoint when the military layer is off) and are suppressed there while the military layer renders them.
|
||||
- **Satellites**: 838-sat core catalog (stations/visual/GPS/GLONASS/Galileo/GEO), tracking lands ~726km out via `viewFrom` (tracked entity owns a point graphic so the tracking camera engages), rings realign via primitive modelMatrix (no per-second rebuild flicker), optional `setParams({catalog:'dense'})` Starlink mode.
|
||||
|
||||
@@ -347,6 +347,7 @@
|
||||
"src/layers/flights/tracking.js",
|
||||
"src/layers/flights/rendering.js",
|
||||
"src/layers/flights/testing.js",
|
||||
"src/layers/flights/hiddenContactRotation.test.mjs",
|
||||
"src/layers/flights/evidence.js",
|
||||
"src/layers/flights/ingestion.js",
|
||||
"src/layers/flights/queries.js",
|
||||
|
||||
@@ -2336,6 +2336,7 @@
|
||||
"src/app/layers/alprCameras.js",
|
||||
"src/app/layers/bikeshare.js",
|
||||
"src/app/layers/cctv.js",
|
||||
"src/app/layers/cyclones.js",
|
||||
"src/app/layers/earthquakes.js",
|
||||
"src/app/layers/firms.js",
|
||||
"src/app/layers/flights.js",
|
||||
@@ -3380,6 +3381,7 @@
|
||||
"src/app/layers/alprCameras.js",
|
||||
"src/app/layers/bikeshare.js",
|
||||
"src/app/layers/cctv.js",
|
||||
"src/app/layers/cyclones.js",
|
||||
"src/app/layers/earthquakes.js",
|
||||
"src/app/layers/firms.js",
|
||||
"src/app/layers/flights.js",
|
||||
|
||||
+114
-1
@@ -5,7 +5,9 @@
|
||||
* Optional: QA_BASE_URL, QA_SCREENSHOT_DIR (screenshots stay outside the repo).
|
||||
* Proves a fresh boot defaults to Scope ON at 100%, native checkbox click/Space,
|
||||
* feather 0/11/100/200, layout independence,
|
||||
* share/scene restore, and visible text backing including rotated metadata.
|
||||
* share/scene restore, visible text backing including rotated metadata,
|
||||
* Minimal's banner removal, and coordinate/attribution clearance for Minimal
|
||||
* and Tactical in map view and Minimal in Cockpit.
|
||||
*/
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
@@ -666,6 +668,117 @@ try {
|
||||
await settleTextBackings(false);
|
||||
checkSceneChrome(`narrow ${layout}`, before, await readSceneChrome());
|
||||
}
|
||||
// The lower-left readout sits --gev-credits-reserve (2.75rem) above the
|
||||
// attribution anchor; the reserve covers a 28px logo row, so the readout box
|
||||
// clears the row by 1rem.
|
||||
const readCornerGeometry = () =>
|
||||
page.evaluate(() => {
|
||||
const banner = document.querySelector('#intel-hud .hud-bottom-bar');
|
||||
const coordinates = document.querySelector('#intel-hud .hud-bottom-left');
|
||||
// The fixed host has zero size; Cesium positions its actual row inside it.
|
||||
const credits = document.querySelector(
|
||||
'#cesium-credits .cesium-widget-credits',
|
||||
);
|
||||
const coordinateBox = coordinates.getBoundingClientRect();
|
||||
const creditBox = credits.getBoundingClientRect();
|
||||
return {
|
||||
bannerHidden: getComputedStyle(banner).display === 'none',
|
||||
coordinatesVisible: coordinateBox.width > 0 && coordinateBox.height > 0,
|
||||
coordinatesHidden: getComputedStyle(coordinates).display === 'none',
|
||||
creditsVisible: creditBox.width > 0 && creditBox.height > 0,
|
||||
creditRowHeight: creditBox.height,
|
||||
gap: creditBox.top - coordinateBox.bottom,
|
||||
bottom: getComputedStyle(coordinates).bottom,
|
||||
};
|
||||
});
|
||||
const clearsCredits = (geometry) =>
|
||||
geometry.coordinatesVisible &&
|
||||
geometry.creditsVisible &&
|
||||
geometry.creditRowHeight <= 28.5 &&
|
||||
geometry.gap >= 15.5;
|
||||
for (const viewport of [
|
||||
{ width: 1600, height: 900 },
|
||||
{ width: 1024, height: 700 },
|
||||
{ width: 820, height: 700 },
|
||||
{ width: 390, height: 844 },
|
||||
]) {
|
||||
await page.setViewport(viewport);
|
||||
for (const layout of ['minimal', 'tactical']) {
|
||||
await page.select('#hud-layout-select', layout);
|
||||
for (const enabled of [true, false]) {
|
||||
await page.evaluate(
|
||||
(enabled) =>
|
||||
window.__godsEyeView.styleManager.applyVisualState({
|
||||
scope: { enabled },
|
||||
}),
|
||||
enabled,
|
||||
);
|
||||
await settleTextBackings(enabled);
|
||||
// Wait for the responsive shell and the real attribution logo to settle.
|
||||
await page.waitForFunction(
|
||||
(mobile) =>
|
||||
document.body.classList.contains('mobile-shell') === mobile &&
|
||||
[...document.querySelectorAll('#cesium-credits img')].some(
|
||||
(img) => img.complete && img.naturalWidth > 0,
|
||||
),
|
||||
{},
|
||||
viewport.width <= 720,
|
||||
);
|
||||
const geometry = await readCornerGeometry();
|
||||
const name = `${layout === 'minimal' ? 'Minimal' : 'Tactical'} ${viewport.width}px Scope ${enabled ? 'ON' : 'OFF'}`;
|
||||
if (layout === 'minimal') {
|
||||
check(
|
||||
`${name}: centred coordinate banner stays hidden`,
|
||||
geometry.bannerHidden,
|
||||
geometry,
|
||||
);
|
||||
}
|
||||
if (viewport.width <= 720) {
|
||||
check(
|
||||
`${name}: phone hides the coordinate readout and keeps attribution`,
|
||||
geometry.coordinatesHidden && geometry.creditsVisible,
|
||||
geometry,
|
||||
);
|
||||
} else {
|
||||
check(
|
||||
`${name}: coordinate block clears the full attribution row by 1rem`,
|
||||
clearsCredits(geometry),
|
||||
geometry,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Cockpit's corner offset outranks every layout's. The body class carries
|
||||
// that layout; entering for real needs a tracked aircraft.
|
||||
await page.select('#hud-layout-select', 'minimal');
|
||||
for (const viewport of [
|
||||
{ width: 1600, height: 900 },
|
||||
{ width: 1024, height: 700 },
|
||||
]) {
|
||||
await page.setViewport(viewport);
|
||||
await page.waitForFunction(
|
||||
() => !document.body.classList.contains('mobile-shell'),
|
||||
);
|
||||
await page.evaluate(() => document.body.classList.add('cockpit-mode'));
|
||||
for (const enabled of [true, false]) {
|
||||
await page.evaluate(
|
||||
(enabled) =>
|
||||
window.__godsEyeView.styleManager.applyVisualState({
|
||||
scope: { enabled },
|
||||
}),
|
||||
enabled,
|
||||
);
|
||||
await settleTextBackings(enabled);
|
||||
const geometry = await readCornerGeometry();
|
||||
check(
|
||||
`Cockpit Minimal ${viewport.width}px Scope ${enabled ? 'ON' : 'OFF'}: coordinate block clears the full attribution row by 1rem`,
|
||||
clearsCredits(geometry),
|
||||
geometry,
|
||||
);
|
||||
}
|
||||
await page.evaluate(() => document.body.classList.remove('cockpit-mode'));
|
||||
}
|
||||
check('runtime has no uncaught browser errors', errors.length === 0, errors);
|
||||
} finally {
|
||||
await browser.close();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { resolveCatalogSources } from './sourceComposition.js';
|
||||
import { createWeatherClock } from '../layers/weather/clock.js';
|
||||
import { createWeatherLayer } from '../layers/weather/index.js';
|
||||
import { createCyclonesLayer } from '../layers/cyclones/index.js';
|
||||
import { createApplicationCyclones } from './layers/cyclones.js';
|
||||
import { createWindLayer } from '../layers/wind/index.js';
|
||||
import { createLayerCatalog } from './catalog.js';
|
||||
import { LAYER_STATE_REGISTRY } from '../data/layerState.js';
|
||||
@@ -182,7 +182,7 @@ export function createApplicationCatalog({
|
||||
id: 'weather-lightning',
|
||||
clock: weatherClock,
|
||||
}),
|
||||
createCyclonesLayer({ feed: sources.cyclones }),
|
||||
createApplicationCyclones({ feed: sources.cyclones }),
|
||||
...createOsmOverlayLayers(),
|
||||
...createInfrastructureLayers(localGeoJsonServices),
|
||||
createApplicationCables({ source: sources.cables }),
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { createCyclonesLayer } from '../../layers/cyclones/index.js';
|
||||
import * as context from '../../data/contextStore.js';
|
||||
|
||||
/** Wire cyclone selection to the application shared context. */
|
||||
export function createApplicationCyclones(options) {
|
||||
return createCyclonesLayer({
|
||||
context: typeof window !== 'undefined' ? context : null,
|
||||
...options,
|
||||
});
|
||||
}
|
||||
@@ -299,9 +299,26 @@ function resolve(candidates, prop, width, label) {
|
||||
return winner;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline a custom property declared once, on a bare :root, so an offset written
|
||||
* through it evaluates to the length it stands for. Anything else stays a
|
||||
* var() for toPx to vet.
|
||||
*/
|
||||
function inlineRootVars(value) {
|
||||
const inlined = value.replace(/var\((--[\w-]+)\)/g, (match, name) => {
|
||||
const declarations = RULES.flatMap((rule) =>
|
||||
rule.decls.filter((decl) => decl.prop === name).map((decl) => ({ rule, decl })));
|
||||
if (declarations.length !== 1) return match;
|
||||
const [{ rule, decl }] = declarations;
|
||||
if (rule.media.length || rule.parts.join() !== ':root') return match;
|
||||
return decl.value.replace(/^calc\((.*)\)$/, '$1');
|
||||
});
|
||||
return inlined === value || /^calc\(/.test(inlined) ? inlined : `calc(${inlined})`;
|
||||
}
|
||||
|
||||
/** Evaluate a length. Anything the model cannot resolve exactly fails loudly. */
|
||||
function toPx(value, viewportHeight, where) {
|
||||
const trimmed = value.trim();
|
||||
const trimmed = inlineRootVars(value.trim());
|
||||
const inner = /^calc\(/.test(trimmed) ? trimmed.slice(5, -1) : trimmed;
|
||||
assert.doesNotMatch(inner, /\bcalc\(/, `nested calc() in ${where}: "${value}"`);
|
||||
assert.doesNotMatch(inner, /\b(min|max|clamp|env|attr|round|mod)\(/, `unmodelled function in ${where}: "${value}"`);
|
||||
@@ -579,7 +596,7 @@ test('the dock anchor changes at 720px — the 2vh cancellation is band-limited'
|
||||
assert.equal(resolve(['#command-dock'], 'bottom', 800, 'dock').decl.value, '2vh');
|
||||
assert.equal(resolve(['#command-dock'], 'bottom', 720, 'dock').decl.value, '8px');
|
||||
assert.equal(
|
||||
resolve(CREDIT_SELECTORS, 'bottom', 720, 'credit').decl.value,
|
||||
inlineRootVars(resolve(CREDIT_SELECTORS, 'bottom', 720, 'credit').decl.value),
|
||||
'calc(2vh + 5rem)',
|
||||
'the credit keeps its 2vh base below 720px — that asymmetry is the whole hazard',
|
||||
);
|
||||
|
||||
@@ -67,6 +67,7 @@ export function createCyclonesLayer({
|
||||
feed,
|
||||
hitTestOverlay = hitTestWorldOverlay,
|
||||
overlayHost,
|
||||
context = null,
|
||||
cesium = Cesium,
|
||||
createRendering = createCycloneRendering,
|
||||
matchMedia = globalThis.matchMedia?.bind(globalThis),
|
||||
@@ -82,6 +83,7 @@ export function createCyclonesLayer({
|
||||
selectedId = null,
|
||||
selectionIntent = 'auto',
|
||||
navigationGeneration = 0,
|
||||
selectedContextStormId = null,
|
||||
clickHandler = null,
|
||||
removeClickCapture = null;
|
||||
let enabled = false,
|
||||
@@ -92,6 +94,62 @@ export function createCyclonesLayer({
|
||||
const notify = () => listener?.();
|
||||
const selected = () =>
|
||||
snapshot?.storms.find((storm) => storm.id === selectedId) || null;
|
||||
function clearSelectedContext({ evicted = false } = {}) {
|
||||
if (selectedContextStormId) {
|
||||
if (!evicted)
|
||||
context.clearSelectedEntityContextForLayer('weather-cyclones');
|
||||
context.removeEntityContextsForLayer('weather-cyclones');
|
||||
}
|
||||
selectedContextStormId = null;
|
||||
}
|
||||
|
||||
// Callers publish only while selectedId names a storm in the snapshot.
|
||||
function publishSelectedContext({ announce = false } = {}) {
|
||||
const storm = selected();
|
||||
if (!context) return;
|
||||
const entity = {};
|
||||
const contextId = `cyclone:${storm.id}`;
|
||||
context.registerEntityContext(entity, {
|
||||
id: contextId,
|
||||
layerId: 'weather-cyclones',
|
||||
layerName: 'Cyclone advisories',
|
||||
source: 'NOAA NHC / CPHC',
|
||||
label: storm.name,
|
||||
latitude: storm.position.latitude,
|
||||
longitude: storm.position.longitude,
|
||||
// At most 12 properties reach the voice summary; keep the list short.
|
||||
properties: {
|
||||
classification: storm.classification,
|
||||
classificationName: Object.hasOwn(
|
||||
CLASSIFICATION_NAMES,
|
||||
storm.classification,
|
||||
)
|
||||
? CLASSIFICATION_NAMES[storm.classification]
|
||||
: undefined,
|
||||
basin: storm.basin,
|
||||
advisoryNumber: storm.advisoryNumber,
|
||||
issuedAt: storm.issuedAt,
|
||||
positionAt: storm.positionAt,
|
||||
windKt: storm.windKt,
|
||||
pressureHpa: storm.pressureHpa,
|
||||
// Flat scalars: the voice context summary drops nested objects.
|
||||
movementDirectionDegrees: storm.movement?.directionDegrees,
|
||||
movementSpeedKt: storm.movement?.speedKt,
|
||||
advisoryUrl: storm.advisoryUrl,
|
||||
// Only while the proxy serves its cache because NHC is unreachable.
|
||||
staleCacheFetchedAt:
|
||||
snapshot.stale && Number.isFinite(snapshot.fetchedAt)
|
||||
? new Date(snapshot.fetchedAt).toISOString()
|
||||
: undefined,
|
||||
},
|
||||
});
|
||||
selectedContextStormId = storm.id;
|
||||
if (announce) context.selectEntityContext(entity);
|
||||
context.removeEntityContextsForLayer('weather-cyclones', {
|
||||
retainIds: new Set([contextId]),
|
||||
});
|
||||
}
|
||||
|
||||
function select(id) {
|
||||
if (selectedId !== id) ++navigationGeneration;
|
||||
selectedId = id;
|
||||
@@ -236,6 +294,7 @@ export function createCyclonesLayer({
|
||||
selectedId = null;
|
||||
selectionIntent = 'auto';
|
||||
++navigationGeneration;
|
||||
clearSelectedContext();
|
||||
rendering?.clear();
|
||||
},
|
||||
async update(_viewer, { signal } = {}) {
|
||||
@@ -259,6 +318,7 @@ export function createCyclonesLayer({
|
||||
snapshot = next;
|
||||
selectedId = null;
|
||||
++navigationGeneration;
|
||||
clearSelectedContext({ evicted: true });
|
||||
error = next.reason || ADVISORIES_UNAVAILABLE;
|
||||
return true;
|
||||
}
|
||||
@@ -281,6 +341,10 @@ export function createCyclonesLayer({
|
||||
: snapshot.storms[0]?.id || null,
|
||||
);
|
||||
else rendering.setSelection(selectedId);
|
||||
if (selectedContextStormId) {
|
||||
if (selectedId === selectedContextStormId) publishSelectedContext();
|
||||
else clearSelectedContext({ evicted: true });
|
||||
}
|
||||
return true;
|
||||
} catch (cause) {
|
||||
if (controller.signal.aborted || request !== controller) return false;
|
||||
@@ -290,6 +354,7 @@ export function createCyclonesLayer({
|
||||
snapshot = null;
|
||||
selectedId = null;
|
||||
++navigationGeneration;
|
||||
clearSelectedContext({ evicted: true });
|
||||
return true;
|
||||
} finally {
|
||||
signal?.removeEventListener('abort', abort);
|
||||
@@ -305,6 +370,7 @@ export function createCyclonesLayer({
|
||||
if (params.clear === true || params.stormId === null) {
|
||||
selectionIntent = 'cleared';
|
||||
select(null);
|
||||
clearSelectedContext();
|
||||
notify();
|
||||
} else if (
|
||||
typeof params.stormId === 'string' &&
|
||||
@@ -312,6 +378,7 @@ export function createCyclonesLayer({
|
||||
) {
|
||||
selectionIntent = 'user';
|
||||
select(params.stormId);
|
||||
publishSelectedContext({ announce: true });
|
||||
notify();
|
||||
}
|
||||
const storm = selected();
|
||||
|
||||
@@ -7,6 +7,9 @@ import {
|
||||
isPointerFree,
|
||||
} from '../../data/inputOwnership.js';
|
||||
import { isOwnedByOtherLayer } from '../../data/pickRegistry.js';
|
||||
import * as contextStore from '../../data/contextStore.js';
|
||||
import { createGevActionRunner } from '../../voice/gevActions.js';
|
||||
import * as Cesium from 'cesium';
|
||||
|
||||
const time = '2026-09-16T03:00:00.000Z';
|
||||
const storm = (id = 'ep152026') => ({
|
||||
@@ -338,7 +341,7 @@ test('a click on a storm card or lead-hour label selects that storm without pick
|
||||
});
|
||||
function harness(
|
||||
feed = { getSnapshot: async () => snapshot() },
|
||||
{ reducedMotion = false } = {},
|
||||
{ reducedMotion = false, context = null } = {},
|
||||
) {
|
||||
const applied = [],
|
||||
navigation = [],
|
||||
@@ -368,6 +371,7 @@ function harness(
|
||||
};
|
||||
const layer = createCyclonesLayer({
|
||||
feed,
|
||||
context,
|
||||
createRendering: () => rendering,
|
||||
matchMedia: () => ({ matches: reducedMotion }),
|
||||
openLink: (url) => opened.push(url),
|
||||
@@ -401,6 +405,256 @@ function harness(
|
||||
},
|
||||
};
|
||||
}
|
||||
// Real shared-context store on a stand-in window; the voice runner reads it.
|
||||
async function withSharedContext(t, body) {
|
||||
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, 'window');
|
||||
const host = new EventTarget();
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: host,
|
||||
});
|
||||
const layers = [];
|
||||
t.after(() => {
|
||||
try {
|
||||
for (const layer of layers) layer.destroy();
|
||||
} finally {
|
||||
if (previousWindow)
|
||||
Object.defineProperty(globalThis, 'window', previousWindow);
|
||||
else delete globalThis.window;
|
||||
}
|
||||
});
|
||||
const events = [];
|
||||
host.addEventListener('gev:entity-selected', ({ detail }) =>
|
||||
events.push(['selected', detail.id]),
|
||||
);
|
||||
host.addEventListener('gev:entity-selection-cleared', ({ detail }) =>
|
||||
events.push(['cleared', detail.layerId, detail.reason]),
|
||||
);
|
||||
// The voice runner bounds its basemap wait with the window timers.
|
||||
Object.assign(host, { setTimeout, clearTimeout });
|
||||
const position = Cesium.Cartesian3.fromDegrees(-125.8, 15.5, 5_000_000);
|
||||
const runner = createGevActionRunner({
|
||||
viewer: {
|
||||
clock: { onTick: { addEventListener: () => () => {} } },
|
||||
scene: { canvas: { addEventListener() {}, removeEventListener() {} } },
|
||||
camera: {
|
||||
moveEnd: { addEventListener() {} },
|
||||
positionWC: position,
|
||||
positionCartographic: Cesium.Cartographic.fromCartesian(position),
|
||||
heading: 0,
|
||||
pitch: 0,
|
||||
},
|
||||
},
|
||||
styleManager: {},
|
||||
dataManager: {
|
||||
layers: new Map(),
|
||||
isEnabled: (id) => id === 'weather-cyclones',
|
||||
getAll: () => [],
|
||||
},
|
||||
});
|
||||
const selectedProperties = async () => {
|
||||
const result = await runner('get_entity_context', { scope: 'selected' });
|
||||
assert.equal(result.scope, 'selected');
|
||||
return result.selected.properties;
|
||||
};
|
||||
const store = contextStore.getContextStore();
|
||||
const open = (feed) => {
|
||||
const h = harness(feed, { context: contextStore });
|
||||
layers.push(h.layer);
|
||||
return h;
|
||||
};
|
||||
await body({ events, selectedProperties, store, open });
|
||||
}
|
||||
|
||||
test('shared cyclone context distinguishes switches, deliberate clears and feed eviction', async (t) => {
|
||||
await withSharedContext(
|
||||
t,
|
||||
async ({ events, selectedProperties, store, open }) => {
|
||||
let next = snapshot([
|
||||
{ ...storm(), movement: { directionDegrees: 315, speedKt: 12 } },
|
||||
{
|
||||
...storm('ep162026'),
|
||||
movement: { directionDegrees: 0, speedKt: 10 },
|
||||
},
|
||||
]);
|
||||
const h = open({ getSnapshot: async () => next });
|
||||
h.layer.enable();
|
||||
await h.layer.update();
|
||||
assert.deepEqual(
|
||||
events,
|
||||
[],
|
||||
'the automatic first-storm row selection must not claim shared context',
|
||||
);
|
||||
assert.equal(store.entities.size, 0);
|
||||
|
||||
h.layer.setParams({ stormId: 'ep152026' });
|
||||
assert.equal(store.entities.get('cyclone:ep152026').label, 'Fifteen-E');
|
||||
let properties = await selectedProperties();
|
||||
assert.equal(properties.classification, 'PTC');
|
||||
assert.equal(properties.classificationName, 'Post-tropical cyclone');
|
||||
assert.equal(properties.advisoryNumber, '10');
|
||||
assert.equal(
|
||||
properties.movementDirectionDegrees,
|
||||
'315',
|
||||
'selected storm movement should survive voice summarization',
|
||||
);
|
||||
assert.equal(properties.movementSpeedKt, '12');
|
||||
assert.equal(
|
||||
properties.advisoryUrl,
|
||||
'https://www.nhc.noaa.gov/text/MIATCMEP5.shtml',
|
||||
);
|
||||
assert.equal(properties.staleCacheFetchedAt, undefined);
|
||||
|
||||
h.layer.setParams({ stormId: 'ep162026' });
|
||||
properties = await selectedProperties();
|
||||
assert.equal(
|
||||
properties.movementDirectionDegrees,
|
||||
'0',
|
||||
'a due-north heading is a value, not a missing one',
|
||||
);
|
||||
assert.equal(properties.movementSpeedKt, '10');
|
||||
assert.deepEqual(events, [
|
||||
['selected', 'cyclone:ep152026'],
|
||||
['selected', 'cyclone:ep162026'],
|
||||
]);
|
||||
assert.deepEqual([...store.entities.keys()], ['cyclone:ep162026']);
|
||||
|
||||
next = snapshot([
|
||||
{ ...storm(), movement: { directionDegrees: 315, speedKt: 12 } },
|
||||
{ ...storm('ep162026'), movement: { directionDegrees: 0, speedKt: 0 } },
|
||||
]);
|
||||
await h.layer.update();
|
||||
assert.equal(events.length, 2, 'refresh must not announce selection');
|
||||
properties = await selectedProperties();
|
||||
assert.equal(
|
||||
properties.movementSpeedKt,
|
||||
'0',
|
||||
'stationary is a value, refreshed into the selected record',
|
||||
);
|
||||
|
||||
h.layer.setParams({ clear: true });
|
||||
assert.deepEqual(events.at(-1), [
|
||||
'cleared',
|
||||
'weather-cyclones',
|
||||
'deliberate',
|
||||
]);
|
||||
assert.equal(store.entities.size, 0);
|
||||
|
||||
h.layer.setParams({ stormId: 'ep162026' });
|
||||
next = snapshot([storm()]);
|
||||
await h.layer.update();
|
||||
assert.deepEqual(
|
||||
events.at(-1),
|
||||
['cleared', 'weather-cyclones', 'evicted'],
|
||||
'a vanished user-selected storm must clear instead of transferring context to the fallback row',
|
||||
);
|
||||
assert.equal(store.entities.size, 0);
|
||||
assert.equal(store.selectedEntityId, null);
|
||||
|
||||
h.layer.setParams({ stormId: 'ep152026' });
|
||||
h.layer.disable();
|
||||
assert.deepEqual(events.at(-1), [
|
||||
'cleared',
|
||||
'weather-cyclones',
|
||||
'deliberate',
|
||||
]);
|
||||
assert.equal(store.entities.size, 0);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('shared cyclone context is evicted when the source fails and marks a stale cache', async (t) => {
|
||||
await withSharedContext(
|
||||
t,
|
||||
async ({ events, selectedProperties, store, open }) => {
|
||||
let next = snapshot([{ ...storm(), classification: 'XX' }]);
|
||||
let fail = false;
|
||||
const h = open({
|
||||
getSnapshot: async () => {
|
||||
if (fail) throw new Error('Network unavailable');
|
||||
return next;
|
||||
},
|
||||
});
|
||||
h.layer.enable();
|
||||
await h.layer.update();
|
||||
h.layer.setParams({ stormId: 'ep152026' });
|
||||
let properties = await selectedProperties();
|
||||
assert.equal(properties.classification, 'XX');
|
||||
assert.equal(
|
||||
properties.classificationName,
|
||||
undefined,
|
||||
'an unknown code is not given a guessed meaning',
|
||||
);
|
||||
|
||||
next = { ...snapshot(), stale: true };
|
||||
await h.layer.update();
|
||||
properties = await selectedProperties();
|
||||
assert.equal(properties.staleCacheFetchedAt, time);
|
||||
assert.equal(events.length, 1, 'refresh must not announce selection');
|
||||
|
||||
next = {
|
||||
...snapshot([]),
|
||||
unavailable: true,
|
||||
reason: 'Cyclone data unavailable',
|
||||
};
|
||||
await h.layer.update();
|
||||
assert.deepEqual(events.at(-1), [
|
||||
'cleared',
|
||||
'weather-cyclones',
|
||||
'evicted',
|
||||
]);
|
||||
assert.equal(store.entities.size, 0);
|
||||
|
||||
next = snapshot();
|
||||
await h.layer.update();
|
||||
h.layer.setParams({ stormId: 'ep152026' });
|
||||
assert.equal(store.selectedEntityId, 'cyclone:ep152026');
|
||||
fail = true;
|
||||
await h.layer.update();
|
||||
assert.deepEqual(events.at(-1), [
|
||||
'cleared',
|
||||
'weather-cyclones',
|
||||
'evicted',
|
||||
]);
|
||||
assert.equal(store.entities.size, 0);
|
||||
assert.equal(store.selectedEntityId, null);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('cyclone clears leave another layer selection and its records alone', async (t) => {
|
||||
await withSharedContext(t, async ({ events, store, open }) => {
|
||||
const h = open({ getSnapshot: async () => snapshot() });
|
||||
h.layer.enable();
|
||||
await h.layer.update();
|
||||
h.layer.setParams({ stormId: 'ep152026' });
|
||||
const foreign = {};
|
||||
contextStore.registerEntityContext(foreign, {
|
||||
id: 'flight:abc123',
|
||||
layerId: 'flights',
|
||||
label: 'Foreign',
|
||||
});
|
||||
contextStore.selectEntityContext(foreign);
|
||||
const before = events.length;
|
||||
|
||||
h.layer.setParams({ clear: true });
|
||||
assert.equal(store.selectedEntityId, 'flight:abc123');
|
||||
assert.ok(store.entities.has('flight:abc123'));
|
||||
assert.equal(store.entities.has('cyclone:ep152026'), false);
|
||||
assert.equal(
|
||||
events.length,
|
||||
before,
|
||||
'no clear is reported for a foreign selection',
|
||||
);
|
||||
|
||||
// A cyclone clear does not report a dangling foreign selection as evicted.
|
||||
store.entities.delete('flight:abc123');
|
||||
h.layer.disable();
|
||||
assert.equal(store.selectedEntityId, 'flight:abc123');
|
||||
assert.equal(events.length, before);
|
||||
});
|
||||
});
|
||||
|
||||
test('refresh preserves selection intent, including explicit clears and missing storms', async () => {
|
||||
let next = snapshot([storm(), storm('ep162026')]);
|
||||
const h = harness({ getSnapshot: async () => next });
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import { createFlightState } from './state.js';
|
||||
import { createRendering } from './rendering.js';
|
||||
import {
|
||||
FLEET_DR_INTERVAL_MS,
|
||||
HIDDEN_CONTACT_DR_INTERVAL_MS,
|
||||
HIDDEN_CONTACT_DR_TICKS,
|
||||
hiddenContactDrPhase,
|
||||
} from './policy.js';
|
||||
|
||||
// Contacts hidden behind the horizon re-reckon on one fleet tick per rotation
|
||||
// instead of every tick. Their positions still feed hidden-inclusive proximity
|
||||
// queries, so the rotation must reach every contact about once a second, and
|
||||
// it must spread them across ticks so no single tick carries the whole fleet.
|
||||
|
||||
const icao = (n) => n.toString(16).padStart(6, '0');
|
||||
|
||||
test('the rotation revisits each hidden contact about once a second', () => {
|
||||
const periodMs = HIDDEN_CONTACT_DR_TICKS * FLEET_DR_INTERVAL_MS;
|
||||
assert.ok(HIDDEN_CONTACT_DR_TICKS > 1);
|
||||
assert.ok(
|
||||
Math.abs(periodMs - HIDDEN_CONTACT_DR_INTERVAL_MS) <= FLEET_DR_INTERVAL_MS,
|
||||
);
|
||||
});
|
||||
|
||||
test('a contact keeps the same slot, inside the rotation', () => {
|
||||
for (let n = 0; n < 500; n++) {
|
||||
const phase = hiddenContactDrPhase(icao(n * 7919));
|
||||
assert.ok(Number.isInteger(phase));
|
||||
assert.ok(phase >= 0 && phase < HIDDEN_CONTACT_DR_TICKS);
|
||||
assert.equal(hiddenContactDrPhase(icao(n * 7919)), phase);
|
||||
}
|
||||
});
|
||||
|
||||
test('a worldwide fleet is spread evenly enough that no tick carries a burst', () => {
|
||||
const fleet = 13000;
|
||||
const perSlot = new Array(HIDDEN_CONTACT_DR_TICKS).fill(0);
|
||||
// Real ICAO24 addresses cluster by national block; sample several blocks.
|
||||
for (let n = 0; n < fleet; n++)
|
||||
perSlot[hiddenContactDrPhase(icao(0x0a0000 + n * 53))]++;
|
||||
const fair = fleet / HIDDEN_CONTACT_DR_TICKS;
|
||||
for (const count of perSlot) assert.ok(count < fair * 1.5, `${count} > 1.5×`);
|
||||
});
|
||||
|
||||
/** Real fleet tick and horizon culling, with motion and presentation injected. */
|
||||
function fleetFixture() {
|
||||
let now = 1000;
|
||||
let reckonings = 0;
|
||||
const services = {
|
||||
groundSnap: { createGroundSnap: () => ({ clear() {} }) },
|
||||
militaryRegistry: { isMilitaryIcao: () => false },
|
||||
aircraftPresentation: { isTr3b: () => false },
|
||||
camera: {},
|
||||
focus: {
|
||||
focusNowMs: () => now,
|
||||
getFocusTarget: () => null,
|
||||
nearFarScalarValueAtDistance: () => 1,
|
||||
advanceProjectedSpriteFocus: () => ({ factor: 1 }),
|
||||
},
|
||||
recession: {
|
||||
applyAircraftBillboardTreatment: () => ({
|
||||
factors: { scale: 1 },
|
||||
alpha: 1,
|
||||
}),
|
||||
},
|
||||
};
|
||||
const flightState = createFlightState({ services });
|
||||
const camera = {
|
||||
positionWC: Cesium.Cartesian3.fromDegrees(0, 0, 100),
|
||||
positionCartographic: { height: 100 },
|
||||
heading: 0,
|
||||
pitch: 0,
|
||||
roll: 0,
|
||||
frustum: {
|
||||
computeCullingVolume: () => ({
|
||||
computeVisibility: () => Cesium.Intersect.INSIDE,
|
||||
}),
|
||||
},
|
||||
};
|
||||
const id = 'abc123';
|
||||
// 111 km away at ground level is beyond a 100 m camera's horizon, but
|
||||
// inside the model keep radius. No WebGL scene is needed for the culler.
|
||||
const billboard = {
|
||||
show: false,
|
||||
position: Cesium.Cartesian3.fromDegrees(1, 0, 0),
|
||||
};
|
||||
flightState._viewer = { camera, scene: {} };
|
||||
flightState._billboardCollection = { show: true };
|
||||
flightState._billboards.set(id, billboard);
|
||||
flightState.records.data.set(id, {});
|
||||
flightState._models3dEnabled = false;
|
||||
// Far cockpit contacts avoid glyph rasterization, keeping the fixture DOM-free.
|
||||
flightState._cockpitContactMode = true;
|
||||
const parts = {
|
||||
tracking: { _refreshCockpitNearContacts() {} },
|
||||
motion: {
|
||||
_deadReckon() {
|
||||
reckonings++;
|
||||
return billboard.position;
|
||||
},
|
||||
_floorGroundedDisplayPosition(id, info, position) {
|
||||
return position;
|
||||
},
|
||||
},
|
||||
};
|
||||
const rendering = createRendering({ flightState, services, parts });
|
||||
return {
|
||||
flightState,
|
||||
camera,
|
||||
billboard,
|
||||
id,
|
||||
reckonings: () => reckonings,
|
||||
tick() {
|
||||
now += FLEET_DR_INTERVAL_MS;
|
||||
rendering._fleetTick();
|
||||
},
|
||||
offTurn() {
|
||||
flightState._fleetTickSeq =
|
||||
(hiddenContactDrPhase(id) + 1) % HIDDEN_CONTACT_DR_TICKS;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('the fleet tick dead-reckons a hidden contact once per complete rotation', () => {
|
||||
const f = fleetFixture();
|
||||
for (let i = 0; i < HIDDEN_CONTACT_DR_TICKS; i++) f.tick();
|
||||
assert.equal(f.reckonings(), 1);
|
||||
assert.equal(f.billboard.show, false);
|
||||
});
|
||||
|
||||
test('a camera reveal bypasses the hidden rotation on the same fleet tick', () => {
|
||||
const f = fleetFixture();
|
||||
f.offTurn();
|
||||
f.tick();
|
||||
assert.equal(f.reckonings(), 0);
|
||||
f.camera.positionWC = Cesium.Cartesian3.fromDegrees(1, 0, 100);
|
||||
f.offTurn();
|
||||
f.tick();
|
||||
assert.equal(f.reckonings(), 1);
|
||||
assert.equal(f.billboard.show, true);
|
||||
});
|
||||
|
||||
test('a hidden contact with a retained fleet model is never rotation-skipped', () => {
|
||||
const f = fleetFixture();
|
||||
f.flightState._models3dEnabled = true;
|
||||
const model = { ready: true, show: true };
|
||||
f.flightState._models.set(f.id, model);
|
||||
f.offTurn();
|
||||
f.tick();
|
||||
assert.equal(f.reckonings(), 1);
|
||||
assert.equal(
|
||||
f.flightState._models.get(f.id),
|
||||
model,
|
||||
'model remains in keep range',
|
||||
);
|
||||
assert.equal(
|
||||
model.show,
|
||||
false,
|
||||
'the full pass hides its model beyond the horizon',
|
||||
);
|
||||
});
|
||||
@@ -198,6 +198,37 @@ export const FLEET_DR_INTERVAL_MS = 80;
|
||||
|
||||
export const ROTATION_REFRESH_MS = 1000;
|
||||
|
||||
/**
|
||||
* @constant {number} Dead-reckoning cadence (ms) for contacts hidden behind the
|
||||
* horizon. The worldwide feed carries ~13k contacts and a zoomed-in view shows
|
||||
* well under 1% of them; the rest still need positions for hidden-inclusive
|
||||
* proximity queries, but ~1 s old (≈250 m for an airliner) is well inside the
|
||||
* 30 s poll's own uncertainty. Each hidden contact is re-reckoned on one tick in
|
||||
* every HIDDEN_CONTACT_DR_TICKS, staggered by id so no tick carries them all.
|
||||
*/
|
||||
|
||||
export const HIDDEN_CONTACT_DR_INTERVAL_MS = 1000;
|
||||
|
||||
export const HIDDEN_CONTACT_DR_TICKS = Math.max(
|
||||
1,
|
||||
Math.round(HIDDEN_CONTACT_DR_INTERVAL_MS / FLEET_DR_INTERVAL_MS),
|
||||
);
|
||||
|
||||
/**
|
||||
* Stable slot in [0, HIDDEN_CONTACT_DR_TICKS) for a contact id, so each hidden
|
||||
* contact re-reckons on the same tick of every rotation.
|
||||
* @param {string} id Contact id (ICAO24).
|
||||
* @returns {number}
|
||||
*/
|
||||
|
||||
export function hiddenContactDrPhase(id) {
|
||||
const text = String(id);
|
||||
let hash = 0;
|
||||
for (let i = 0; i < text.length; i++)
|
||||
hash = (hash * 31 + text.charCodeAt(i)) >>> 0;
|
||||
return hash % HIDDEN_CONTACT_DR_TICKS;
|
||||
}
|
||||
|
||||
/** Max course slew (deg/s) — well above real turns (≤4°/s), hides fix-boundary
|
||||
* steps. Scaled down toward COURSE_MIN_DPS at low speed (courseSlewCapDps). */
|
||||
|
||||
|
||||
@@ -36,6 +36,8 @@ import {
|
||||
COURSE_SLEW_DT_MAX_SEC,
|
||||
ROTATION_REFRESH_MS,
|
||||
COURSE_MAX_DPS,
|
||||
HIDDEN_CONTACT_DR_TICKS,
|
||||
hiddenContactDrPhase,
|
||||
} from './policy.js';
|
||||
import { AIRCRAFT_ENVIRONMENT_MAP_OPTIONS } from '../../data/aircraftModelLighting.js';
|
||||
|
||||
@@ -839,6 +841,12 @@ export function createRendering({
|
||||
}
|
||||
}
|
||||
|
||||
/** The contact's hidden-rotation slot, cached on its billboard. */
|
||||
function _hiddenDrPhase(bb, icao24) {
|
||||
bb._gevHiddenDrPhase ??= hiddenContactDrPhase(icao24);
|
||||
return bb._gevHiddenDrPhase;
|
||||
}
|
||||
|
||||
function _fleetTick() {
|
||||
if (
|
||||
!flightState._viewer ||
|
||||
@@ -965,9 +973,25 @@ export function createRendering({
|
||||
for (const icao of toRelease) _releaseModel(icao);
|
||||
}
|
||||
|
||||
const hiddenDrPhase = flightState._fleetTickSeq++ % HIDDEN_CONTACT_DR_TICKS;
|
||||
|
||||
for (const [icao24, bb] of flightState._billboards) {
|
||||
if (icao24 === flightState._trackedIcao) continue; // tracked entity owns its own motion
|
||||
|
||||
// A contact still behind the horizon at its last position would only be
|
||||
// re-reckoned to be hidden again, so off its turn it keeps that position
|
||||
// (see HIDDEN_CONTACT_DR_TICKS). Anything the stale position puts in view,
|
||||
// and any contact with a fleet model, takes the full pass below at once.
|
||||
if (
|
||||
!bb.show &&
|
||||
_hiddenDrPhase(bb, icao24) !== hiddenDrPhase &&
|
||||
!flightState._models.has(icao24) &&
|
||||
!occluder.isPointVisible(
|
||||
flightState._cullPositions.get(icao24) || bb.position,
|
||||
)
|
||||
)
|
||||
continue;
|
||||
|
||||
const info = flightState.records.data.get(icao24);
|
||||
|
||||
const dr = parts.motion._deadReckon(
|
||||
|
||||
@@ -204,6 +204,10 @@ export function createFlightState({ source, services }) {
|
||||
|
||||
flightState._lastFleetTickMs = 0;
|
||||
|
||||
/** @type {number} Fleet ticks run; selects which hidden contacts re-reckon */
|
||||
|
||||
flightState._fleetTickSeq = 0;
|
||||
|
||||
/** @type {string} Camera pose signature at the last rotation pass */
|
||||
|
||||
flightState._lastCamPoseSig = '';
|
||||
|
||||
+37
-13
@@ -429,24 +429,48 @@ test('Cyber dock pins escape the tray mask and its decoration overrides the lega
|
||||
assert.match(decoration, /pointer-events:\s*none;/);
|
||||
});
|
||||
|
||||
test('Cyber lower-left telemetry tracks the credit anchor with clearance for its logo row', () => {
|
||||
const telemetry = cssRuleBody(
|
||||
cyberStyles,
|
||||
/#intel-hud\[data-variant='cyber'\] \.hud-bottom-left/,
|
||||
);
|
||||
const offset = telemetry.match(/bottom:\s*calc\(2vh \+ ([\d.]+)rem\);/);
|
||||
assert.ok(offset, 'telemetry must retain the attribution viewport anchor');
|
||||
test('lower-left telemetry tracks the credit anchor with clearance for its logo row', () => {
|
||||
const credits = read('./styles/command-dock-trays.css');
|
||||
const anchor = cssRuleBody(credits, /:root/);
|
||||
assert.match(anchor, /--gev-credits-bottom:\s*calc\(2vh \+ 5rem\);/);
|
||||
const reserve = anchor.match(/--gev-credits-reserve:\s*([\d.]+)rem;/);
|
||||
assert.ok(
|
||||
Number(reserve?.[1]) >= 2.75,
|
||||
'reserve the 28px logo row, baseline descent and 12px clearance at the 16px root size',
|
||||
);
|
||||
const creditOffsets = [
|
||||
...credits.matchAll(
|
||||
/#cesium-credits\s*\{[^}]*bottom:\s*calc\(2vh \+ ([\d.]+)rem\);/g,
|
||||
),
|
||||
...credits.matchAll(/#cesium-credits\s*\{[^}]*bottom:\s*([^;]+);/g),
|
||||
];
|
||||
assert.ok(creditOffsets.length > 0);
|
||||
for (const credit of creditOffsets) {
|
||||
assert.ok(
|
||||
Number(offset[1]) - Number(credit[1]) >= 2.75,
|
||||
'reserve the 28px logo row, baseline descent and 12px clearance at the 16px root size',
|
||||
assert.equal(credit[1], 'var(--gev-credits-bottom)');
|
||||
}
|
||||
const reserved =
|
||||
/bottom:\s*calc\(var\(--gev-credits-bottom\) \+ var\(--gev-credits-reserve\)\);/;
|
||||
assert.match(
|
||||
cssRuleBody(cyberStyles, /#intel-hud\[data-variant='cyber'\] \.hud-bottom-left/),
|
||||
reserved,
|
||||
);
|
||||
assert.match(
|
||||
cssRuleBody(
|
||||
credits,
|
||||
/#intel-hud\[data-variant='tactical'\] \.hud-bottom-left,\s*#intel-hud\[data-variant='minimal'\] \.hud-bottom-left/,
|
||||
),
|
||||
reserved,
|
||||
);
|
||||
// Cockpit's corner offset outranks the layouts' rules, so it reserves too.
|
||||
assert.match(
|
||||
read('./styles/cockpit.css'),
|
||||
new RegExp(
|
||||
`\\nbody\\.cockpit-mode #intel-hud \\.hud-bottom-left\\s*\\{[^}]*${reserved.source}`,
|
||||
),
|
||||
);
|
||||
// One rule owns each layout's lower-left offset; import order must not decide it.
|
||||
for (const file of ['overlays.css', 'command-dock.css', 'command-dock-compact.css']) {
|
||||
assert.doesNotMatch(
|
||||
read(`./styles/${file}`),
|
||||
/\[data-variant='\w+'\] \.hud-bottom-left\s*\{[^}]*bottom:/,
|
||||
file,
|
||||
);
|
||||
}
|
||||
assert.doesNotMatch(cyberStyles, /\.hud-bottom-left\s*\{\s*bottom:\s*60px;/);
|
||||
|
||||
@@ -2059,10 +2059,16 @@ body.cockpit-mode #intel-hud .hud-top-left,
|
||||
body.cockpit-mode #intel-hud .hud-top-right {
|
||||
top: clamp(104px, 13vh, 138px);
|
||||
}
|
||||
body.cockpit-mode #intel-hud .hud-bottom-left,
|
||||
body.cockpit-mode #intel-hud .hud-bottom-right {
|
||||
bottom: clamp(84px, 11vh, 118px);
|
||||
}
|
||||
/* The lower-left readout sits over the attribution row, which keeps its map
|
||||
position in Cockpit. Scaling from its bottom edge keeps it on that reserve
|
||||
and below the context window's rail. */
|
||||
body.cockpit-mode #intel-hud .hud-bottom-left {
|
||||
bottom: calc(var(--gev-credits-bottom) + var(--gev-credits-reserve));
|
||||
transform-origin: left bottom;
|
||||
}
|
||||
body.cockpit-mode #intel-hud .hud-summary-wrap {
|
||||
max-width: min(34vw, 380px);
|
||||
}
|
||||
|
||||
@@ -380,14 +380,6 @@ body:not(.ui-clean-view):not(.recording-mode) #cesium-credits {
|
||||
bottom: calc(2vh + 4.7rem);
|
||||
}
|
||||
|
||||
#intel-hud[data-variant='minimal'] .hud-bottom-left {
|
||||
bottom: calc(2vh + 5.4rem);
|
||||
}
|
||||
|
||||
#intel-hud[data-variant='minimal'] .hud-bottom-bar {
|
||||
bottom: calc(2vh + 8.9rem);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
#command-dock #location-bar .location-toolbar-label {
|
||||
overflow: hidden;
|
||||
|
||||
@@ -448,6 +448,14 @@
|
||||
letter-spacing: 0.035rem;
|
||||
}
|
||||
|
||||
/* The attribution row's anchor above the command dock. HUD readouts over the
|
||||
row sit the reserve above it: the 28px logo row, baseline descent and 12px
|
||||
clearance at the 16px root size. */
|
||||
:root {
|
||||
--gev-credits-bottom: calc(2vh + 5rem);
|
||||
--gev-credits-reserve: 2.75rem;
|
||||
}
|
||||
|
||||
/* Keep the complete inline attribution row above the command dock. */
|
||||
body:not(.ui-clean-view):not(.recording-mode) #cesium-credits,
|
||||
body:not(.ui-clean-view):not(.recording-mode):has(
|
||||
@@ -455,30 +463,20 @@ body:not(.ui-clean-view):not(.recording-mode):has(
|
||||
)
|
||||
#cesium-credits {
|
||||
right: auto;
|
||||
bottom: calc(2vh + 5rem);
|
||||
bottom: var(--gev-credits-bottom);
|
||||
left: 1.5rem;
|
||||
width: auto !important;
|
||||
max-width: calc(100vw - 3rem);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
#intel-hud[data-variant='tactical'] .hud-bottom-left,
|
||||
#intel-hud[data-variant='tactical'] .hud-bottom-right {
|
||||
bottom: calc(2vh + 6.5rem);
|
||||
}
|
||||
|
||||
#intel-hud[data-variant='tactical'] .hud-bottom-left,
|
||||
#intel-hud[data-variant='minimal'] .hud-bottom-left {
|
||||
bottom: calc(2vh + 5.4rem);
|
||||
}
|
||||
|
||||
:root[data-scope-mask='off']
|
||||
#intel-hud[data-variant='minimal']
|
||||
.hud-bottom-left {
|
||||
bottom: calc(2vh + 6.5rem);
|
||||
}
|
||||
|
||||
#intel-hud[data-variant='minimal'] .hud-bottom-bar {
|
||||
bottom: calc(2vh + 9rem);
|
||||
bottom: calc(var(--gev-credits-bottom) + var(--gev-credits-reserve));
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
@@ -487,7 +485,7 @@ body:not(.ui-clean-view):not(.recording-mode):has(
|
||||
#intel-hud[data-variant='minimal'].active
|
||||
)
|
||||
#cesium-credits {
|
||||
bottom: calc(2vh + 5rem);
|
||||
bottom: var(--gev-credits-bottom);
|
||||
left: 0.75rem;
|
||||
max-width: calc(100vw - 1.5rem);
|
||||
}
|
||||
|
||||
@@ -243,14 +243,6 @@ body.recording-mode #cesium-credits {
|
||||
left: 36px;
|
||||
}
|
||||
|
||||
#intel-hud[data-variant='minimal'] .hud-bottom-left {
|
||||
bottom: calc(2vh + 7.5rem);
|
||||
}
|
||||
|
||||
#intel-hud[data-variant='minimal'] .hud-bottom-bar {
|
||||
bottom: calc(2vh + 11.5rem);
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
#command-dock {
|
||||
grid-template-areas:
|
||||
@@ -277,14 +269,6 @@ body.recording-mode #cesium-credits {
|
||||
body:not(.ui-clean-view):not(.recording-mode) #cesium-credits {
|
||||
bottom: calc(2vh + 9.5rem);
|
||||
}
|
||||
|
||||
#intel-hud[data-variant='minimal'] .hud-bottom-left {
|
||||
bottom: calc(2vh + 12rem);
|
||||
}
|
||||
|
||||
#intel-hud[data-variant='minimal'] .hud-bottom-bar {
|
||||
bottom: calc(2vh + 16rem);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
|
||||
@@ -1605,9 +1605,9 @@
|
||||
}
|
||||
|
||||
/* Attribution's logo row extends above its text baseline. Reserve the whole
|
||||
row plus a gap above the dock-relative 2vh + 5rem credit position. */
|
||||
row plus a gap above the dock-relative credit anchor. */
|
||||
#intel-hud[data-variant='cyber'] .hud-bottom-left {
|
||||
bottom: calc(2vh + 7.75rem);
|
||||
bottom: calc(var(--gev-credits-bottom) + var(--gev-credits-reserve));
|
||||
}
|
||||
|
||||
#intel-hud[data-variant='cyber'] .hud-corner {
|
||||
|
||||
@@ -306,15 +306,6 @@
|
||||
top: 88px;
|
||||
}
|
||||
|
||||
#intel-hud[data-variant='minimal'] .hud-bottom-left {
|
||||
bottom: 12vh;
|
||||
}
|
||||
|
||||
#intel-hud[data-variant='minimal'] .hud-bottom-bar {
|
||||
display: flex;
|
||||
bottom: calc(12vh + 4rem);
|
||||
}
|
||||
|
||||
#intel-hud[data-variant='minimal'] .hud-summary-wrap {
|
||||
max-width: 280px;
|
||||
}
|
||||
@@ -374,29 +365,6 @@
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
:root[data-scope-mask='off']
|
||||
#intel-hud[data-variant='minimal']
|
||||
.hud-bottom-bar {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
:root[data-scope-mask='off']
|
||||
#intel-hud[data-variant='minimal']
|
||||
.hud-bottom-bar {
|
||||
width: fit-content;
|
||||
max-width: calc(100vw - 120px);
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
:root[data-scope-mask='off']
|
||||
#intel-hud[data-variant='minimal']
|
||||
.hud-bottom-bar
|
||||
.hud-readout {
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
:root[data-scope-mask='off']:not([data-ui-theme='cyber'])
|
||||
#title-bar
|
||||
h1::before {
|
||||
|
||||
Reference in new issue
Block a user