mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
Merge main; keep inline panels polling while their host reports them hidden
Main now has #1282, which pauses periodic layer polling in a hidden page with its own catch-up and listener cleanup, so this PR drops its duplicate lifecycle.js change and test in favor of main's. Review fixes: - data.js passes panelAwareVisibility(document) (new in app/embed.js) as the lifecycle's visibility target, so an inline panel whose host reports it hidden keeps polling. The HUD's 250 ms readout follows the same document.hidden && !isEmbeddedInline() rule as tools.js. - Tests: embed.test.mjs (layers poll in an inline panel reported hidden, pause without the flag) and hudPageVisibility.test.mjs (HUD readout). Both use mock timers and clean up in finally. - changelog.d/1344-inline-panel-visibility.md and a note in docs/CURRENT-STATE.md. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
commit
1161899aea
83 files changed
+5697
-378
No files matched your search
+4
-9
@@ -1,6 +1,7 @@
|
||||
import { LayerLifecycle } from '../data/lifecycle.js';
|
||||
import { LayerPresentation } from './layerPresentation.js';
|
||||
import { createCyberSonarScene } from '../cyberSonarScene.js';
|
||||
import { panelAwareVisibility } from './embed.js';
|
||||
/** Register the application layer catalog before allowing state restoration. */
|
||||
export function createApplicationData({
|
||||
scene: { viewer, mapStackController },
|
||||
@@ -13,16 +14,10 @@ export function createApplicationData({
|
||||
// Initialize data layer manager
|
||||
const dataManager = new LayerLifecycle(viewer, {
|
||||
allowQaRegistration,
|
||||
// A panel's host may report it hidden while it is on screen; its layers
|
||||
// keep polling (see keepPanelRendering in embed.js).
|
||||
visibilityTarget: panelAwareVisibility(document),
|
||||
getSourceAvailability: catalog?.getSourceAvailability,
|
||||
// Polled layers skip refreshes while the tab is hidden and catch up
|
||||
// once when it is shown again.
|
||||
pageVisibility: {
|
||||
isHidden: () => document.visibilityState === 'hidden',
|
||||
subscribe(callback) {
|
||||
document.addEventListener('visibilitychange', callback);
|
||||
return () => document.removeEventListener('visibilitychange', callback);
|
||||
},
|
||||
},
|
||||
});
|
||||
defer(async () => {
|
||||
await dataManager.destroyAll();
|
||||
|
||||
@@ -45,6 +45,28 @@ export function isEmbeddedInline() {
|
||||
return globalThis.GEV_EMBED_INLINE === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* `doc`'s visibility for work that pauses in a hidden page. A panel's host
|
||||
* may report it hidden while it is on screen (see keepPanelRendering), so an
|
||||
* inline panel always reads as visible.
|
||||
*/
|
||||
export function panelAwareVisibility(doc = globalThis.document) {
|
||||
const hidden = () =>
|
||||
(doc.hidden === true || doc.visibilityState === 'hidden') &&
|
||||
!isEmbeddedInline();
|
||||
return {
|
||||
get hidden() {
|
||||
return hidden();
|
||||
},
|
||||
get visibilityState() {
|
||||
return hidden() ? 'hidden' : 'visible';
|
||||
},
|
||||
addEventListener: (type, listener) => doc.addEventListener(type, listener),
|
||||
removeEventListener: (type, listener) =>
|
||||
doc.removeEventListener(type, listener),
|
||||
};
|
||||
}
|
||||
|
||||
// A panel keeps drawing at about this rate when its host stops animation
|
||||
// frames; see keepPanelRendering.
|
||||
const PANEL_FRAME_MS = 33;
|
||||
|
||||
@@ -5,7 +5,9 @@ import {
|
||||
installViews,
|
||||
isEmbedded,
|
||||
keepPanelRendering,
|
||||
panelAwareVisibility,
|
||||
} from './embed.js';
|
||||
import { LayerLifecycle } from '../data/lifecycle.js';
|
||||
import { createView } from '../view/index.js';
|
||||
|
||||
const fakeViewer = () => {
|
||||
@@ -211,6 +213,60 @@ test('an app loaded inline into a panel page talks through its own window', asyn
|
||||
}
|
||||
});
|
||||
|
||||
test('polled layers keep refreshing in an inline panel its host reports hidden', async (t) => {
|
||||
const listeners = new Set();
|
||||
const doc = {
|
||||
hidden: true,
|
||||
visibilityState: 'hidden',
|
||||
addEventListener: (_type, fn) => listeners.add(fn),
|
||||
removeEventListener: (_type, fn) => listeners.delete(fn),
|
||||
};
|
||||
const settle = async () => {
|
||||
for (let i = 0; i < 3; i++) await Promise.resolve();
|
||||
};
|
||||
t.mock.timers.enable({ apis: ['setInterval', 'Date'], now: 1000 });
|
||||
let updates = 0;
|
||||
const manager = new LayerLifecycle(
|
||||
{},
|
||||
{ visibilityTarget: panelAwareVisibility(doc) },
|
||||
);
|
||||
manager.register({
|
||||
id: 'flights',
|
||||
name: 'flights',
|
||||
icon: '',
|
||||
source: 'test',
|
||||
updateInterval: 1000,
|
||||
async init() {},
|
||||
enable() {},
|
||||
disable() {},
|
||||
async update() {
|
||||
updates++;
|
||||
await Promise.resolve();
|
||||
},
|
||||
getStats: () => ({ count: 0, lastUpdate: null }),
|
||||
});
|
||||
globalThis.GEV_EMBED_INLINE = true;
|
||||
try {
|
||||
await manager.setEnabled('flights', true);
|
||||
await settle();
|
||||
const enabled = updates;
|
||||
t.mock.timers.tick(1000);
|
||||
await settle();
|
||||
t.mock.timers.tick(1000);
|
||||
await settle();
|
||||
assert.equal(updates, enabled + 2, 'the panel keeps polling');
|
||||
|
||||
delete globalThis.GEV_EMBED_INLINE;
|
||||
t.mock.timers.tick(1000);
|
||||
await settle();
|
||||
assert.equal(updates, enabled + 2, 'outside a panel a hidden page pauses');
|
||||
} finally {
|
||||
delete globalThis.GEV_EMBED_INLINE;
|
||||
await manager.destroyAll();
|
||||
}
|
||||
assert.equal(listeners.size, 0);
|
||||
});
|
||||
|
||||
test('annotations in a link are drawn after it restores, embedded or not', async () => {
|
||||
const { calls, run } = recorder();
|
||||
const an = encodeURIComponent(
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -223,7 +223,7 @@ test('validated voice camera destinations share the UI navigation authority faca
|
||||
'move camera',
|
||||
);
|
||||
ordered(move, [
|
||||
"if (!['orbit', 'pan', 'tilt', 'rotate'].includes(motion))",
|
||||
"if (!['orbit', 'pan', 'tilt', 'rotate', 'rotate_globe'].includes(motion))",
|
||||
'const start = () => {',
|
||||
/const preserveCameraFlight =\s*motion === 'orbit'/,
|
||||
'runNavigation(start, { preserveCameraFlight })',
|
||||
|
||||
+300
-12
@@ -4,7 +4,7 @@
|
||||
*
|
||||
* One motion at a time, driven per clock tick. `once` = bounded eased nudge;
|
||||
* `continuous` runs until move_camera{stop}, ANY manual camera input on the
|
||||
* canvas (pointerdown/wheel — the cancelFlight reflex), or a navigation tool
|
||||
* canvas (pointerdown/wheel/touchstart — the cancelFlight reflex), or a navigation tool
|
||||
* starts (the shared UI navigation policy releases this owner before the new
|
||||
* destination mutates the camera). fly_route is a
|
||||
* continuous motion in the same slot: a cinematic dolly along an existing
|
||||
@@ -13,14 +13,24 @@
|
||||
* Orbit circles the current screen-center ground target holding range and
|
||||
* pitch while advancing heading (per-frame camera.lookAt; the lookAt
|
||||
* transform is cleared on every stop so manual control returns cleanly).
|
||||
* Globe rotation turns the world-frame camera about its screen-up axis through
|
||||
* Earth's center, preserving range and framing. Left/right name the visible
|
||||
* globe motion, opposite to the camera's travel around Earth.
|
||||
*/
|
||||
|
||||
import * as Cesium from 'cesium';
|
||||
import { GLOBE_VIEW } from './locations.js';
|
||||
import {
|
||||
projectEarthDiscToViewport,
|
||||
earthDiscScreenRadius,
|
||||
getKeyholeGeometry,
|
||||
} from './celestialRing.js';
|
||||
import {
|
||||
holdContinuousRender,
|
||||
releaseContinuousRender,
|
||||
} from './renderGovernor.js';
|
||||
|
||||
let _isCockpitActive = null;
|
||||
/** °/s by speed word — orbit; pan uses fractions of view height/s. */
|
||||
const ORBIT_DEG_S = { slow: 2, normal: 6, fast: 15 };
|
||||
const TILT_DEG_S = { slow: 4, normal: 10, fast: 20 };
|
||||
@@ -952,6 +962,7 @@ let _viewer = null;
|
||||
let _getTarget = null;
|
||||
let _active = null; // { kind, motion, direction, speed, mode, motionId, ...state }
|
||||
let _tickRemover = null;
|
||||
let _trackingRemover = null;
|
||||
let _inputRemovers = [];
|
||||
/** Monotonic id for the motion currently running; 0 means "no motion". */
|
||||
let _motionSeq = 0;
|
||||
@@ -1162,7 +1173,11 @@ function onTick() {
|
||||
else if (m.direction === 'right') cam.moveRight(step);
|
||||
else if (m.direction === 'up') cam.moveUp(step);
|
||||
else cam.moveDown(step);
|
||||
} else if (m.kind === 'tilt' || m.kind === 'rotate') {
|
||||
} else if (
|
||||
m.kind === 'tilt' ||
|
||||
m.kind === 'rotate' ||
|
||||
m.kind === 'rotate_globe'
|
||||
) {
|
||||
const degS = TILT_DEG_S[m.speed];
|
||||
const stepRad =
|
||||
m.mode === 'once'
|
||||
@@ -1172,7 +1187,38 @@ function onTick() {
|
||||
(scale - (m.lastScale || 0))
|
||||
: Cesium.Math.toRadians(degS) * dt;
|
||||
m.lastScale = scale;
|
||||
if (m.kind === 'tilt') {
|
||||
if (m.kind === 'rotate_globe') {
|
||||
// Tracking can acquire the camera without going through navigation.
|
||||
// Yield without resetting its pose/frame: the world-space arc proof
|
||||
// only describes Cesium's rotation while the frame is identity.
|
||||
if (
|
||||
_viewer.scene.mode !== Cesium.SceneMode.SCENE3D ||
|
||||
_viewer.trackedEntity ||
|
||||
_isCockpitActive?.() ||
|
||||
!Cesium.Matrix4.equals(cam.transform, Cesium.Matrix4.IDENTITY)
|
||||
) {
|
||||
interruptCameraMotion('globe-camera-owner-changed');
|
||||
return;
|
||||
}
|
||||
// Cesium rotates both position and basis about the reference-frame
|
||||
// origin. The captured screen-up axis keeps left/right horizontal even
|
||||
// in a tilted or rolled view; a positive angle moves the globe right.
|
||||
const angle = m.direction === 'left' ? -stepRad : stepRad;
|
||||
if (globeRotationFitsKeyhole() !== true) {
|
||||
interruptCameraMotion('globe-framing-changed');
|
||||
return;
|
||||
}
|
||||
if (
|
||||
!globeRotationHasClearance(cam.positionWC) ||
|
||||
Math.abs(
|
||||
Cesium.Cartesian3.magnitude(cam.positionWC) - m.rotationRadius,
|
||||
) > 0.01
|
||||
) {
|
||||
interruptCameraMotion('globe-surface-limit');
|
||||
return;
|
||||
}
|
||||
cam.rotate(m.rotationAxis, angle);
|
||||
} else if (m.kind === 'tilt') {
|
||||
const up = m.direction === 'up';
|
||||
// Clamp pitch to [-89°, -5°]: never through the ground, never at the sky.
|
||||
const next = cam.pitch + (up ? stepRad : -stepRad);
|
||||
@@ -1206,6 +1252,133 @@ function onTick() {
|
||||
}
|
||||
}
|
||||
|
||||
/** Rigid origin-centred rotation stays outside the actual ellipsoid's bounding sphere. */
|
||||
function globeRotationHasClearance(position) {
|
||||
const ellipsoid = _viewer.scene.globe?.ellipsoid ?? Cesium.Ellipsoid.WGS84;
|
||||
const { minimumRadius, maximumRadius } = ellipsoid;
|
||||
const radius = Cesium.Cartesian3.magnitude(position);
|
||||
return (
|
||||
Number.isFinite(minimumRadius) &&
|
||||
minimumRadius > 0 &&
|
||||
Number.isFinite(maximumRadius) &&
|
||||
maximumRadius >= minimumRadius &&
|
||||
Number.isFinite(radius) &&
|
||||
radius > maximumRadius * (1 + 1e-12)
|
||||
);
|
||||
}
|
||||
|
||||
/** Use the existing WGS84/CSS-pixel projection; null means unsupported, not close-up. */
|
||||
function globeRotationFitsKeyhole(camera = _viewer.camera) {
|
||||
const ellipsoid = _viewer.scene.globe?.ellipsoid ?? Cesium.Ellipsoid.WGS84;
|
||||
if (!Cesium.Cartesian3.equals(ellipsoid.radii, Cesium.Ellipsoid.WGS84.radii))
|
||||
return null;
|
||||
const canvas = _viewer.scene.canvas;
|
||||
const width = canvas.clientWidth;
|
||||
const height = canvas.clientHeight;
|
||||
const frustum = camera.frustum;
|
||||
if (
|
||||
!Number.isFinite(width) ||
|
||||
!Number.isFinite(height) ||
|
||||
width <= 0 ||
|
||||
height <= 0 ||
|
||||
!(frustum instanceof Cesium.PerspectiveFrustum) ||
|
||||
!Number.isFinite(frustum.near) ||
|
||||
frustum.near <= 0 ||
|
||||
!Number.isFinite(frustum.far) ||
|
||||
frustum.far <= frustum.near ||
|
||||
frustum.xOffset !== 0 ||
|
||||
frustum.yOffset !== 0
|
||||
)
|
||||
return null;
|
||||
try {
|
||||
const cam = camera;
|
||||
const distance = Cesium.Cartesian3.magnitude(cam.positionWC);
|
||||
const sphereRadius = ellipsoid.maximumRadius;
|
||||
if (!Number.isFinite(distance) || distance <= 0) return null;
|
||||
if (distance <= sphereRadius) return false; // Near-surface polar views are close-up too.
|
||||
const radiusPx = earthDiscScreenRadius(distance, height, frustum.fovy);
|
||||
if (!radiusPx) return null;
|
||||
const keyhole = getKeyholeGeometry(width, height);
|
||||
// The known nadir reset probe uses the same exact cone below; projecting
|
||||
// through SceneTransforms would read the live scene.camera instead.
|
||||
if (
|
||||
camera === _viewer.camera &&
|
||||
!projectEarthDiscToViewport(_viewer, width, height)
|
||||
) {
|
||||
// A near-surface horizon view cannot fit even when pointed at Earth;
|
||||
// an orbital camera looking away is unsupported, not a close-up offer.
|
||||
return radiusPx > Math.min(keyhole.radius, width / 2, height / 2)
|
||||
? false
|
||||
: null;
|
||||
}
|
||||
// The shared disc is an overlay approximation off-axis. Bound the sphere
|
||||
// against the existing keyhole cone. Canvas-edge clipping does not change
|
||||
// framing during rigid globe rotation and is not a clearance condition,
|
||||
// without changing the UI projection or defining another keyhole/mask.
|
||||
const toCenter = Cesium.Cartesian3.negate(
|
||||
cam.positionWC,
|
||||
new Cesium.Cartesian3(),
|
||||
);
|
||||
const depth = Cesium.Cartesian3.dot(toCenter, cam.directionWC);
|
||||
const right = Math.abs(Cesium.Cartesian3.dot(toCenter, cam.rightWC));
|
||||
const up = Math.abs(Cesium.Cartesian3.dot(toCenter, cam.upWC));
|
||||
const focalY = height / (2 * Math.tan(frustum.fovy / 2));
|
||||
const focalX =
|
||||
width / (2 * Math.tan(frustum.fovy / 2) * frustum.aspectRatio);
|
||||
if (
|
||||
![depth, right, up, focalX, focalY].every(Number.isFinite) ||
|
||||
focalX <= 0 ||
|
||||
focalY <= 0
|
||||
)
|
||||
return null;
|
||||
const halfAngle = Math.asin(sphereRadius / distance);
|
||||
const centerAngle = Math.atan2(Math.hypot(right, up), depth);
|
||||
const keyholeAngle = Math.atan(keyhole.radius / Math.max(focalX, focalY));
|
||||
// No overlay visibility hysteresis: a fully contained smaller globe needs no reset.
|
||||
const framed = centerAngle + halfAngle <= keyholeAngle;
|
||||
if (!framed) return false;
|
||||
// Projection alone does not prove that a tiny globe is within depth planes.
|
||||
if (
|
||||
depth - sphereRadius < frustum.near ||
|
||||
depth + sphereRadius > frustum.far
|
||||
)
|
||||
return null;
|
||||
return true;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Check the unchanged reset destination without moving the live camera. */
|
||||
function globeResetFitsKeyhole() {
|
||||
try {
|
||||
const camera = Cesium.Camera.clone(_viewer.camera);
|
||||
const { longitude, latitude } = _viewer.camera.positionCartographic;
|
||||
camera.setView({
|
||||
destination: Cesium.Cartesian3.fromRadians(
|
||||
longitude,
|
||||
latitude,
|
||||
GLOBE_VIEW.heightM,
|
||||
),
|
||||
orientation: {
|
||||
heading: 0,
|
||||
pitch: Cesium.Math.toRadians(GLOBE_VIEW.pitchDeg),
|
||||
roll: 0,
|
||||
},
|
||||
endTransform: Cesium.Matrix4.IDENTITY,
|
||||
});
|
||||
return globeRotationFitsKeyhole(camera) === true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Release only globe rotation when a new camera intent takes ownership. */
|
||||
export function interruptGlobeRotation(reason = 'camera-intent') {
|
||||
if (_active?.kind === 'rotate_globe') return interruptCameraMotion(reason);
|
||||
return { wasActive: false, reason, leveled: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* Wire the module to the viewer once (idempotent). The view-target getter is
|
||||
* optional so a non-voice caller (the Directions FLY chip) can arm route
|
||||
@@ -1218,24 +1391,42 @@ function onTick() {
|
||||
* call brings none, so a callback from the old viewer can never answer for the
|
||||
* new one.
|
||||
*/
|
||||
export function initCameraVerbs(viewer, getViewTargetCartesian = null) {
|
||||
export function initCameraVerbs(
|
||||
viewer,
|
||||
getViewTargetCartesian = null,
|
||||
{ isCockpitActive } = {},
|
||||
) {
|
||||
const nextTarget =
|
||||
typeof getViewTargetCartesian === 'function'
|
||||
? getViewTargetCartesian
|
||||
: null;
|
||||
if (_viewer === viewer) {
|
||||
if (nextTarget) _getTarget = nextTarget;
|
||||
if (typeof isCockpitActive === 'function')
|
||||
_isCockpitActive = isCockpitActive;
|
||||
return;
|
||||
}
|
||||
// A globe motion belongs to the viewer whose world pose it captured.
|
||||
if (_active?.kind === 'rotate_globe')
|
||||
interruptCameraMotion('viewer-replaced');
|
||||
_getTarget = nextTarget;
|
||||
_isCockpitActive =
|
||||
typeof isCockpitActive === 'function' ? isCockpitActive : null;
|
||||
_viewer = viewer;
|
||||
if (_tickRemover) _tickRemover();
|
||||
_tickRemover = viewer.clock.onTick.addEventListener(onTick);
|
||||
if (_trackingRemover) _trackingRemover();
|
||||
// Cockpit can acquire then clear tracking before the next clock tick.
|
||||
// Yield on acquisition itself without changing legacy camera motions.
|
||||
_trackingRemover = viewer.trackedEntityChanged?.addEventListener((entity) => {
|
||||
if (entity && _active?.kind === 'rotate_globe')
|
||||
interruptCameraMotion('globe-camera-owner-changed');
|
||||
});
|
||||
for (const rm of _inputRemovers) rm();
|
||||
_inputRemovers = [];
|
||||
const canvas = viewer.scene.canvas;
|
||||
// ANY manual camera input reclaims control — the cancelFlight reflex.
|
||||
for (const evt of ['pointerdown', 'wheel']) {
|
||||
for (const evt of ['pointerdown', 'touchstart', 'wheel']) {
|
||||
const h = () => interruptCameraMotion('manual-input');
|
||||
canvas.addEventListener(evt, h, { passive: true });
|
||||
_inputRemovers.push(() => canvas.removeEventListener(evt, h));
|
||||
@@ -1256,7 +1447,11 @@ export function moveCamera(args = {}, runNavigation = null) {
|
||||
const speed = ORBIT_DEG_S[String(args.speed || 'normal').toLowerCase()]
|
||||
? String(args.speed || 'normal').toLowerCase()
|
||||
: 'normal';
|
||||
const mode = args.mode === 'continuous' ? 'continuous' : 'once';
|
||||
const mode =
|
||||
args.mode === 'continuous' ||
|
||||
(motion === 'rotate_globe' && args.mode === undefined)
|
||||
? 'continuous'
|
||||
: 'once';
|
||||
if (motion === 'stop') {
|
||||
const wasActiveBeforeHandoff = Boolean(_active);
|
||||
const stop = () => {
|
||||
@@ -1270,15 +1465,16 @@ export function moveCamera(args = {}, runNavigation = null) {
|
||||
};
|
||||
return typeof runNavigation === 'function' ? runNavigation(stop) : stop();
|
||||
}
|
||||
if (!['orbit', 'pan', 'tilt', 'rotate'].includes(motion)) {
|
||||
if (!['orbit', 'pan', 'tilt', 'rotate', 'rotate_globe'].includes(motion)) {
|
||||
return {
|
||||
ok: false,
|
||||
action: 'move_camera',
|
||||
error: `Unknown motion "${args.motion}" — use orbit, pan, tilt, rotate, or stop.`,
|
||||
error: `Unknown motion "${args.motion}" — use orbit, pan, tilt, rotate, rotate_globe, or stop.`,
|
||||
};
|
||||
}
|
||||
if (
|
||||
motion !== 'orbit' &&
|
||||
motion !== 'rotate_globe' &&
|
||||
!['left', 'right', 'up', 'down'].includes(direction)
|
||||
) {
|
||||
return {
|
||||
@@ -1294,6 +1490,17 @@ export function moveCamera(args = {}, runNavigation = null) {
|
||||
error: 'rotate needs left or right.',
|
||||
};
|
||||
}
|
||||
if (
|
||||
motion === 'rotate_globe' &&
|
||||
direction !== null &&
|
||||
!['left', 'right'].includes(direction)
|
||||
) {
|
||||
return {
|
||||
ok: false,
|
||||
action: 'move_camera',
|
||||
error: 'rotate_globe needs left or right.',
|
||||
};
|
||||
}
|
||||
if (motion === 'tilt') {
|
||||
// Honest limits: at the clamp a tilt is a no-op — say so instead of
|
||||
// reporting success and letting the model gaslight itself (field test).
|
||||
@@ -1318,9 +1525,9 @@ export function moveCamera(args = {}, runNavigation = null) {
|
||||
}
|
||||
}
|
||||
if (
|
||||
motion === 'orbit' &&
|
||||
_viewer?.trackedEntity &&
|
||||
typeof runNavigation !== 'function'
|
||||
(motion === 'rotate_globe' ||
|
||||
(motion === 'orbit' && typeof runNavigation !== 'function')) &&
|
||||
_viewer?.trackedEntity
|
||||
) {
|
||||
// The follow camera owns a tracked view; a lookAt orbit fights it frame
|
||||
// by frame and stop rips the camera out (field finding). Honest refusal
|
||||
@@ -1332,9 +1539,74 @@ export function moveCamera(args = {}, runNavigation = null) {
|
||||
return {
|
||||
ok: false,
|
||||
action: 'move_camera',
|
||||
error: `Already following ${label} — the follow camera owns the view while tracking. Say "stop tracking" first if you want a free orbit.`,
|
||||
error: `Already following ${label} — the follow camera owns the view while tracking. Say "stop tracking" first if you want ${motion === 'rotate_globe' ? 'to rotate the globe' : 'a free orbit'}.`,
|
||||
};
|
||||
}
|
||||
let globePose;
|
||||
if (motion === 'rotate_globe') {
|
||||
if (_viewer.scene.mode !== Cesium.SceneMode.SCENE3D) {
|
||||
return {
|
||||
ok: false,
|
||||
action: 'move_camera',
|
||||
error: 'Globe rotation requires a 3D view.',
|
||||
};
|
||||
}
|
||||
const cam = _viewer.camera;
|
||||
globePose = {
|
||||
position: Cesium.Cartesian3.clone(cam.positionWC),
|
||||
direction: Cesium.Cartesian3.clone(cam.directionWC),
|
||||
up: Cesium.Cartesian3.normalize(cam.upWC, new Cesium.Cartesian3()),
|
||||
right: Cesium.Cartesian3.clone(cam.rightWC),
|
||||
};
|
||||
if (_isCockpitActive?.())
|
||||
return {
|
||||
ok: false,
|
||||
action: 'move_camera',
|
||||
reason: 'camera-owned',
|
||||
error: 'Exit Cockpit before rotating the globe.',
|
||||
};
|
||||
const ellipsoid = _viewer.scene.globe?.ellipsoid ?? Cesium.Ellipsoid.WGS84;
|
||||
if (!(
|
||||
Number.isFinite(ellipsoid.minimumRadius) &&
|
||||
ellipsoid.minimumRadius > 0 &&
|
||||
Number.isFinite(ellipsoid.maximumRadius) &&
|
||||
ellipsoid.maximumRadius >= ellipsoid.minimumRadius &&
|
||||
Number.isFinite(Cesium.Cartesian3.magnitude(globePose.position))
|
||||
))
|
||||
return {
|
||||
ok: false,
|
||||
action: 'move_camera',
|
||||
reason: 'unsafe-ellipsoid',
|
||||
error:
|
||||
'Globe rotation requires a finite supported ellipsoid and a clear globe view.',
|
||||
};
|
||||
const fits = globeRotationFitsKeyhole();
|
||||
if (fits === null)
|
||||
return {
|
||||
ok: false,
|
||||
action: 'move_camera',
|
||||
reason: 'unsupported-view',
|
||||
error: 'Globe rotation requires a supported perspective view.',
|
||||
};
|
||||
if (!fits) {
|
||||
const canReset = globeResetFitsKeyhole();
|
||||
return {
|
||||
ok: false,
|
||||
action: 'move_camera',
|
||||
reason: canReset ? 'globe-view-required' : 'globe-reset-unavailable',
|
||||
error: canReset
|
||||
? 'Globe rotation isn’t available from this view. You can say ‘Reset to Globe View and rotate the globe.’'
|
||||
: 'Zoom out further to fit the globe inside the keyhole; the Globe View preset does not fit this viewport.',
|
||||
};
|
||||
}
|
||||
if (!globeRotationHasClearance(globePose.position))
|
||||
return {
|
||||
ok: false,
|
||||
action: 'move_camera',
|
||||
reason: 'unsafe-ellipsoid',
|
||||
error: 'Globe rotation requires a clear ellipsoid bounding sphere.',
|
||||
};
|
||||
}
|
||||
const start = () => {
|
||||
interruptCameraMotion('replaced');
|
||||
const state = {
|
||||
@@ -1343,6 +1615,22 @@ export function moveCamera(args = {}, runNavigation = null) {
|
||||
speed,
|
||||
mode,
|
||||
};
|
||||
if (motion === 'rotate_globe') {
|
||||
// Convert an inherited ground-target frame without changing the world
|
||||
// pose. Rotation must pivot about Earth, never the previous local target.
|
||||
const cam = _viewer.camera;
|
||||
cam.cancelFlight();
|
||||
cam.lookAtTransform(Cesium.Matrix4.IDENTITY);
|
||||
// Handoff may level a route's bank. Keep the caller's exact world pose.
|
||||
// Copy the basis directly: setView's heading/pitch conversion is singular
|
||||
// at nadir and can change the captured orientation near the poles.
|
||||
Cesium.Cartesian3.clone(globePose.position, cam.position);
|
||||
Cesium.Cartesian3.clone(globePose.direction, cam.direction);
|
||||
Cesium.Cartesian3.clone(globePose.up, cam.up);
|
||||
Cesium.Cartesian3.clone(globePose.right, cam.right);
|
||||
state.rotationAxis = globePose.up;
|
||||
state.rotationRadius = Cesium.Cartesian3.magnitude(globePose.position);
|
||||
}
|
||||
if (motion === 'orbit') {
|
||||
if (_viewer.scene.tweens.length > 0) {
|
||||
// A camera flight is in progress ("fly to X and orbit it") — ALWAYS arm
|
||||
|
||||
@@ -1011,6 +1011,34 @@ test('vessel interaction wire: flights-owned pick preserves vessel selection', (
|
||||
}
|
||||
});
|
||||
|
||||
test('vessel touch interaction yields to the first nearby flight behind map tiles', () => {
|
||||
const previous = Object.getOwnPropertyDescriptor(globalThis, 'matchMedia');
|
||||
Object.defineProperty(globalThis, 'matchMedia', {
|
||||
configurable: true,
|
||||
value: () => ({ matches: true }),
|
||||
});
|
||||
registerPickOwner('flights', (pickedId) => pickedId === 'a1b2c3');
|
||||
const tile = { primitive: { isCesium3DTileset: true } };
|
||||
const harness = installWireHarness(tile);
|
||||
let drills = 0;
|
||||
harness.viewer.scene.drillPick = (_position, limit, width, height) => {
|
||||
drills++;
|
||||
assert.deepEqual([limit, width, height], [8, 14, 14]);
|
||||
return [tile, { id: 'a1b2c3' }];
|
||||
};
|
||||
try {
|
||||
harness.handler.click({ position: { x: 10, y: 20 } });
|
||||
assert.equal(drills, 1);
|
||||
assert.equal(aisLiveVesselsLayer.getSelectedInfo()?.mmsi, harness.record.mmsi);
|
||||
assert.equal(harness.trail.clearCalls, 0);
|
||||
} finally {
|
||||
harness.cleanup();
|
||||
unregisterPickOwner('flights');
|
||||
if (previous) Object.defineProperty(globalThis, 'matchMedia', previous);
|
||||
else delete globalThis.matchMedia;
|
||||
}
|
||||
});
|
||||
|
||||
test('vessel interaction wire: CCTV-owned pick preserves vessel selection', () => {
|
||||
registerPickOwner('cctv', (pickedId) => pickedId === 'atx-cam-3');
|
||||
const harness = installWireHarness({ id: 'atx-cam-3' });
|
||||
|
||||
+33
-10
@@ -81,21 +81,44 @@ export function parseAcquisitionMs(date, time, cache = new Map()) {
|
||||
const cached = cache.get(key);
|
||||
if (cached !== undefined) return cached;
|
||||
|
||||
// Same calendar as the CSV gate. Date.UTC would otherwise roll 30 February
|
||||
// into March, and the fire's age would describe a detection that never happened.
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
|
||||
cache.set(key, 0);
|
||||
return 0;
|
||||
}
|
||||
const timeText = String(time ?? '').trim();
|
||||
if (!/^\d{1,4}$/.test(timeText)) {
|
||||
cache.set(key, 0);
|
||||
return 0;
|
||||
}
|
||||
const hhmm = timeText.padStart(4, '0');
|
||||
const year = Number(date.slice(0, 4));
|
||||
const month = Number(date.slice(5, 7));
|
||||
const day = Number(date.slice(8, 10));
|
||||
const hhmm = String(time ?? '0000').padStart(4, '0');
|
||||
const hours = Number(hhmm.slice(0, 2));
|
||||
const minutes = Number(hhmm.slice(2, 4));
|
||||
const valid =
|
||||
Number.isFinite(year) &&
|
||||
Number.isFinite(month) &&
|
||||
Number.isFinite(day) &&
|
||||
Number.isFinite(hours) &&
|
||||
Number.isFinite(minutes);
|
||||
const ms = valid ? Date.UTC(year, month - 1, day, hours, minutes) : 0;
|
||||
cache.set(key, ms);
|
||||
return ms;
|
||||
if (
|
||||
month < 1 ||
|
||||
month > 12 ||
|
||||
day < 1 ||
|
||||
day > 31 ||
|
||||
hours > 23 ||
|
||||
minutes > 59
|
||||
) {
|
||||
cache.set(key, 0);
|
||||
return 0;
|
||||
}
|
||||
const ms = Date.UTC(year, month - 1, day, hours, minutes);
|
||||
const parsed = new Date(ms);
|
||||
const result =
|
||||
parsed.getUTCFullYear() === year &&
|
||||
parsed.getUTCMonth() === month - 1 &&
|
||||
parsed.getUTCDate() === day
|
||||
? ms
|
||||
: 0;
|
||||
cache.set(key, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -63,12 +63,39 @@ test('unpadded acq_time parses in the adapter path ("45" = 00:45Z)', () => {
|
||||
|
||||
test('parseAcquisitionMs: memo cache is honored, invalid input → 0', () => {
|
||||
const cache = new Map();
|
||||
assert.equal(parseAcquisitionMs('2026-07-16', '1006', cache), Date.UTC(2026, 6, 16, 10, 6));
|
||||
assert.equal(
|
||||
parseAcquisitionMs('2026-07-16', '1006', cache),
|
||||
Date.UTC(2026, 6, 16, 10, 6),
|
||||
);
|
||||
assert.equal(cache.size, 1);
|
||||
assert.equal(parseAcquisitionMs('2026-07-16', '1006', cache), Date.UTC(2026, 6, 16, 10, 6));
|
||||
assert.equal(
|
||||
parseAcquisitionMs('2026-07-16', '1006', cache),
|
||||
Date.UTC(2026, 6, 16, 10, 6),
|
||||
);
|
||||
assert.equal(parseAcquisitionMs(undefined, '1006', cache), 0);
|
||||
});
|
||||
|
||||
test('impossible acquisition dates stay untimed instead of rolling forward', () => {
|
||||
for (const [date, time] of [
|
||||
['2026-02-30', '1200'],
|
||||
['2026-04-31', '1200'],
|
||||
['2026-13-01', '1200'],
|
||||
['2026-07-16', '2400'],
|
||||
['2026-07-16', '1260'],
|
||||
]) {
|
||||
assert.equal(parseAcquisitionMs(date, time), 0, `${date}:${time}`);
|
||||
const [fire] = adaptFirmsRecords([
|
||||
proxyRecord({ acqDate: date, acqTime: time }),
|
||||
]);
|
||||
assert.equal(fire.acqMs, 0, `${date}:${time}`);
|
||||
assert.equal(fire.lat, 38.99488);
|
||||
}
|
||||
const [leap] = adaptFirmsRecords([
|
||||
proxyRecord({ acqDate: '2024-02-29', acqTime: '45' }),
|
||||
]);
|
||||
assert.equal(leap.acqMs, Date.UTC(2024, 1, 29, 0, 45));
|
||||
});
|
||||
|
||||
test('daynight "D" → night false', () => {
|
||||
const [fire] = adaptFirmsRecords([proxyRecord({ daynight: 'D' })]);
|
||||
assert.equal(fire.night, false);
|
||||
@@ -89,11 +116,16 @@ test('records with non-finite lat/lon are skipped; index stays sequential', () =
|
||||
proxyRecord({ lat: 40.1 }),
|
||||
]);
|
||||
assert.equal(fires.length, 2);
|
||||
assert.deepEqual(fires.map((f) => f.index), [0, 1]);
|
||||
assert.deepEqual(
|
||||
fires.map((f) => f.index),
|
||||
[0, 1],
|
||||
);
|
||||
});
|
||||
|
||||
test('non-finite frp/brightness → 0; empty input → []', () => {
|
||||
const [fire] = adaptFirmsRecords([proxyRecord({ frp: 'n/a', brightness: undefined })]);
|
||||
const [fire] = adaptFirmsRecords([
|
||||
proxyRecord({ frp: 'n/a', brightness: undefined }),
|
||||
]);
|
||||
assert.equal(fire.frp, 0);
|
||||
assert.equal(fire.brightness, 0);
|
||||
assert.deepEqual(adaptFirmsRecords([]), []);
|
||||
|
||||
@@ -134,7 +134,10 @@ test('every scene click handler consults ownership before it picks', () => {
|
||||
'src/layers/submarineCables/interaction.js',
|
||||
'viewer.scene.pick(click.position)',
|
||||
],
|
||||
['src/layers/vessels/selection.js', 'viewer.scene.pick(click.position)'],
|
||||
[
|
||||
'src/layers/vessels/selection.js',
|
||||
'pickContact(viewer.scene, click.position)',
|
||||
],
|
||||
];
|
||||
for (const [file, firstPick] of guarded) {
|
||||
const source = read(file);
|
||||
|
||||
+70
-50
@@ -72,27 +72,20 @@ function refreshFailureFromStats(stats, label) {
|
||||
/**
|
||||
* LayerLifecycle — Manages registration, toggling, and update loops
|
||||
* for real-time data overlays on the CesiumJS globe.
|
||||
* Hosts may inject a visibilityTarget with hidden/visibilityState and event
|
||||
* listener methods. Without one, periodic work retains its headless behavior.
|
||||
*/
|
||||
export class LayerLifecycle {
|
||||
/**
|
||||
* @param {object} viewer
|
||||
* @param {object} [options]
|
||||
* @param {boolean} [options.allowQaRegistration]
|
||||
* @param {Function} [options.getSourceAvailability]
|
||||
* @param {{isHidden(): boolean, subscribe(callback: Function): Function}|null} [options.pageVisibility]
|
||||
* Whether the page is hidden, and a visibilitychange subscription. With
|
||||
* it, polled refreshes pause in a hidden page; without it they always run.
|
||||
*/
|
||||
constructor(
|
||||
viewer,
|
||||
{
|
||||
allowQaRegistration = false,
|
||||
getSourceAvailability,
|
||||
pageVisibility = null,
|
||||
visibilityTarget = null,
|
||||
} = {},
|
||||
) {
|
||||
this._catalogSourceAvailability = getSourceAvailability;
|
||||
this._pageVisibility = pageVisibility;
|
||||
this._visibilityTarget = visibilityTarget;
|
||||
this.viewer = viewer;
|
||||
this._activityListeners = new Set();
|
||||
this.layers = new Map(); // id → { module, enabled, initialized, intervalId, lifecycleState, lifecycleUncertain }
|
||||
@@ -144,10 +137,6 @@ export class LayerLifecycle {
|
||||
enabled: false,
|
||||
initialized: false,
|
||||
intervalId: null,
|
||||
// A periodic refresh that fell due while the page was hidden; it runs
|
||||
// once when the page is visible again (see _armUpdateLoop).
|
||||
updateDue: false,
|
||||
pageResume: null,
|
||||
// Periodic data refreshes are manager-owned work, independent from the
|
||||
// authoritative enable/disable lifecycle above. Every registered layer
|
||||
// receives the same normalized presentation contract even when its own
|
||||
@@ -546,6 +535,13 @@ export class LayerLifecycle {
|
||||
}
|
||||
}
|
||||
|
||||
_stopUpdateLoop(entry) {
|
||||
clearInterval(entry.intervalId);
|
||||
entry.intervalId = null;
|
||||
entry.removeVisibilityListener?.();
|
||||
entry.removeVisibilityListener = null;
|
||||
}
|
||||
|
||||
_armUpdateLoop(layerId, entry) {
|
||||
const configuredRefreshInterval = Number(entry.module.refreshInterval);
|
||||
const updateInterval = Number(entry.module.updateInterval);
|
||||
@@ -556,24 +552,45 @@ export class LayerLifecycle {
|
||||
? updateInterval
|
||||
: 0;
|
||||
if (refreshInterval > 0) {
|
||||
// A hidden tab shows nobody the result, so polled feeds (flights,
|
||||
// quakes, weather...) skip their refresh while hidden and catch up
|
||||
// with one refresh when the page is shown again.
|
||||
const page = this._pageVisibility;
|
||||
const pageHidden = () => page?.isHidden?.() === true;
|
||||
entry.intervalId = setInterval(() => {
|
||||
if (pageHidden()) {
|
||||
entry.updateDue = true;
|
||||
return;
|
||||
}
|
||||
const visibilityDocument = this._visibilityTarget;
|
||||
let intervalId = null;
|
||||
let dueAt = 0;
|
||||
const canPoll = () =>
|
||||
!visibilityDocument?.hidden &&
|
||||
visibilityDocument?.visibilityState !== 'hidden' &&
|
||||
entry.enabled &&
|
||||
!entry.destroying &&
|
||||
!entry.refreshing &&
|
||||
entry.intervalId === intervalId;
|
||||
// dueAt and the interval phase share one origin, so every interval tick
|
||||
// is a due poll. A visibility catch-up re-arms the interval at the new
|
||||
// deadline; otherwise the old phase would skip its next tick.
|
||||
const schedule = () => {
|
||||
clearInterval(intervalId);
|
||||
dueAt = Date.now() + refreshInterval;
|
||||
intervalId = setInterval(onInterval, refreshInterval);
|
||||
entry.intervalId = intervalId;
|
||||
};
|
||||
const onInterval = () => {
|
||||
if (!canPoll()) return;
|
||||
dueAt = Date.now() + refreshInterval;
|
||||
void this._runPeriodicUpdate(layerId, entry);
|
||||
}, refreshInterval);
|
||||
if (typeof page?.subscribe === 'function')
|
||||
entry.pageResume = page.subscribe(() => {
|
||||
if (pageHidden() || !entry.updateDue || !entry.intervalId) return;
|
||||
entry.updateDue = false;
|
||||
void this._runPeriodicUpdate(layerId, entry);
|
||||
});
|
||||
};
|
||||
const onVisibilityChange = () => {
|
||||
if (!canPoll() || Date.now() < dueAt) return;
|
||||
schedule();
|
||||
void this._runPeriodicUpdate(layerId, entry);
|
||||
};
|
||||
schedule();
|
||||
visibilityDocument?.addEventListener?.(
|
||||
'visibilitychange',
|
||||
onVisibilityChange,
|
||||
);
|
||||
entry.removeVisibilityListener = () =>
|
||||
visibilityDocument?.removeEventListener?.(
|
||||
'visibilitychange',
|
||||
onVisibilityChange,
|
||||
);
|
||||
} else if (updateInterval === 0) {
|
||||
entry.intervalId = setInterval(() => {
|
||||
if (!entry.enabled) return;
|
||||
@@ -582,17 +599,6 @@ export class LayerLifecycle {
|
||||
}
|
||||
}
|
||||
|
||||
/** Stop a layer's update loop and its page-visibility catch-up. */
|
||||
_clearUpdateLoop(entry) {
|
||||
if (entry.intervalId) {
|
||||
clearInterval(entry.intervalId);
|
||||
entry.intervalId = null;
|
||||
}
|
||||
entry.updateDue = false;
|
||||
if (typeof entry.pageResume === 'function') entry.pageResume();
|
||||
entry.pageResume = null;
|
||||
}
|
||||
|
||||
toggle(layerId, { origin = 'programmatic', notificationToken = null } = {}) {
|
||||
const entry = this.layers.get(layerId);
|
||||
if (!entry || entry.destroying) return Promise.resolve(false);
|
||||
@@ -815,7 +821,9 @@ export class LayerLifecycle {
|
||||
entry.enabled = compensated || !cleanupConfirmed;
|
||||
entry.lifecycleUncertain = !compensated && !cleanupConfirmed;
|
||||
settleLifecycle();
|
||||
if (!entry.enabled) this._clearUpdateLoop(entry);
|
||||
if (!entry.enabled && entry.intervalId) {
|
||||
this._stopUpdateLoop(entry);
|
||||
}
|
||||
this._publishActivity({ type: 'status' });
|
||||
if (!compensated) {
|
||||
recordVisibilityFailure(
|
||||
@@ -873,7 +881,9 @@ export class LayerLifecycle {
|
||||
return false;
|
||||
}
|
||||
if (signal?.aborted) return finishCancelledDisable('disable');
|
||||
this._clearUpdateLoop(entry);
|
||||
if (entry.intervalId) {
|
||||
this._stopUpdateLoop(entry);
|
||||
}
|
||||
entry.enabled = false;
|
||||
entry.lifecycleUncertain = false;
|
||||
if (!settleLifecycle() || signal?.aborted)
|
||||
@@ -882,7 +892,9 @@ export class LayerLifecycle {
|
||||
// Enable
|
||||
let abortCleanup = null;
|
||||
const cancelEnable = () => {
|
||||
this._clearUpdateLoop(entry);
|
||||
if (entry.intervalId) {
|
||||
this._stopUpdateLoop(entry);
|
||||
}
|
||||
// Disable immediately so modules with their own AbortController (Radio)
|
||||
// cancel pending update work at the same turn boundary. A second
|
||||
// disable after the current lifecycle await settles closes the race
|
||||
@@ -910,7 +922,9 @@ export class LayerLifecycle {
|
||||
// aborting the caller's signal. Release that signal's listener now so
|
||||
// a later abort cannot revoke a successful retry.
|
||||
signal?.removeEventListener('abort', cancelEnable);
|
||||
this._clearUpdateLoop(entry);
|
||||
if (entry.intervalId) {
|
||||
this._stopUpdateLoop(entry);
|
||||
}
|
||||
await abortCleanup;
|
||||
let cleanupConfirmed = false;
|
||||
try {
|
||||
@@ -949,7 +963,9 @@ export class LayerLifecycle {
|
||||
return false;
|
||||
};
|
||||
const finishFailedEnable = async (phase, error) => {
|
||||
this._clearUpdateLoop(entry);
|
||||
if (entry.intervalId) {
|
||||
this._stopUpdateLoop(entry);
|
||||
}
|
||||
let cleanupConfirmed = false;
|
||||
try {
|
||||
cleanupConfirmed =
|
||||
@@ -1057,7 +1073,9 @@ export class LayerLifecycle {
|
||||
|
||||
// Always clear any stale interval before assigning a new one, so we never
|
||||
// orphan a running timer and end up double-polling.
|
||||
this._clearUpdateLoop(entry);
|
||||
if (entry.intervalId) {
|
||||
this._stopUpdateLoop(entry);
|
||||
}
|
||||
|
||||
// Manager-owned periodic refresh work has one normalized loading/error
|
||||
// contract. Camera-driven layers may keep updateInterval=0 and opt into
|
||||
@@ -2183,7 +2201,9 @@ export class LayerLifecycle {
|
||||
this._publishActivity({ type: 'status' });
|
||||
return false;
|
||||
}
|
||||
this._clearUpdateLoop(entry);
|
||||
if (entry.intervalId) {
|
||||
this._stopUpdateLoop(entry);
|
||||
}
|
||||
entry.enabled = false;
|
||||
}
|
||||
if (typeof entry.module.destroy === 'function') {
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@ export { layerFeedState } from '../ui/layers.js';
|
||||
/** Compatibility facade for callers that construct a manager with its panel. */
|
||||
export class DataLayerManager extends LayerLifecycle {
|
||||
constructor(viewer, options) {
|
||||
super(viewer, options);
|
||||
super(viewer, { visibilityTarget: globalThis.document, ...options });
|
||||
this._presentation = new LayerPresentation(this);
|
||||
}
|
||||
buildTogglePanel(container) {
|
||||
|
||||
+89
-49
@@ -568,55 +568,6 @@ test('setEnabled is idempotent and serializes with toggle', async () => {
|
||||
assert.equal(entry.intervalId, null, 'stats interval cleared on disable');
|
||||
});
|
||||
|
||||
test('polled layers skip refreshes in a hidden tab and catch up once when shown', async () => {
|
||||
const listeners = new Set();
|
||||
let state = 'visible';
|
||||
const pageVisibility = {
|
||||
isHidden: () => state === 'hidden',
|
||||
subscribe(fn) {
|
||||
listeners.add(fn);
|
||||
return () => listeners.delete(fn);
|
||||
},
|
||||
};
|
||||
const setVisibility = (next) => {
|
||||
state = next;
|
||||
for (const fn of [...listeners]) fn();
|
||||
};
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
{
|
||||
const mgr = new DataLayerManager({}, { pageVisibility });
|
||||
const layer = makeSlowLayer('flights', { updateInterval: 40 });
|
||||
mgr.register(layer.module);
|
||||
await mgr.setEnabled('flights', true);
|
||||
assert.equal(listeners.size, 1, 'one catch-up listener while polling');
|
||||
|
||||
setVisibility('hidden');
|
||||
const hiddenStart = layer.calls.update;
|
||||
await sleep(130);
|
||||
assert.equal(layer.calls.update, hiddenStart, 'no refresh while hidden');
|
||||
assert.equal(mgr.layers.get('flights').updateDue, true);
|
||||
|
||||
// Each listener call while still hidden must not refresh either.
|
||||
setVisibility('hidden');
|
||||
assert.equal(layer.calls.update, hiddenStart);
|
||||
|
||||
setVisibility('visible');
|
||||
assert.equal(layer.calls.update, hiddenStart + 1, 'one catch-up refresh on show');
|
||||
assert.equal(mgr.layers.get('flights').updateDue, false);
|
||||
setVisibility('visible');
|
||||
assert.equal(layer.calls.update, hiddenStart + 1, 'no second catch-up');
|
||||
|
||||
await mgr.setEnabled('flights', false);
|
||||
assert.equal(mgr.layers.get('flights').intervalId, null);
|
||||
assert.equal(listeners.size, 0, 'disable removes the catch-up listener');
|
||||
|
||||
await mgr.setEnabled('flights', true);
|
||||
assert.equal(listeners.size, 1);
|
||||
await mgr.destroyLayer('flights');
|
||||
assert.equal(listeners.size, 0, 'destroy removes the catch-up listener');
|
||||
}
|
||||
});
|
||||
|
||||
test('idempotent absolute intent publishes its newer origin without rerunning lifecycle', async () => {
|
||||
const mgr = new DataLayerManager({});
|
||||
const layer = makeSlowLayer('rocket-launches', { updateInterval: -1 });
|
||||
@@ -3541,3 +3492,92 @@ test('refreshLayerStats reaches presentation through the bare lifecycle', async
|
||||
lifecycle.refreshLayerStats();
|
||||
assert.deepEqual(changes, ['status']);
|
||||
});
|
||||
|
||||
test('periodic polls pause hidden, catch up once when due, and dispose visibility listeners', async (t) => {
|
||||
const listeners = new Set();
|
||||
const doc = {
|
||||
hidden: false,
|
||||
addEventListener: (_name, fn) => listeners.add(fn),
|
||||
removeEventListener: (_name, fn) => listeners.delete(fn),
|
||||
};
|
||||
t.mock.timers.enable({ apis: ['setInterval', 'Date'], now: 1000 });
|
||||
const { LayerLifecycle } = await import('./lifecycle.js');
|
||||
const manager = new LayerLifecycle({}, { visibilityTarget: doc });
|
||||
const { module, calls } = makeSlowLayer('hidden-poll');
|
||||
manager.register(module);
|
||||
await manager.setEnabled('hidden-poll', true);
|
||||
assert.equal(calls.update, 1);
|
||||
doc.hidden = true;
|
||||
t.mock.timers.tick(10000);
|
||||
assert.equal(calls.update, 1);
|
||||
assert.equal(await manager.refreshLayer('hidden-poll'), true);
|
||||
assert.equal(calls.update, 2, 'explicit refresh remains allowed');
|
||||
doc.hidden = false;
|
||||
for (const fn of listeners) fn();
|
||||
for (const fn of listeners) fn();
|
||||
assert.equal(calls.update, 3);
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await manager.destroyAll();
|
||||
assert.equal(listeners.size, 0);
|
||||
t.mock.timers.tick(10000);
|
||||
assert.equal(calls.update, 3);
|
||||
});
|
||||
|
||||
async function resumeBetweenTicks(t, { failResume = false } = {}) {
|
||||
const listeners = new Set();
|
||||
const doc = {
|
||||
hidden: false,
|
||||
addEventListener: (_name, fn) => listeners.add(fn),
|
||||
removeEventListener: (_name, fn) => listeners.delete(fn),
|
||||
};
|
||||
t.mock.timers.enable({ apis: ['setInterval', 'Date'], now: 1000 });
|
||||
const { LayerLifecycle } = await import('./lifecycle.js');
|
||||
const manager = new LayerLifecycle({}, { visibilityTarget: doc });
|
||||
const { module, calls } = makeSlowLayer('resume-poll');
|
||||
const updates = [];
|
||||
module.update = async () => {
|
||||
calls.update++;
|
||||
updates.push(Date.now());
|
||||
await Promise.resolve();
|
||||
if (failResume && updates.length === 2) throw new Error('resume failed');
|
||||
};
|
||||
manager.register(module);
|
||||
await manager.setEnabled('resume-poll', true);
|
||||
assert.deepEqual(updates, [1000]);
|
||||
doc.hidden = true;
|
||||
t.mock.timers.tick(1000); // 2000: due tick skipped while hidden
|
||||
t.mock.timers.tick(500); // 2500: return between ticks
|
||||
doc.hidden = false;
|
||||
for (const fn of listeners) fn();
|
||||
assert.deepEqual(updates, [1000, 2500], 'overdue poll runs once on return');
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
const managerRefreshError = manager.layers.get('resume-poll').managerRefreshError;
|
||||
if (failResume) assert.equal(managerRefreshError, 'resume failed');
|
||||
else assert.equal(managerRefreshError, null);
|
||||
t.mock.timers.tick(999); // 3499: the old 3000 phase must not fire
|
||||
assert.deepEqual(updates, [1000, 2500]);
|
||||
t.mock.timers.tick(1); // 3500: one interval after the resume poll
|
||||
assert.deepEqual(updates, [1000, 2500, 3500]);
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
t.mock.timers.tick(1000);
|
||||
assert.deepEqual(updates, [1000, 2500, 3500, 4500], 'new phase keeps one interval');
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await manager.destroyAll();
|
||||
assert.equal(listeners.size, 0);
|
||||
t.mock.timers.tick(5000);
|
||||
assert.equal(updates.length, 4);
|
||||
}
|
||||
|
||||
test('periodic polls realign to the deadline after a between-ticks resume', async (t) => {
|
||||
await resumeBetweenTicks(t);
|
||||
});
|
||||
|
||||
test('periodic polls realign to the deadline after a failed resume refresh', async (t) => {
|
||||
await resumeBetweenTicks(t, { failResume: true });
|
||||
});
|
||||
@@ -26,7 +26,7 @@ test('preflight checks memory, in-flight, then disk before consuming limiter quo
|
||||
const memoryHit = await resolveOverpassPreflight({
|
||||
cacheKey: key,
|
||||
memoryCache: new Map([[key, fresh]]),
|
||||
inFlight: new Map([[key, Promise.resolve(joined)]]),
|
||||
inFlight: new Map([[key, Promise.resolve({ source: 'UPSTREAM', payload: joined })]]),
|
||||
readDisk: async () => { diskReads += 1; return disk; },
|
||||
allowUpstream,
|
||||
now: 1000,
|
||||
@@ -40,7 +40,7 @@ test('preflight checks memory, in-flight, then disk before consuming limiter quo
|
||||
const inFlightHit = await resolveOverpassPreflight({
|
||||
cacheKey: key,
|
||||
memoryCache: new Map([[key, { id: 'stale', status: 200, cachedAt: 0 }]]),
|
||||
inFlight: new Map([[key, Promise.resolve(joined)]]),
|
||||
inFlight: new Map([[key, Promise.resolve({ source: 'UPSTREAM', payload: joined })]]),
|
||||
readDisk: async () => { diskReads += 1; return disk; },
|
||||
allowUpstream,
|
||||
now: 1000,
|
||||
@@ -51,9 +51,10 @@ test('preflight checks memory, in-flight, then disk before consuming limiter quo
|
||||
assert.equal(diskReads, 0, 'in-flight join must short-circuit before disk');
|
||||
assert.equal(limiterCalls, 0, 'in-flight join must not consume limiter quota');
|
||||
|
||||
const diskMemory = new Map();
|
||||
const diskHit = await resolveOverpassPreflight({
|
||||
cacheKey: key,
|
||||
memoryCache: new Map(),
|
||||
memoryCache: diskMemory,
|
||||
inFlight: new Map(),
|
||||
readDisk: async () => { diskReads += 1; return disk; },
|
||||
allowUpstream,
|
||||
@@ -62,6 +63,8 @@ test('preflight checks memory, in-flight, then disk before consuming limiter quo
|
||||
assert.equal(diskHit.payload, disk);
|
||||
assert.equal(diskReads, 1);
|
||||
assert.equal(limiterCalls, 0, 'disk hit must not consume limiter quota');
|
||||
const publishedDisk = await resolveOverpassPreflight({ cacheKey: key, memoryCache: diskMemory, inFlight: new Map(), now: 1000, readDisk: async () => { throw new Error('disk result was not published'); }, allowUpstream });
|
||||
assert.equal(publishedDisk.source, 'HIT');
|
||||
|
||||
const upstreamMiss = await resolveOverpassPreflight({
|
||||
cacheKey: key,
|
||||
@@ -214,3 +217,66 @@ function pointSegDistDeg(p, a, b) {
|
||||
const t = c1 / c2;
|
||||
return Math.hypot(wx - t * vx, wy - t * vy);
|
||||
}
|
||||
|
||||
|
||||
test('cold disk lookup is single-flight through upstream publication and quota admission', async () => {
|
||||
const inFlight = new Map();
|
||||
let release;
|
||||
const disk = new Promise((resolve) => { release = resolve; });
|
||||
let reads = 0, admissions = 0, upstreams = 0;
|
||||
const payload = { status: 200, body: '{"elements":[]}' };
|
||||
const options = {
|
||||
cacheKey: 'cold', memoryCache: new Map(), inFlight,
|
||||
readDisk: () => { reads++; return disk; },
|
||||
allowUpstream: () => { admissions++; return true; },
|
||||
loadUpstream: async () => { upstreams++; return payload; },
|
||||
};
|
||||
const pending = Array.from({ length: 4 }, () => resolveOverpassPreflight(options));
|
||||
await Promise.resolve();
|
||||
assert.equal(inFlight.size, 1);
|
||||
release(null);
|
||||
const results = await Promise.all(pending);
|
||||
assert.equal(reads, 1);
|
||||
assert.equal(admissions, 1);
|
||||
assert.equal(upstreams, 1);
|
||||
assert.ok(results.every((result) => result.payload === payload));
|
||||
assert.deepEqual(results.map((result) => result.source), ['UPSTREAM', 'INFLIGHT', 'INFLIGHT', 'INFLIGHT']);
|
||||
assert.equal(inFlight.size, 0);
|
||||
const failed = { ...options, readDisk: async () => { throw new Error('disk failed'); } };
|
||||
const rejected = await Promise.allSettled([resolveOverpassPreflight(failed), resolveOverpassPreflight(failed)]);
|
||||
assert.ok(rejected.every((result) => result.status === 'rejected'));
|
||||
assert.equal(inFlight.size, 0);
|
||||
await resolveOverpassPreflight({ ...options, readDisk: async () => null });
|
||||
assert.equal(upstreams, 2, 'a rejected reservation does not poison the next retry');
|
||||
});
|
||||
|
||||
test('a joined client retries admission with its own quota after another client is rate limited', async () => {
|
||||
const inFlight = new Map();
|
||||
let release;
|
||||
const disk = new Promise((resolve) => { release = resolve; });
|
||||
let reads = 0, upstreams = 0;
|
||||
const admissions = { a: 0, b: 0, c: 0 };
|
||||
const quota = { a: false, b: true, c: false };
|
||||
const payload = { status: 200, body: '{"elements":[]}' };
|
||||
const client = (name) => resolveOverpassPreflight({
|
||||
cacheKey: 'quota', memoryCache: new Map(), inFlight,
|
||||
readDisk: () => { reads++; return reads === 1 ? disk : null; },
|
||||
allowUpstream: () => { admissions[name]++; return quota[name]; },
|
||||
loadUpstream: async () => { upstreams++; return payload; },
|
||||
});
|
||||
// A (quota exhausted) owns the cold reservation; B (quota available) and
|
||||
// C (quota exhausted) join it while the disk lookup is pending.
|
||||
const pending = [client('a'), client('b'), client('c')];
|
||||
await Promise.resolve();
|
||||
assert.equal(inFlight.size, 1);
|
||||
release(null);
|
||||
const [a, b, c] = await Promise.all(pending);
|
||||
assert.equal(a.source, 'RATE_LIMITED', "A's own quota still rejects A");
|
||||
assert.equal(b.source, 'UPSTREAM', 'B is admitted on its own quota');
|
||||
assert.equal(b.payload, payload);
|
||||
assert.equal(c.source, 'INFLIGHT', "C coalesces onto B's admitted fetch");
|
||||
assert.equal(c.payload, payload);
|
||||
assert.deepEqual(admissions, { a: 1, b: 1, c: 0 });
|
||||
assert.equal(upstreams, 1);
|
||||
assert.equal(inFlight.size, 0);
|
||||
});
|
||||
@@ -11,6 +11,8 @@
|
||||
* that belong to someone else and leave them alone.
|
||||
*/
|
||||
|
||||
import { isSurfacePick } from './scenePick.js';
|
||||
|
||||
/** @type {Map<string, (pickedId: string) => boolean>} layerId -> ownership predicate */
|
||||
const _owners = new Map();
|
||||
|
||||
@@ -100,3 +102,36 @@ export function isOwnedByOtherLayer(layerId, pickedId) {
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a moving-contact click without letting map tiles hide a nearby touch
|
||||
* target. Exact picks always win; only a coarse primary pointer may expand a
|
||||
* surface miss. Every consumer uses the same ordered, bounded scene hits, so sibling layers
|
||||
* do not each choose their own contact under one tap.
|
||||
* Unowned non-surface objects remain barriers, not permission to pick through.
|
||||
*
|
||||
* @param {object} scene Cesium scene with pick and optional drillPick.
|
||||
* @param {{x: number, y: number}} position Screen position in CSS pixels.
|
||||
* @returns {object|null|undefined} Original pick or the first registered touch hit.
|
||||
*/
|
||||
export function pickContact(scene, position) {
|
||||
const picked = scene.pick(position);
|
||||
if (
|
||||
!globalThis.matchMedia?.('(pointer: coarse)').matches ||
|
||||
findPickOwner(resolvePickId(picked)) ||
|
||||
!isSurfacePick(picked) ||
|
||||
typeof scene.drillPick !== 'function'
|
||||
)
|
||||
return picked;
|
||||
let hits;
|
||||
try {
|
||||
hits = scene.drillPick(position, 8, 14, 14) || [];
|
||||
} catch {
|
||||
return picked;
|
||||
}
|
||||
for (const hit of hits.slice(0, 8)) {
|
||||
if (findPickOwner(resolvePickId(hit))) return hit;
|
||||
if (!isSurfacePick(hit)) break;
|
||||
}
|
||||
return picked;
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
unregisterPickOwner,
|
||||
isOwnedByOtherLayer,
|
||||
resolvePickId,
|
||||
pickContact,
|
||||
} from './pickRegistry.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -97,3 +98,122 @@ test('ownership: unregister removes the predicate', () => {
|
||||
unregisterPickOwner('bikeshare');
|
||||
assert.equal(isOwnedByOtherLayer('flights', 'station:1'), false);
|
||||
});
|
||||
|
||||
function primaryPointer(t, coarse) {
|
||||
const previous = Object.getOwnPropertyDescriptor(globalThis, 'matchMedia');
|
||||
Object.defineProperty(globalThis, 'matchMedia', {
|
||||
configurable: true,
|
||||
value: (query) => ({ matches: query === '(pointer: coarse)' && coarse }),
|
||||
});
|
||||
t.after(() => {
|
||||
if (previous) Object.defineProperty(globalThis, 'matchMedia', previous);
|
||||
else delete globalThis.matchMedia;
|
||||
});
|
||||
}
|
||||
|
||||
function contactOwners(t) {
|
||||
registerPickOwner('flights', (id) => id === 'civil');
|
||||
registerPickOwner('military', (id) => id === 'military');
|
||||
registerPickOwner('ais-live-vessels', (id) => id === '123456789');
|
||||
t.after(() => {
|
||||
for (const id of ['flights', 'military', 'ais-live-vessels'])
|
||||
unregisterPickOwner(id);
|
||||
});
|
||||
}
|
||||
|
||||
const mapTile = { primitive: { isCesium3DTileset: true } };
|
||||
|
||||
test('touch contact pick expands only a surface miss, bounded to 8 hits in 14 CSS pixels', (t) => {
|
||||
primaryPointer(t, true);
|
||||
contactOwners(t);
|
||||
const position = { x: 30, y: 50 };
|
||||
const contact = { primitive: { id: 'civil' } };
|
||||
let drills = 0;
|
||||
const scene = {
|
||||
pick(point) {
|
||||
assert.equal(point, position);
|
||||
return mapTile;
|
||||
},
|
||||
drillPick(...args) {
|
||||
drills++;
|
||||
assert.deepEqual(args, [position, 8, 14, 14]);
|
||||
return [mapTile, contact];
|
||||
},
|
||||
};
|
||||
assert.equal(pickContact(scene, position), contact);
|
||||
assert.equal(drills, 1);
|
||||
});
|
||||
|
||||
test('touch contact pick returns the first registered sibling, never a caller-specific preference', (t) => {
|
||||
primaryPointer(t, true);
|
||||
contactOwners(t);
|
||||
for (const first of [{ id: 'military' }, { id: { mmsi: '123456789' } }]) {
|
||||
const scene = {
|
||||
pick: () => mapTile,
|
||||
drillPick: () => [mapTile, first, { id: 'civil' }],
|
||||
};
|
||||
assert.equal(pickContact(scene, { x: 1, y: 1 }), first);
|
||||
}
|
||||
});
|
||||
|
||||
test('touch contact pick does not drill through exact registered or unregistered interactive picks', (t) => {
|
||||
primaryPointer(t, true);
|
||||
contactOwners(t);
|
||||
for (const exact of [
|
||||
{ id: 'military' },
|
||||
{ id: 'custom-annotation' },
|
||||
{ primitive: { id: 'custom-gizmo' } },
|
||||
{ primitive: { id: 'civil', isCesium3DTileset: true } },
|
||||
]) {
|
||||
const scene = {
|
||||
pick: () => exact,
|
||||
drillPick: () => assert.fail('must keep exact interactive hit'),
|
||||
};
|
||||
assert.equal(pickContact(scene, { x: 1, y: 1 }), exact);
|
||||
}
|
||||
});
|
||||
|
||||
test('touch expansion stops at a non-surface object and respects the drill bound', (t) => {
|
||||
primaryPointer(t, true);
|
||||
contactOwners(t);
|
||||
for (const hits of [
|
||||
[mapTile, { id: 'custom-annotation' }, { id: 'civil' }],
|
||||
[...Array(8).fill(mapTile), { id: 'civil' }],
|
||||
[mapTile],
|
||||
]) {
|
||||
assert.equal(
|
||||
pickContact(
|
||||
{ pick: () => mapTile, drillPick: () => hits },
|
||||
{ x: 1, y: 1 },
|
||||
),
|
||||
mapTile,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('fine primary pointers keep the existing exact mouse pick without a drill', (t) => {
|
||||
primaryPointer(t, false);
|
||||
contactOwners(t);
|
||||
const scene = {
|
||||
pick: () => mapTile,
|
||||
drillPick: () => assert.fail('mouse must not expand'),
|
||||
};
|
||||
assert.equal(pickContact(scene, { x: 1, y: 1 }), mapTile);
|
||||
});
|
||||
|
||||
test('touch picking tolerates unavailable or failing drill support and preserves empty sky', (t) => {
|
||||
primaryPointer(t, true);
|
||||
contactOwners(t);
|
||||
for (const drillPick of [
|
||||
undefined,
|
||||
() => {
|
||||
throw new Error('context lost');
|
||||
},
|
||||
() => [],
|
||||
]) {
|
||||
assert.equal(
|
||||
pickContact({ pick: () => undefined, drillPick }, { x: 1, y: 1 }),
|
||||
undefined,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -13,8 +13,11 @@ function approximateDistanceM(latA, lonA, latB, lonB) {
|
||||
return Number.MAX_SAFE_INTEGER;
|
||||
const latitudeScale = 111320;
|
||||
const longitudeScale = latitudeScale * Math.cos((latA * Math.PI) / 180);
|
||||
// The short way round, so places either side of 180° stay close.
|
||||
let dLon = lonB - lonA;
|
||||
dLon -= 360 * Math.round(dLon / 360);
|
||||
return Math.round(
|
||||
Math.hypot((latB - latA) * latitudeScale, (lonB - lonA) * longitudeScale),
|
||||
Math.hypot((latB - latA) * latitudeScale, dLon * longitudeScale),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -988,6 +988,16 @@ test('the voice TOOL SCHEMA matches the pinned contract — the mission mapping
|
||||
]);
|
||||
// Cyber deliberately adds one layout; first-run missions still change no tools.
|
||||
hudLayout.enum = hudLayout.enum.filter((layout) => layout !== 'cyber');
|
||||
const cameraMotion = legacyTools.find((tool) => tool.name === 'move_camera')
|
||||
.parameters.properties.motion;
|
||||
assert.deepEqual(cameraMotion.enum, [
|
||||
'orbit',
|
||||
'pan',
|
||||
'tilt',
|
||||
'rotate',
|
||||
'rotate_globe',
|
||||
'stop',
|
||||
]);
|
||||
const block = JSON.stringify(legacyTools);
|
||||
// Re-derived for the voice layer manifest (generated layer enums, alias and
|
||||
// field hints), point-and-ask's pointer/referent arguments and the prompt
|
||||
@@ -997,10 +1007,14 @@ test('the voice TOOL SCHEMA matches the pinned contract — the mission mapping
|
||||
// list descriptions; the missions still ride existing tools.
|
||||
// Street Level and GNSS extend the manifest; broadcast caveats describe tracked and analyst aircraft.
|
||||
// Air quality (ECCC AQHI) extends the manifest.
|
||||
assert.equal(block.length, 30655, 'serialized tool schema length drifted');
|
||||
// Globe rotation adds one camera motion and its descriptions; Globe View
|
||||
// framing/default guidance updates only move_camera and mode descriptions.
|
||||
// Radio markup and the complete production-request comparison pin locality.
|
||||
// Restore the base camera/Globe View wording with the globe-rotation append.
|
||||
assert.equal(block.length, 30878, 'serialized tool schema length drifted');
|
||||
assert.equal(
|
||||
crypto.createHash('sha256').update(block).digest('hex'),
|
||||
'3b1d5e77c10a92f6543a4f9d1ff1870101a0306b65e5b8a6113111c81eda371e',
|
||||
'7b95f0f4258896fe5c3316265a19f3a6602809d4e7e681ee7ecd8f2df514ef1f',
|
||||
'the first-run missions must ride EXISTING tools: no schema edit, no cache bust',
|
||||
);
|
||||
const instructions = fs.readFileSync(
|
||||
|
||||
+5
-2
@@ -39,6 +39,7 @@ import {
|
||||
} from './hudLayouts.js';
|
||||
import { formatNumber, msg, t } from './i18n/index.js';
|
||||
import { localizeLayerName } from './ui/layerPanel.js';
|
||||
import { isEmbeddedInline } from './app/embed.js';
|
||||
|
||||
/** Color palettes keyed by shader mode; applied as CSS custom properties. */
|
||||
const HUD_COLORS = {
|
||||
@@ -348,9 +349,11 @@ export class IntelHUD {
|
||||
dot.style.visibility = this._recBlinkState ? 'visible' : 'hidden';
|
||||
}, 800);
|
||||
|
||||
// Camera-derived data — 4 updates/second (250ms)
|
||||
// Camera-derived data — 4 updates/second (250ms). Skipped in a hidden
|
||||
// page, except an inline panel, whose host may report it hidden while it
|
||||
// is on screen (see keepPanelRendering in app/embed.js).
|
||||
this._updateInterval = setInterval(() => {
|
||||
if (!this._visible || document.hidden) return;
|
||||
if (!this._visible || (document.hidden && !isEmbeddedInline())) return;
|
||||
this._updateCameraData();
|
||||
}, 250);
|
||||
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
// The HUD's 250 ms telemetry readout skips ticks in a hidden page, except in
|
||||
// an inline panel, whose host may report it hidden while it is on screen.
|
||||
//
|
||||
// hud.js imports `mgrs`, which Node's ESM loader cannot read named exports
|
||||
// from, so a module hook swaps it for a stub before the import (the same
|
||||
// setup as hudAltitudeDatum.test.mjs).
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { registerHooks } from 'node:module';
|
||||
|
||||
const MGRS_STUB_URL = 'gev-test-stub:mgrs';
|
||||
registerHooks({
|
||||
resolve(specifier, context, next) {
|
||||
if (specifier === 'mgrs') return { url: MGRS_STUB_URL, shortCircuit: true };
|
||||
return next(specifier, context);
|
||||
},
|
||||
load(url, context, next) {
|
||||
if (url === MGRS_STUB_URL) {
|
||||
return {
|
||||
format: 'module',
|
||||
shortCircuit: true,
|
||||
source: 'export function forward() { return "10SEG55776339"; }\nexport default { forward };\n',
|
||||
};
|
||||
}
|
||||
return next(url, context);
|
||||
},
|
||||
});
|
||||
|
||||
const { IntelHUD } = await import('./hud.js');
|
||||
|
||||
function installHiddenPage() {
|
||||
const elements = new Map(
|
||||
['hud-alt', 'hud-summary', 'hud-mgrs', 'hud-latlon', 'hud-bottom-line', 'hud-gsd', 'hud-coll', 'hud-ona', 'hud-mode']
|
||||
.map((id) => [id, { textContent: '' }]),
|
||||
);
|
||||
const previousDocument = globalThis.document;
|
||||
globalThis.document = {
|
||||
hidden: true,
|
||||
visibilityState: 'hidden',
|
||||
getElementById: (id) => elements.get(id) ?? null,
|
||||
querySelectorAll: () => [],
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
};
|
||||
const viewer = {
|
||||
camera: {
|
||||
pitch: -Math.PI / 2,
|
||||
positionCartographic: { latitude: 0.7, longitude: -1.2, height: 500 },
|
||||
computeViewRectangle: () => undefined,
|
||||
moveEnd: { addEventListener() {}, removeEventListener() {} },
|
||||
},
|
||||
};
|
||||
return {
|
||||
alt: () => elements.get('hud-alt').textContent,
|
||||
viewer,
|
||||
restore() {
|
||||
if (previousDocument === undefined) delete globalThis.document;
|
||||
else globalThis.document = previousDocument;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('the HUD readout pauses in a hidden page but keeps updating in an inline panel', (t) => {
|
||||
t.mock.timers.enable({ apis: ['setInterval'] });
|
||||
const page = installHiddenPage();
|
||||
let hud;
|
||||
try {
|
||||
hud = new IntelHUD(page.viewer);
|
||||
hud._visible = true;
|
||||
|
||||
t.mock.timers.tick(250);
|
||||
assert.equal(page.alt(), '', 'a hidden page skips the telemetry tick');
|
||||
|
||||
globalThis.GEV_EMBED_INLINE = true;
|
||||
t.mock.timers.tick(250);
|
||||
assert.match(
|
||||
page.alt(),
|
||||
/^ALT: 500m/,
|
||||
'an inline panel reported hidden keeps its readout updating',
|
||||
);
|
||||
} finally {
|
||||
delete globalThis.GEV_EMBED_INLINE;
|
||||
hud?.destroy();
|
||||
page.restore();
|
||||
}
|
||||
});
|
||||
@@ -47,7 +47,7 @@ export function createTracking({
|
||||
const { isMilitaryLayerActive, isMilitaryIcao } = services.militaryRegistry;
|
||||
const { trackedLabelModelFromText, refreshTrackedReadout } = services.readout;
|
||||
const { applyTrackedCameraFrame } = services.camera;
|
||||
const { resolvePickId, isOwnedByOtherLayer } = services.picking;
|
||||
const { resolvePickId, isOwnedByOtherLayer, pickContact } = services.picking;
|
||||
const flightsLayer = layer;
|
||||
|
||||
function _emitAwarenessEvent(type, detail) {
|
||||
@@ -1174,7 +1174,9 @@ export function createTracking({
|
||||
// first-person reference. A globe click must not fall through to the
|
||||
// normal empty-space deselection path; cockpit has explicit exit controls.
|
||||
if (document.body.classList.contains('cockpit-mode')) return;
|
||||
const picked = viewer.scene.pick(click.position);
|
||||
const picked = pickContact
|
||||
? pickContact(viewer.scene, click.position)
|
||||
: viewer.scene.pick(click.position);
|
||||
|
||||
if (picked) {
|
||||
// Clicking the tracked entity itself — ignore (don't deselect)
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { Event } from 'cesium';
|
||||
import { createLaunchHover } from './lifecycle.js';
|
||||
|
||||
function fakeTimers() {
|
||||
const timers = new Map();
|
||||
let nextId = 1;
|
||||
const api = {
|
||||
setTimer(fn, ms) {
|
||||
const id = nextId++;
|
||||
timers.set(id, { fn, ms });
|
||||
return id;
|
||||
},
|
||||
clearTimer(id) {
|
||||
timers.delete(id);
|
||||
},
|
||||
pending: () => [...timers.values()],
|
||||
flush() {
|
||||
const due = [...timers.entries()];
|
||||
timers.clear();
|
||||
for (const [, timer] of due) timer.fn();
|
||||
},
|
||||
};
|
||||
return api;
|
||||
}
|
||||
|
||||
function hoverRig({ overMarker }) {
|
||||
let at = 0,
|
||||
picks = 0,
|
||||
enabled = true,
|
||||
free = true;
|
||||
const timers = fakeTimers();
|
||||
const viewer = {
|
||||
camera: { moveStart: new Event(), moveEnd: new Event() },
|
||||
scene: {
|
||||
canvas: { style: { cursor: '' } },
|
||||
drillPick(position) {
|
||||
picks++;
|
||||
return overMarker(position) ? [{ id: 'launch' }] : [];
|
||||
},
|
||||
},
|
||||
};
|
||||
const hover = createLaunchHover({
|
||||
viewer,
|
||||
enabled: () => enabled,
|
||||
entityLaunchId: (id) => id === 'launch',
|
||||
now: () => at,
|
||||
documentRef: { hidden: false },
|
||||
pointerFree: () => free,
|
||||
setTimer: timers.setTimer,
|
||||
clearTimer: timers.clearTimer,
|
||||
});
|
||||
return {
|
||||
hover,
|
||||
timers,
|
||||
viewer,
|
||||
cursor: () => viewer.scene.canvas.style.cursor,
|
||||
picks: () => picks,
|
||||
setTime: (value) => (at = value),
|
||||
setEnabled: (value) => (enabled = value),
|
||||
setFree: (value) => (free = value),
|
||||
};
|
||||
}
|
||||
|
||||
const onMarker = (position) => position?.x === 1;
|
||||
const marker = () => ({ endPosition: { x: 1, y: 1 } });
|
||||
const empty = () => ({ endPosition: { x: 9, y: 9 } });
|
||||
|
||||
test('mission hover runs one trailing check for a pointer that stops on empty space', () => {
|
||||
const rig = hoverRig({ overMarker: onMarker });
|
||||
rig.hover.move(marker());
|
||||
assert.equal(rig.cursor(), 'pointer');
|
||||
rig.setTime(50);
|
||||
const movement = empty();
|
||||
rig.hover.move(movement);
|
||||
// Cesium reuses its movement object; the trailing check must use a copy.
|
||||
movement.endPosition.x = 1;
|
||||
assert.equal(rig.picks(), 1);
|
||||
assert.deepEqual(
|
||||
rig.timers.pending().map((timer) => timer.ms),
|
||||
[70],
|
||||
);
|
||||
rig.setTime(80);
|
||||
rig.hover.move(empty());
|
||||
assert.equal(rig.timers.pending().length, 1, 'one trailing check at a time');
|
||||
rig.setTime(120);
|
||||
rig.timers.flush();
|
||||
assert.equal(rig.picks(), 2);
|
||||
assert.equal(rig.cursor(), '');
|
||||
assert.equal(rig.timers.pending().length, 0);
|
||||
rig.hover.destroy();
|
||||
});
|
||||
|
||||
test('mission hover trailing check sets the cursor for a pointer that stops on a mission', () => {
|
||||
const rig = hoverRig({ overMarker: onMarker });
|
||||
rig.hover.move(empty());
|
||||
assert.equal(rig.cursor(), '');
|
||||
rig.setTime(50);
|
||||
rig.hover.move(marker());
|
||||
rig.setTime(120);
|
||||
rig.timers.flush();
|
||||
assert.equal(rig.picks(), 2);
|
||||
assert.equal(rig.cursor(), 'pointer');
|
||||
rig.hover.destroy();
|
||||
assert.equal(rig.cursor(), '');
|
||||
});
|
||||
|
||||
test('mission hover trailing check is cancelled by reset, destroy, and camera motion', () => {
|
||||
for (const cancel of [
|
||||
(rig) => rig.hover.reset(),
|
||||
(rig) => rig.hover.destroy(),
|
||||
(rig) => rig.viewer.camera.moveStart.raiseEvent(),
|
||||
]) {
|
||||
const rig = hoverRig({ overMarker: onMarker });
|
||||
rig.hover.move(empty());
|
||||
rig.setTime(50);
|
||||
rig.hover.move(marker());
|
||||
assert.equal(rig.timers.pending().length, 1);
|
||||
cancel(rig);
|
||||
assert.equal(rig.timers.pending().length, 0);
|
||||
assert.equal(rig.picks(), 1);
|
||||
assert.equal(rig.cursor(), '');
|
||||
rig.hover.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
test('mission hover trailing check rechecks eligibility before picking', () => {
|
||||
for (const block of [
|
||||
(rig) => rig.setEnabled(false),
|
||||
(rig) => rig.setFree(false),
|
||||
]) {
|
||||
const rig = hoverRig({ overMarker: onMarker });
|
||||
rig.hover.move(marker());
|
||||
rig.setTime(50);
|
||||
rig.hover.move(marker());
|
||||
block(rig);
|
||||
rig.setTime(120);
|
||||
rig.timers.flush();
|
||||
assert.equal(rig.picks(), 1);
|
||||
assert.equal(rig.cursor(), '');
|
||||
rig.hover.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
test('mission hover throttles, clears on motion/hidden, and releases listeners', () => {
|
||||
let at = 0,
|
||||
picks = 0,
|
||||
enabled = true;
|
||||
const listeners = new Set();
|
||||
const doc = {
|
||||
hidden: false,
|
||||
addEventListener: (_n, fn) => listeners.add(fn),
|
||||
removeEventListener: (_n, fn) => listeners.delete(fn),
|
||||
};
|
||||
const viewer = {
|
||||
camera: { moveStart: new Event(), moveEnd: new Event() },
|
||||
scene: {
|
||||
canvas: { style: {} },
|
||||
drillPick() {
|
||||
picks++;
|
||||
return [{ id: 'launch' }];
|
||||
},
|
||||
},
|
||||
};
|
||||
const hover = createLaunchHover({
|
||||
viewer,
|
||||
enabled: () => enabled,
|
||||
entityLaunchId: (id) => id === 'launch',
|
||||
now: () => at,
|
||||
documentRef: doc,
|
||||
pointerFree: () => true,
|
||||
...fakeTimers(),
|
||||
});
|
||||
for (at = 0; at < 120; at++) hover.move({ endPosition: {} });
|
||||
assert.equal(picks, 1);
|
||||
assert.equal(viewer.scene.canvas.style.cursor, 'pointer');
|
||||
hover.move({ endPosition: {} });
|
||||
assert.equal(picks, 2);
|
||||
viewer.camera.moveStart.raiseEvent();
|
||||
at = 500;
|
||||
hover.move({ endPosition: {} });
|
||||
assert.equal(picks, 2);
|
||||
assert.equal(viewer.scene.canvas.style.cursor, '');
|
||||
viewer.camera.moveEnd.raiseEvent();
|
||||
hover.move({ endPosition: {} });
|
||||
assert.equal(picks, 3);
|
||||
doc.hidden = true;
|
||||
for (const fn of listeners) fn();
|
||||
hover.move({ endPosition: {} });
|
||||
assert.equal(picks, 3);
|
||||
assert.equal(viewer.scene.canvas.style.cursor, '');
|
||||
doc.hidden = false;
|
||||
enabled = false;
|
||||
hover.move({ endPosition: {} });
|
||||
assert.equal(picks, 3);
|
||||
hover.destroy();
|
||||
assert.equal(listeners.size, 0);
|
||||
assert.equal(viewer.camera.moveStart.numberOfListeners, 0);
|
||||
assert.equal(viewer.camera.moveEnd.numberOfListeners, 0);
|
||||
});
|
||||
|
||||
test('mission hover preserves the cursor of a pointer-owning tool', () => {
|
||||
let free = true,
|
||||
picks = 0;
|
||||
const viewer = {
|
||||
camera: { moveStart: new Event(), moveEnd: new Event() },
|
||||
scene: {
|
||||
canvas: { style: { cursor: '' } },
|
||||
drillPick() {
|
||||
picks++;
|
||||
return [{ id: 'launch' }];
|
||||
},
|
||||
},
|
||||
};
|
||||
const hover = createLaunchHover({
|
||||
viewer,
|
||||
enabled: () => true,
|
||||
entityLaunchId: () => true,
|
||||
documentRef: null,
|
||||
pointerFree: () => free,
|
||||
});
|
||||
hover.move({ endPosition: {} });
|
||||
assert.equal(viewer.scene.canvas.style.cursor, 'pointer');
|
||||
free = false;
|
||||
viewer.scene.canvas.style.cursor = 'crosshair';
|
||||
hover.move({ endPosition: {} });
|
||||
viewer.camera.moveStart.raiseEvent();
|
||||
hover.destroy();
|
||||
assert.equal(picks, 1);
|
||||
assert.equal(viewer.scene.canvas.style.cursor, 'crosshair');
|
||||
});
|
||||
@@ -1,6 +1,116 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { isPointerFree } from '../../data/inputOwnership.js';
|
||||
|
||||
const HOVER_PICK_INTERVAL_MS = 120;
|
||||
|
||||
/**
|
||||
* Bound hover picking independently of precise click selection. A move inside
|
||||
* the throttle window is not dropped: its position is kept and one trailing
|
||||
* check runs when the window ends, so a pointer that stops is still resolved.
|
||||
*/
|
||||
export function createLaunchHover({
|
||||
viewer,
|
||||
enabled,
|
||||
entityLaunchId,
|
||||
now = () => performance.now(),
|
||||
documentRef = globalThis.document,
|
||||
pointerFree = isPointerFree,
|
||||
setTimer = globalThis.setTimeout,
|
||||
clearTimer = globalThis.clearTimeout,
|
||||
}) {
|
||||
let moving = false;
|
||||
let lastPick = -Infinity;
|
||||
let ownsCursor = false;
|
||||
let pendingPosition = null;
|
||||
let trailingTimer = null;
|
||||
const clearCursor = () => {
|
||||
if (ownsCursor && viewer.scene.canvas.style.cursor === 'pointer')
|
||||
viewer.scene.canvas.style.cursor = '';
|
||||
ownsCursor = false;
|
||||
};
|
||||
const cancelTrailing = () => {
|
||||
if (trailingTimer !== null) clearTimer(trailingTimer);
|
||||
trailingTimer = null;
|
||||
pendingPosition = null;
|
||||
};
|
||||
const reset = () => {
|
||||
cancelTrailing();
|
||||
clearCursor();
|
||||
lastPick = -Infinity;
|
||||
};
|
||||
const start = () => {
|
||||
moving = true;
|
||||
reset();
|
||||
};
|
||||
const end = () => {
|
||||
moving = false;
|
||||
reset();
|
||||
};
|
||||
const visibility = () => {
|
||||
if (documentRef?.hidden || documentRef?.visibilityState === 'hidden')
|
||||
reset();
|
||||
};
|
||||
const eligible = () =>
|
||||
enabled() &&
|
||||
!moving &&
|
||||
!documentRef?.hidden &&
|
||||
documentRef?.visibilityState !== 'hidden' &&
|
||||
pointerFree();
|
||||
const pick = (position, at) => {
|
||||
lastPick = at;
|
||||
const found = viewer.scene
|
||||
.drillPick(position, 12)
|
||||
.some((picked) => entityLaunchId(picked?.id));
|
||||
if (found) {
|
||||
viewer.scene.canvas.style.cursor = 'pointer';
|
||||
ownsCursor = true;
|
||||
} else clearCursor();
|
||||
};
|
||||
const trailing = () => {
|
||||
trailingTimer = null;
|
||||
const position = pendingPosition;
|
||||
pendingPosition = null;
|
||||
if (!position) return;
|
||||
// Eligibility may have changed while the check waited.
|
||||
if (!eligible()) {
|
||||
reset();
|
||||
return;
|
||||
}
|
||||
pick(position, now());
|
||||
};
|
||||
const removeStart = viewer.camera?.moveStart?.addEventListener(start);
|
||||
const removeEnd = viewer.camera?.moveEnd?.addEventListener(end);
|
||||
documentRef?.addEventListener?.('visibilitychange', visibility);
|
||||
return {
|
||||
move(movement) {
|
||||
if (!eligible()) {
|
||||
reset();
|
||||
return;
|
||||
}
|
||||
const at = now();
|
||||
const wait = HOVER_PICK_INTERVAL_MS - (at - lastPick);
|
||||
if (wait > 0) {
|
||||
// Cesium reuses the movement object, so keep a copy of the position.
|
||||
pendingPosition = Cesium.Cartesian2.clone(
|
||||
movement.endPosition,
|
||||
pendingPosition ?? undefined,
|
||||
);
|
||||
if (trailingTimer === null) trailingTimer = setTimer(trailing, wait);
|
||||
return;
|
||||
}
|
||||
cancelTrailing();
|
||||
pick(movement.endPosition, at);
|
||||
},
|
||||
reset,
|
||||
destroy() {
|
||||
reset();
|
||||
removeStart?.();
|
||||
removeEnd?.();
|
||||
documentRef?.removeEventListener?.('visibilitychange', visibility);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createLifecycle({
|
||||
state: layerState,
|
||||
services,
|
||||
@@ -8,6 +118,7 @@ export function createLifecycle({
|
||||
source,
|
||||
}) {
|
||||
const { holdContinuousRender, releaseContinuousRender } = services.render;
|
||||
let hover = null;
|
||||
|
||||
const methods = {
|
||||
init(viewer) {
|
||||
@@ -45,14 +156,15 @@ export function createLifecycle({
|
||||
layerState._moveHandler = new Cesium.ScreenSpaceEventHandler(
|
||||
viewer.scene.canvas,
|
||||
);
|
||||
layerState._moveHandler.setInputAction((movement) => {
|
||||
if (!layerState._enabled || !layerState._dataSource?.show) return;
|
||||
const missionEntity = viewer.scene
|
||||
.drillPick(movement.endPosition, 12)
|
||||
.map((picked) => picked?.id)
|
||||
.find((candidate) => parts.selection.entityLaunchId(candidate));
|
||||
viewer.scene.canvas.style.cursor = missionEntity ? 'pointer' : '';
|
||||
}, Cesium.ScreenSpaceEventType.MOUSE_MOVE);
|
||||
hover = createLaunchHover({
|
||||
viewer,
|
||||
enabled: () => layerState._enabled && layerState._dataSource?.show,
|
||||
entityLaunchId: parts.selection.entityLaunchId,
|
||||
});
|
||||
layerState._moveHandler.setInputAction(
|
||||
hover.move,
|
||||
Cesium.ScreenSpaceEventType.MOUSE_MOVE,
|
||||
);
|
||||
// Apply horizon visibility before Cesium traverses and picks the scene.
|
||||
// A postRender write is one frame late and can remain visibly stale when
|
||||
// request-on-demand rendering stops after a globe camera move.
|
||||
@@ -100,6 +212,7 @@ export function createLifecycle({
|
||||
},
|
||||
|
||||
async disable() {
|
||||
hover?.reset();
|
||||
layerState._sourceController.abort();
|
||||
layerState._enabled = false;
|
||||
releaseContinuousRender('rocket-launches');
|
||||
@@ -122,6 +235,8 @@ export function createLifecycle({
|
||||
},
|
||||
|
||||
async destroy(viewer) {
|
||||
hover?.destroy();
|
||||
hover = null;
|
||||
layerState._sourceController.abort();
|
||||
releaseContinuousRender('rocket-launches'); // direct-destroy path (perf wave 2 fix)
|
||||
layerState._enabled = false;
|
||||
|
||||
@@ -47,7 +47,7 @@ export function createTracking({
|
||||
const { ensureGeoidReady, geoidHeight } = services.geoid;
|
||||
const { clearFocusTarget } = services.focus;
|
||||
const { applyTrackedCameraFrame } = services.camera;
|
||||
const { resolvePickId, isOwnedByOtherLayer } = services.picking;
|
||||
const { resolvePickId, isOwnedByOtherLayer, pickContact } = services.picking;
|
||||
|
||||
function _emitAwarenessEvent(type, detail) {
|
||||
if (
|
||||
@@ -1079,7 +1079,9 @@ export function createTracking({
|
||||
// first-person reference. Empty globe clicks are inert until the user
|
||||
// exits with C, Escape, or the dedicated button.
|
||||
if (document.body.classList.contains('cockpit-mode')) return;
|
||||
const picked = viewer.scene.pick(click.position);
|
||||
const picked = pickContact
|
||||
? pickContact(viewer.scene, click.position)
|
||||
: viewer.scene.pick(click.position);
|
||||
|
||||
if (picked) {
|
||||
// Clicking the tracked entity itself -- ignore (don't deselect)
|
||||
|
||||
@@ -222,9 +222,32 @@ export function kartaViewTimeMs(value) {
|
||||
String(value ?? ''),
|
||||
);
|
||||
if (!match) return null;
|
||||
const [, y, mo, d, h = '0', mi = '0', s = '0'] = match;
|
||||
if (Number(y) < 1990) return null;
|
||||
const ms = Date.UTC(+y, +mo - 1, +d, +h, +mi, +s);
|
||||
const year = Number(match[1]);
|
||||
const month = Number(match[2]);
|
||||
const day = Number(match[3]);
|
||||
const hours = Number(match[4] ?? 0);
|
||||
const minutes = Number(match[5] ?? 0);
|
||||
const seconds = Number(match[6] ?? 0);
|
||||
if (year < 1990) return null;
|
||||
if (
|
||||
month < 1 ||
|
||||
month > 12 ||
|
||||
day < 1 ||
|
||||
hours > 23 ||
|
||||
minutes > 59 ||
|
||||
seconds > 59
|
||||
)
|
||||
return null;
|
||||
// Date.UTC rolls 31 February into March. A photo would then pass or fail
|
||||
// the day filter on the wrong calendar day, so an impossible date is unknown.
|
||||
const ms = Date.UTC(year, month - 1, day, hours, minutes, seconds);
|
||||
const parsed = new Date(ms);
|
||||
if (
|
||||
parsed.getUTCFullYear() !== year ||
|
||||
parsed.getUTCMonth() !== month - 1 ||
|
||||
parsed.getUTCDate() !== day
|
||||
)
|
||||
return null;
|
||||
return Number.isFinite(ms) ? ms : null;
|
||||
}
|
||||
|
||||
|
||||
@@ -158,6 +158,10 @@ test('timestamps parse as UTC; years before 1990 and garbage are unknown', () =>
|
||||
kartaViewTimeMs('2022-03-21T06:42:59'),
|
||||
Date.UTC(2022, 2, 21, 6, 42, 59),
|
||||
);
|
||||
assert.equal(kartaViewTimeMs('2022-02-31 12:00:00'), null);
|
||||
assert.equal(kartaViewTimeMs('2022-13-01 00:00:00'), null);
|
||||
assert.equal(kartaViewTimeMs('2022-03-21 25:00:00'), null);
|
||||
assert.equal(kartaViewTimeMs('2022-04-31'), null);
|
||||
assert.equal(kartaViewTimeMs('0000-00-00 00:00:00'), null);
|
||||
assert.equal(kartaViewTimeMs(''), null);
|
||||
assert.equal(kartaViewTimeMs(null), null);
|
||||
|
||||
@@ -97,6 +97,7 @@ export function createLifecycle({ state, services, parts }) {
|
||||
else if (hidden) {
|
||||
hidden = false;
|
||||
parts.rendering.resumePlayback();
|
||||
parts.viewport.runProximityCheck();
|
||||
}
|
||||
}
|
||||
function bindVisibility() {
|
||||
|
||||
@@ -75,6 +75,11 @@ export function createViewport({ state, services, parts }) {
|
||||
}
|
||||
|
||||
function runProximityCheck() {
|
||||
if (
|
||||
globalThis.document?.hidden ||
|
||||
globalThis.document?.visibilityState === 'hidden'
|
||||
)
|
||||
return;
|
||||
if (!state._enabled || !state._viewer) return;
|
||||
refreshViewBounds();
|
||||
const altitude = getCameraAltitude();
|
||||
|
||||
@@ -10,7 +10,7 @@ export function createSelection({
|
||||
options,
|
||||
}) {
|
||||
const { state } = vesselState;
|
||||
const { resolvePickId, isOwnedByOtherLayer } = services.picking;
|
||||
const { resolvePickId, isOwnedByOtherLayer, pickContact } = services.picking;
|
||||
const { requestWorldFocus } = services.worldFocus;
|
||||
const {
|
||||
selectEntityContext,
|
||||
@@ -37,7 +37,9 @@ export function createSelection({
|
||||
// A tool owns the pointer (src/data/inputOwnership.js): yield the click.
|
||||
if (!isPointerFree()) return;
|
||||
if (!state.feed.enabled) return;
|
||||
const picked = viewer.scene.pick(click.position);
|
||||
const picked = pickContact
|
||||
? pickContact(viewer.scene, click.position)
|
||||
: viewer.scene.pick(click.position);
|
||||
const pickedId = resolvePickId(picked);
|
||||
let record = pickedId ? state.records.byMmsi.get(pickedId) : null;
|
||||
const rawId = picked?.id ?? picked?.primitive?.id;
|
||||
|
||||
@@ -209,6 +209,35 @@ test('no unchanged Realtime tool definition drifts silently', () => {
|
||||
const hudLayout = unchanged.find((tool) => tool.name === 'set_hud').parameters.properties.layout;
|
||||
assert.deepEqual(hudLayout.enum, ['tactical', 'operator', 'minimal', 'cyber']);
|
||||
hudLayout.enum = hudLayout.enum.filter((layout) => layout !== 'cyber');
|
||||
// Globe rotation adds one motion and clarifies the two affected descriptions.
|
||||
// Check those edits explicitly, then keep the legacy pin for every other field.
|
||||
const camera = unchanged.find((tool) => tool.name === 'move_camera');
|
||||
assert.deepEqual(camera.parameters.properties.motion.enum, [
|
||||
'orbit',
|
||||
'pan',
|
||||
'tilt',
|
||||
'rotate',
|
||||
'rotate_globe',
|
||||
'stop',
|
||||
]);
|
||||
assert.equal(
|
||||
camera.description,
|
||||
'Direct the camera like a drone operator: orbit the current view target, pan, tilt, or rotate — one bounded nudge (mode=once) or continuous motion until stopped (mode=continuous). Continuous motion also stops on any manual camera input or when a navigation tool runs. Say the RESULTING state when confirming ("Orbiting slowly"). Globe rotation (rotate_globe) spins Earth right continuously by default; explicit direction, mode and speed take precedence. It checks fit and may refuse.',
|
||||
);
|
||||
assert.equal(
|
||||
camera.parameters.properties.direction.description,
|
||||
'Required for pan/tilt/rotate; orbit/rotate_globe default right.',
|
||||
);
|
||||
assert.equal(camera.parameters.properties.mode.description, 'once = bounded eased nudge; continuous = until stop/manual input. rotate_globe defaults continuous; other motions default once.');
|
||||
camera.parameters.properties.mode.description = 'once = bounded eased nudge (default); continuous = until stop/manual input.';
|
||||
camera.parameters.properties.motion.enum =
|
||||
camera.parameters.properties.motion.enum.filter(
|
||||
(motion) => motion !== 'rotate_globe',
|
||||
);
|
||||
camera.description =
|
||||
'Direct the camera like a drone operator: orbit the current view target, pan, tilt, or rotate — one bounded nudge (mode=once) or continuous motion until stopped (mode=continuous). Continuous motion also stops on any manual camera input or when a navigation tool runs. Say the RESULTING state when confirming ("Orbiting slowly").';
|
||||
camera.parameters.properties.direction.description =
|
||||
'Required except for orbit (defaults right/clockwise) and stop.';
|
||||
assert.equal(unchanged.length, 14);
|
||||
const digest = createHash('sha256')
|
||||
.update(JSON.stringify(unchanged))
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
npmProcessSpec,
|
||||
readDoctorDotenvValue,
|
||||
resolveOpenSkyAuthMode,
|
||||
resolveOpenSkyCredentialsFile,
|
||||
resolveCredential,
|
||||
} from '../scripts/setup-doctor.mjs';
|
||||
|
||||
@@ -237,7 +238,7 @@ test('doctor retains OAuth capability wording for a complete client pair', () =>
|
||||
);
|
||||
});
|
||||
|
||||
test('doctor reports Basic mode without inferring runtime auth from OAuth credentials', () => {
|
||||
test('doctor reports retired Basic mode as anonymous, whatever OAuth credentials exist', () => {
|
||||
const oauthCredentials = {
|
||||
OPENSKY_CLIENT_ID: { configured: true },
|
||||
OPENSKY_CLIENT_SECRET: { configured: true },
|
||||
@@ -245,7 +246,7 @@ test('doctor reports Basic mode without inferring runtime auth from OAuth creden
|
||||
for (const credentials of [{}, oauthCredentials]) {
|
||||
assert.equal(
|
||||
buildCapabilitySummary(credentials, { openSkyAuthMode: 'basic' }).flights,
|
||||
'OpenSky Basic mode selected (credential presence and validity not verified)',
|
||||
'OpenSky Basic mode selected: OpenSky retired username/password sign-in, so flights load anonymously (rate-limited)',
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -393,3 +394,39 @@ test('doctor never calls a dependency-missing setup ready', () => {
|
||||
assert.match(output, /Setup needs attention/);
|
||||
assert.doesNotMatch(output, /Ready\. Run/);
|
||||
});
|
||||
|
||||
test('doctor counts OPENSKY_CREDENTIALS_FILE as OAuth credentials, from the environment or .env', () => {
|
||||
assert.equal(
|
||||
buildCapabilitySummary({}, { openSkyCredentialsFile: true }).flights,
|
||||
'OpenSky OAuth credentials file set (OPENSKY_CREDENTIALS_FILE; file contents and validity not verified)',
|
||||
);
|
||||
assert.equal(
|
||||
buildCapabilitySummary({}, { openSkyCredentialsFile: false }).flights,
|
||||
'OpenSky keyless anonymous access (rate-limited)',
|
||||
);
|
||||
const root = mkdtempSync(path.join(tmpdir(), 'gev-doctor-opensky-file-'));
|
||||
try {
|
||||
assert.equal(
|
||||
resolveOpenSkyCredentialsFile({ environment: {}, rootDir: root }),
|
||||
false,
|
||||
);
|
||||
writeFileSync(
|
||||
path.join(root, '.env'),
|
||||
'OPENSKY_CREDENTIALS_FILE=~/Downloads/credentials.json\n',
|
||||
);
|
||||
assert.equal(
|
||||
resolveOpenSkyCredentialsFile({ environment: {}, rootDir: root }),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
resolveOpenSkyCredentialsFile({
|
||||
authoritativeEnvironment: true,
|
||||
environment: { OPENSKY_CREDENTIALS_FILE: '' },
|
||||
rootDir: root,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -250,10 +250,13 @@ export function approximateAreaM2(coords) {
|
||||
return Math.abs(area) / 2;
|
||||
}
|
||||
|
||||
/** Local equirectangular distance in metres, the short way round 180°. */
|
||||
export function approximateDistanceM(latA, lonA, latB, lonB) {
|
||||
const latScale = 111_320;
|
||||
const lonScale = latScale * Math.cos(((latA + latB) / 2) * (Math.PI / 180));
|
||||
return Math.hypot((latB - latA) * latScale, (lonB - lonA) * lonScale);
|
||||
let dLon = lonB - lonA;
|
||||
dLon -= 360 * Math.round(dLon / 360);
|
||||
return Math.hypot((latB - latA) * latScale, dLon * lonScale);
|
||||
}
|
||||
|
||||
export function pointInPolygon(lon, lat, coords) {
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { approximateDistanceM } from './featureGeometry.js';
|
||||
|
||||
test('approximate distance takes the short way round 180°', () => {
|
||||
// Lau Islands (-179.0) to Savusavu (179.33): about 178 km apart.
|
||||
const across = approximateDistanceM(-17.5, -179.0, -16.78, 179.33);
|
||||
assert.ok(across > 150_000 && across < 200_000, String(across));
|
||||
assert.equal(
|
||||
Math.round(approximateDistanceM(0, 179.9, 0, -179.9)),
|
||||
Math.round(approximateDistanceM(0, -0.1, 0, 0.1)),
|
||||
);
|
||||
// Away from 180° nothing changes.
|
||||
assert.equal(Math.round(approximateDistanceM(0, 0, 0, 1)), 111_320);
|
||||
});
|
||||
@@ -31,9 +31,7 @@ function openSkyError(response) {
|
||||
const reasons = {
|
||||
oauth_invalid_or_missing: 'OpenSky OAuth client missing/invalid',
|
||||
oauth_invalid_credentials: 'OpenSky OAuth rejected credentials',
|
||||
basic_invalid_credentials: 'OpenSky username/password rejected',
|
||||
missing_basic_creds: 'OpenSky auth missing',
|
||||
missing_oauth_and_basic_creds: 'OpenSky auth missing',
|
||||
missing_oauth_creds: 'OpenSky auth missing',
|
||||
auth_required: 'OpenSky auth required',
|
||||
forced_anonymous: 'OpenSky auth required',
|
||||
};
|
||||
|
||||
@@ -584,6 +584,69 @@ test('track backfill proxy returns 502 on an oversized upstream body and caches
|
||||
assert.equal(callCount, 1);
|
||||
});
|
||||
|
||||
test('OpenSky Basic mode no longer sends username and password: it asks anonymously and warns once', async (t) => {
|
||||
environment(t, {
|
||||
OPENSKY_CLIENT_ID: undefined,
|
||||
OPENSKY_CLIENT_SECRET: undefined,
|
||||
OPENSKY_CREDENTIALS_FILE: undefined,
|
||||
OPENSKY_AUTH_MODE: 'basic',
|
||||
OPENSKY_USERNAME: 'fixture-user',
|
||||
OPENSKY_PASSWORD: 'fixture-password',
|
||||
});
|
||||
const warnings = [];
|
||||
t.mock.method(console, 'warn', (message) => warnings.push(message));
|
||||
const authorizations = [];
|
||||
t.mock.method(globalThis, 'fetch', async (url, options) => {
|
||||
if (url.includes('/states/')) {
|
||||
authorizations.push(options.headers.Authorization);
|
||||
return Response.json({ time: Math.floor(Date.now() / 1000), states: [] });
|
||||
}
|
||||
throw Error(`Unexpected URL: ${url}`);
|
||||
});
|
||||
const fresh = await import(
|
||||
`../../server/providers/aircraft/opensky.js?basic=${Math.random()}`
|
||||
);
|
||||
const flights = install(fresh.openSkyProxy());
|
||||
const first = await flights('/api/flights');
|
||||
assert.equal(first.statusCode, 200);
|
||||
assert.equal(first.headers['x-opensky-auth'], 'anon');
|
||||
assert.equal(first.headers['x-opensky-auth-reason'], 'basic_retired');
|
||||
assert.deepEqual(authorizations, [undefined]);
|
||||
assert.equal(warnings.length, 1);
|
||||
assert.match(warnings[0], /retired username\/password sign-in/);
|
||||
assert.ok(!warnings[0].includes('fixture-password'));
|
||||
});
|
||||
|
||||
test('OpenSky auto mode never falls back to Basic, even when OAuth is rejected', async (t) => {
|
||||
environment(t, {
|
||||
OPENSKY_CLIENT_ID: 'fixture-client',
|
||||
OPENSKY_CLIENT_SECRET: 'fixture-secret',
|
||||
OPENSKY_CREDENTIALS_FILE: undefined,
|
||||
OPENSKY_AUTH_MODE: 'auto',
|
||||
OPENSKY_USERNAME: 'fixture-user',
|
||||
OPENSKY_PASSWORD: 'fixture-password',
|
||||
});
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
t.mock.method(console, 'log', () => {});
|
||||
t.mock.method(console, 'error', () => {});
|
||||
const authorizations = [];
|
||||
t.mock.method(globalThis, 'fetch', async (url, options) => {
|
||||
if (url.includes('/token'))
|
||||
return Response.json({ access_token: 'fixture-token', expires_in: 1800 });
|
||||
if (url.includes('/states/')) {
|
||||
authorizations.push(options.headers.Authorization);
|
||||
return new Response('', { status: 401 });
|
||||
}
|
||||
if (url.includes('/lat/')) return regionalFallback();
|
||||
throw Error(`Unexpected URL: ${url}`);
|
||||
});
|
||||
const fresh = await import(
|
||||
`../../server/providers/aircraft/opensky.js?auto=${Math.random()}`
|
||||
);
|
||||
await install(fresh.openSkyProxy())('/api/flights', '?lat=30&lon=-97');
|
||||
assert.deepEqual(authorizations, ['Bearer fixture-token']);
|
||||
});
|
||||
|
||||
test('retired military history refuses without upstream I/O while validating identifiers', async (t) => {
|
||||
t.mock.method(globalThis, 'fetch', async () => {
|
||||
assert.fail('retired history must not contact the upstream');
|
||||
|
||||
@@ -81,6 +81,24 @@ test('nearby labels rank landmarks, deduplicate names/addresses and bound the pr
|
||||
assert.deepEqual(projectNearbyPlaces({}, 0, 0), []);
|
||||
});
|
||||
|
||||
test('nearby place distances take the short way round 180°', () => {
|
||||
const at = (name, longitude) => ({
|
||||
id: name,
|
||||
displayName: { text: name },
|
||||
location: { latitude: -16.8, longitude },
|
||||
types: ['point_of_interest'],
|
||||
});
|
||||
// From Taveuni at 179.98: 2 km east across 180°, 4 km west on this side.
|
||||
const result = projectNearbyPlaces(
|
||||
{ places: [at('West', 179.942), at('East', -179.999)] },
|
||||
-16.8,
|
||||
179.98,
|
||||
);
|
||||
const byName = Object.fromEntries(result.map((p) => [p.name, p.distanceM]));
|
||||
assert.ok(byName.East > 1500 && byName.East < 2500, String(byName.East));
|
||||
assert.ok(byName.West > 3500 && byName.West < 4500, String(byName.West));
|
||||
});
|
||||
|
||||
test('text search preserves bounds, rejects malformed bounds and tolerates absent locations', () => {
|
||||
const viewport = {
|
||||
low: { latitude: 1, longitude: 2 },
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { getTransitFeed } from '../data/transitFeeds.js';
|
||||
import {
|
||||
chooseLiveCities,
|
||||
selectStyle,
|
||||
@@ -101,6 +102,18 @@ test('live visual cities follow local operating hours in summer and winter', ()
|
||||
const cities = chooseLiveCities(now);
|
||||
assert.ok(cities.length >= 1);
|
||||
for (const city of cities) {
|
||||
const feed = getTransitFeed(city.feedId);
|
||||
assert.ok(feed, `${city.id} has a registered feed`);
|
||||
const distanceKm =
|
||||
111.32 *
|
||||
Math.hypot(
|
||||
feed.center.lat - city.lat,
|
||||
(feed.center.lon - city.lon) * Math.cos((city.lat * Math.PI) / 180),
|
||||
);
|
||||
assert.ok(
|
||||
distanceKm < feed.loadRadiusKm,
|
||||
`${city.id} is inside its feed's admission region`,
|
||||
);
|
||||
const local = Number(
|
||||
new Intl.DateTimeFormat('en', {
|
||||
timeZone: city.zone,
|
||||
|
||||
@@ -234,6 +234,19 @@ test('typed argument checks feed full-call accuracy without changing the legacy
|
||||
const inMeters = gradeCore(altitude, [{ name: 'analyst_query', args: { layers: ['flights'], filters: [{ field: 'altitudeM', op: 'gt', value: 12192 }] } }]);
|
||||
assert.equal(inMeters.fullCallOk, true);
|
||||
assert.equal(where('set_hud', (a) => a.visible === 'off')({ name: 'set_hud', args: { visible: 'off' } }), true);
|
||||
const globe = byPhrase('Keep spinning the Earth slowly to the left');
|
||||
const globeArgs = { motion: 'rotate_globe', direction: 'left', speed: 'slow' };
|
||||
for (const mode of [undefined, 'continuous', 'once', null]) {
|
||||
assert.equal(gradeCore(globe, [{ name: 'move_camera', args: { ...globeArgs, mode } }]).fullCallOk, mode === undefined || mode === 'continuous');
|
||||
}
|
||||
assert.equal(gradeCore(globe, [{ name: 'move_camera', args: { ...globeArgs, direction: 'right' } }]).fullCallOk, false);
|
||||
assert.equal(gradeCore(globe, [{ name: 'move_camera', args: { ...globeArgs, speed: 'normal' } }]).fullCallOk, false);
|
||||
const right = byPhrase('Rotate the globe to the right');
|
||||
assert.equal(gradeCore(right, [{ name: 'move_camera', args: { motion: 'rotate_globe' } }]).fullCallOk, true);
|
||||
for (const direction of ['right', 'left', null]) {
|
||||
assert.equal(gradeCore(right, [{ name: 'move_camera', args: { motion: 'rotate_globe', direction } }]).fullCallOk, direction === 'right');
|
||||
}
|
||||
assert.equal(gradeCore(right, [{ name: 'move_camera', args: { motion: 'rotate', direction: 'right' } }]).fullCallOk, false);
|
||||
});
|
||||
|
||||
test('gradeDialogue scores turn 2 in the context of turn 1', () => {
|
||||
|
||||
@@ -147,6 +147,7 @@ export class StyleManager extends ShellFacade {
|
||||
},
|
||||
searchInput: this._locationSearch,
|
||||
interruptCameraMotion: services.interruptCameraMotion,
|
||||
interruptGlobeRotation: services.interruptGlobeRotation,
|
||||
isCockpitActive: () => !!this.cockpitView?.active,
|
||||
clearLocation: () => this.clearSearchedLocation(),
|
||||
cancelShareSelection: () => this._shareRestoration.cancelSelection(),
|
||||
@@ -322,6 +323,7 @@ export class StyleManager extends ShellFacade {
|
||||
GLOBE_VIEW: services.GLOBE_VIEW,
|
||||
flyToGlobeView: services.flyToGlobeView,
|
||||
interruptCameraMotion: services.interruptCameraMotion,
|
||||
interruptGlobeRotation: services.interruptGlobeRotation,
|
||||
flightsLayer: services.flightsLayer,
|
||||
militaryFlightsLayer: services.militaryFlightsLayer,
|
||||
satellitesLayer: services.satellitesLayer,
|
||||
@@ -862,9 +864,38 @@ export class StyleManager extends ShellFacade {
|
||||
setPanelCollapsed: (panelId, collapsed, options) =>
|
||||
this._panelChrome.setPanelCollapsed(panelId, collapsed, options),
|
||||
onSheetsClosed: () => this._syncShareState(),
|
||||
onTabOpened: (sheet) => {
|
||||
void this._onMobileTabOpened(sheet);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Reveal a tracked aircraft through Contacts when its phone tab is opened. */
|
||||
async _onMobileTabOpened(sheet) {
|
||||
const context = this.getContextModeState();
|
||||
if (
|
||||
sheet !== 'context' ||
|
||||
this._disposed ||
|
||||
!context ||
|
||||
context.mode ||
|
||||
context.changing ||
|
||||
context.entering ||
|
||||
!this.getAircraftTrackingTarget()
|
||||
)
|
||||
return false;
|
||||
try {
|
||||
const result = await this.setContextMode('contacts');
|
||||
if (result?.ok) return true;
|
||||
if (result?.cancelled || this._disposed) return false;
|
||||
} catch {
|
||||
if (this._disposed) return false;
|
||||
}
|
||||
this._showToast(
|
||||
t('Contacts could not complete the requested transition; try again'),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the owner-approved map stack chip row from the matching controller
|
||||
* entries. Cesium ion/Bing chips remain keyboard-focusable but unavailable,
|
||||
|
||||
@@ -9,7 +9,10 @@ import {
|
||||
flyToPOI,
|
||||
searchAndFlyTo,
|
||||
} from '../locations.js';
|
||||
import { interruptCameraMotion } from '../cameraVerbs.js';
|
||||
import {
|
||||
interruptCameraMotion,
|
||||
interruptGlobeRotation,
|
||||
} from '../cameraVerbs.js';
|
||||
import { IntelHUD } from '../hud.js';
|
||||
import { ShareLinkManager } from '../sharelink.js';
|
||||
import { OrbitController } from '../orbit.js';
|
||||
@@ -73,6 +76,7 @@ export class StyleManager extends ApplicationShell {
|
||||
flyToPOI,
|
||||
searchAndFlyTo,
|
||||
interruptCameraMotion,
|
||||
interruptGlobeRotation,
|
||||
IntelHUD,
|
||||
ShareLinkManager,
|
||||
OrbitController,
|
||||
|
||||
@@ -283,6 +283,7 @@ export class LocationNavigation {
|
||||
return;
|
||||
}
|
||||
|
||||
this.services.interruptGlobeRotation?.('orbit-intent');
|
||||
const isActive = this.orbitController.toggle(this._currentTarget, {
|
||||
radius: this._currentPoi?.alt || 500,
|
||||
pitch: this._currentPoi?.pitch || -30,
|
||||
|
||||
@@ -125,6 +125,8 @@ function buildChrome(documentRef) {
|
||||
* @param {(callback: () => void) => number} [input.requestFrame]
|
||||
* @param {() => void} [input.onSheetsClosed] Runs after the last sheet closes
|
||||
* and its panels are put back (e.g. to refresh the share link).
|
||||
* @param {(sheet: string) => void} [input.onTabOpened] Runs only when the user
|
||||
* opens a sheet from its tab, never on close or programmatic presentation.
|
||||
* @returns {{ destroy: () => void, setSheet: (sheet: string|null) => void, getSheet: () => string|null, sharedCollapsed: (panelId: string) => boolean|undefined }}
|
||||
*/
|
||||
export function initMobileShell({
|
||||
@@ -135,6 +137,7 @@ export function initMobileShell({
|
||||
ResizeObserverImpl = globalThis.ResizeObserver,
|
||||
requestFrame = globalThis.requestAnimationFrame?.bind(globalThis),
|
||||
onSheetsClosed = () => {},
|
||||
onTabOpened = () => {},
|
||||
} = {}) {
|
||||
const body = documentRef.body;
|
||||
const { backdrop, tabBar } = buildChrome(documentRef);
|
||||
@@ -159,6 +162,7 @@ export function initMobileShell({
|
||||
*/
|
||||
let borrowed = null;
|
||||
let returnFocus = null;
|
||||
let swipe = null;
|
||||
const written = new Map();
|
||||
|
||||
const writeVar = (name, value) => {
|
||||
@@ -259,6 +263,7 @@ export function initMobileShell({
|
||||
};
|
||||
|
||||
const setSheet = (requested) => {
|
||||
swipe = null;
|
||||
if (!body.classList.contains(MOBILE_SHELL_CLASS)) return;
|
||||
const sheet = sheetBlocked(body) ? null : requested;
|
||||
const previous = currentSheetOf(body);
|
||||
@@ -348,8 +353,123 @@ export function initMobileShell({
|
||||
// Closing from the tab bar returns focus to the tab that was pressed.
|
||||
if (!sheet) returnFocus = button;
|
||||
setSheet(sheet);
|
||||
if (sheet && currentSheetOf(body) === sheet) onTabOpened(sheet);
|
||||
});
|
||||
listen(backdrop, 'click', () => setSheet(null));
|
||||
// Touch events let native scrolling continue until a downward pull at the
|
||||
// top is intentional. Pointer capture/touch-action:none would also seize
|
||||
// ordinary vertical scrolling inside the borrowed panels.
|
||||
const sheetRoot = (target, sheet) => {
|
||||
const roots =
|
||||
sheet === 'layers'
|
||||
? [element('left-panel-stack')]
|
||||
: sheet === 'search'
|
||||
? [dock]
|
||||
: sheet === 'more'
|
||||
? [element('right-context-rail'), dock]
|
||||
: [element('right-context-rail')];
|
||||
return roots.find((root) => root?.contains(target));
|
||||
};
|
||||
const scrolled = (target, root) => {
|
||||
for (let node = target; node; node = node.parentElement) {
|
||||
if (node.scrollTop > 0) return true;
|
||||
if (node === root) break;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
listen(
|
||||
documentRef,
|
||||
'touchstart',
|
||||
(event) => {
|
||||
swipe = null;
|
||||
const sheet = currentSheetOf(body);
|
||||
const target = event.target;
|
||||
if (
|
||||
!sheet ||
|
||||
!body.classList.contains(MOBILE_SHELL_CLASS) ||
|
||||
event.defaultPrevented ||
|
||||
event.touches.length !== 1 ||
|
||||
target?.closest?.(
|
||||
'button, a, input, select, textarea, label, [contenteditable]:not([contenteditable="false"]), [role="slider"], [role="button"]',
|
||||
)
|
||||
)
|
||||
return;
|
||||
const root = sheetRoot(target, sheet);
|
||||
if (!root || scrolled(target, root)) return;
|
||||
const touch = event.touches[0];
|
||||
swipe = {
|
||||
sheet,
|
||||
target,
|
||||
root,
|
||||
id: touch.identifier,
|
||||
x: touch.clientX,
|
||||
y: touch.clientY,
|
||||
claimed: false,
|
||||
};
|
||||
},
|
||||
{ passive: true },
|
||||
);
|
||||
listen(
|
||||
documentRef,
|
||||
'touchmove',
|
||||
(event) => {
|
||||
if (!swipe) return;
|
||||
const touch = event.touches[0];
|
||||
if (
|
||||
event.defaultPrevented ||
|
||||
event.touches.length !== 1 ||
|
||||
touch.identifier !== swipe.id ||
|
||||
currentSheetOf(body) !== swipe.sheet ||
|
||||
scrolled(swipe.target, swipe.root)
|
||||
) {
|
||||
swipe = null;
|
||||
return;
|
||||
}
|
||||
const dx = touch.clientX - swipe.x;
|
||||
const dy = touch.clientY - swipe.y;
|
||||
if (!swipe.claimed) {
|
||||
// Claim direction before the browser crosses its native scroll slop.
|
||||
// Android can make the next move non-cancelable after a ~9px first move.
|
||||
if (Math.max(Math.abs(dx), Math.abs(dy)) < 4) return;
|
||||
if (dy <= Math.abs(dx) * 1.25 || !event.cancelable) {
|
||||
swipe = null;
|
||||
return;
|
||||
}
|
||||
swipe.claimed = true;
|
||||
}
|
||||
// Cancelling only the claimed pull leaves taps, sliders and normal scroll
|
||||
// native, and suppresses the synthetic click at the end of a dismissal.
|
||||
event.preventDefault();
|
||||
},
|
||||
{ passive: false },
|
||||
);
|
||||
listen(
|
||||
documentRef,
|
||||
'touchend',
|
||||
(event) => {
|
||||
const pull = swipe;
|
||||
swipe = null;
|
||||
if (!pull?.claimed || event.touches.length) return;
|
||||
const touch = [...event.changedTouches].find(
|
||||
(item) => item.identifier === pull.id,
|
||||
);
|
||||
if (!touch || currentSheetOf(body) !== pull.sheet) return;
|
||||
const dx = Math.abs(touch.clientX - pull.x);
|
||||
const dy = touch.clientY - pull.y;
|
||||
if (dy < 64 || dy <= dx * 1.25) return;
|
||||
returnFocus = tabFor(pull.sheet) ?? returnFocus;
|
||||
setSheet(null);
|
||||
},
|
||||
{ passive: true },
|
||||
);
|
||||
listen(
|
||||
documentRef,
|
||||
'touchcancel',
|
||||
() => {
|
||||
swipe = null;
|
||||
},
|
||||
{ passive: true },
|
||||
);
|
||||
// Bubble phase, so fields cancelling an edit, lightboxes, viewers and tools
|
||||
// take Escape first; panels yield it to the sheet (PanelChrome), and an open
|
||||
// dialog keeps its own native close.
|
||||
|
||||
+385
-3
@@ -1,6 +1,8 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import test from 'node:test';
|
||||
import { StyleManager } from './applicationShell.js';
|
||||
import { ContextControls } from './contextControls.js';
|
||||
import {
|
||||
MOBILE_SHEET_PANELS,
|
||||
MOBILE_TABS,
|
||||
@@ -21,6 +23,8 @@ function element(tag, document) {
|
||||
textContent: '',
|
||||
className: '',
|
||||
parent: null,
|
||||
scrollTop: 0,
|
||||
parentElement: null,
|
||||
classList: {
|
||||
add: (...names) => names.forEach((name) => classes.add(name)),
|
||||
remove: (...names) => names.forEach((name) => classes.delete(name)),
|
||||
@@ -41,6 +45,7 @@ function element(tag, document) {
|
||||
append(...children) {
|
||||
for (const child of children) {
|
||||
child.parent = node;
|
||||
child.parentElement = node;
|
||||
node.children.push(child);
|
||||
if (child.id) document.ids.set(child.id, child);
|
||||
}
|
||||
@@ -56,9 +61,20 @@ function element(tag, document) {
|
||||
return false;
|
||||
},
|
||||
closest(selector) {
|
||||
for (let current = node; current; current = current.parent)
|
||||
for (let current = node; current; current = current.parent) {
|
||||
if (
|
||||
selector.startsWith('button, a') &&
|
||||
(['BUTTON', 'A', 'INPUT', 'SELECT', 'TEXTAREA', 'LABEL'].includes(
|
||||
current.tagName,
|
||||
) ||
|
||||
['slider', 'button'].includes(current.getAttribute('role')) ||
|
||||
(current.getAttribute('contenteditable') !== null &&
|
||||
current.getAttribute('contenteditable') !== 'false'))
|
||||
)
|
||||
return current;
|
||||
if (selector === '[data-mobile-sheet]' && current.dataset.mobileSheet)
|
||||
return current;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
querySelectorAll(selector) {
|
||||
@@ -94,7 +110,11 @@ function element(tag, document) {
|
||||
|
||||
const PANEL_IDS = [...new Set(Object.values(MOBILE_SHEET_PANELS).flat())];
|
||||
|
||||
function fixture({ mobile = true, expanded = [] } = {}) {
|
||||
function fixture({
|
||||
mobile = true,
|
||||
expanded = [],
|
||||
onTabOpened = () => {},
|
||||
} = {}) {
|
||||
const document = new EventTarget();
|
||||
document.ids = new Map();
|
||||
document.createElement = (tag) => element(tag, document);
|
||||
@@ -112,6 +132,12 @@ function fixture({ mobile = true, expanded = [] } = {}) {
|
||||
const search = element('input', document);
|
||||
search.id = 'location-search';
|
||||
document.body.append(search);
|
||||
const rails = {};
|
||||
for (const id of ['left-panel-stack', 'right-context-rail', 'command-dock']) {
|
||||
rails[id] = element('div', document);
|
||||
rails[id].id = id;
|
||||
document.body.append(rails[id]);
|
||||
}
|
||||
for (const id of PANEL_IDS) {
|
||||
const panel = element('section', document);
|
||||
panel.id = id;
|
||||
@@ -123,7 +149,13 @@ function fixture({ mobile = true, expanded = [] } = {}) {
|
||||
const collapse = element('button', document);
|
||||
collapse.className = 'panel-collapse-btn';
|
||||
panel.append(toggle, collapse, element('button', document));
|
||||
document.body.append(panel);
|
||||
const root =
|
||||
id === 'data-panel'
|
||||
? 'left-panel-stack'
|
||||
: ['location-bar', 'control-panel'].includes(id)
|
||||
? 'command-dock'
|
||||
: 'right-context-rail';
|
||||
rails[root].append(panel);
|
||||
}
|
||||
|
||||
const media = new EventTarget();
|
||||
@@ -143,6 +175,7 @@ function fixture({ mobile = true, expanded = [] } = {}) {
|
||||
}
|
||||
const shell = initMobileShell({
|
||||
documentRef: document,
|
||||
onTabOpened,
|
||||
matchMedia: () => media,
|
||||
MutationObserverImpl: FakeMutationObserver,
|
||||
ResizeObserverImpl: null,
|
||||
@@ -481,3 +514,352 @@ test('the phone shell keeps the required attribution visible', () => {
|
||||
assert.ok(z(sheetCredit) > z(openDock));
|
||||
assert.ok(z(sheetCredit) > z(railSheet));
|
||||
});
|
||||
|
||||
function touchEvent(
|
||||
document,
|
||||
type,
|
||||
target,
|
||||
{ x = 10, y = 10, count = 1, id = 1, cancelable = true } = {},
|
||||
) {
|
||||
const event = new Event(type, { cancelable });
|
||||
const touch = { identifier: id, clientX: x, clientY: y };
|
||||
Object.defineProperty(event, 'target', { value: target });
|
||||
event.touches =
|
||||
type === 'touchend' || type === 'touchcancel'
|
||||
? []
|
||||
: Array.from({ length: count }, (_, i) => ({
|
||||
...touch,
|
||||
identifier: id + i,
|
||||
}));
|
||||
event.changedTouches = [touch];
|
||||
document.dispatchEvent(event);
|
||||
return event;
|
||||
}
|
||||
|
||||
test('downward sheet pull closes through borrowed-panel lifecycle and returns tab focus', () => {
|
||||
for (const sheet of Object.keys(MOBILE_SHEET_PANELS)) {
|
||||
const phone = fixture();
|
||||
const { document, shell, press, tab } = phone;
|
||||
press(sheet);
|
||||
const target = document.getElementById(MOBILE_SHEET_PANELS[sheet][0]);
|
||||
touchEvent(document, 'touchstart', target);
|
||||
const move = touchEvent(document, 'touchmove', target, { y: 90 });
|
||||
assert.equal(move.defaultPrevented, true);
|
||||
assert.equal(shell.getSheet(), sheet, 'waits until finger lifts');
|
||||
touchEvent(document, 'touchend', target, { y: 90 });
|
||||
assert.equal(shell.getSheet(), null);
|
||||
assert.equal(phone.closedCount(), 1);
|
||||
assert.equal(document.activeElement, tab(sheet));
|
||||
for (const id of MOBILE_SHEET_PANELS[sheet])
|
||||
assert.equal(phone.collapsed(id), true);
|
||||
assert.ok(
|
||||
phone.calls.every(
|
||||
(call) => call.persist === false && call.syncShare === false,
|
||||
),
|
||||
);
|
||||
shell.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
test('content scroll stays native, including a scroll that reaches the top mid-gesture', () => {
|
||||
for (const nested of [false, true]) {
|
||||
const { document, shell, press } = fixture();
|
||||
press('layers');
|
||||
const panel = document.getElementById('data-panel');
|
||||
const scroller = nested
|
||||
? panel
|
||||
: document.getElementById('left-panel-stack');
|
||||
scroller.scrollTop = 40;
|
||||
touchEvent(document, 'touchstart', panel);
|
||||
scroller.scrollTop = 0;
|
||||
assert.equal(
|
||||
touchEvent(document, 'touchmove', panel, { y: 120 }).defaultPrevented,
|
||||
false,
|
||||
);
|
||||
touchEvent(document, 'touchend', panel, { y: 120 });
|
||||
assert.equal(shell.getSheet(), 'layers');
|
||||
shell.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
test('taps, short pulls, upward and horizontal gestures never dismiss', () => {
|
||||
for (const path of [
|
||||
[],
|
||||
[{ x: 12, y: 14 }],
|
||||
[{ y: 40 }],
|
||||
[{ y: -30 }, { y: 120 }],
|
||||
[{ x: 60, y: 12 }, { y: 120 }],
|
||||
]) {
|
||||
const { document, shell, press } = fixture();
|
||||
press('layers');
|
||||
const panel = document.getElementById('data-panel');
|
||||
touchEvent(document, 'touchstart', panel);
|
||||
for (const point of path) touchEvent(document, 'touchmove', panel, point);
|
||||
touchEvent(document, 'touchend', panel, path.at(-1) ?? {});
|
||||
assert.equal(shell.getSheet(), 'layers');
|
||||
shell.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
test('native controls and custom sliders keep touch gestures', () => {
|
||||
for (const tag of [
|
||||
'button',
|
||||
'a',
|
||||
'input',
|
||||
'select',
|
||||
'textarea',
|
||||
'label',
|
||||
'slider',
|
||||
'editable',
|
||||
]) {
|
||||
const { document, shell, press } = fixture();
|
||||
press('more');
|
||||
const control = document.createElement(
|
||||
['slider', 'editable'].includes(tag) ? 'div' : tag,
|
||||
);
|
||||
if (tag === 'slider') control.setAttribute('role', 'slider');
|
||||
if (tag === 'editable') control.setAttribute('contenteditable', 'true');
|
||||
const child = document.createElement('span');
|
||||
control.append(child);
|
||||
document.getElementById('control-panel').append(control);
|
||||
touchEvent(document, 'touchstart', child);
|
||||
assert.equal(
|
||||
touchEvent(document, 'touchmove', child, { y: 120 }).defaultPrevented,
|
||||
false,
|
||||
);
|
||||
touchEvent(document, 'touchend', child, { y: 120 });
|
||||
assert.equal(shell.getSheet(), 'more');
|
||||
shell.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
test('multitouch, cancellation, sheet changes and desktop transitions abandon the gesture', () => {
|
||||
for (const cancel of [
|
||||
'multitouch',
|
||||
'cancel',
|
||||
'sheet',
|
||||
'desktop',
|
||||
'noncancelable',
|
||||
]) {
|
||||
const { document, shell, press, resize } = fixture();
|
||||
press('layers');
|
||||
const panel = document.getElementById('data-panel');
|
||||
touchEvent(document, 'touchstart', panel);
|
||||
if (cancel === 'multitouch')
|
||||
touchEvent(document, 'touchstart', document.body, { count: 2 });
|
||||
if (cancel === 'cancel') touchEvent(document, 'touchcancel', panel);
|
||||
if (cancel === 'sheet') press('context');
|
||||
if (cancel === 'desktop') {
|
||||
resize(false);
|
||||
resize(true);
|
||||
press('layers');
|
||||
}
|
||||
if (cancel === 'noncancelable')
|
||||
touchEvent(document, 'touchmove', panel, { y: 30, cancelable: false });
|
||||
assert.equal(
|
||||
touchEvent(document, 'touchmove', panel, { y: 120 }).defaultPrevented,
|
||||
false,
|
||||
);
|
||||
touchEvent(document, 'touchend', panel, { y: 120 });
|
||||
assert.equal(shell.getSheet(), cancel === 'sheet' ? 'context' : 'layers');
|
||||
shell.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
test('touches outside the active sheet and destroyed listeners cannot dismiss or claim a gesture', () => {
|
||||
const { document, shell, press } = fixture();
|
||||
press('layers');
|
||||
const hiddenPanel = document.getElementById('global-context-panel');
|
||||
touchEvent(document, 'touchstart', hiddenPanel);
|
||||
assert.equal(
|
||||
touchEvent(document, 'touchmove', hiddenPanel, { y: 120 }).defaultPrevented,
|
||||
false,
|
||||
);
|
||||
touchEvent(document, 'touchend', hiddenPanel, { y: 120 });
|
||||
assert.equal(shell.getSheet(), 'layers');
|
||||
const panel = document.getElementById('data-panel');
|
||||
touchEvent(document, 'touchstart', panel);
|
||||
shell.destroy();
|
||||
assert.equal(
|
||||
touchEvent(document, 'touchmove', panel, { y: 120 }).defaultPrevented,
|
||||
false,
|
||||
);
|
||||
touchEvent(document, 'touchend', panel, { y: 120 });
|
||||
assert.equal(shell.getSheet(), null);
|
||||
});
|
||||
|
||||
test('the tab-open callback reports only a successful manual opening', () => {
|
||||
const opened = [];
|
||||
const phone = fixture({ onTabOpened: (sheet) => opened.push(sheet) });
|
||||
phone.shell.setSheet('context');
|
||||
assert.deepEqual(opened, []);
|
||||
phone.press('context');
|
||||
assert.deepEqual(opened, [], 'closing is not a new context request');
|
||||
phone.press('context');
|
||||
assert.deepEqual(opened, ['context']);
|
||||
phone.setBodyClass('cockpit-mode');
|
||||
phone.press('context');
|
||||
assert.deepEqual(
|
||||
opened,
|
||||
['context'],
|
||||
'blocked sheets cannot activate a mode',
|
||||
);
|
||||
phone.shell.destroy();
|
||||
});
|
||||
|
||||
test('manual Context opens the existing transaction without disabling the tracked aircraft', async () => {
|
||||
const calls = [];
|
||||
const tracked = { layerId: 'flights', icao24: 'abc123', callsign: 'EJA225' };
|
||||
const viewer = { trackedEntity: { gevTrackedId: 'flights:abc123' } };
|
||||
const entity = viewer.trackedEntity;
|
||||
const enabled = new Set(['flights', 'weather']);
|
||||
const controls = new ContextControls({
|
||||
elements: {},
|
||||
installations: {},
|
||||
actions: {
|
||||
getCockpit: () => null,
|
||||
claimVisualAuthority: () => calls.push('claim'),
|
||||
syncDetection() {},
|
||||
scheduleLayout() {},
|
||||
showToast() {},
|
||||
},
|
||||
});
|
||||
controls.connect({
|
||||
layers: new Map(
|
||||
['flights', 'weather', 'military-awareness'].map((id) => [id, {}]),
|
||||
),
|
||||
getEnabledLayerIds: () => [...enabled],
|
||||
getLayerParams: () => ({}),
|
||||
isEffectivelyEnabled: (id) => enabled.has(id),
|
||||
isEnabled: (id) => enabled.has(id),
|
||||
setLayerParams: (id, params) => calls.push({ id, params }),
|
||||
setEnabled: async (id, value) => {
|
||||
calls.push({ id, value });
|
||||
if (!value) enabled.delete(id);
|
||||
if (id === 'flights' && !value) viewer.trackedEntity = null;
|
||||
return true;
|
||||
},
|
||||
_setEnabledWithIntent: (id, value) => {
|
||||
calls.push({ id, value });
|
||||
enabled.add(id);
|
||||
return { intentEpoch: 1, promise: Promise.resolve(true) };
|
||||
},
|
||||
});
|
||||
const shell = {
|
||||
cockpitView: {
|
||||
readAircraftInfo: () => (viewer.trackedEntity ? tracked : null),
|
||||
},
|
||||
getAircraftTrackingTarget: StyleManager.prototype.getAircraftTrackingTarget,
|
||||
getContextModeState: () => controls.getContextModeState(),
|
||||
setContextMode: (mode) => controls.setContextMode(mode),
|
||||
_showToast: () => assert.fail('successful entry must not report a failure'),
|
||||
};
|
||||
try {
|
||||
assert.equal(
|
||||
await StyleManager.prototype._onMobileTabOpened.call(shell, 'context'),
|
||||
true,
|
||||
);
|
||||
assert.equal(controls.getContextModeState().mode, 'flights');
|
||||
assert.equal(viewer.trackedEntity, entity);
|
||||
assert.deepEqual(shell.getAircraftTrackingTarget(), {
|
||||
layerId: 'flights',
|
||||
id: 'abc123',
|
||||
});
|
||||
assert.ok(enabled.has('flights'));
|
||||
assert.ok(
|
||||
calls.some(
|
||||
(call) => call.id === 'military-awareness' && call.value === true,
|
||||
),
|
||||
);
|
||||
assert.ok(!calls.some((call) => call.id === 'flights'));
|
||||
const count = calls.length;
|
||||
assert.equal(
|
||||
await StyleManager.prototype._onMobileTabOpened.call(shell, 'context'),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
calls.length,
|
||||
count,
|
||||
'an already active Contacts session is not restarted',
|
||||
);
|
||||
} finally {
|
||||
controls.stop();
|
||||
controls.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
test('manual Context respects Space Missions, busy transitions, missing tracks and disposal', async () => {
|
||||
for (const state of [
|
||||
{ mode: 'space-missions' },
|
||||
{ mode: 'flights' },
|
||||
{ changing: true },
|
||||
{ entering: 'space-missions' },
|
||||
{ noTrack: true },
|
||||
{ disposed: true },
|
||||
{ sheet: 'layers' },
|
||||
]) {
|
||||
const shell = {
|
||||
_disposed: state.disposed,
|
||||
getContextModeState: () => state,
|
||||
getAircraftTrackingTarget: () =>
|
||||
state.noTrack ? null : { layerId: 'flights', id: 'abc123' },
|
||||
setContextMode: () =>
|
||||
assert.fail('guarded tab must not mutate Context or tracking'),
|
||||
};
|
||||
assert.equal(
|
||||
await StyleManager.prototype._onMobileTabOpened.call(
|
||||
shell,
|
||||
state.sheet || 'context',
|
||||
),
|
||||
false,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('failed mobile Contacts entry uses the existing notice and cancelled/disposed entries stay quiet', async () => {
|
||||
for (const outcome of ['failure', 'throw', 'cancelled', 'disposed']) {
|
||||
const notices = [];
|
||||
const shell = {
|
||||
getContextModeState: () => ({ mode: null }),
|
||||
getAircraftTrackingTarget: () => ({ layerId: 'flights', id: 'abc123' }),
|
||||
setContextMode: async () => {
|
||||
if (outcome === 'throw') throw new Error('source unavailable');
|
||||
if (outcome === 'disposed') shell._disposed = true;
|
||||
return { ok: false, cancelled: outcome === 'cancelled' };
|
||||
},
|
||||
_showToast: (message) => notices.push(message),
|
||||
};
|
||||
assert.equal(
|
||||
await StyleManager.prototype._onMobileTabOpened.call(shell, 'context'),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
notices.length,
|
||||
['failure', 'throw'].includes(outcome) ? 1 : 0,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('a slow downward pull is claimed before Android native scroll takes ownership', () => {
|
||||
const { document, shell, press } = fixture();
|
||||
press('layers');
|
||||
const panel = document.getElementById('data-panel');
|
||||
touchEvent(document, 'touchstart', panel, { y: 100 });
|
||||
assert.equal(
|
||||
touchEvent(document, 'touchmove', panel, { y: 102 }).defaultPrevented,
|
||||
false,
|
||||
'tap jitter is left native',
|
||||
);
|
||||
assert.equal(
|
||||
touchEvent(document, 'touchmove', panel, { y: 109.3 }).defaultPrevented,
|
||||
true,
|
||||
'cancel the first directional move while Android still allows it',
|
||||
);
|
||||
assert.equal(
|
||||
touchEvent(document, 'touchmove', panel, { y: 131 }).defaultPrevented,
|
||||
true,
|
||||
);
|
||||
touchEvent(document, 'touchend', panel, { y: 175 });
|
||||
assert.equal(shell.getSheet(), null);
|
||||
shell.destroy();
|
||||
});
|
||||
@@ -14,6 +14,7 @@ export class NavigationController {
|
||||
tracking,
|
||||
searchInput,
|
||||
interruptCameraMotion,
|
||||
interruptGlobeRotation = () => {},
|
||||
isCockpitActive,
|
||||
clearLocation,
|
||||
cancelShareSelection,
|
||||
@@ -27,6 +28,7 @@ export class NavigationController {
|
||||
tracking,
|
||||
searchInput,
|
||||
interruptCameraMotion,
|
||||
interruptGlobeRotation,
|
||||
isCockpitActive,
|
||||
clearLocation,
|
||||
cancelShareSelection,
|
||||
@@ -46,6 +48,7 @@ export class NavigationController {
|
||||
cancelPendingSelection = true,
|
||||
clearSearchedLocation = true,
|
||||
} = {}) {
|
||||
this.interruptGlobeRotation('navigation-intent');
|
||||
cancelCameraArrival(this.viewer);
|
||||
this.cancelOrientation();
|
||||
const { flightsLayer, militaryFlightsLayer, satellitesLayer } =
|
||||
|
||||
@@ -86,10 +86,14 @@ body.mobile-shell[data-mobile-sheet] #mobile-sheet-backdrop:not([hidden]) {
|
||||
|
||||
/* ── Top bar: logo left, globe actions right ───────────────────────────── */
|
||||
body.mobile-shell #title-bar {
|
||||
top: calc(var(--mobile-top-inset) + 12px);
|
||||
top: calc(var(--mobile-top-inset) + 10px);
|
||||
left: 14px;
|
||||
}
|
||||
|
||||
body.mobile-shell #title-bar .title-logo {
|
||||
width: 58px;
|
||||
}
|
||||
|
||||
body.mobile-shell #title-bar h1 > :not(.title-logo),
|
||||
body.mobile-shell #title-bar .subtitle {
|
||||
display: none;
|
||||
|
||||
@@ -729,7 +729,7 @@ const schemas = [
|
||||
properties: {
|
||||
motion: {
|
||||
type: 'string',
|
||||
enum: ['orbit', 'pan', 'tilt', 'rotate', 'stop'],
|
||||
enum: ['orbit', 'pan', 'tilt', 'rotate', 'rotate_globe', 'stop'],
|
||||
},
|
||||
direction: {
|
||||
type: 'string',
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { realtimeInstructions } from '../../server/providers/openai/instructions.js';
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createHash } from 'node:crypto';
|
||||
@@ -38,7 +39,10 @@ test('the complete Realtime tool payload pins the manifest-generated layer relea
|
||||
// requested radius and center authority in descriptions only.
|
||||
// Street Level and GNSS extend the manifest; tracked and analyst aircraft describe broadcasts.
|
||||
// Air quality (ECCC AQHI) extends the manifest.
|
||||
'775ba323384b4c1b62fb8415bac52aecc3fc668e20bedcfa57e426dcd1da3969',
|
||||
// Globe rotation adds one motion; visual keyhole fit and explicit
|
||||
// reset guidance/defaults update only move_camera and mode descriptions.
|
||||
// Restore base descriptions; append only globe-rotation defaults and fit guidance.
|
||||
'967b5a043b812e2f8c1e190fc571cf12b960c9d4486df736902221aab4d99cf6',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -106,6 +110,17 @@ test('all legacy action arguments are byte-identical after removing the delibera
|
||||
delete property('show_data_layers_menu').layerId.enum;
|
||||
delete property('get_entity_context').layerId.enum;
|
||||
delete property('analyst_query').layers.items.enum;
|
||||
// Earth-centered rotation is additive; local camera yaw keeps its contract.
|
||||
const motion = property('move_camera').motion;
|
||||
assert.deepEqual(motion.enum, [
|
||||
'orbit',
|
||||
'pan',
|
||||
'tilt',
|
||||
'rotate',
|
||||
'rotate_globe',
|
||||
'stop',
|
||||
]);
|
||||
motion.enum = motion.enum.filter((value) => value !== 'rotate_globe');
|
||||
const panels = property('set_panel_open').panelId;
|
||||
panels.enum = panels.enum.filter(
|
||||
(id) => !['weather-panel', 'recent-imagery-panel'].includes(id),
|
||||
@@ -145,6 +160,24 @@ test('all legacy action arguments are byte-identical after removing the delibera
|
||||
);
|
||||
});
|
||||
|
||||
test('globe rotation is an additive camera motion with the existing control arguments', () => {
|
||||
const schema = GEV_ACTION_SCHEMAS.find(
|
||||
(tool) => tool.name === 'move_camera',
|
||||
).parameters;
|
||||
for (const motion of ['rotate_globe', 'rotate', 'orbit', 'pan', 'tilt']) {
|
||||
assert.deepEqual(
|
||||
validateArgs(schema, {
|
||||
motion,
|
||||
direction: 'right',
|
||||
speed: 'slow',
|
||||
mode: 'continuous',
|
||||
}),
|
||||
[],
|
||||
);
|
||||
}
|
||||
assert.ok(validateArgs(schema, { motion: 'spin_camera' }).length > 0);
|
||||
});
|
||||
|
||||
test('track_entity accepts a referent alone but rejects an empty target', () => {
|
||||
const schema = GEV_ACTION_SCHEMAS.find(
|
||||
(tool) => tool.name === 'track_entity',
|
||||
@@ -183,3 +216,15 @@ test('the benchmark still rejects an empty track_entity although the model-facin
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
test('globe reset guidance requires a separate explicit command after refusal', () => {
|
||||
const paragraph = realtimeInstructions()
|
||||
.split('\n')
|
||||
.find((line) => line.includes('globe-view-required'));
|
||||
assert.ok(paragraph);
|
||||
assert.match(paragraph, /calls only move_camera motion rotate_globe/);
|
||||
assert.match(paragraph, /explicit user Reset\/Show Globe View/);
|
||||
assert.match(paragraph, /never from your own plan/);
|
||||
assert.match(paragraph, /bare yes\/ok/);
|
||||
assert.match(paragraph, /Do not ask a question/);
|
||||
});
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
shouldSendViewportImage,
|
||||
shouldStopVoiceAfterRadioTool,
|
||||
responseInstructionForToolResult,
|
||||
isGlobeViewRefusal,
|
||||
} from './realtimeProtocol.js';
|
||||
|
||||
const SOCKET_PATH =
|
||||
@@ -182,6 +183,8 @@ export class GeminiSessionController {
|
||||
this.model = null;
|
||||
this.epoch = 0;
|
||||
this.turn = 0;
|
||||
this.globeToolsBlocked = false;
|
||||
this.globeResetFailed = false;
|
||||
this.inputGeneration = 0;
|
||||
this.turnAbort = new AbortController();
|
||||
this.disposed = false;
|
||||
@@ -659,6 +662,8 @@ export class GeminiSessionController {
|
||||
return;
|
||||
if (!pushToTalk || activityCaptureId === metadata.captureId) return;
|
||||
this.captureOwner.admitted = true;
|
||||
this.globeToolsBlocked = false;
|
||||
this.globeResetFailed = false;
|
||||
if (
|
||||
!this.interrupt('push-to-talk', {
|
||||
keepPlayback: !this.pttPlaybackSuppressed,
|
||||
@@ -1294,6 +1299,8 @@ export class GeminiSessionController {
|
||||
this.replyPending = false;
|
||||
if (!this.interrupt()) return;
|
||||
} else if (!this.retiredReplyPending && !this.interrupt()) return;
|
||||
this.globeToolsBlocked = false;
|
||||
this.globeResetFailed = false;
|
||||
// Local activity ownership is only for expiry liveness, not tools.
|
||||
// Assign after retiring observed output so its boundary cannot clear
|
||||
// a newer detected activity that has not received a reply yet.
|
||||
@@ -1573,6 +1580,7 @@ export class GeminiSessionController {
|
||||
.then(async () => {
|
||||
if (!this.owns(epoch) || turn !== this.turn) return;
|
||||
if (this.cancelledCalls.has(call.id)) {
|
||||
if (call.name === 'zoom_to_globe') this.globeResetFailed = true;
|
||||
if (this.tools.get(call.id) === action) this.tools.delete(call.id);
|
||||
this.pendingTools--;
|
||||
if (!this.pendingTools) {
|
||||
@@ -1618,8 +1626,28 @@ export class GeminiSessionController {
|
||||
(authority !== 'playback' ||
|
||||
handoffEpoch === this.radio.radioHandoffEpoch);
|
||||
let result;
|
||||
let skippedGlobeRotation = false;
|
||||
let blockedGlobeTool = false;
|
||||
try {
|
||||
if (!actionCurrent()) throw cancelled();
|
||||
// Live has no response-scoped tool_choice. Recheck here so queued siblings
|
||||
// cannot reset after a rotation refusal.
|
||||
if (this.globeToolsBlocked) {
|
||||
blockedGlobeTool = true;
|
||||
throw new Error(
|
||||
'Wait for fresh user input before calling another tool.',
|
||||
);
|
||||
}
|
||||
if (
|
||||
this.globeResetFailed &&
|
||||
call.name === 'move_camera' &&
|
||||
String(args.motion || '').toLowerCase() === 'rotate_globe'
|
||||
) {
|
||||
skippedGlobeRotation = true;
|
||||
throw new Error(
|
||||
'Not run: the Globe View reset/rotation sequence failed or was cancelled.',
|
||||
);
|
||||
}
|
||||
if (stronger)
|
||||
reservation = this.radio.reserveRadioToolHandoff({ abortScope });
|
||||
handoffEpoch = this.radio.radioHandoffEpoch;
|
||||
@@ -1689,7 +1717,12 @@ export class GeminiSessionController {
|
||||
} catch (error) {
|
||||
result = {
|
||||
ok: false,
|
||||
tool: call.name,
|
||||
...(skippedGlobeRotation
|
||||
? { action: call.name, reason: 'globe-reset-failed', skipped: true }
|
||||
: {
|
||||
tool: call.name,
|
||||
...(blockedGlobeTool ? { skipped: true } : {}),
|
||||
}),
|
||||
...(radioVisibility ? { layerId: 'radio' } : {}),
|
||||
error:
|
||||
error?.name === 'AbortError'
|
||||
@@ -1716,6 +1749,11 @@ export class GeminiSessionController {
|
||||
this.noteRadioStrongerCommit(action, abortScope, turn);
|
||||
}
|
||||
if (!this.owns(epoch) || turn !== this.turn) return;
|
||||
if (
|
||||
call.name === 'zoom_to_globe' &&
|
||||
(result?.ok !== true || result.cancelled)
|
||||
)
|
||||
this.globeResetFailed = true;
|
||||
const playbackObsolete =
|
||||
authority === 'playback' && handoffEpoch !== this.radio.radioHandoffEpoch;
|
||||
if (radioControl || radioVisibility) {
|
||||
@@ -1744,6 +1782,8 @@ export class GeminiSessionController {
|
||||
this.stopAfterReply = true;
|
||||
// Native call cancellation suppresses its output, without canceling siblings.
|
||||
if (!this.cancelledCalls.has(call.id)) {
|
||||
if (isGlobeViewRefusal(result) && !skippedGlobeRotation)
|
||||
this.globeToolsBlocked = true;
|
||||
this.postToolOutput = false;
|
||||
this.modelComplete = false;
|
||||
this.replyPending = true;
|
||||
@@ -1751,7 +1791,9 @@ export class GeminiSessionController {
|
||||
const mapEvents = mapSnapshot.map((text) => JSON.parse(text));
|
||||
const response = {
|
||||
...(result ?? { ok: false, error: 'Command returned no result' }),
|
||||
responseGuidance: responseInstructionForToolResult(result),
|
||||
responseGuidance: blockedGlobeTool
|
||||
? 'That action did not run. Wait for fresh user input; do not claim any change or call another tool.'
|
||||
: responseInstructionForToolResult(result),
|
||||
};
|
||||
this.send(
|
||||
{
|
||||
@@ -1902,6 +1944,8 @@ export class GeminiSessionController {
|
||||
{
|
||||
onSent: () => {
|
||||
if (!owned() || this.typedIntent !== typedIntent) return;
|
||||
this.globeToolsBlocked = false;
|
||||
this.globeResetFailed = false;
|
||||
this.replyPending = true;
|
||||
this.bindTextIntent(typedIntent);
|
||||
if (!owned() || this.typedIntent !== typedIntent) return;
|
||||
@@ -2455,6 +2499,8 @@ export class GeminiSessionController {
|
||||
this.cancelledCalls.clear();
|
||||
this.pendingMapEvents = [];
|
||||
this.userTurnPending = false;
|
||||
this.globeToolsBlocked = false;
|
||||
this.globeResetFailed = false;
|
||||
this.replyPending = false;
|
||||
this.unrequestedReply = false;
|
||||
this.automaticTurnOpen = false;
|
||||
|
||||
@@ -3398,3 +3398,215 @@ test('a narration callback that submits a successor keeps the old packet tools f
|
||||
assert.deepEqual(h.calls, []);
|
||||
h.c.stop();
|
||||
});
|
||||
|
||||
for (const reply of ['typed', 'push-to-talk', 'open-mic']) {
|
||||
test(`globe refusal blocks queued and later tools until fresh ${reply} input`, async () => {
|
||||
const names = [];
|
||||
const h = harness({
|
||||
runAction: async (name) => {
|
||||
names.push(name);
|
||||
return name === 'move_camera'
|
||||
? { ok: false, action: 'move_camera', reason: 'globe-view-required' }
|
||||
: { ok: true, action: name };
|
||||
},
|
||||
});
|
||||
try {
|
||||
await h.c.start({ pushToTalk: reply === 'push-to-talk' });
|
||||
establishToolInput(h, 'rotate the globe');
|
||||
const ws = h.sockets[0];
|
||||
ws.message({
|
||||
toolCall: {
|
||||
functionCalls: [
|
||||
{
|
||||
id: 'rotate',
|
||||
name: 'move_camera',
|
||||
args: { motion: 'rotate_globe' },
|
||||
},
|
||||
{ id: 'queued-reset', name: 'zoom_to_globe', args: {} },
|
||||
],
|
||||
},
|
||||
});
|
||||
await h.c.toolQueue;
|
||||
assert.deepEqual(names, ['move_camera']);
|
||||
const blocked = ws.sent
|
||||
.flatMap((entry) => entry.toolResponse?.functionResponses || [])
|
||||
.find((call) => call.id === 'queued-reset');
|
||||
assert.equal(blocked.response.ok, false);
|
||||
assert.match(blocked.response.error, /fresh user input/i);
|
||||
assert.equal(blocked.response.skipped, true);
|
||||
assert.match(blocked.response.responseGuidance, /did not run/i);
|
||||
assert.doesNotMatch(
|
||||
blocked.response.responseGuidance,
|
||||
/confirm.*completed/i,
|
||||
);
|
||||
requestTool(ws, 'repeated-rotation', 'move_camera', {
|
||||
motion: 'rotate_globe',
|
||||
});
|
||||
await h.c.toolQueue;
|
||||
assert.deepEqual(names, ['move_camera']);
|
||||
const repeated = toolResponses(ws).find(
|
||||
(call) => call.id === 'repeated-rotation',
|
||||
).response;
|
||||
assert.equal(repeated.ok, false);
|
||||
assert.equal(repeated.skipped, true);
|
||||
assert.match(repeated.responseGuidance, /did not run/i);
|
||||
assert.doesNotMatch(
|
||||
repeated.responseGuidance,
|
||||
/confirm.*completed/i,
|
||||
);
|
||||
|
||||
if (reply === 'typed') {
|
||||
const stimuli = [
|
||||
() => {},
|
||||
() => ws.message({ serverContent: { interrupted: true } }),
|
||||
() =>
|
||||
ws.message({
|
||||
serverContent: {
|
||||
inputTranscription: { text: 'reset to globe view' },
|
||||
},
|
||||
}),
|
||||
() => {
|
||||
h.c.input.setMicrophoneEnabled(false);
|
||||
h.c.input.setMicrophoneEnabled(true);
|
||||
},
|
||||
];
|
||||
for (const [i, stimulus] of stimuli.entries()) {
|
||||
stimulus();
|
||||
requestTool(ws, `unrequested-reset-${i}`, 'zoom_to_globe', {});
|
||||
await h.c.toolQueue;
|
||||
assert.deepEqual(names, ['move_camera']);
|
||||
}
|
||||
}
|
||||
// Finish the refusal reply before admitting the actual next user input.
|
||||
ws.message({ serverContent: { turnComplete: true } });
|
||||
if (reply === 'typed')
|
||||
assert.equal(h.c.sendTextCommand('reset to globe view'), true);
|
||||
else if (reply === 'push-to-talk') {
|
||||
h.c.input.pushToTalkKeyHeld = true;
|
||||
h.c.input.spaceKeyHeld = true;
|
||||
h.c.input.setMicrophoneEnabled(true);
|
||||
h.c.input.setMicrophoneEnabled(false);
|
||||
} else ws.message({ voiceActivity: { type: 'ACTIVITY_START' } });
|
||||
requestTool(ws, 'accepted-reset', 'zoom_to_globe', {});
|
||||
await h.c.toolQueue;
|
||||
assert.deepEqual(names, ['move_camera', 'zoom_to_globe']);
|
||||
} finally {
|
||||
h.c.stop();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test('a cancelled globe reset skips queued rotation, preserves Stop and recovers on fresh input', async () => {
|
||||
const calls = [];
|
||||
let resets = 0;
|
||||
const h = harness({
|
||||
runAction: async (name, args) => {
|
||||
calls.push(name === 'move_camera' ? args.motion : name);
|
||||
return name === 'zoom_to_globe' && resets++ === 0
|
||||
? { ok: false, action: name, cancelled: true }
|
||||
: { ok: true, action: name };
|
||||
},
|
||||
});
|
||||
try {
|
||||
await h.c.start();
|
||||
establishToolInput(h, 'reset to globe view and rotate');
|
||||
const ws = h.sockets[0];
|
||||
ws.message({
|
||||
toolCall: {
|
||||
functionCalls: [
|
||||
{ id: 'reset-failed', name: 'zoom_to_globe', args: {} },
|
||||
{
|
||||
id: 'rotate-skipped',
|
||||
name: 'move_camera',
|
||||
args: { motion: 'rotate_globe' },
|
||||
},
|
||||
{ id: 'stop-allowed', name: 'move_camera', args: { motion: 'stop' } },
|
||||
],
|
||||
},
|
||||
});
|
||||
await h.c.toolQueue;
|
||||
assert.deepEqual(calls, ['zoom_to_globe', 'stop']);
|
||||
const skipped = toolResponses(ws).find(
|
||||
(call) => call.id === 'rotate-skipped',
|
||||
).response;
|
||||
assert.equal(skipped.ok, false);
|
||||
assert.equal(skipped.reason, 'globe-reset-failed');
|
||||
assert.equal(skipped.skipped, true);
|
||||
assert.equal(
|
||||
toolResponses(ws).find((call) => call.id === 'stop-allowed').response.ok,
|
||||
true,
|
||||
);
|
||||
|
||||
ws.message({ serverContent: { turnComplete: true } });
|
||||
establishToolInput(h, 'reset to globe view and rotate');
|
||||
ws.message({
|
||||
toolCall: {
|
||||
functionCalls: [
|
||||
{ id: 'reset-success', name: 'zoom_to_globe', args: {} },
|
||||
{
|
||||
id: 'rotate-success',
|
||||
name: 'move_camera',
|
||||
args: { motion: 'rotate_globe' },
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
await h.c.toolQueue;
|
||||
assert.deepEqual(calls, [
|
||||
'zoom_to_globe',
|
||||
'stop',
|
||||
'zoom_to_globe',
|
||||
'rotate_globe',
|
||||
]);
|
||||
} finally {
|
||||
h.c.stop();
|
||||
}
|
||||
});
|
||||
|
||||
test('cancelling a queued globe reset also skips its dependent rotation', async () => {
|
||||
const blocker = deferred();
|
||||
const calls = [];
|
||||
const h = harness({
|
||||
runAction: async (name, args) => {
|
||||
calls.push(name === 'move_camera' ? args.motion : name);
|
||||
return args.motion === 'pan'
|
||||
? blocker.promise
|
||||
: { ok: true, action: name };
|
||||
},
|
||||
});
|
||||
try {
|
||||
await h.c.start();
|
||||
establishToolInput(h, 'pan, reset to globe view and rotate');
|
||||
const ws = h.sockets[0];
|
||||
ws.message({
|
||||
toolCall: {
|
||||
functionCalls: [
|
||||
{
|
||||
id: 'blocker',
|
||||
name: 'move_camera',
|
||||
args: { motion: 'pan', direction: 'left' },
|
||||
},
|
||||
{ id: 'reset-cancelled', name: 'zoom_to_globe', args: {} },
|
||||
{
|
||||
id: 'rotate-after-cancel',
|
||||
name: 'move_camera',
|
||||
args: { motion: 'rotate_globe' },
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
await tick();
|
||||
ws.message({ toolCallCancellation: { ids: ['reset-cancelled'] } });
|
||||
blocker.resolve({ ok: true, action: 'move_camera' });
|
||||
await h.c.toolQueue;
|
||||
assert.deepEqual(calls, ['pan']);
|
||||
const skipped = toolResponses(ws).find(
|
||||
(call) => call.id === 'rotate-after-cancel',
|
||||
).response;
|
||||
assert.equal(skipped.reason, 'globe-reset-failed');
|
||||
assert.equal(skipped.skipped, true);
|
||||
} finally {
|
||||
blocker.resolve({ ok: true });
|
||||
h.c.stop();
|
||||
}
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import { interruptGlobeRotation } from '../cameraVerbs.js';
|
||||
import {
|
||||
layerSnapshot,
|
||||
feedProvenanceEnvelope,
|
||||
@@ -262,7 +263,9 @@ export function createGevActionRunner({
|
||||
}),
|
||||
));
|
||||
installViewTargetPrewarm(viewer);
|
||||
initCameraVerbs(viewer, getViewTargetCartesian);
|
||||
initCameraVerbs(viewer, getViewTargetCartesian, {
|
||||
isCockpitActive: () => Boolean(styleManager?.getCockpitState?.()?.active),
|
||||
});
|
||||
// Nested calls (select_nearest_aircraft composing layer + flight) use the
|
||||
// raw runner; only the outer call gets a voice envelope.
|
||||
const runGevAction = async function runGevAction(
|
||||
@@ -298,6 +301,7 @@ export function createGevActionRunner({
|
||||
// Zoom during an active orbit adjusts the orbit RADIUS (spiral in/out) —
|
||||
// a straight camera move would be snapped back by the per-frame lookAt.
|
||||
if (name === 'adjust_camera_zoom') {
|
||||
interruptGlobeRotation('voice-zoom-intent');
|
||||
const zoomOut = String(args.direction || '').toLowerCase() === 'out';
|
||||
const amt = String(args.amount || 'medium').toLowerCase();
|
||||
const factor = { little: 1.25, medium: 1.6, lot: 2.4 }[amt] || 1.6;
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -99,6 +99,18 @@ export function hasStructuredViewIdentity(result) {
|
||||
);
|
||||
}
|
||||
|
||||
export function isGlobeViewRefusal(result) {
|
||||
return (
|
||||
result?.action === 'move_camera' &&
|
||||
result.ok === false &&
|
||||
[
|
||||
'globe-view-required',
|
||||
'globe-reset-unavailable',
|
||||
'globe-reset-failed',
|
||||
].includes(result.reason)
|
||||
);
|
||||
}
|
||||
|
||||
export function responseInstructionForToolResult(result) {
|
||||
if (result?.action === 'control_radio' && result.radioPlaybackSuppressed) {
|
||||
if (result.audioState === 'paused') {
|
||||
@@ -123,6 +135,20 @@ export function responseInstructionForToolResult(result) {
|
||||
: 'Be concise: useful names, place, layer and one or two notable properties.',
|
||||
].join(' ');
|
||||
}
|
||||
if (isGlobeViewRefusal(result)) {
|
||||
// A refusal is not a completed action. Keep tool text out of instructions.
|
||||
if (result.reason === 'globe-reset-failed')
|
||||
return 'The globe did not rotate after the Globe View reset attempt. Say so briefly. Do not retry, offer or perform another reset for this request.';
|
||||
if (result.reason === 'globe-reset-unavailable')
|
||||
return 'The globe did not rotate. Ask the user to zoom out further; the Globe View preset does not fit this viewport. Do not offer or perform a reset.';
|
||||
return 'The globe did not rotate. Say "Globe rotation isn’t available from this view. You can say ‘Reset to Globe View and rotate the globe.’" The suggested command is not a user request. Do not ask a question, perform a reset, or call tools in this reply.';
|
||||
}
|
||||
if (
|
||||
(result?.action ?? result?.tool) === 'zoom_to_globe' &&
|
||||
(result.ok !== true || result.cancelled)
|
||||
) {
|
||||
return 'The Globe View reset did not finish. Say so briefly; do not claim Globe View was reached. Do not retry the reset or rotate the globe for this request.';
|
||||
}
|
||||
if (result?.action === 'adjust_camera_zoom') {
|
||||
return result.ok
|
||||
? `Confirm once that the camera zoomed ${result.direction}. Do not claim any other change.`
|
||||
|
||||
+229
-10
@@ -6,6 +6,7 @@ import {
|
||||
SUPERSEDED_RESPONSE_MEMORY,
|
||||
shouldStopVoiceAfterRadioTool,
|
||||
responseInstructionForToolResult,
|
||||
isGlobeViewRefusal,
|
||||
extractFunctionCalls,
|
||||
callDedupeKeys,
|
||||
parseArguments,
|
||||
@@ -66,6 +67,11 @@ export class RealtimeTurns {
|
||||
this.pendingUserTextResponse = false;
|
||||
this.activeResponseId = null;
|
||||
this.supersededResponseIds = new Set();
|
||||
this.globeRefusals = new Map();
|
||||
this.globeAdmissionGates = new Map();
|
||||
this.globeResets = new Map();
|
||||
this.globeResponseRequests = new Map();
|
||||
this.globeRequest = { failed: false, pending: new Set() };
|
||||
this.activeToolAbortControllers = new Set();
|
||||
this.narration = null;
|
||||
this.narrationSequence = 0;
|
||||
@@ -138,6 +144,7 @@ export class RealtimeTurns {
|
||||
},
|
||||
};
|
||||
if (!this.sendRealtimeEvent(itemEvent, 'client.user_text')) return;
|
||||
this.globeRequest = { failed: false, pending: new Set() };
|
||||
this.requestUserTextResponse();
|
||||
}
|
||||
|
||||
@@ -330,6 +337,7 @@ export class RealtimeTurns {
|
||||
this.userTurnPending = true;
|
||||
this.pendingResponseInstructions = null;
|
||||
this.cancelRadioHandoff({ abortTools: true });
|
||||
this.globeRequest = { failed: false, pending: new Set() };
|
||||
this.setVoiceSpeaker('user');
|
||||
// Point-and-ask: read the pointer as speech starts (open mic), or reuse
|
||||
// the push-to-talk keydown snapshot, before the user audio commits.
|
||||
@@ -384,6 +392,27 @@ export class RealtimeTurns {
|
||||
}
|
||||
|
||||
const toolResponseId = payload.response_id || payload.response?.id || null;
|
||||
// Follow-up responses retain reset ownership until fresh admitted input.
|
||||
const arrivalRequest = this.globeRequest;
|
||||
const globeRequest =
|
||||
this.globeResponseRequests.get(toolResponseId) ?? arrivalRequest;
|
||||
// Later siblings must wait for globe admission before they can mutate state.
|
||||
// Other tool responses keep their existing concurrency.
|
||||
let heldForAdmission = false;
|
||||
while (toolResponseId && this.globeAdmissionGates.has(toolResponseId)) {
|
||||
// Drop claimed duplicates before every wait, before their keys can expire.
|
||||
if (
|
||||
calls.every((call) =>
|
||||
callDedupeKeys(call).some((key) => this.processedCalls.has(key)),
|
||||
)
|
||||
)
|
||||
return;
|
||||
heldForAdmission = true;
|
||||
await this.globeAdmissionGates.get(toolResponseId).promise;
|
||||
if (!this.ownsConversation(eventChannel) || this.isSessionEnding())
|
||||
return;
|
||||
}
|
||||
|
||||
// A newer typed command superseded the response these calls belong to.
|
||||
// They are stale intent — dispatching one would let the old turn mutate
|
||||
// the map after the operator asked for something else.
|
||||
@@ -394,7 +423,10 @@ export class RealtimeTurns {
|
||||
// hazard `callDedupeKeys` is written to avoid). So each refused call gets
|
||||
// a terminal output saying plainly that the turn moved on. No
|
||||
// `response.create` follows — the deferred typed turn is the single answer.
|
||||
if (this.isSupersededResponse(toolResponseId)) {
|
||||
if (
|
||||
this.isSupersededResponse(toolResponseId) ||
|
||||
(heldForAdmission && arrivalRequest !== this.globeRequest)
|
||||
) {
|
||||
this.pruneProcessedCalls();
|
||||
for (const call of calls) {
|
||||
const keys = callDedupeKeys(call);
|
||||
@@ -429,7 +461,19 @@ export class RealtimeTurns {
|
||||
if (keys.some((key) => this.processedCalls.has(key))) continue;
|
||||
keys.forEach((key) => this.processedCalls.set(key, performance.now()));
|
||||
|
||||
if (this.globeRefusals.get(toolResponseId)?.blocking) {
|
||||
this.sendToolOutput(call.call_id || call.id, {
|
||||
ok: false,
|
||||
action: call.name,
|
||||
skipped: true,
|
||||
error: 'Not run: this response must wait for the Globe View reply.',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
let result;
|
||||
let globeAdmissionGate = null;
|
||||
let globeResetGate = null;
|
||||
const resultChannel = this.dc;
|
||||
let radioHandoffEpochAtStart = this.radio.radioHandoffEpoch;
|
||||
let toolController = null;
|
||||
@@ -443,6 +487,80 @@ export class RealtimeTurns {
|
||||
this.narration?.preamble();
|
||||
try {
|
||||
const parsedArguments = parseArguments(call.arguments);
|
||||
if (
|
||||
toolResponseId &&
|
||||
call.name === 'move_camera' &&
|
||||
String(parsedArguments.motion || '').toLowerCase() === 'rotate_globe'
|
||||
) {
|
||||
let resolve;
|
||||
const promise = new Promise((settle) => {
|
||||
resolve = settle;
|
||||
});
|
||||
globeAdmissionGate = { promise, resolve };
|
||||
this.globeAdmissionGates.set(toolResponseId, globeAdmissionGate);
|
||||
// OpenAI delivers sibling calls concurrently. A reset dispatched
|
||||
// first must finish before rotation evaluates the camera's fit.
|
||||
// Hold later siblings at admission while this rotation waits.
|
||||
const resets = this.globeResets.get(toolResponseId);
|
||||
if (
|
||||
resets ||
|
||||
globeRequest.failed ||
|
||||
globeRequest.pending.size ||
|
||||
arrivalRequest !== this.globeRequest
|
||||
) {
|
||||
while (resets?.pending.size || globeRequest.pending.size)
|
||||
await Promise.all(
|
||||
[
|
||||
...new Set([
|
||||
...(resets?.pending || []),
|
||||
...globeRequest.pending,
|
||||
]),
|
||||
].map((gate) => gate.promise),
|
||||
);
|
||||
if (!this.ownsConversation(eventChannel) || this.isSessionEnding())
|
||||
return;
|
||||
keys.forEach((key) =>
|
||||
this.processedCalls.set(key, performance.now()),
|
||||
);
|
||||
const superseded =
|
||||
this.isSupersededResponse(toolResponseId) ||
|
||||
arrivalRequest !== this.globeRequest;
|
||||
if (superseded || resets?.failed || globeRequest.failed) {
|
||||
result = {
|
||||
ok: false,
|
||||
action: call.name,
|
||||
reason: 'globe-reset-failed',
|
||||
skipped: true,
|
||||
...(superseded ? { superseded: true } : {}),
|
||||
error: superseded
|
||||
? 'Superseded by a newer command from the operator — this call was not run.'
|
||||
: 'Not run: the Globe View reset/rotation sequence failed or was cancelled.',
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
if (toolResponseId && call.name === 'zoom_to_globe') {
|
||||
let resolve;
|
||||
const promise = new Promise((settle) => {
|
||||
resolve = settle;
|
||||
});
|
||||
let resets = this.globeResets.get(toolResponseId);
|
||||
if (!resets) {
|
||||
resets = { pending: new Set(), failed: false };
|
||||
this.globeResets.set(toolResponseId, resets);
|
||||
// Retain settled outcomes for later siblings, without evicting an
|
||||
// active reset that another response still needs to await.
|
||||
for (const [id, record] of this.globeResets) {
|
||||
if (this.globeResets.size <= SUPERSEDED_RESPONSE_MEMORY) break;
|
||||
if (!record.pending.size && id !== toolResponseId)
|
||||
this.globeResets.delete(id);
|
||||
}
|
||||
}
|
||||
globeResetGate = { promise, resolve, resets, request: globeRequest };
|
||||
resets.pending.add(globeResetGate);
|
||||
globeRequest.pending.add(globeResetGate);
|
||||
}
|
||||
|
||||
const isRadioControlCall = call.name === 'control_radio';
|
||||
const isRadioVisibilityCall =
|
||||
call.name === 'set_layer_visibility' &&
|
||||
@@ -499,7 +617,7 @@ export class RealtimeTurns {
|
||||
authorityDomain: radioAuthorityDomain,
|
||||
});
|
||||
}
|
||||
result = await (this.actionExecutor || this.runner)(
|
||||
result ??= await (this.actionExecutor || this.runner)(
|
||||
call.name,
|
||||
parsedArguments,
|
||||
{
|
||||
@@ -515,7 +633,28 @@ export class RealtimeTurns {
|
||||
radioHandoffEpochAtStart === this.radio.radioHandoffEpoch),
|
||||
},
|
||||
);
|
||||
if (
|
||||
globeAdmissionGate &&
|
||||
this.globeResets.has(toolResponseId) &&
|
||||
result?.reason === 'globe-view-required'
|
||||
)
|
||||
result = { ...result, reason: 'globe-reset-failed' };
|
||||
if (!this.ownsConversation(eventChannel)) return;
|
||||
if (
|
||||
isGlobeViewRefusal(result) &&
|
||||
toolResponseId &&
|
||||
arrivalRequest === this.globeRequest &&
|
||||
!this.isSupersededResponse(toolResponseId)
|
||||
) {
|
||||
this.globeRefusals.set(toolResponseId, {
|
||||
result,
|
||||
followupQueued: false,
|
||||
blocking: !result.skipped,
|
||||
});
|
||||
while (this.globeRefusals.size > SUPERSEDED_RESPONSE_MEMORY) {
|
||||
this.globeRefusals.delete(this.globeRefusals.keys().next().value);
|
||||
}
|
||||
}
|
||||
if (result?.ok && result.radioPlaybackRequested) {
|
||||
const sessionIsCurrent =
|
||||
this.activeToolAbortControllers.has(toolController) &&
|
||||
@@ -595,6 +734,23 @@ export class RealtimeTurns {
|
||||
: {}),
|
||||
};
|
||||
} finally {
|
||||
if (globeResetGate) {
|
||||
const { resets, request } = globeResetGate;
|
||||
if (result?.ok !== true || result.cancelled) {
|
||||
resets.failed = true;
|
||||
request.failed = true;
|
||||
}
|
||||
resets.pending.delete(globeResetGate);
|
||||
request.pending.delete(globeResetGate);
|
||||
globeResetGate.resolve();
|
||||
}
|
||||
if (globeAdmissionGate) {
|
||||
if (
|
||||
this.globeAdmissionGates.get(toolResponseId) === globeAdmissionGate
|
||||
)
|
||||
this.globeAdmissionGates.delete(toolResponseId);
|
||||
globeAdmissionGate.resolve();
|
||||
}
|
||||
if (toolController) {
|
||||
this.activeToolAbortControllers.delete(toolController);
|
||||
this.radio.releaseTool(toolController);
|
||||
@@ -671,13 +827,38 @@ export class RealtimeTurns {
|
||||
});
|
||||
}
|
||||
if (!this.ownsConversation(eventChannel)) return;
|
||||
// Keep the Radio handoff wording authoritative even when another tool
|
||||
// result follows Radio in the same multi-intent response.
|
||||
this.queueResponseCreate(
|
||||
responseInstructionForToolResult(
|
||||
this.radio.pendingRadioPlaybackResult || lastResult,
|
||||
),
|
||||
);
|
||||
const refusal = this.globeRefusals.get(toolResponseId);
|
||||
// A newer turn may arrive during admission or the viewport-image await.
|
||||
// Preserve its reply; neither the refusal map nor lastResult may revive
|
||||
// an old refusal follow-up after supersession.
|
||||
if (
|
||||
(this.isSupersededResponse(toolResponseId) ||
|
||||
arrivalRequest !== this.globeRequest) &&
|
||||
(refusal || isGlobeViewRefusal(lastResult) || lastResult?.superseded)
|
||||
) {
|
||||
// Complete an idle turn below, but leave a newer running tool's status
|
||||
// authoritative while suppressing this obsolete refusal follow-up.
|
||||
if (this.activeToolAbortControllers.size) return;
|
||||
} else if (refusal) {
|
||||
// Even an earlier sibling finishing late cannot replace the reset instruction.
|
||||
if (!refusal.followupQueued) {
|
||||
refusal.followupQueued = true;
|
||||
this.queueResponseCreate({
|
||||
instructions: responseInstructionForToolResult(refusal.result),
|
||||
tool_choice: 'none',
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Keep Radio handoff wording authoritative for ordinary multi-tool replies.
|
||||
const followupResult =
|
||||
this.radio.pendingRadioPlaybackResult || lastResult;
|
||||
const instructions = responseInstructionForToolResult(followupResult);
|
||||
this.queueResponseCreate(
|
||||
isGlobeViewRefusal(followupResult)
|
||||
? { instructions, tool_choice: 'none' }
|
||||
: instructions,
|
||||
);
|
||||
}
|
||||
}
|
||||
this.setStatus('listening', t('Ask or command'));
|
||||
}
|
||||
@@ -966,6 +1147,22 @@ export class RealtimeTurns {
|
||||
this.userTurnPending = false;
|
||||
this.activeResponseId =
|
||||
payload.response?.id || payload.response_id || null;
|
||||
// Late calls retain only their globe reset/request ownership. Ordinary
|
||||
// tool admission and concurrency continue to use their existing policy.
|
||||
if (
|
||||
this.activeResponseId &&
|
||||
!this.globeResponseRequests.has(this.activeResponseId)
|
||||
) {
|
||||
this.globeResponseRequests.set(
|
||||
this.activeResponseId,
|
||||
this.globeRequest,
|
||||
);
|
||||
while (this.globeResponseRequests.size > SUPERSEDED_RESPONSE_MEMORY)
|
||||
this.globeResponseRequests.delete(
|
||||
this.globeResponseRequests.keys().next().value,
|
||||
);
|
||||
}
|
||||
|
||||
this.setVoiceSpeaker('ai');
|
||||
return;
|
||||
}
|
||||
@@ -1045,7 +1242,8 @@ export class RealtimeTurns {
|
||||
const sent = this.sendRealtimeEvent(
|
||||
{
|
||||
type: 'response.create',
|
||||
response: { instructions },
|
||||
response:
|
||||
typeof instructions === 'string' ? { instructions } : instructions,
|
||||
},
|
||||
'client.response_create.tool_followup',
|
||||
);
|
||||
@@ -1057,6 +1255,20 @@ export class RealtimeTurns {
|
||||
controller.abort();
|
||||
this.activeToolAbortControllers.clear();
|
||||
this.radio.clearTools();
|
||||
this.releaseGlobeResets();
|
||||
}
|
||||
|
||||
releaseGlobeResets() {
|
||||
for (const resets of this.globeResets.values()) {
|
||||
if (!resets.pending.size) continue;
|
||||
resets.failed = true;
|
||||
for (const gate of resets.pending) {
|
||||
gate.request.failed = true;
|
||||
gate.request.pending.delete(gate);
|
||||
gate.resolve();
|
||||
}
|
||||
resets.pending.clear();
|
||||
}
|
||||
}
|
||||
|
||||
reset() {
|
||||
@@ -1068,6 +1280,13 @@ export class RealtimeTurns {
|
||||
this.pendingUserTextResponse = false;
|
||||
this.activeResponseId = null;
|
||||
this.supersededResponseIds.clear();
|
||||
this.globeRefusals.clear();
|
||||
for (const gate of this.globeAdmissionGates.values()) gate.resolve();
|
||||
this.globeAdmissionGates.clear();
|
||||
this.releaseGlobeResets();
|
||||
this.globeResets.clear();
|
||||
this.globeResponseRequests.clear();
|
||||
this.globeRequest = { failed: false, pending: new Set() };
|
||||
this.narration?.cancel();
|
||||
this.assistantAudioPlaying = false;
|
||||
this.speechResponseIds.clear();
|
||||
|
||||
Reference in new issue
Block a user