Merge branch 'main' into codex/fix-overpass-cache-singleflight

This commit is contained in:
Said Turksever authored and GitHub committed 2026-10-24 01:11:43 +01:00
commit 1c2fe8201f
90 files changed
+6131 -288

No files matched your search

+14
View File
@@ -72,3 +72,17 @@ test('the defaults are Mapillary (given its source), Street View, KartaView and
// Without the application's Street Level source there are none.
assert.deepEqual(createApplicationStreetLevel({}).providerIds, []);
});
test('every built-in provider is exported, so hosts can choose their own list', async () => {
const google = await import('gods-eye-view/layers/street-level/google');
const kartaview = await import('gods-eye-view/layers/street-level/kartaview');
const panoramax = await import('gods-eye-view/layers/street-level/panoramax');
const layer = createApplicationStreetLevel({
providers: [
google.createGoogleProvider({ getApiKey: () => null }),
kartaview.createKartaViewProvider(),
panoramax.createPanoramaxProvider(),
],
});
assert.deepEqual(layer.providerIds, ['google', 'kartaview', 'panoramax']);
});
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -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
View File
@@ -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
+28
View File
@@ -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' });
+95 -18
View File
@@ -1040,26 +1040,103 @@ export function detachImportedTime(dataSource) {
return dataSource;
}
/** Repair only the first KML opening tag, scanning its delimiter once. */
export function repairKmlRootNamespaces(value, what = 'KML') {
const text = String(value);
// Skip XML comments and processing instructions without treating their
// literal '<kml' text as an opening root tag.
let start = 0;
for (;;) {
start = text.indexOf('<', start);
if (start < 0) return text;
if (text.slice(start, start + 9).toUpperCase() === '<!DOCTYPE') {
let quote = null;
let subsetDepth = 0;
let end = start + 9;
for (; end < text.length; end++) {
const char = text[end];
if (quote) {
if (char === quote) quote = null;
} else if (char === '"' || char === "'") quote = char;
else if (char === '<' && text.startsWith('<!--', end)) {
// Comments and processing instructions inside the internal subset
// carry no declaration syntax; jump past them as a whole.
const skip = text.indexOf('-->', end + 4);
if (skip < 0) return text;
end = skip + 2;
} else if (char === '<' && text.startsWith('<?', end)) {
const skip = text.indexOf('?>', end + 2);
if (skip < 0) return text;
end = skip + 1;
} else if (char === '[') subsetDepth++;
else if (char === ']') subsetDepth--;
else if (char === '>' && subsetDepth === 0) break;
}
if (end === text.length) return text;
start = end + 1;
continue;
}
const close = text.startsWith('<!--', start)
? '-->'
: text.startsWith('<?', start)
? '?>'
: null;
if (!close) break;
const end = text.indexOf(close, start + 2);
if (end < 0) return text; // The XML parser reports an unterminated preamble.
start = end + close.length;
}
const match = /^<kml\b/i.exec(text.slice(start, start + 5));
if (!match) return text;
let quote = null;
let end = -1;
for (let index = start + match[0].length; index < text.length; index++) {
const char = text[index];
if (char === '<') break; // Raw '<' is invalid even inside an XML attribute.
if (quote) {
if (char === quote) quote = null;
} else if (char === '"' || char === "'") {
quote = char;
} else if (char === '>') {
end = index + 1;
break;
}
}
if (end < 0)
throw new GeoImportError(
`That ${what} file is not valid XML.`,
msg('That {format} file is not valid XML.'),
{ format: what },
);
// Walk the root's attributes in order so quoted values are never read as
// declarations. Anything the walk cannot tokenize is kept verbatim for the
// XML parser to judge.
const seen = new Set();
const attribute = /(\s+)([^\s=/>"']+)\s*=\s*("[^"]*"|'[^']*')/y;
let position = start + match[0].length;
let root = text.slice(start, position);
for (;;) {
attribute.lastIndex = position;
const found = attribute.exec(text);
if (!found || attribute.lastIndex > end) break;
position = attribute.lastIndex;
const prefix = /^xmlns:([\w.-]+)$/.exec(found[2])?.[1];
if (prefix && seen.has(prefix)) continue;
if (prefix) seen.add(prefix);
root += found[0];
}
root += text.slice(position, end);
if (!seen.has('xsi') && /[<\s]xsi:/.test(text))
root = root.replace(
/(\/?)>$/,
' xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"$1>',
);
return text.slice(0, start) + root + text.slice(end);
}
function parseXml(text, what) {
// Match the namespace repairs Cesium applies to ordinary exported KML.
if (what !== 'GPX')
text = String(text).replace(/<kml\b[^>]*>/i, (root) => {
const seen = new Set();
root = root.replace(
/\s+xmlns:([\w.-]+)\s*=\s*(["'])[^"']*\2/g,
(declaration, prefix) => {
if (seen.has(prefix)) return '';
seen.add(prefix);
return declaration;
},
);
if (!seen.has('xsi') && /[<\s]xsi:/.test(text))
root = root.replace(
/>$/,
' xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">',
);
return root;
});
if (what !== 'GPX') text = repairKmlRootNamespaces(text, what);
const doc = new DOMParser().parseFromString(
String(text).replace(/^/, ''),
'application/xml',
+91
View File
@@ -1,9 +1,11 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { deflateRawSync } from 'node:zlib';
import { spawnSync } from 'node:child_process';
import * as Cesium from 'cesium';
import {
GeoImportError,
repairKmlRootNamespaces,
loadGeoFile,
MAX_IMPORT_POSITIONS,
MAX_IMPORT_ENTITIES,
@@ -1171,3 +1173,92 @@ test('a dated import exposes no clock and draws for all time', () => {
assert.equal(recorded.getValue(Cesium.JulianDate.now()).length, 2);
assert.ok(track.polyline.clampToGround.getValue());
});
test('KML root namespace repair respects quoted delimiters and preserves the document tail', () => {
const text =
'<KML note="a > b" xmlns:x="first" xmlns:x="second"><Placemark xsi:type="test"/></KML>';
const result = repairKmlRootNamespaces(text);
assert.match(result, /note="a > b"/);
assert.match(result, /xmlns:x="first"/);
assert.doesNotMatch(result, /second/);
assert.match(
result,
/xmlns:xsi="http:\/\/www.w3.org\/2001\/XMLSchema-instance"/,
);
assert.ok(result.endsWith('<Placemark xsi:type="test"/></KML>'));
assert.match(
repairKmlRootNamespaces('<kml xsi:type="test"/>'),
/XMLSchema-instance"\/>$/,
);
for (const malformed of ['<kml ', '<kml name="unterminated>', '<kml <kml >'])
assert.throws(() => repairKmlRootNamespaces(malformed), GeoImportError);
});
test('repeated incomplete KML roots finish within an isolated process deadline', () => {
const source = `import { repairKmlRootNamespaces } from ${JSON.stringify(new URL('./geoFileImport.js', import.meta.url).href)};
try { repairKmlRootNamespaces('<kml '.repeat(1000000)); process.exit(1); }
catch (error) { if (error.name !== 'GeoImportError') throw error; }
try { repairKmlRootNamespaces('<!DOCTYPE kml [' + ${JSON.stringify("<!-- ' --><?pi ' ?>")}.repeat(100000) + ']><kml '); process.exit(1); }
catch (error) { if (error.name !== 'GeoImportError') throw error; }
const wide = '<kml' + ${JSON.stringify(` xmlns:gx="a" note='xmlns:gx="b"'`)}.repeat(20000) + '/>';
if (repairKmlRootNamespaces(wide).split('xmlns:gx="a"').length !== 2) process.exit(1);`;
const child = spawnSync(
process.execPath,
['--input-type=module', '-e', source],
{ timeout: 5000, encoding: 'utf8' },
);
assert.ifError(child.error);
assert.equal(child.status, 0, child.stderr);
});
test('KML root repair ignores incomplete tag text inside preamble comments', () => {
const input =
'<?xml version="1.0"?><!-- example <kml incomplete -->\n<kml xmlns:gx="a" xmlns:gx="b"><Document/></kml>';
assert.equal(
repairKmlRootNamespaces(input),
input.replace(' xmlns:gx="b"', ''),
);
});
test('KML namespace repair follows a doctype with a quoted root-like entity', () => {
const input =
'<!DOCTYPE kml [<!ENTITY example "<kml incomplete >">]><kml xmlns:gx="a" xmlns:gx="b"/>';
assert.equal(
repairKmlRootNamespaces(input),
input.replace(' xmlns:gx="b"', ''),
);
});
test('KML namespace repair skips doctype comments and processing instructions whole', () => {
for (const prolog of [
"<!DOCTYPE kml [<!-- don't count this quote -->]>",
'<!DOCTYPE kml [<!-- a ] or > here --><!ENTITY x "y">]>',
"<!DOCTYPE kml [<?note it's ] > fine?>]>",
'<!DOCTYPE kml [<!ENTITY x "<!-- \' -->">]>',
]) {
const input = `${prolog}<kml xmlns:gx="a" xmlns:gx="b"><Placemark xsi:type="t"/></kml>`;
assert.equal(
repairKmlRootNamespaces(input),
input.replace(
' xmlns:gx="b">',
' xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">',
),
prolog,
);
}
const unterminated = '<!DOCTYPE kml [<!-- open <kml xmlns:gx="a">';
assert.equal(repairKmlRootNamespaces(unterminated), unterminated);
});
test('KML namespace repair ignores declaration text inside quoted attribute values', () => {
const input =
'<kml note=\'a > b xmlns:gx="fake"\' xmlns:gx="http://www.google.com/kml/ext/2.2" xmlns:gx="dup"><gx:Track/></kml>';
const result = repairKmlRootNamespaces(input);
assert.equal(result, input.replace(' xmlns:gx="dup"', ''));
assert.match(result, /\sxmlns:gx="http:\/\/www.google.com\/kml\/ext\/2.2"/);
const xsi = '<kml note="xmlns:xsi=\'x\'"><Placemark xsi:type="t"/></kml>';
assert.match(
repairKmlRootNamespaces(xsi),
/note="xmlns:xsi='x'" xmlns:xsi="http:\/\/www.w3.org\/2001\/XMLSchema-instance">/,
);
});
+4 -1
View File
@@ -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);
+35
View File
@@ -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;
}
+120
View File
@@ -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,
);
}
});
+52
View File
@@ -712,6 +712,58 @@ test('unusable directory responses preserve warm client state atomically', async
assert.equal(getRadioUIState().stale, true);
assert.equal(getRadioUIState().degraded, true);
}
globalThis.fetch = async () => new Response(JSON.stringify({
stations: [replacement, { id: 'malformed' }], stale: false, degraded: false,
acceptedGeneration: 2, catalogInstance: 'qa-instance-a', updatedAt: now,
}));
await radioLayer.update();
assert.equal(getRadioUIState().stationCount, 1);
assert.equal(getRadioUIState().rejectedStationCount, 1);
assert.equal(getRadioUIState().degraded, true);
assert.equal(getRadioUIState().stale, false);
assert.equal(getRadioAcceptedCatalogSnapshot().generation, 2);
assert.equal(getRadioAcceptedCatalogSnapshot().stations[0].id, replacement.id);
// Server-side structural rejections are combined with client rejections
// after admission; the healthy generation is still accepted.
globalThis.fetch = async () => new Response(JSON.stringify({
stations: [replacement, { id: 'malformed' }], stale: false, degraded: false,
acceptedGeneration: 3, catalogInstance: 'qa-instance-a', updatedAt: now,
rejectedStationCount: 2,
}));
await radioLayer.update();
assert.equal(getRadioUIState().rejectedStationCount, 3);
assert.equal(getRadioUIState().degraded, true);
assert.equal(getRadioAcceptedCatalogSnapshot().generation, 3);
globalThis.fetch = async () => new Response(JSON.stringify({
stations: [replacement], stale: false, degraded: false,
acceptedGeneration: 4, catalogInstance: 'qa-instance-a', updatedAt: now,
rejectedStationCount: 10_000,
}));
await radioLayer.update();
assert.equal(getRadioUIState().rejectedStationCount, 10_000);
assert.equal(getRadioAcceptedCatalogSnapshot().generation, 4);
for (const rejectedStationCount of [-1, 1.5, '2', null, 10_001]) {
globalThis.fetch = async () => new Response(JSON.stringify({
stations: [replacement], stale: false, degraded: false,
acceptedGeneration: 5, catalogInstance: 'qa-instance-a', updatedAt: now,
rejectedStationCount,
}));
await radioLayer.update();
assert.equal(getRadioAcceptedCatalogSnapshot().generation, 4);
assert.equal(getRadioUIState().stale, true);
}
globalThis.fetch = async () => new Response(JSON.stringify({
stations: [{ id: 'malformed' }], stale: false, degraded: false,
acceptedGeneration: 5, catalogInstance: 'qa-instance-a', updatedAt: now,
}));
await radioLayer.update();
assert.equal(getRadioAcceptedCatalogSnapshot().generation, 4);
assert.equal(getRadioUIState().stale, true);
assert.equal(getRadioUIState().rejectedStationCount, 10_000);
radioLayer.destroy();
radioLayer.init(viewer);
assert.equal(getRadioUIState().rejectedStationCount, 0);
assert.equal(getRadioUIState().degraded, false);
} finally {
globalThis.fetch = originalFetch;
radioLayer.destroy();
+67 -1
View File
@@ -11,7 +11,14 @@ import {
publicRadioStation,
publicRadioHttpsUrl,
} from '../../vite.config.js';
import { rankRadioStationsForRequest } from './radio.js';
import {
getRadioAcceptedCatalogSnapshot,
getRadioUIState,
radioLayer,
rankRadioStationsForRequest,
} from './radio.js';
import { RADIO_REJECTED_STATION_COUNT_MAX as SERVER_REJECTED_MAX } from '../../server/providers/radio/constants.js';
import { RADIO_REJECTED_STATION_COUNT_MAX as CLIENT_REJECTED_MAX } from '../layers/radio/policy.js';
const UUID = '12345678-1234-4234-8234-123456789abc';
const publicLookup = async () => [{ address: '93.184.216.34', family: 4 }];
@@ -533,3 +540,62 @@ test('a mirror answering an invalid reason phrase rejects instead of crashing',
const crashed = mockMirrorStatus(t, 200, 'OK\x7f');
await assert.rejects(Promise.race([fetchMirror(), crashed]), TypeError);
});
test('case expansion is capped after tag normalization in the portable and server station path', () => {
const row = normalizeRadioBrowserStation(station({tags: 'İ'.repeat(80) + ', NEWS__RADIO ,news-radio'}));
assert.equal(row.tags[0].length, 80);
assert.equal(row.tags[0], 'i\u0307'.repeat(40));
assert.deepEqual(row.tags.slice(1), ['news radio']);
});
test('server structural rejections reach the client diagnostic without discarding a healthy generation', async () => {
const originalFetch = globalThis.fetch;
const middleware = createProxy({
now: () => Date.now(),
fetchImpl: async (url) => {
if (String(url).includes('/json/servers')) return responseJson([{ name: 'de1.api.radio-browser.info' }]);
const rows = healthyRowsForQuery(url);
if (queryTag(url)) return responseJson(rows);
// 400 valid broad rows, one row missing its required name, and one
// schema-valid row that normalization filters by policy (not counted).
const { name: _name, ...nameless } = station({ stationuuid: '50000000-0000-4000-8000-000000000001' });
return responseJson([...rows, nameless, station({ stationuuid: '50000000-0000-4000-8000-000000000002', lastcheckok: 0 })]);
},
});
const response = await invoke(middleware, '/stations');
assert.equal(response.status, 200);
const body = JSON.parse(response.body);
assert.equal(body.degraded, false);
assert.equal(body.degradedReason, null);
assert.equal(body.acceptedGeneration, 1);
assert.equal(body.rejectedStationCount, 1);
assert.equal(SERVER_REJECTED_MAX, CLIENT_REJECTED_MAX);
const viewer = {
camera: { positionWC: { x: 7_000_000, y: 0, z: 0 } },
scene: { canvas: { disableRootEvents: true, onwheel: null, addEventListener() {}, removeEventListener() {} } },
dataSources: { add() {}, remove() {} },
entities: { add(entity) { return entity; }, remove() {} },
};
radioLayer.destroy();
try {
globalThis.fetch = async () => new Response(response.body, {
status: response.status,
headers: { 'Content-Type': 'application/json' },
});
radioLayer.init(viewer);
radioLayer.enable();
await radioLayer.update();
const state = getRadioUIState();
assert.equal(state.stationCount, body.stations.length);
assert.equal(state.rejectedStationCount, 1);
assert.equal(state.degraded, true);
assert.equal(state.stale, false);
assert.equal(getRadioAcceptedCatalogSnapshot().generation, 1);
assert.equal(getRadioAcceptedCatalogSnapshot().instance, body.catalogInstance);
} finally {
globalThis.fetch = originalFetch;
radioLayer.destroy();
}
});
+16 -2
View File
@@ -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(
+4 -2
View File
@@ -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)
+232
View File
@@ -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');
});
+123 -8
View File
@@ -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;
+5 -3
View File
@@ -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 (
@@ -593,7 +593,7 @@ export function createTracking({
}
/**
* Fire-and-forget adsb.lol readsb trace backfill (PRD F2). Trace points are
* Fire-and-forget optional source history backfill (PRD F2). Trace points are
* stride-thinned to the remaining vertex budget, then spliced strictly older
* than the oldest seeded fix AHEAD of the locally accumulated fine segment,
* capped at TRAIL_MAX_POINTS (newest kept). Any failure (404/timeout/
@@ -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)
+22 -6
View File
@@ -2,6 +2,7 @@ import { msg } from '../../i18n/index.js';
import {
RADIO_DIRECTORY_STALE_MS,
RADIO_DIRECTORY_FUTURE_SKEW_MS,
RADIO_REJECTED_STATION_COUNT_MAX,
} from './policy.js';
// Directory errors stay English in layer state and stats; the layer row and
@@ -64,17 +65,24 @@ export function createIngestion({
typeof body.degraded !== 'boolean'
)
throw new Error('Radio directory freshness metadata was malformed');
// Rows the server skipped as structurally malformed. Older envelopes
// omit the field; a present value must be a bounded count.
const serverRejectedStationCount =
body.rejectedStationCount === undefined
? 0
: body.rejectedStationCount;
if (
!Number.isSafeInteger(serverRejectedStationCount) ||
serverRejectedStationCount < 0 ||
serverRejectedStationCount > RADIO_REJECTED_STATION_COUNT_MAX
)
throw new Error('Radio directory diagnostic metadata was malformed');
const rows = body.stations;
const acceptedRows = rows.filter(
parts.catalogModel.isValidRadioDirectoryStation,
);
if (!acceptedRows.length)
throw new Error('Radio directory returned no usable stations');
if (acceptedRows.length !== rows.length) {
throw new Error(
'Radio directory response contained malformed stations',
);
}
const acceptedGeneration = body.acceptedGeneration;
if (
acceptedGeneration !== null &&
@@ -147,7 +155,15 @@ export function createIngestion({
}
if (!repeatingAcceptedGeneration) layerState._updatedAt = updatedAt;
}
layerState._degraded = body.degraded;
// Combine server-side structural rejections with rows this client
// refused. The count is diagnostic: it marks coverage degraded but does
// not undo the generation admitted above.
layerState._rejectedStationCount = Math.min(
RADIO_REJECTED_STATION_COUNT_MAX,
serverRejectedStationCount + rows.length - acceptedRows.length,
);
layerState._degraded =
body.degraded || layerState._rejectedStationCount > 0;
layerState._stale = body.stale;
layerState._error = preservingWarmCatalog
? ERROR_REFRESH_DEGRADED
+1
View File
@@ -108,6 +108,7 @@ export function createLifecycle({
layerState._loading = false;
layerState._stale = false;
layerState._degraded = false;
layerState._rejectedStationCount = 0;
layerState._error = null;
layerState._updatedAt = null;
layerState._managerLifecyclePresentation = null;
+4
View File
@@ -24,6 +24,10 @@ export const RADIO_DIRECTORY_STALE_MS = 7 * 24 * 60 * 60 * 1000;
export const RADIO_DIRECTORY_FUTURE_SKEW_MS = 5 * 60 * 1000;
// Bound for the server's `rejectedStationCount` envelope field and the
// combined client counter; matches server/providers/radio/constants.js.
export const RADIO_REJECTED_STATION_COUNT_MAX = 10_000;
export const RADIO_UUID_RE =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
+1
View File
@@ -20,6 +20,7 @@ export function createPresentation({
loading: layerState._loading,
stale: layerState._stale,
degraded: layerState._degraded,
rejectedStationCount: layerState._rejectedStationCount,
error: layerState._error,
updatedAt: layerState._updatedAt,
filter: layerState._filter,
+1
View File
@@ -25,6 +25,7 @@ export function createState({ services }) {
layerState._stale = false;
layerState._degraded = false;
layerState._rejectedStationCount = 0;
layerState._error = null;
@@ -36,16 +36,29 @@ async function readJsonOrThrow(response, label) {
return payload;
}
/** `endpoint` with exactly one trailing slash, or '' when unset. */
function endpointBase(endpoint) {
const text = String(endpoint || '').trim();
return text ? text.replace(/\/*$/, '/') : '';
}
/**
* Mapillary network access: tiles via the local proxy, which adds the token
* and caches; Graph API lookups direct with the client token, as MapillaryJS.
* @param {{token?: string, fetchImpl?: Function, endpoints?: object}} options
* With `graphEndpoint` (a Graph API base that adds the token itself, such as
* a same-origin proxy), the source's Graph lookups go there through
* `fetchImpl` instead, and the photo viewer sends its own lookups there too;
* no token is sent or needed.
* @param {{token?: string, fetchImpl?: Function, endpoints?: object,
* graphEndpoint?: string}} options
*/
export function createMapillarySource({
token = '',
fetchImpl = (...args) => globalThis.fetch(...args),
endpoints = {},
graphEndpoint = '',
} = {}) {
const graphBase = endpointBase(graphEndpoint);
const urls = {
status: '/api/mapillary/status',
tiles: '/api/mapillary/tiles',
@@ -54,6 +67,13 @@ export function createMapillarySource({
};
async function graph(path, params = {}, { signal } = {}) {
if (graphBase) {
const search = new URLSearchParams(params);
const response = await fetchImpl(`${graphBase}${path}?${search}`, {
signal,
});
return readJsonOrThrow(response, 'Mapillary graph');
}
if (!token)
throw new MapillarySourceError('Mapillary token not configured', {
status: 503,
@@ -70,6 +90,10 @@ export function createMapillarySource({
get token() {
return token;
},
/** The Graph API base that adds the token itself, or '' (direct). */
get graphEndpoint() {
return graphBase;
},
hasToken: () => Boolean(token),
async getStatus({ signal } = {}) {
@@ -125,3 +125,35 @@ test('a rejected key and a rate limit are told apart from other tile errors', as
'the Retry-After header wins over the body',
);
});
test('with a Graph endpoint, sequence lookups go there without a token', async () => {
const { calls, fetchImpl } = fakeFetch(() =>
jsonResponse({ data: [{ id: '1', geometry: { coordinates: [1, 2] } }] }),
);
const source = createMapillarySource({
graphEndpoint: '/api/mapillary/graph',
fetchImpl,
});
assert.equal(source.graphEndpoint, '/api/mapillary/graph/');
assert.equal(source.hasToken(), false);
const images = await source.getSequenceImages('seq-1');
assert.equal(images.length, 1);
const url = new URL(calls[0].url, 'http://localhost');
assert.equal(url.pathname, '/api/mapillary/graph/images');
assert.equal(url.searchParams.get('sequence_ids'), 'seq-1');
assert.equal(url.searchParams.has('access_token'), false);
});
test('Graph endpoint errors keep their status', async () => {
const { fetchImpl } = fakeFetch(() =>
jsonResponse({ error: 'no_key', keyRequired: true }, 503),
);
const source = createMapillarySource({
graphEndpoint: '/api/mapillary/graph/',
fetchImpl,
});
await assert.rejects(
() => source.getSequenceImages('1'),
(error) => error.status === 503 && error.keyRequired === true,
);
});
@@ -13,6 +13,20 @@ import { mapillaryImageUrl } from './policy.js';
* @property {string} externalUrl Deep link to the image on mapillary.com.
*/
/**
* The source's Graph endpoint as an absolute URL ending in `/`, as MapillaryJS
* resolves its lookup paths against it; '' when the source has none.
*/
export function graphEndpointUrl(source) {
if (!source?.graphEndpoint) return '';
const url = new URL(
source.graphEndpoint,
globalThis.location?.href || 'http://localhost/',
);
url.pathname = url.pathname.replace(/\/*$/, '/');
return url.href;
}
/**
* The panel's photo viewer: loads MapillaryJS on the first `mount` and emits
* a pose on every image, view or position change.
@@ -115,12 +129,18 @@ export function createMapillaryViewer({ source, render } = {}) {
async function buildViewer(host) {
const built = generation;
destroyViewer();
const { Viewer } = await ensureLibrary();
const { Viewer, GraphDataProvider } = await ensureLibrary();
// Unmounted (layer off) while the library loaded: a viewer built now
// would hold a WebGL context nobody releases.
if (built !== generation) throw new Error('Mapillary viewer was unmounted');
const endpoint = graphEndpointUrl(source);
viewer = new Viewer({
accessToken: source.token,
// A source with its own Graph endpoint (which adds the token itself)
// answers the viewer's lookups, sent without a token; otherwise
// MapillaryJS calls the Graph API with the client token.
...(endpoint
? { dataProvider: new GraphDataProvider({ endpoint }) }
: { accessToken: source.token }),
container: host,
component: {
cover: false,
+55 -3
View File
@@ -22,13 +22,16 @@ registerHooks({
const source = url.includes('/library?')
? 'const lib = await globalThis.__gevMapillaryLibrary();\n' +
'export const Viewer = lib.Viewer;\n' +
'export const RenderMode = lib.RenderMode;\n'
'export const RenderMode = lib.RenderMode;\n' +
'export const GraphDataProvider = lib.GraphDataProvider;\n'
: 'export default {};\n';
return { format: 'module', shortCircuit: true, source };
},
});
const { createMapillaryViewer } =
const { createMapillaryViewer, graphEndpointUrl } =
await import('./providers/mapillary/viewer.js');
const { createMapillarySource } =
await import('./providers/mapillary/source.js');
/**
* A viewer host over `adapter`. `marker` records where the position marker
@@ -345,7 +348,7 @@ function fakeLibrary() {
}
globalThis.__gevMapillaryLibrary = async () => {
await gate.promise;
return { Viewer, RenderMode: {} };
return { Viewer, RenderMode: {}, GraphDataProvider: FakeGraphDataProvider };
};
return { gate, viewers };
}
@@ -565,3 +568,52 @@ test('a pose’s licence reaches the caption state, and a closed viewer forgets
host.close();
assert.equal(state.street.license, null);
});
/** MapillaryJS 4's Graph provider, recording what it was built with. */
class FakeGraphDataProvider {
constructor(options = {}) {
this.options = options;
}
}
test('without a Graph endpoint the viewer gets the client token, as before', async () => {
const library = fakeLibrary();
library.gate.resolve();
const adapter = createMapillaryViewer({ source: { token: 't' } });
await adapter.mount({});
const [viewer] = library.viewers.instances;
assert.equal(viewer.options.accessToken, 't');
assert.equal(viewer.options.dataProvider, undefined);
});
test("with a Graph endpoint the viewer uses MapillaryJS's Graph provider there, without a token", async () => {
const library = fakeLibrary();
library.gate.resolve();
const source = createMapillarySource({
graphEndpoint: 'https://app.example/api/mapillary/graph',
fetchImpl: async () => {
throw new Error('the viewer does not use the source transport');
},
});
const adapter = createMapillaryViewer({ source });
await adapter.mount({});
const [viewer] = library.viewers.instances;
assert.equal(viewer.options.accessToken, undefined);
const provider = viewer.options.dataProvider;
assert.ok(provider instanceof FakeGraphDataProvider);
assert.deepEqual(provider.options, {
endpoint: 'https://app.example/api/mapillary/graph/',
});
});
test('a relative Graph endpoint resolves against the page, with one trailing slash', () => {
assert.equal(
graphEndpointUrl({ graphEndpoint: '/api/mapillary/graph' }),
'http://localhost/api/mapillary/graph/',
);
assert.equal(
graphEndpointUrl({ graphEndpoint: '/api/mapillary/graph//' }),
'http://localhost/api/mapillary/graph/',
);
assert.equal(graphEndpointUrl({ token: 't' }), '');
});
+28 -1
View File
@@ -20,6 +20,23 @@ export function createAnimation({
parts,
source,
}) {
function syncRenderDemand() {
const needed = Boolean(
layerState._enabled &&
(layerState._dots.length || layerState._heatJamPrim),
);
// Height easing only runs in a held frame. Settle it before releasing, so
// a population emptied mid-correction cannot report loading forever.
if (!needed && layerState._heightPending) parts.retention.finishHeights();
if (needed === Boolean(layerState._renderHeld)) return;
layerState._renderHeld = needed;
if (needed) {
layerState._lastAnimTime = 0;
services.render.holdContinuousRender('traffic');
layerState._viewer?.scene?.requestRender?.();
} else services.render.releaseContinuousRender('traffic');
}
/**
* Spawn animated dot primitives along a single road.
*
@@ -188,6 +205,7 @@ export function createAnimation({
: null,
});
}
syncRenderDemand();
}
/**
@@ -206,6 +224,8 @@ export function createAnimation({
*/
function animate() {
syncRenderDemand();
if (!layerState._renderHeld) return;
const now = Date.now();
// Delta time in seconds, capped to avoid jumps when returning from background tab
const dt = layerState._lastAnimTime
@@ -339,6 +359,13 @@ export function createAnimation({
layerState._count = 0;
layerState._bucketCounts = { free: 0, slow: 0, jam: 0, sim: 0 };
layerState._closedRoads = 0;
syncRenderDemand();
}
return { spawnDotsForRoad, animate, maybeStopLight, clearDots };
return {
spawnDotsForRoad,
animate,
maybeStopLight,
clearDots,
syncRenderDemand,
};
}
+2 -3
View File
@@ -12,7 +12,6 @@ export function createLifecycle({
parts,
source,
}) {
const { holdContinuousRender, releaseContinuousRender } = services.render;
const { resetFlowTileCache } = source;
const methods = {
@@ -81,7 +80,7 @@ export function createLifecycle({
layerState._enabled = true;
layerState._surfaceFrameRemover = observeTrafficSurface(viewer.scene);
if (layerState._roadMode !== 'tomtom') source.prefetch?.();
holdContinuousRender('traffic'); // per-frame animator (perf wave 2)
parts.animation.syncRenderDemand();
layerState._lastAnimTime = 0;
layerState._pointCollection.show = true;
@@ -142,7 +141,7 @@ export function createLifecycle({
disable(viewer) {
layerState._enabled = false;
services.credits?.hideOsmCredit?.(layerState._viewer, 'traffic');
releaseContinuousRender('traffic');
parts.animation.syncRenderDemand();
clearTimeout(layerState._fetchTimeout);
clearInterval(layerState._enableKickTimer);
layerState._enableKickTimer = null;
+39 -1
View File
@@ -37,7 +37,14 @@ function setup(t) {
visibleRoadsForAltitude: (roads) => roads,
},
};
const ctx = { state, services: {}, parts, source: {} };
const ctx = {
state,
services: {
render: { holdContinuousRender() {}, releaseContinuousRender() {} },
},
parts,
source: {},
};
parts.style = createStyle(ctx);
parts.model = createModel(ctx);
parts.animation = createAnimation(ctx);
@@ -558,6 +565,37 @@ test('provisional opposite-direction heights cannot overwrite a measured sibling
assert.notEqual(forward.waypoints, reverse.waypoints);
});
test('a closure that empties the population mid-correction settles heights and clears loading before release', (t) => {
const h = setup(t);
h.parts.retention.reconcile(h.roads(), 450, true);
h.settle();
assert.equal(h.state._renderHeld, true);
const road = h.state._roads[0];
// Republishing the sole road starts a height correction.
const corrected = h.roads(0, 120);
h.parts.retention.reconcile(corrected, 450, true);
assert.equal(h.state._heightPending, true);
assert.equal(road.heightMoving, true);
// The closure arrives before the next population pass, which then removes
// the final points after their fade, long before the correction finishes.
corrected[0].flow = { level: 0, closure: true };
for (let i = 0; i < 20 && h.state._dots.length; i++) h.frame();
assert.equal(h.state._dots.length, 0);
assert.equal(h.state._heatJamPrim, null);
assert.equal(h.state._renderHeld, false);
assert.equal(h.state._heightPending, false);
assert.equal(h.state._populationPending, false);
assert.equal(road.heightMoving, false);
assert.ok(
Math.abs(C.Cartographic.fromCartesian(road.waypoints[0]).height - 120) <
0.01,
);
// Frames driven by other layers stay idle and cannot revive the work.
h.frame();
assert.equal(h.state._heightPending, false);
assert.equal(h.state._renderHeld, false);
});
test('large height corrections obey a screen-pixel bound and reuse projection scratch objects', (t) => {
const h = setup(t);
h.state._viewer.scene = {
+45
View File
@@ -0,0 +1,45 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createAnimation } from './animation.js';
test('traffic render demand follows animated population and jam lines across empty, disable and re-enable', () => {
const calls = [];
const state = { _enabled: true, _dots: [], _motion: { rebuilds: 0 } };
const animation = createAnimation({
state,
services: {
render: {
holdContinuousRender: (id) => calls.push(['hold', id]),
releaseContinuousRender: (id) => calls.push(['release', id]),
},
},
parts: {
retention: { clear() {} },
rendering: {
removeHeatLines() {
state._heatJamPrim = null;
},
},
},
});
animation.syncRenderDemand();
assert.deepEqual(calls, []);
state._dots.push({});
animation.syncRenderDemand();
animation.syncRenderDemand();
assert.deepEqual(calls, [['hold', 'traffic']]);
animation.clearDots();
assert.deepEqual(calls.at(-1), ['release', 'traffic']);
state._heatJamPrim = {};
animation.syncRenderDemand();
assert.deepEqual(calls.at(-1), ['hold', 'traffic']);
state._enabled = false;
animation.syncRenderDemand();
assert.deepEqual(calls.at(-1), ['release', 'traffic']);
state._enabled = true;
animation.clearDots();
assert.equal(state._renderHeld, false);
state._dots.push({});
animation.syncRenderDemand();
assert.equal(state._renderHeld, true);
});
+2
View File
@@ -50,6 +50,7 @@ export function createRendering({
layerState._heatSlowPrim = null;
}
layerState._heatLineCount = 0;
parts.animation.syncRenderDemand?.();
}
/**
@@ -152,6 +153,7 @@ export function createRendering({
}
layerState._heatLineCount = kept.length;
parts.animation.syncRenderDemand?.();
if (candidates.length > kept.length) {
console.log(
`[Data:Traffic] Heat-lines capped at ${HEAT_LINE_CAP} (${candidates.length} congested roads in view)`,
+25 -1
View File
@@ -125,6 +125,7 @@ export function createRetention({ state, parts }) {
count++;
}
state._count = count;
parts.animation.syncRenderDemand?.();
}
function reconcile(roads, height, replace) {
altitude = height;
@@ -241,6 +242,29 @@ export function createRetention({ state, parts }) {
road.heightMoving = moving;
}
}
/**
* Settle every retained road on its corrected heights at once. Easing only
* runs inside the held animation frame, so once nothing is left to animate
* the remaining correction would otherwise stay pending indefinitely.
* Published roads whose record was just dropped are settled too, so a later
* held frame cannot revive their correction.
*/
function finishHeights() {
const roads = new Set(state._roads);
for (const record of records.values()) roads.add(record.road);
for (const road of roads) {
if (!road.heightMoving) continue;
for (let i = 0; i < road.waypoints.length; i++)
Cesium.Cartesian3.clone(road.targetWaypoints[i], road.waypoints[i]);
for (let i = 0; i < road.segmentDist.length; i++)
road.segmentDist[i] = Cesium.Cartesian3.distance(
road.waypoints[i],
road.waypoints[i + 1],
);
road.heightMoving = false;
}
state._heightPending = false;
}
function clear() {
if (timer !== null) {
if (typeof cancelAnimationFrame === 'function')
@@ -252,5 +276,5 @@ export function createRetention({ state, parts }) {
state._populationPending = false;
state._heightPending = false;
}
return { reconcile, easeHeights, clear, refreshCounts };
return { reconcile, easeHeights, finishHeights, clear, refreshCounts };
}
+1 -1
View File
@@ -3574,7 +3574,7 @@ test('actual Traffic, Bikeshare and Transit lifecycles retain sensitivity in eve
source: {},
parts: {
flow: { ensureFlowStatus: noop },
animation: { animate: noop, clearDots: noop },
animation: { animate: noop, clearDots: noop, syncRenderDemand: noop },
viewport: { onCameraChanged: noop },
ingestion: { cancelActiveFetch: noop },
},
+4 -2
View File
@@ -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;
+8 -7
View File
@@ -2,6 +2,7 @@ import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { providerCooldownMs } from '../server/providers/space/cooldown.js';
import { parseTerrainPoints } from './data/terrainHeightsProxy.js';
import { readResponseTextCapped, coalesceProxyRequest } from './sources/httpBody.js';
import { makeRateLimiter, clientKey } from '../server/providers/common/rate-limit.js';
@@ -23,7 +24,7 @@ function extract(name) {
function fixture(name, overrides = {}, preview = false) {
const logs = [];
const deps = {
readResponseTextCapped, coalesceProxyRequest,
readResponseTextCapped, coalesceProxyRequest, providerCooldownMs,
// The rate-limited proxies reach for these; give them the real ones so a
// fixture request is throttled exactly as production would be.
makeRateLimiter, clientKey,
@@ -117,7 +118,7 @@ test('Launch Library retains single-flight, fresh cache, stale fallback, and met
test('CelesTrak unexpected failures hide details', async () => {
const app = fixture('celestrakProxy', { Date: { now() { throw new Error(detail); } } });
const res = await app.request('/active');
const res = await app.request('/stations');
assert.equal(res.status, 500);
assert.equal(res.body, 'celestrak proxy error');
assert.equal(res.headers['x-tle-cache'], 'ERROR');
@@ -126,7 +127,7 @@ test('CelesTrak unexpected failures hide details', async () => {
test('CelesTrak retains invalid-group and unavailable responses', async () => {
const app = fixture('celestrakProxy');
assert.equal((await app.request('/../')).status, 400);
const res = await app.request('/active');
const res = await app.request('/stations');
assert.equal(res.status, 502);
assert.equal(res.headers['x-tle-cache'], 'NONE');
});
@@ -135,13 +136,13 @@ test('CelesTrak retains fresh and stale TLE caches', async () => {
let now = Date.now();
let calls = 0;
const app = fixture('celestrakProxy', {
Date: { now: () => now },
Date: class extends Date { static now() { return now; } },
fetch: async () => { if (++calls > 1) throw new Error(detail); return new Response('1 valid-fixture-TLE'); },
});
assert.equal((await app.request('/active')).headers['x-tle-cache'], 'MISS');
assert.equal((await app.request('/active')).headers['x-tle-cache'], 'HIT');
assert.equal((await app.request('/stations')).headers['x-tle-cache'], 'MISS');
assert.equal((await app.request('/stations')).headers['x-tle-cache'], 'HIT');
now += 7 * 3600_000;
const stale = await app.request('/active');
const stale = await app.request('/stations');
assert.equal(stale.status, 200);
assert.equal(stale.body, '1 valid-fixture-TLE');
assert.equal(stale.headers['x-tle-cache'], 'STALE-ERROR');
+56 -1
View File
@@ -1,6 +1,10 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { MACOS_CHROME, chromeExecutablePath } from '../scripts/qa-chrome.mjs';
import {
MACOS_CHROME,
chromeExecutablePath,
captureQaScreenshot,
} from '../scripts/qa-chrome.mjs';
test('gates keep macOS Chrome where it is installed', () => {
assert.equal(
@@ -25,3 +29,54 @@ test('an explicit executable path wins', () => {
'C:\\Chrome\\chrome.exe',
);
});
test('screenshots restore minimized test windows before capture and detach their session', async () => {
const calls = [];
const session = {
send: async (method, params) => {
calls.push([method, params]);
return { windowId: 7, bounds: { windowState: 'minimized' } };
},
detach: async () => calls.push(['detach']),
};
const page = {
createCDPSession: async () => session,
bringToFront: async () => calls.push(['front']),
screenshot: async (options) => {
calls.push(['capture', options]);
return 'pixels';
},
};
assert.equal(
await captureQaScreenshot(page, { path: 'proof.png' }),
'pixels',
);
assert.deepEqual(
calls.map((c) => c[0]),
[
'Browser.getWindowForTarget',
'Browser.setWindowBounds',
'front',
'detach',
'capture',
],
);
assert.deepEqual(calls[1][1], {
windowId: 7,
bounds: { windowState: 'normal' },
});
});
test('headless capture does not manipulate a native window', async () => {
const options = { path: 'proof.png' };
const page = {
screenshot: async (supplied) => {
assert.equal(supplied, options);
return 'pixels';
},
};
assert.equal(
await captureQaScreenshot(page, options, { headful: false }),
'pixels',
);
});
+29
View File
@@ -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))
+4 -5
View File
@@ -214,11 +214,10 @@ test('construction is inert; adapters preserve routes, viewport query, cache epo
assert.equal(mil.observedAtMs, now - 60000);
assert.equal(mil.records[0].positionTimeMs, now - 75000);
assert.equal(mil.stale, true);
const track = await military.getTrack('abc123');
assert.equal(track.complete, false);
assert.equal(track.records[0].observedAtMs, now - 50000);
assert.equal(track.records[0].baroAltitudeM, 304.8);
assert.equal(track.records[0].ellipsoidAltitudeM, null);
assert.equal(military.getTrack, undefined);
assert.ok(
requests.every(({ url }) => !url.startsWith('/api/military/track')),
);
});
test('cancellation after slow body parsing rejects even with a transport that ignores abort', async () => {
+2 -21
View File
@@ -1,5 +1,4 @@
import {
epoch,
finite,
httpError,
LiveSourceError,
@@ -159,26 +158,8 @@ export function createMilitarySource({
status: response.status,
};
},
async getTrack(reference, { signal } = {}) {
const { response, payload } = await readResponse(
fetchImpl,
'/api/military/track?hex=' + encodeURIComponent(reference),
{ signal },
'Military aircraft',
);
if (!response.ok) throw httpError(response, 'Military aircraft');
const baseTimeMs = epoch(payload?.timestamp, 1000);
return {
records:
baseTimeMs == null
? []
: normalizeAircraftTrack(payload?.trace, {
baseTimeMs,
readsb: true,
}),
complete: false,
};
},
// ADSB.lol does not expose a supported live-history endpoint. Omitting
// optional getTrack keeps the tracking layer on its accumulated trail.
};
}
+11 -7
View File
@@ -11,6 +11,16 @@ export function cleanRadioText(value, maxLength) {
.trim();
}
/** Normalize a station/filter tag and cap its final, case-folded representation. */
export function normalizeRadioTag(value) {
return cleanRadioText(
String(value ?? '')
.toLowerCase()
.replace(/[_-]+/g, ' '),
80,
);
}
export function isNonGlobalIpv4(hostname) {
const pieces = hostname.split('.');
if (pieces.length !== 4 || pieces.some((piece) => !/^\d{1,3}$/.test(piece)))
@@ -99,13 +109,7 @@ export function normalizeRadioBrowserStation(
if (!name) return null;
const tags = String(raw?.tags ?? '')
.split(',')
.map((tag) =>
cleanRadioText(tag, 80)
.toLocaleLowerCase()
.replace(/[_-]+/g, ' ')
.replace(/\s+/g, ' ')
.trim(),
)
.map(normalizeRadioTag)
.filter(Boolean)
.filter((tag, index, all) => all.indexOf(tag) === index)
.slice(0, 24);
+18
View File
@@ -584,6 +584,24 @@ test('track backfill proxy returns 502 on an oversized upstream body and caches
assert.equal(callCount, 1);
});
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');
});
const route = install(providers.trackBackfillProxies());
for (const hex of ['abc123', 'abc456']) {
const response = await route('/api/military/track', '?hex=' + hex);
assert.equal(response.statusCode, 503);
assert.equal(JSON.parse(response.body).code, 'history_unavailable');
assert.equal(response.headers['retry-after'], '1800');
assert.equal(response.headers['cache-control'], 'no-store');
}
assert.equal(
(await route('/api/military/track', '?hex=invalid')).statusCode,
400,
);
});
test('global aircraft snapshots coalesce cold and expired callers and clear rejected flights', async (t) => {
environment(t, { OPENSKY_AUTH_MODE: 'anon' });
t.mock.method(console, 'warn', () => {});
+205 -3
View File
@@ -12,6 +12,7 @@ import {
launchLibraryRecentUrl,
} from 'gods-eye-view/sources/space';
import * as compatibility from '../../server/providers/local.js';
import { createLaunchSource } from 'gods-eye-view/layers/launches/source';
function install(plugin, preview = false) {
const routes = new Map();
@@ -22,17 +23,23 @@ function install(plugin, preview = false) {
},
},
});
return async (route, url = '/', method = 'GET') => {
return async (route, url = '/', method = 'GET', headers = {}) => {
const res = {
headersSent: false,
headers: {},
writeHead(status, headers) {
Object.assign(this, { status, headers, headersSent: true });
},
// The same-site gates answer 403 through statusCode + setHeader.
setHeader(name, value) {
this.headers[name] = value;
},
end(body) {
this.body = body;
this.status ??= this.statusCode;
},
};
await routes.get(route)({ url, method }, res);
await routes.get(route)({ url, method, headers, socket: {} }, res);
return res;
};
}
@@ -161,7 +168,11 @@ test('CelesTrak answers 502 to an oversized TLE body without caching it', async
assert.ok(bodies[0].pulled <= 17, 'reading stops at the 16 MiB cap');
assert.equal(fsp.writeFile.mock.callCount(), 0);
assert.equal((await request('/api/celestrak', '/stations')).status, 502);
assert.equal(bodies.length, 2, 'nothing was cached in memory');
assert.equal(
bodies.length,
1,
'failed bodies are not cached but a cooldown bounds retry work',
);
});
for (const preview of [false, true])
@@ -190,3 +201,194 @@ for (const preview of [false, true])
assert.equal(hit.body, first.body);
assert.equal(calls, 1);
});
test('space providers honor Retry-After, gate groups, preserve stale dates and recover', async (t) => {
isolateDisk(t);
t.mock.method(console, 'warn', () => {});
let now = Date.now();
t.mock.method(Date, 'now', () => now);
for (const kind of ['tle', 'launches']) {
for (const retryAfter of ['90', new Date(now + 90000).toUTCString()]) {
let calls = 0;
let fail = false;
let cancelled = false;
t.mock.method(globalThis, 'fetch', async () => {
calls++;
if (fail)
return new Response(
new ReadableStream({
cancel() {
cancelled = true;
},
}),
{
status: kind === 'tle' ? 403 : 429,
headers: { 'Retry-After': retryAfter },
},
);
return new Response(
kind === 'tle'
? 'ISS\n1 25544U fixture\n2 25544 fixture\n'
: '{"results":[]}',
);
});
const request = install(
kind === 'tle' ? celestrakProxy() : rocketLaunchesProxy(),
);
const route = kind === 'tle' ? '/api/celestrak' : '/api/launches';
const url = kind === 'tle' ? '/stations' : '/';
if (kind === 'tle') {
assert.equal(
(await request(route, '/not-a-supported-group')).status,
400,
);
assert.equal(calls, 0);
}
const first = await request(route, url);
now += 7 * 3600000;
// Recompute the HTTP-date relative to the clock at the failed refresh.
const originalFetch = globalThis.fetch;
t.mock.method(globalThis, 'fetch', async (...args) => {
const response = await originalFetch(...args);
if (fail && !/^\d+$/.test(retryAfter))
response.headers.set(
'Retry-After',
new Date(now + 90000).toUTCString(),
);
return response;
});
fail = true;
const stale = await request(route, url);
assert.equal(stale.status, 200);
assert.equal(stale.body, first.body);
assert.equal(stale.headers['Retry-After'], '90');
assert.ok(
stale.headers[kind === 'tle' ? 'X-TLE-Cached-At' : 'X-GEV-Cached-At'],
);
assert.equal(cancelled, true);
await request(route, url);
if (kind === 'tle')
assert.equal((await request(route, '/visual')).status, 502);
assert.equal(calls, 2, 'cooldown applies across TLE groups');
now += 91000;
fail = false;
assert.equal((await request(route, url)).status, 200);
assert.equal(calls, 3);
}
}
});
test('Space Missions reads the active catalog through the real proxy while unknown groups stay rejected', async (t) => {
isolateDisk(t);
const tle = 'ISS\n1 25544U fixture\n2 25544 fixture\n';
const fetched = [];
t.mock.method(globalThis, 'fetch', async (url) => {
fetched.push(new URL(url).searchParams.get('GROUP'));
return new Response(tle);
});
const request = install(celestrakProxy());
// Route the browser source's real request into the middleware.
const source = createLaunchSource({
fetchImpl: async (url) => {
const prefix = '/api/celestrak';
assert.ok(url.startsWith(`${prefix}/`));
const res = await request(prefix, url.slice(prefix.length));
return new Response(res.body, { status: res.status });
},
});
assert.equal(await source.getActiveTle(), tle);
assert.deepEqual(fetched, ['active']);
for (const group of ['/not-a-group', '/active-extra', '/../active'])
assert.equal((await request('/api/celestrak', group)).status, 400);
assert.deepEqual(fetched, ['active'], 'rejected groups never reach upstream');
});
test('CelesTrak admits a same-origin request behind the documented reverse proxy and refuses foreign origins', async (t) => {
isolateDisk(t);
let calls = 0;
t.mock.method(globalThis, 'fetch', async () => {
calls++;
return new Response('ISS\n1 25544U fixture\n2 25544 fixture\n');
});
const request = install(celestrakProxy());
const proxied = {
host: 'gev.example.test',
'x-forwarded-for': '203.0.113.7',
'x-forwarded-host': 'gev.example.test',
'x-forwarded-proto': 'https',
via: '1.1 proxy',
};
const app = await request('/api/celestrak', '/stations', 'GET', {
...proxied,
origin: 'https://gev.example.test',
'sec-fetch-site': 'same-origin',
});
assert.equal(app.status, 200);
assert.equal(app.headers['x-tle-cache'], 'MISS');
assert.equal(calls, 1);
for (const headers of [
{ ...proxied, origin: 'https://unrelated.example' },
{ ...proxied, origin: 'null' },
{ ...proxied, 'sec-fetch-site': 'cross-site' },
]) {
const refused = await request('/api/celestrak', '/visual', 'GET', headers);
assert.equal(refused.status, 403);
}
assert.equal(calls, 1, 'refused requests do no provider or cache work');
});
for (const longFirst of [true, false])
test(`CelesTrak keeps the longest cooldown when concurrent failures finish ${longFirst ? 'long then short' : 'short then long'}`, async (t) => {
isolateDisk(t);
t.mock.method(console, 'warn', () => {});
let now = Date.now();
t.mock.method(Date, 'now', () => now);
const pending = new Map();
let calls = 0;
t.mock.method(globalThis, 'fetch', async (url) => {
calls++;
const group = new URL(url).searchParams.get('GROUP');
await new Promise((resolve) => pending.set(group, resolve));
return group === 'stations'
? new Response('slow down', {
status: 429,
headers: { 'Retry-After': '1800' },
})
: new Response('unavailable', { status: 503 });
});
const request = install(celestrakProxy());
const long = request('/api/celestrak', '/stations');
const short = request('/api/celestrak', '/visual');
while (pending.size < 2) await new Promise(setImmediate);
const order = longFirst
? [
['stations', long],
['visual', short],
]
: [
['visual', short],
['stations', long],
];
for (const [group, response] of order) {
pending.get(group)();
assert.equal((await response).status, 502);
}
assert.equal(calls, 2);
const cooling = await request('/api/celestrak', '/geo');
assert.equal(cooling.status, 502);
assert.equal(cooling.headers['Retry-After'], '1800');
now += 61_000;
assert.equal((await request('/api/celestrak', '/geo')).status, 502);
assert.equal(
calls,
2,
'the 60 s fallback did not replace the 30 min deadline',
);
now += 1800_000;
t.mock.method(
globalThis,
'fetch',
async () => new Response('ISS\n1 25544U fixture\n2 25544 fixture\n'),
);
assert.equal((await request('/api/celestrak', '/geo')).status, 200);
});
+170
View File
@@ -0,0 +1,170 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
trafficFixtureResponse,
trafficFixtureTile,
} from '../../scripts/traffic-fixtures.mjs';
import { decodeOpenFreeMapTile } from '../sources/openFreeMap.js';
import { isDrivableFlowSegment } from '../layers/traffic/roadModes.js';
import { decodeFlowTile } from '../layers/traffic/flowDecode.js';
test('traffic fixtures provide aligned current road tiles and partial flow coverage', () => {
for (const z of [12, 14]) {
const roads = decodeOpenFreeMapTile(
trafficFixtureTile(z),
z,
935,
1686,
).roads;
const flow = decodeFlowTile(trafficFixtureTile(z, true), z, 935, 1686);
assert.ok(roads.length > flow.length && flow.length > 0);
for (const segment of flow) {
assert.equal(segment.trafficLevel, 0.6);
assert.equal(isDrivableFlowSegment(segment), true);
assert.ok(
roads.some(
(road) =>
JSON.stringify(road.coordinates) === JSON.stringify(segment.coords),
),
);
}
}
});
test('fixture TileJSON and every road/flow request stay on intercepted sources', () => {
const request = (url) => trafficFixtureResponse({ url: () => url });
const tileJson = request('https://tiles.openfreemap.org/planet');
assert.equal(tileJson.headers['access-control-allow-origin'], '*');
const metadata = JSON.parse(tileJson.body);
const tileUrl = metadata.tiles[0]
.replace('{z}', '14')
.replace('{x}', '935')
.replace('{y}', '1686');
assert.ok(
decodeOpenFreeMapTile(request(tileUrl).body, 14, 935, 1686).roads.length,
);
assert.ok(
decodeFlowTile(
request('http://localhost/api/tomtom/flow/12/935/1686.pbf').body,
12,
935,
1686,
).length,
);
assert.throws(
() => request('https://tiles.openfreemap.org/unexpected'),
/Unrecognized/,
);
assert.equal(request('http://localhost/api/unrelated'), null);
});
test('flat terrain fixture decodes through Cesium and advertises no network children', async () => {
const { createServer } = await import('node:http');
const { CesiumTerrainProvider } = await import('cesium');
const server = createServer((req, res) => {
const response = trafficFixtureResponse({
url: () => `https://terrain.reearth.land${req.url}`,
});
res.writeHead(response.status, { 'content-type': response.contentType });
res.end(response.body);
});
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
try {
const provider = await CesiumTerrainProvider.fromUrl(
`http://127.0.0.1:${server.address().port}`,
);
for (const x of [0, 1]) {
const data = await provider.requestTileGeometry(x, 0, 0);
const rect = provider.tilingScheme.tileXYToRectangle(x, 0, 0);
assert.equal(
data.interpolateHeight(rect, (rect.west + rect.east) / 2, 0),
0,
);
for (const dx of [0, 1])
for (const dy of [0, 1]) {
assert.equal(data.isChildAvailable(x, 0, x * 2 + dx, dy), false);
}
}
} finally {
server.closeAllConnections();
await new Promise((resolve) => server.close(resolve));
}
});
test('fixture interception targets provider URLs and leaves app workers alone', async () => {
const { installTrafficRoutes } =
await import('../../scripts/traffic-fixtures.mjs');
const sent = [];
let paused;
const session = {
on: (_, handler) => {
paused = handler;
},
send: async (name, args) => {
sent.push({ name, args });
},
};
await installTrafficRoutes(
{ createCDPSession: async () => session, isClosed: () => false },
{ origin: 'http://localhost:4173', fixtures: true },
);
const patterns = sent[0].args.patterns.map((p) => p.urlPattern);
assert.deepEqual(patterns, [
'http://localhost:4173/api/tomtom/status*',
'http://localhost:4173/api/tomtom/flow/*',
'https://tiles.openfreemap.org/*',
'https://terrain.reearth.land/*',
]);
await paused({
requestId: 'terrain',
request: { url: 'https://terrain.reearth.land/layer.json' },
});
const reply = sent.at(-1);
assert.equal(reply.name, 'Fetch.fulfillRequest');
assert.equal(
JSON.parse(Buffer.from(reply.args.body, 'base64')).format,
'heightmap-1.0',
);
assert.ok(
reply.args.responseHeaders.some(
(h) => h.name === 'access-control-allow-origin' && h.value === '*',
),
);
});
test('keyless interception refuses and records unexpected flow requests', async () => {
const { installTrafficRoutes } =
await import('../../scripts/traffic-fixtures.mjs');
const sent = [],
flows = [];
let paused;
const session = {
on: (_, handler) => {
paused = handler;
},
send: async (name, args) => {
sent.push({ name, args });
},
};
await installTrafficRoutes(
{ createCDPSession: async () => session, isClosed: () => false },
{
origin: 'http://localhost:4173',
keyless: true,
onFlow: (url) => flows.push(url),
},
);
await paused({
requestId: 'status',
request: { url: 'http://localhost:4173/api/tomtom/status' },
});
assert.deepEqual(JSON.parse(Buffer.from(sent.at(-1).args.body, 'base64')), {
hasKey: false,
});
await paused({
requestId: 'flow',
request: { url: 'http://localhost:4173/api/tomtom/flow/12/1/1.pbf' },
});
assert.equal(sent.at(-1).args.responseCode, 503);
assert.equal(flows.length, 1);
});
+13
View File
@@ -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', () => {
+13 -6
View File
@@ -333,13 +333,20 @@ export const getAircraftTrack = defineTool({
requires: ['aircraft'],
async run(args, { services, signal }) {
const icao24 = args.icao24.toLowerCase();
const source = feedFor(services, args.military);
// History is optional: the standalone military source has none.
if (typeof source.getTrack !== 'function')
throw new ToolError(
'unsupported',
args.military
? 'Track history is not available for the military aircraft feed'
: 'Track history is not available for the aircraft feed',
);
// A track server answers 404 for an aircraft it has no positions for.
const track = await feedFor(services, args.military)
.getTrack(icao24, { signal })
.catch((error) => {
if (error?.status === 404) return { records: [] };
throw error;
});
const track = await source.getTrack(icao24, { signal }).catch((error) => {
if (error?.status === 404) return { records: [] };
throw error;
});
const points = [...track.records].sort(
(a, b) => a.observedAtMs - b.observedAtMs,
);
+57
View File
@@ -3,6 +3,8 @@ import test from 'node:test';
import { composeCatalog, coreTools } from '../index.js';
import { airlineDesignator } from './aviation.js';
import { LiveSourceError } from '../../sources/live/contract.js';
import { createMcpServer } from '../mcp/index.js';
import { createToolServices } from '../services.js';
const record = (id, latitude, longitude, extra = {}) => ({
id,
@@ -242,6 +244,61 @@ test('tracks are ordered and thinned to 200 points', async () => {
);
});
test('military track history is unsupported by the default composition and kept for custom sources', async () => {
const requests = [];
const services = createToolServices({
fetchImpl: async (url) => {
requests.push(String(url));
throw new Error('no request expected');
},
appUrl: 'http://localhost:5173',
});
const catalog = composeCatalog({ tools: coreTools, services });
await assert.rejects(
catalog.call('get_aircraft_track', { icao24: 'AE0001', military: true }),
(error) =>
error.name === 'ToolError' &&
error.code === 'unsupported' &&
/military/.test(error.message),
);
const server = createMcpServer({ catalog, name: 'test', version: '1.0.0' });
const { result } = await server.handle({
jsonrpc: '2.0',
id: 1,
method: 'tools/call',
params: {
name: 'get_aircraft_track',
arguments: { icao24: 'ae0001', military: true },
},
});
assert.equal(result.isError, true);
assert.equal(result.structuredContent.error, 'unsupported');
assert.doesNotMatch(result.content[0].text, /try again|later/i);
assert.deepEqual(requests, []);
const track = [
{
latitude: 37.6,
longitude: -122.3,
observedAtMs: Date.UTC(2026, 0, 1),
baroAltitudeM: 3000,
onGround: false,
},
];
const custom = feed(military, { track });
const withHistory = composeCatalog({
tools: coreTools,
services: { aircraft: feed(civil), military: custom },
});
const kept = await withHistory.call('get_aircraft_track', {
icao24: 'AE0001',
military: true,
});
assert.equal(custom.calls[0], 'ae0001');
assert.equal(kept.data.total, 1);
assert.equal(kept.data.view.follow.kind, 'military_aircraft');
});
test('aircraft info combines type and route lookups', async () => {
const aircraft = feed(civil, {
enrichment: {
+31
View File
@@ -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,
+5 -1
View File
@@ -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,
+1
View File
@@ -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,
+120
View File
@@ -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
View File
@@ -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();
});
+3
View File
@@ -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 } =
+5 -1
View File
@@ -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;
+1 -1
View File
@@ -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',
+46 -1
View File
@@ -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/);
});
+48 -2
View File
@@ -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;
+212
View File
@@ -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();
}
});
+5 -1
View File
@@ -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
+26
View File
@@ -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
View File
@@ -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();