Compare commits

...
Author SHA1 Message Date
German Khokhlov bdd6081fa0 docs: describe the shared attribution anchor 2026-11-06 21:31:56 +04:00
German Khokhlov d496de0d40 test(qa): check Tactical and Cockpit readout clearance
Require the 1rem reserve above a credits row no taller than 28px for Minimal and Tactical, and for Minimal in Cockpit. The phone check now asserts only what phones do: hide the readout and keep attribution.
2026-11-06 21:31:56 +04:00
German Khokhlov 055709c12b fix(hud): anchor lower-left readouts to the attribution row
The credits row takes its position from --gev-credits-bottom, and the
lower-left readout of Minimal, Tactical, Cyber and Cockpit sits
--gev-credits-reserve (2.75rem) above it: 16px over the 28px logo row.
Tactical's box overlapped the row by 4px, and Cockpit's corner offset
outranked every layout and put the readout 21-32px into it. Cockpit now
scales the readout from its bottom edge so it stays below the context
window.

Remove the overridden Minimal offsets in overlays.css, command-dock.css
and command-dock-compact.css so one rule owns each position.
2026-11-06 21:31:56 +04:00
German Khokhlov d61a9225eb Merge remote-tracking branch 'origin/main' into fix/minimal-hud-bottom-banner 2026-11-06 20:39:40 +04:00
German Khokhlov d544613aae Merge pull request #847 from kvnloo/exp/cyclone-shared-context
feat(cyclones): publish explicit storm selection to shared context
2026-11-06 20:35:22 +04:00
German Khokhlov 5bab5ec26d refactor(cyclones): drop defensive shared-context state and checks 2026-11-06 20:15:27 +04:00
German Khokhlov 5e64e599e1 docs(cyclones): tighten shared-context comments, changelog and notes 2026-11-06 20:07:56 +04:00
German Khokhlov ea22d16c1a Merge branch 'main' into exp/cyclone-shared-context 2026-11-06 19:44:54 +04:00
German Khokhlov 4e518821b5 fix(cyclones): name the classification and mark a stale cache in shared context
- Publish classificationName next to the raw NHC code; unknown codes stay
  unexpanded.
- Publish staleCacheFetchedAt only while the proxy serves its cache.
- Touch the shared store on clear only after this layer published, so a
  feed failure no longer evicts another layer's dangling selection.
- Drop the unreachable no-storm branch and the unused return value.
- Tests: split the heading fixture into due north and stationary, cover
  the unavailable and failure paths with the real store, and check that
  clear/disable leave another layer's selection alone; fold the fake-store
  test into the real-store one.
2026-11-06 19:43:04 +04:00
Said Turksever 3d6423c6f8 Merge pull request #870 from waar19/perf/fleet-tick-skip-hidden
perf(flights): skip dead-reckoning contacts that stay behind the horizon
2026-11-06 15:03:33 +00:00
Said Turksever 15fdf65a73 Merge branch 'main' into perf/fleet-tick-skip-hidden 2026-11-06 14:52:56 +00:00
German Khokhlov aca5317a2a Merge branch 'main' into exp/cyclone-shared-context 2026-11-06 16:03:13 +04:00
Said Turksever 141181f83a docs(flights): changelog names the one-in-13-ticks rotation, about once a second 2026-11-06 12:02:30 +00:00
Said Turksever 0dbbd9047e Merge remote-tracking branch 'upstream/main' into HEAD 2026-11-06 11:54:29 +00:00
German Khokhlov 7f8556c691 Merge branch 'main' into exp/cyclone-shared-context
# Conflicts:
#	src/app/constructCatalog.js
2026-11-05 23:01:23 +04:00
Said Turksever 2c936ae131 docs: correct hidden-contact rotation interval 2026-11-03 16:26:45 +00:00
Said Turksever b68cd25e9e Merge commit '2ebde97fb3af19402eab83fca2bfbec9ddfd166a' into easy-wins-20261103-pr870 2026-11-03 16:17:34 +00:00
Said Turksever 66a5c483c2 fix: resolve review findings and refresh PR #870 against main 2026-11-03 16:17:31 +00:00
Said Turksever f45d4bcc93 fix: resolve review findings and refresh PR #1523 against main 2026-11-03 16:17:24 +00:00
Said Turksever 3689d2e6ca docs: complete review bookkeeping for #1523 2026-11-03 16:10:09 +00:00
Said Turksever 6af7cef3a3 Merge remote-tracking branch 'upstream/main' into codex/pr1523-current-main 2026-11-03 15:11:27 +00:00
Said Turksever 25340f735c test: cover Minimal HUD banner and attribution clearance 2026-11-03 15:07:58 +00:00
Said Turksever 2a6a3922af Keep the Minimal HUD readout clear of the attribution row
The bottom-left MGRS / lat-lon readout sat 5.4rem up while the Cesium /
Esri credits row, logo included, reaches about 6.75rem, so the two
overlapped. Raise it to 7.25rem and drop the Scope-off override: the
backplates add no vertical padding, so both modes now leave the same gap.
2026-11-03 14:46:32 +00:00
Said Turksever 11602742a8 Document the Minimal HUD banner removal (#1523) 2026-11-03 14:31:16 +00:00
Said Turksever 657277dbf9 Hide the bottom MGRS banner in the Minimal HUD layout
The bottom-left readout already shows MGRS and lat/lon, and the centred
bar sat across the globe.
2026-11-03 14:30:55 +00:00
German Khokhlov 92666fdbaf fix(cyclones): preserve movement in selected voice context 2026-11-03 15:10:20 +04:00
Kevin Rajan deb02f635e Merge current upstream main into explicit cyclone context PR
Preserve reviewed selection event-ordering semantics and real-store tests.
Register the new app-layer wrapper in both package boundary sections.
2026-10-22 17:39:51 -05:00
Said Turksever c6decfced4 Merge latest main before publishing PR #847 fixes 2026-10-20 14:42:17 +01:00
Said Turksever b358b5dba1 fix(cyclones): distinguish deliberate clears from feed eviction 2026-10-20 14:17:39 +01:00
Said Turksever 1330605c18 Merge main into PR #847 preparation candidate 2026-10-20 14:14:38 +01:00
Said Turksever 9a5cafca27 Merge upstream/main into exp/cyclone-shared-context 2026-10-19 14:23:54 +01:00
Kevin Rajan 0e2ae76f0a test(cyclones): assert source-owned context label 2026-10-17 03:36:09 -05:00
Kevin Rajan 3a7c7b7c6a fix(cyclones): keep selected names source-owned for i18n 2026-10-17 03:36:07 -05:00
Kevin Rajan abaaf0c254 fix(cyclones): reconcile explicit shared selection with current main
Carry reviewer-verified stale A-to-B record eviction, explicit selection
and refresh semantics over latest i18n/cyclone source ownership.
2026-10-17 03:31:28 -05:00
Kevin Rajan a803be1af2 fix(cyclones): evict prior shared selection on storm switch
Regression: explicit storm A→B→A keeps one active record.
Reviewer finding: quanticsoul4772 (#847).
2026-10-16 23:01:44 -05:00
Wilmer AlzateandClaude Opus 5.5 81f49406b8 perf(flights): skip dead-reckoning contacts that stay behind the horizon
The fleet tick dead-reckoned, floored and distance-tested every contact on
every ~80 ms tick, then hid the ones behind the horizon. The worldwide feed
carries ~13k contacts; zoomed in, well under 1% are on screen, so nearly all
of that work produced a hidden billboard.

A contact that is already hidden and still behind the horizon at its last
position now re-reckons only on its turn in a staggered one-in-12-ticks
rotation (about once a second, HIDDEN_CONTACT_DR_TICKS). Anything that
position puts in view, and any contact with a fleet model, takes the full
pass immediately, so on-screen behaviour is unchanged. Hidden-inclusive
proximity queries (getNearby includeHidden, getAllPositions) read positions
at most about a second old, well inside the 30 s poll's uncertainty.

Measured in Chrome on a Radeon 780M, ~12.9k contacts, 56 on screen over
Colombia at 4 km, same view before and after:
  fleet tick   7.66 -> 2.03 ms/frame, longest tick 50.9 -> ~16 ms
  frame        20.6 -> 14.0 ms, 42.9 -> 57.8 FPS
Teleporting the camera to Madrid showed 975 contacts within 100 ms.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-30 12:31:59 -05:00
Kevin Rajan 28f3243090 feat(cyclones): publish explicit selection to shared context 2026-09-29 00:20:16 -05:00
23 changed files with 778 additions and 93 deletions

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).
+4
View File
@@ -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
View File
@@ -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.
+1
View File
@@ -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",
+2
View File
@@ -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
View File
@@ -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();
+2 -2
View File
@@ -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 }),
+10
View File
@@ -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,
});
}
+19 -2
View File
@@ -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
View File
@@ -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();
+255 -1
View File
@@ -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',
);
});
+31
View File
@@ -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). */
+24
View File
@@ -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(
+4
View File
@@ -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
View File
@@ -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;/);
+7 -1
View File
@@ -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);
}
-8
View File
@@ -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;
+12 -14
View File
@@ -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);
}
-16
View File
@@ -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) {
+2 -2
View File
@@ -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 {
-32
View File
@@ -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 {