mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-12 14:22:21 +00:00
Merge branch 'main' into upstream/nominatim-cache-mode-windows
This commit is contained in:
263 files changed
+32869
-1090
No files matched your search
@@ -2,7 +2,20 @@ import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { createApplication } from './application.js';
|
||||
import { installTrackpadPinchZoom } from './viewer.js';
|
||||
import { KEYHOLE_OUTER_RADIUS } from '../celestialRing.js';
|
||||
import * as Cesium from 'cesium';
|
||||
import {
|
||||
installFreeLookHeightLimit,
|
||||
installGlobeInViewRecovery,
|
||||
installTrackpadPinchZoom,
|
||||
} from './viewer.js';
|
||||
import {
|
||||
FREE_LOOK_MAX_HEIGHT_METERS,
|
||||
globeFrameShare,
|
||||
globeOutOfFrame,
|
||||
lookAngleWithinFrame,
|
||||
lookStepLosesGlobe,
|
||||
} from '../globeInView.js';
|
||||
|
||||
const phases = ['Scene', 'Controls', 'Data', 'Tools'];
|
||||
function fixture(overrides = {}) {
|
||||
@@ -343,3 +356,652 @@ test('trackpad pinch falls back to the original event when relay construction fa
|
||||
assert.equal(event.propagationStopped, false);
|
||||
assert.equal(f.relayed.length, 0);
|
||||
});
|
||||
|
||||
function freeLookFixture({ enableLook = true, height = 0 } = {}) {
|
||||
const listeners = new Set();
|
||||
const controller = { enableLook, minimumTrackBallHeight: 7_500_000 };
|
||||
const camera = { positionCartographic: { height } };
|
||||
const viewer = {
|
||||
camera,
|
||||
scene: {
|
||||
screenSpaceCameraController: controller,
|
||||
preUpdate: {
|
||||
addEventListener(listener) {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const frame = (nextHeight) => {
|
||||
camera.positionCartographic = { height: nextHeight };
|
||||
for (const listener of listeners) listener();
|
||||
};
|
||||
return { viewer, controller, listeners, frame };
|
||||
}
|
||||
|
||||
test('free look follows camera height and is restored on dispose', () => {
|
||||
const f = freeLookFixture({ height: 500 });
|
||||
const dispose = installFreeLookHeightLimit(f.viewer);
|
||||
assert.equal(f.controller.enableLook, true);
|
||||
|
||||
f.frame(FREE_LOOK_MAX_HEIGHT_METERS + 1);
|
||||
assert.equal(f.controller.enableLook, false);
|
||||
f.frame(FREE_LOOK_MAX_HEIGHT_METERS);
|
||||
assert.equal(f.controller.enableLook, true);
|
||||
f.frame(Number.NaN);
|
||||
assert.equal(f.controller.enableLook, true);
|
||||
|
||||
f.frame(FREE_LOOK_MAX_HEIGHT_METERS * 20);
|
||||
dispose();
|
||||
dispose();
|
||||
assert.equal(f.controller.enableLook, true);
|
||||
assert.equal(f.listeners.size, 0);
|
||||
});
|
||||
|
||||
test('drags that start on the stars orbit above the free-look height', () => {
|
||||
// Below Cesium's trackball height such a drag turns the camera in place.
|
||||
const f = freeLookFixture({ height: 500 });
|
||||
const dispose = installFreeLookHeightLimit(f.viewer);
|
||||
assert.equal(
|
||||
f.controller.minimumTrackBallHeight,
|
||||
FREE_LOOK_MAX_HEIGHT_METERS,
|
||||
);
|
||||
dispose();
|
||||
assert.equal(f.controller.minimumTrackBallHeight, 7_500_000);
|
||||
|
||||
const lower = freeLookFixture({ height: 500 });
|
||||
lower.controller.minimumTrackBallHeight = 500_000;
|
||||
const disposeLower = installFreeLookHeightLimit(lower.viewer);
|
||||
assert.equal(
|
||||
lower.controller.minimumTrackBallHeight,
|
||||
500_000,
|
||||
'never raised',
|
||||
);
|
||||
disposeLower();
|
||||
assert.equal(lower.controller.minimumTrackBallHeight, 500_000);
|
||||
|
||||
const later = freeLookFixture({ height: 500 });
|
||||
const disposeLater = installFreeLookHeightLimit(later.viewer);
|
||||
later.controller.minimumTrackBallHeight = 2_000_000;
|
||||
disposeLater();
|
||||
assert.equal(
|
||||
later.controller.minimumTrackBallHeight,
|
||||
2_000_000,
|
||||
'dispose keeps a value set after install',
|
||||
);
|
||||
});
|
||||
|
||||
test('free look keeps a setting other code makes after it is installed', () => {
|
||||
const f = freeLookFixture({ height: 500 });
|
||||
const dispose = installFreeLookHeightLimit(f.viewer);
|
||||
f.controller.enableLook = false;
|
||||
f.frame(10);
|
||||
assert.equal(f.controller.enableLook, false, 'turned off stays off');
|
||||
f.frame(FREE_LOOK_MAX_HEIGHT_METERS * 20);
|
||||
f.frame(10);
|
||||
assert.equal(f.controller.enableLook, false, 'and stays off after orbit');
|
||||
f.controller.enableLook = true;
|
||||
f.frame(10);
|
||||
assert.equal(f.controller.enableLook, true, 'turned back on');
|
||||
f.frame(FREE_LOOK_MAX_HEIGHT_METERS * 20);
|
||||
assert.equal(f.controller.enableLook, false, 'still limited from orbit');
|
||||
f.frame(10);
|
||||
f.controller.enableLook = false;
|
||||
dispose();
|
||||
assert.equal(f.controller.enableLook, false, 'dispose keeps a later setting');
|
||||
});
|
||||
|
||||
test('free look stays off when the viewer had it disabled', () => {
|
||||
const f = freeLookFixture({ enableLook: false, height: 500 });
|
||||
installFreeLookHeightLimit(f.viewer);
|
||||
f.frame(10);
|
||||
assert.equal(f.controller.enableLook, false);
|
||||
});
|
||||
|
||||
// A real Cesium camera on the smallest scene its constructor reads, so the
|
||||
// frustum test runs on Cesium's own view math rather than a stub.
|
||||
function realCamera({ width = 1600, height = 900, mode } = {}) {
|
||||
const scene = {
|
||||
canvas: { clientWidth: width, clientHeight: height },
|
||||
drawingBufferWidth: width,
|
||||
drawingBufferHeight: height,
|
||||
mapProjection: new Cesium.GeographicProjection(),
|
||||
mode: mode ?? Cesium.SceneMode.SCENE3D,
|
||||
pixelRatio: 1,
|
||||
ellipsoid: Cesium.Ellipsoid.WGS84,
|
||||
mapMode2D: Cesium.MapMode2D.INFINITE_SCROLL,
|
||||
tweens: new Cesium.TweenCollection(),
|
||||
renders: 0,
|
||||
requestRender() {
|
||||
this.renders += 1;
|
||||
},
|
||||
};
|
||||
const camera = new Cesium.Camera(scene);
|
||||
camera.frustum.aspectRatio = width / height;
|
||||
return { camera, scene, viewer: { camera, scene } };
|
||||
}
|
||||
|
||||
function aim(
|
||||
camera,
|
||||
{ lon = -94.69, lat = 18.87, alt, heading = 0, pitch, roll = 0 },
|
||||
) {
|
||||
camera.setView({
|
||||
destination: Cesium.Cartesian3.fromDegrees(lon, lat, alt),
|
||||
orientation: {
|
||||
heading: Cesium.Math.toRadians(heading),
|
||||
pitch: Cesium.Math.toRadians(pitch),
|
||||
roll: Cesium.Math.toRadians(roll),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const reportedStuckView = {
|
||||
alt: 17_996_811,
|
||||
heading: 180,
|
||||
pitch: -42,
|
||||
roll: 180,
|
||||
};
|
||||
// Oblique poses that still show part of the planet: Director recipe shots
|
||||
// (Global Flights Radar, Omniscience Pullback) and a voice tilt from orbit.
|
||||
const partialGlobeViews = [
|
||||
{ lon: -30, lat: 20, alt: 19_000_000, pitch: -65 },
|
||||
{ lon: 2, lat: 46, alt: 8_000_000, pitch: -52 },
|
||||
{ lon: 110, lat: 20, alt: 9_500_000, pitch: -66 },
|
||||
{ lon: 0, lat: 0, alt: 2_000_000, pitch: -30 },
|
||||
];
|
||||
|
||||
// Stars and a few pixels of limb, no surface: where a frame limit measured
|
||||
// against a sphere of the planet's largest radius used to stop a voice tilt.
|
||||
const limbOnlyView = { alt: 18_000_000, pitch: -56.75 };
|
||||
|
||||
/**
|
||||
* An oracle independent of the code under test: the share of an 80×45 grid of
|
||||
* Cesium pick rays across the canvas that hit the ellipsoid surface.
|
||||
*/
|
||||
function surfacePixelShare(camera, width, height, { keyhole = false } = {}) {
|
||||
const radius = (KEYHOLE_OUTER_RADIUS * height) / 2;
|
||||
let hits = 0;
|
||||
let counted = 0;
|
||||
for (let row = 0; row < 45; row += 1) {
|
||||
for (let column = 0; column < 80; column += 1) {
|
||||
const x = ((column + 0.5) * width) / 80;
|
||||
const y = ((row + 0.5) * height) / 45;
|
||||
if (keyhole && Math.hypot(x - width / 2, y - height / 2) > radius)
|
||||
continue;
|
||||
counted += 1;
|
||||
const ray = camera.getPickRay(new Cesium.Cartesian2(x, y));
|
||||
if (Cesium.IntersectionTests.rayEllipsoid(ray, Cesium.Ellipsoid.WGS84))
|
||||
hits += 1;
|
||||
}
|
||||
}
|
||||
return hits / counted;
|
||||
}
|
||||
|
||||
test('the globe is out of frame only when no surface is on screen', () => {
|
||||
const cases = [
|
||||
[reportedStuckView, 1225, 923, true],
|
||||
[limbOnlyView, 1600, 900, true],
|
||||
[{ alt: 17_996_811, pitch: -90 }, 1225, 923, false],
|
||||
...partialGlobeViews.map((view) => [view, 1600, 900, false]),
|
||||
];
|
||||
for (const [view, width, height, out] of cases) {
|
||||
const { camera } = realCamera({ width, height });
|
||||
aim(camera, view);
|
||||
const label = JSON.stringify(view);
|
||||
assert.equal(globeOutOfFrame(camera), out, label);
|
||||
assert.equal(surfacePixelShare(camera, width, height) === 0, out, label);
|
||||
assert.ok(
|
||||
Math.abs(
|
||||
globeFrameShare(camera) - surfacePixelShare(camera, width, height),
|
||||
) < 0.03,
|
||||
`${label}: the frame share matches the pick-ray oracle`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('a move that ends with the globe out of frame turns back to nadir', () => {
|
||||
const { camera, scene, viewer } = realCamera({ width: 1225, height: 923 });
|
||||
const dispose = installGlobeInViewRecovery(viewer);
|
||||
aim(camera, reportedStuckView);
|
||||
const position = Cesium.Cartesian3.clone(camera.positionWC);
|
||||
const heading = camera.heading;
|
||||
camera.moveEnd.raiseEvent();
|
||||
assert.ok(Cesium.Math.equalsEpsilon(camera.pitch, -Math.PI / 2, 1e-6));
|
||||
assert.ok(Cesium.Math.equalsEpsilon(camera.heading, heading, 1e-6));
|
||||
assert.ok(
|
||||
Cesium.Cartesian3.equalsEpsilon(camera.positionWC, position, 0, 1e-3),
|
||||
);
|
||||
assert.equal(globeOutOfFrame(camera), false);
|
||||
assert.equal(scene.renders, 1);
|
||||
|
||||
// Looking up near the ground, then zoomed out past the limit.
|
||||
aim(camera, { alt: 3_834_000, pitch: 10 });
|
||||
camera.moveEnd.raiseEvent();
|
||||
assert.ok(Cesium.Math.equalsEpsilon(camera.pitch, -Math.PI / 2, 1e-6));
|
||||
|
||||
// Only the limb on a 16:9 screen still counts as lost.
|
||||
const wide = realCamera({ width: 1600, height: 900 });
|
||||
const disposeWide = installGlobeInViewRecovery(wide.viewer);
|
||||
aim(wide.camera, limbOnlyView);
|
||||
wide.camera.moveEnd.raiseEvent();
|
||||
assert.ok(Cesium.Math.equalsEpsilon(wide.camera.pitch, -Math.PI / 2, 1e-6));
|
||||
disposeWide();
|
||||
|
||||
dispose();
|
||||
dispose();
|
||||
aim(camera, reportedStuckView);
|
||||
camera.moveEnd.raiseEvent();
|
||||
assert.equal(globeOutOfFrame(camera), true, 'disposed recovery is inert');
|
||||
});
|
||||
|
||||
// True when a moveEnd leaves the camera's view direction as it was.
|
||||
function moveEndKeepsView(camera) {
|
||||
const direction = Cesium.Cartesian3.clone(camera.directionWC);
|
||||
camera.moveEnd.raiseEvent();
|
||||
return Cesium.Cartesian3.equalsEpsilon(camera.directionWC, direction, 1e-9);
|
||||
}
|
||||
|
||||
test('globe recovery leaves partial, low, locked and 2D views alone', () => {
|
||||
for (const view of partialGlobeViews) {
|
||||
const { camera, viewer } = realCamera();
|
||||
installGlobeInViewRecovery(viewer);
|
||||
aim(camera, view);
|
||||
assert.ok(moveEndKeepsView(camera), JSON.stringify(view));
|
||||
}
|
||||
|
||||
const low = realCamera();
|
||||
installGlobeInViewRecovery(low.viewer);
|
||||
aim(low.camera, { alt: 900_000, pitch: 85 });
|
||||
assert.equal(globeOutOfFrame(low.camera), true);
|
||||
assert.ok(moveEndKeepsView(low.camera), 'below the limit');
|
||||
|
||||
// Locked on a point in space, between it and the planet, facing away.
|
||||
const locked = realCamera();
|
||||
installGlobeInViewRecovery(locked.viewer);
|
||||
locked.camera.lookAtTransform(
|
||||
Cesium.Matrix4.fromTranslation(new Cesium.Cartesian3(30_000_000, 0, 0)),
|
||||
new Cesium.Cartesian3(-1_000_000, 0, 0),
|
||||
);
|
||||
assert.equal(globeOutOfFrame(locked.camera), true);
|
||||
assert.ok(moveEndKeepsView(locked.camera), 'locked camera');
|
||||
|
||||
// Tracking an entity that has no position yet keeps an identity transform.
|
||||
const tracked = realCamera();
|
||||
tracked.viewer.trackedEntity = { id: 'pending-fix' };
|
||||
installGlobeInViewRecovery(tracked.viewer);
|
||||
aim(tracked.camera, reportedStuckView);
|
||||
assert.ok(moveEndKeepsView(tracked.camera), 'tracked camera');
|
||||
|
||||
const flat = realCamera({ mode: Cesium.SceneMode.COLUMBUS_VIEW });
|
||||
installGlobeInViewRecovery(flat.viewer);
|
||||
flat.scene.mode = Cesium.SceneMode.SCENE3D;
|
||||
aim(flat.camera, reportedStuckView);
|
||||
flat.scene.mode = Cesium.SceneMode.COLUMBUS_VIEW;
|
||||
assert.ok(moveEndKeepsView(flat.camera), 'Columbus view');
|
||||
});
|
||||
|
||||
test('the look limit from orbit tolerates grid noise and never reaches zero', () => {
|
||||
const radians = Cesium.Math.toRadians;
|
||||
// About 2% of a 16:9 frame on the planet, below the 10% limit.
|
||||
const sliver = { alt: 18_000_000, pitch: -60 };
|
||||
const { camera } = realCamera();
|
||||
aim(camera, sliver);
|
||||
const start = surfacePixelShare(camera, 1600, 900);
|
||||
assert.ok(start > 0 && start < 0.1, `starts below the limit: ${start}`);
|
||||
|
||||
// Sideways across the sliver: the share wobbles by a cell, the planet stays.
|
||||
assert.equal(lookStepLosesGlobe(camera, 'lookLeft', radians(0.5)), false);
|
||||
const startShare = globeFrameShare(camera);
|
||||
for (let i = 0; i < 20; i += 1) {
|
||||
const step = lookAngleWithinFrame(camera, 'lookLeft', radians(0.25), {
|
||||
startShare,
|
||||
});
|
||||
assert.equal(step, radians(0.25), `sideways step ${i + 1}`);
|
||||
camera.lookLeft(step);
|
||||
}
|
||||
assert.ok(surfacePixelShare(camera, 1600, 900) > 0);
|
||||
|
||||
// Toward the planet is always allowed.
|
||||
aim(camera, sliver);
|
||||
assert.equal(
|
||||
lookAngleWithinFrame(camera, 'lookDown', radians(10)),
|
||||
radians(10),
|
||||
);
|
||||
|
||||
// Away from it stops short of losing it.
|
||||
aim(camera, sliver);
|
||||
const away = lookAngleWithinFrame(camera, 'lookUp', radians(30));
|
||||
assert.ok(away < radians(30));
|
||||
camera.lookUp(away);
|
||||
assert.ok(globeFrameShare(camera) > 0);
|
||||
assert.ok(surfacePixelShare(camera, 1600, 900) > 0);
|
||||
});
|
||||
|
||||
test('the look limit leaves a view with no surface to the move-end recovery', () => {
|
||||
const { camera } = realCamera({ width: 1225, height: 923 });
|
||||
aim(camera, reportedStuckView);
|
||||
assert.equal(
|
||||
lookAngleWithinFrame(camera, 'lookUp', Cesium.Math.toRadians(5)),
|
||||
Cesium.Math.toRadians(5),
|
||||
);
|
||||
});
|
||||
|
||||
// Surface only in the corners of a 16:9 frame, all of it outside the scope's
|
||||
// keyhole: the full frame shows some planet, the scope shows none.
|
||||
const cornerOnlyView = { alt: 18_000_000, pitch: -52, roll: 60 };
|
||||
|
||||
test('with a scope keyhole, surface hidden in the masked corners counts as none', () => {
|
||||
const { camera } = realCamera();
|
||||
aim(camera, cornerOnlyView);
|
||||
const keyhole = { keyholeRadius: KEYHOLE_OUTER_RADIUS };
|
||||
assert.ok(surfacePixelShare(camera, 1600, 900) > 0);
|
||||
assert.equal(surfacePixelShare(camera, 1600, 900, { keyhole: true }), 0);
|
||||
assert.equal(globeOutOfFrame(camera), false);
|
||||
assert.equal(globeOutOfFrame(camera, keyhole), true);
|
||||
aim(camera, { alt: 18_000_000, pitch: -90 });
|
||||
assert.ok(
|
||||
Math.abs(
|
||||
globeFrameShare(camera, keyhole) -
|
||||
surfacePixelShare(camera, 1600, 900, { keyhole: true }),
|
||||
) < 0.03,
|
||||
'the keyhole share matches the pick-ray oracle',
|
||||
);
|
||||
});
|
||||
|
||||
test('the recovery follows the scope: corner-only views are turned back only behind it', () => {
|
||||
for (const [scopeOn, expectRecovery] of [
|
||||
[true, true],
|
||||
[false, false],
|
||||
]) {
|
||||
const { camera, viewer } = realCamera();
|
||||
installGlobeInViewRecovery(viewer, {
|
||||
keyholeRadius: () => (scopeOn ? KEYHOLE_OUTER_RADIUS : 0),
|
||||
});
|
||||
aim(camera, cornerOnlyView);
|
||||
camera.moveEnd.raiseEvent();
|
||||
assert.equal(
|
||||
Cesium.Math.equalsEpsilon(camera.pitch, -Math.PI / 2, 1e-6),
|
||||
expectRecovery,
|
||||
`scope ${scopeOn ? 'on' : 'off'}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('the recovery leaves a camera flight and an authored scene alone', () => {
|
||||
let playing = true;
|
||||
const { camera, scene, viewer } = realCamera({ width: 1225, height: 923 });
|
||||
scene.camera = camera;
|
||||
scene.screenSpaceCameraController = { enableInputs: true };
|
||||
installGlobeInViewRecovery(viewer, { paused: () => playing });
|
||||
aim(camera, reportedStuckView);
|
||||
camera.moveEnd.raiseEvent();
|
||||
assert.equal(globeOutOfFrame(camera), true, 'a playing scene is left alone');
|
||||
playing = false;
|
||||
|
||||
// A flight that pauses looking away is left alone until it ends.
|
||||
camera.flyTo({
|
||||
destination: Cesium.Cartesian3.fromDegrees(10, 20, 18_000_000),
|
||||
duration: 5,
|
||||
});
|
||||
camera.moveEnd.raiseEvent();
|
||||
assert.equal(globeOutOfFrame(camera), true, 'a flight is left alone');
|
||||
camera.cancelFlight();
|
||||
camera.moveEnd.raiseEvent();
|
||||
assert.ok(Cesium.Math.equalsEpsilon(camera.pitch, -Math.PI / 2, 1e-6));
|
||||
});
|
||||
|
||||
test('a check a scene or flight defers runs once it ends', () => {
|
||||
for (const viaMoveEnd of [false, true]) {
|
||||
let playing = true;
|
||||
const timers = fakeTimers();
|
||||
const { camera, viewer } = realCamera({ width: 1225, height: 923 });
|
||||
installGlobeInViewRecovery(viewer, {
|
||||
paused: () => playing,
|
||||
...timers,
|
||||
});
|
||||
aim(camera, reportedStuckView);
|
||||
camera.moveStart.raiseEvent();
|
||||
// A hold on a stars-only shot: quiet, with or without Cesium's moveEnd.
|
||||
for (let i = 0; i < 4; i += 1) timers.tick();
|
||||
if (viaMoveEnd) camera.moveEnd.raiseEvent();
|
||||
// Longer than the settle check's five-minute bound.
|
||||
for (let i = 0; i < 1500; i += 1) timers.tick();
|
||||
assert.equal(globeOutOfFrame(camera), true, 'left alone while it plays');
|
||||
assert.equal(timers.pending(), 1, 'still watching');
|
||||
playing = false;
|
||||
timers.tick();
|
||||
assert.ok(
|
||||
Cesium.Math.equalsEpsilon(camera.pitch, -Math.PI / 2, 1e-6),
|
||||
`recovered once the scene ends (${viaMoveEnd ? 'after moveEnd' : 'no moveEnd'})`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// Holding a drag still for half a second on a view that already showed no
|
||||
// surface looked like a settled camera, and the view was turned down under
|
||||
// the user's hand.
|
||||
test('the recovery waits while a pointer is held on the globe', () => {
|
||||
const timers = fakeTimers();
|
||||
const { camera, scene, viewer } = realCamera({ width: 1225, height: 923 });
|
||||
const document = new EventTarget();
|
||||
scene.canvas = Object.assign(new EventTarget(), {
|
||||
clientWidth: 1225,
|
||||
clientHeight: 923,
|
||||
ownerDocument: document,
|
||||
});
|
||||
const dispose = installGlobeInViewRecovery(viewer, timers);
|
||||
aim(camera, reportedStuckView);
|
||||
scene.canvas.dispatchEvent(new Event('pointerdown'));
|
||||
camera.moveStart.raiseEvent();
|
||||
for (let i = 0; i < 8; i += 1) timers.tick();
|
||||
camera.moveEnd.raiseEvent();
|
||||
for (let i = 0; i < 8; i += 1) timers.tick();
|
||||
assert.equal(globeOutOfFrame(camera), true, 'left alone while held');
|
||||
document.dispatchEvent(new Event('pointerup'));
|
||||
for (let i = 0; i < 3; i += 1) timers.tick();
|
||||
assert.ok(
|
||||
Cesium.Math.equalsEpsilon(camera.pitch, -Math.PI / 2, 1e-6),
|
||||
'recovered once the pointer is released',
|
||||
);
|
||||
dispose();
|
||||
aim(camera, reportedStuckView);
|
||||
scene.canvas.dispatchEvent(new Event('pointerdown'));
|
||||
document.dispatchEvent(new Event('pointerup'));
|
||||
assert.equal(timers.pending(), 0, 'disposed recovery holds no listeners');
|
||||
});
|
||||
|
||||
/** A held-pointer recovery over a canvas in a document in a window. */
|
||||
function heldPointerRecovery() {
|
||||
const timers = fakeTimers();
|
||||
const { camera, scene, viewer } = realCamera({ width: 1225, height: 923 });
|
||||
const window = new EventTarget();
|
||||
const document = Object.assign(new EventTarget(), {
|
||||
defaultView: window,
|
||||
visibilityState: 'visible',
|
||||
});
|
||||
scene.canvas = Object.assign(new EventTarget(), {
|
||||
clientWidth: 1225,
|
||||
clientHeight: 923,
|
||||
ownerDocument: document,
|
||||
});
|
||||
const dispose = installGlobeInViewRecovery(viewer, timers);
|
||||
const pointer = (target, type, pointerId, init = {}) =>
|
||||
target.dispatchEvent(
|
||||
Object.assign(new Event(type), { pointerId, ...init }),
|
||||
);
|
||||
/** Press, move and hold still: the check defers while a pointer is held. */
|
||||
const holdStill = () => {
|
||||
aim(camera, reportedStuckView);
|
||||
camera.moveStart.raiseEvent();
|
||||
for (let i = 0; i < 8; i += 1) timers.tick();
|
||||
camera.moveEnd.raiseEvent();
|
||||
for (let i = 0; i < 8; i += 1) timers.tick();
|
||||
};
|
||||
const settle = () => {
|
||||
for (let i = 0; i < 3; i += 1) timers.tick();
|
||||
};
|
||||
const recovered = () =>
|
||||
Cesium.Math.equalsEpsilon(camera.pitch, -Math.PI / 2, 1e-6);
|
||||
return {
|
||||
camera,
|
||||
document,
|
||||
window,
|
||||
dispose,
|
||||
pointer,
|
||||
holdStill,
|
||||
settle,
|
||||
recovered,
|
||||
canvas: scene.canvas,
|
||||
};
|
||||
}
|
||||
|
||||
// Releasing one of two fingers cleared the held flag, and the recovery
|
||||
// turned the view down under the finger still on the glass.
|
||||
test('the recovery waits until every held pointer is released', () => {
|
||||
const h = heldPointerRecovery();
|
||||
h.pointer(h.canvas, 'pointerdown', 1);
|
||||
h.pointer(h.canvas, 'pointerdown', 2);
|
||||
h.holdStill();
|
||||
h.pointer(h.document, 'pointerup', 1);
|
||||
h.settle();
|
||||
assert.equal(h.recovered(), false, 'left alone under the second finger');
|
||||
assert.equal(globeOutOfFrame(h.camera), true);
|
||||
h.pointer(h.document, 'pointercancel', 2);
|
||||
h.settle();
|
||||
assert.equal(h.recovered(), true, 'recovered once both are released');
|
||||
h.dispose();
|
||||
});
|
||||
|
||||
// A release the page never sees (the pointer let go outside the window)
|
||||
// must not hold the recovery off for good.
|
||||
for (const [label, lose] of [
|
||||
['the window loses focus', (h) => h.window.dispatchEvent(new Event('blur'))],
|
||||
[
|
||||
'the page is hidden',
|
||||
(h) => {
|
||||
h.document.visibilityState = 'hidden';
|
||||
h.document.dispatchEvent(new Event('visibilitychange'));
|
||||
},
|
||||
],
|
||||
[
|
||||
'the canvas loses the pointer capture',
|
||||
(h) => h.pointer(h.canvas, 'lostpointercapture', 1),
|
||||
],
|
||||
[
|
||||
'the pointer moves with no button down',
|
||||
(h) => h.pointer(h.document, 'pointermove', 1, { buttons: 0 }),
|
||||
],
|
||||
]) {
|
||||
test(`a held pointer whose release is lost stops deferring when ${label}`, () => {
|
||||
const h = heldPointerRecovery();
|
||||
h.pointer(h.canvas, 'pointerdown', 1);
|
||||
h.holdStill();
|
||||
h.pointer(h.document, 'pointermove', 1, { buttons: 1 });
|
||||
h.settle();
|
||||
assert.equal(h.recovered(), false, 'left alone while held');
|
||||
lose(h);
|
||||
h.settle();
|
||||
assert.equal(h.recovered(), true, 'recovered once the hold is let go');
|
||||
h.dispose();
|
||||
});
|
||||
}
|
||||
|
||||
function fakeTimers() {
|
||||
let next = 1;
|
||||
const pending = new Map();
|
||||
return {
|
||||
setTimeout: (callback) => {
|
||||
const id = next++;
|
||||
pending.set(id, callback);
|
||||
return id;
|
||||
},
|
||||
clearTimeout: (id) => pending.delete(id),
|
||||
pending: () => pending.size,
|
||||
/** Run the earliest pending callback, as 250 ms passing would. */
|
||||
tick() {
|
||||
const [id, callback] = pending.entries().next().value ?? [];
|
||||
if (id === undefined) return false;
|
||||
pending.delete(id);
|
||||
callback();
|
||||
return true;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('a stuck view is recovered once it has held still, without waiting for moveEnd', () => {
|
||||
// At this pose the camera can drift by a rounding error every frame, so
|
||||
// Cesium never sees it stop and never raises moveEnd for it.
|
||||
const timers = fakeTimers();
|
||||
const { camera, viewer } = realCamera({ width: 1225, height: 923 });
|
||||
installGlobeInViewRecovery(viewer, timers);
|
||||
aim(camera, reportedStuckView);
|
||||
camera.moveStart.raiseEvent();
|
||||
timers.tick();
|
||||
assert.equal(globeOutOfFrame(camera), true, 'one quiet sample is not enough');
|
||||
// A rounding-error drift between samples still counts as at rest.
|
||||
camera.lookUp(1e-15);
|
||||
timers.tick();
|
||||
assert.ok(Cesium.Math.equalsEpsilon(camera.pitch, -Math.PI / 2, 1e-6));
|
||||
// Under that drift Cesium raises no new moveStart, so a later move that
|
||||
// ends looking away is caught by the same check.
|
||||
aim(camera, reportedStuckView);
|
||||
timers.tick();
|
||||
timers.tick();
|
||||
assert.equal(globeOutOfFrame(camera), true, 'not yet at rest');
|
||||
timers.tick();
|
||||
assert.ok(Cesium.Math.equalsEpsilon(camera.pitch, -Math.PI / 2, 1e-6));
|
||||
camera.moveEnd.raiseEvent();
|
||||
assert.equal(timers.pending(), 0, 'moveEnd ends the check');
|
||||
});
|
||||
|
||||
test('the settle check follows a long move and gives up five minutes after the last one', () => {
|
||||
// A drag of half a minute that stops at a stuck pose is still recovered.
|
||||
const long = fakeTimers();
|
||||
const dragged = realCamera({ width: 1225, height: 923 });
|
||||
installGlobeInViewRecovery(dragged.viewer, long);
|
||||
aim(dragged.camera, reportedStuckView);
|
||||
dragged.camera.moveStart.raiseEvent();
|
||||
for (let i = 0; i < 120; i += 1) {
|
||||
dragged.camera.moveLeft(0.01);
|
||||
long.tick();
|
||||
}
|
||||
assert.equal(globeOutOfFrame(dragged.camera), true, 'still moving');
|
||||
while (long.tick());
|
||||
assert.ok(
|
||||
Cesium.Math.equalsEpsilon(dragged.camera.pitch, -Math.PI / 2, 1e-6),
|
||||
'recovered once it stopped',
|
||||
);
|
||||
|
||||
// A camera that keeps moving is followed, and left alone, for as long as it
|
||||
// moves; once it rests the check gives up five minutes later.
|
||||
const timers = fakeTimers();
|
||||
const { camera, viewer } = realCamera({ width: 1225, height: 923 });
|
||||
installGlobeInViewRecovery(viewer, timers);
|
||||
aim(camera, reportedStuckView);
|
||||
camera.moveStart.raiseEvent();
|
||||
for (let i = 0; i < 2000; i += 1) {
|
||||
camera.moveLeft(0.01);
|
||||
timers.tick();
|
||||
}
|
||||
assert.equal(timers.pending(), 1, 'still following after eight minutes');
|
||||
assert.equal(globeOutOfFrame(camera), true, 'a moving camera is left alone');
|
||||
let idleSamples = 0;
|
||||
while (timers.tick()) idleSamples += 1;
|
||||
assert.ok(
|
||||
Cesium.Math.equalsEpsilon(camera.pitch, -Math.PI / 2, 1e-6),
|
||||
'recovered once it stopped',
|
||||
);
|
||||
assert.ok(
|
||||
idleSamples >= 1200 && idleSamples <= 1205,
|
||||
`gave up ${idleSamples} samples after it stopped`,
|
||||
);
|
||||
|
||||
const disposed = fakeTimers();
|
||||
const other = realCamera({ width: 1225, height: 923 });
|
||||
const dispose = installGlobeInViewRecovery(other.viewer, disposed);
|
||||
aim(other.camera, reportedStuckView);
|
||||
other.camera.moveStart.raiseEvent();
|
||||
dispose();
|
||||
assert.equal(disposed.pending(), 0, 'dispose cancels the pending check');
|
||||
});
|
||||
@@ -5,6 +5,7 @@ const CONTROL_LAYER_IDS = Object.freeze({
|
||||
satellitesLayer: 'satellites',
|
||||
cctvLayer: 'cctv',
|
||||
streetLevelLayer: 'street-level',
|
||||
wildlifeSightingsLayer: 'wildlife-sightings',
|
||||
radioLayer: 'radio',
|
||||
bikeshareLayer: 'bikeshare',
|
||||
transitLayer: 'transit',
|
||||
|
||||
@@ -139,6 +139,7 @@ test('controls bind catalog instances rather than similarly named defaults', ()
|
||||
'rocket-launches',
|
||||
'street-level',
|
||||
'local-adsb',
|
||||
'wildlife-sightings',
|
||||
];
|
||||
const layers = ids.map((id) => ({ id }));
|
||||
const catalog = createLayerCatalog(layers, metadata(layers));
|
||||
@@ -147,6 +148,7 @@ test('controls bind catalog instances rather than similarly named defaults', ()
|
||||
assert.equal(services.transitLayer, layers[7]);
|
||||
assert.equal(services.aisLiveVesselsLayer, layers[8]);
|
||||
assert.equal(services.localAdsbLayer, layers[13]);
|
||||
assert.equal(services.wildlifeSightingsLayer, layers[14]);
|
||||
assert.equal(new Set(Object.values(services)).size, layers.length);
|
||||
assert.throws(
|
||||
() => catalogControlServices(createLayerCatalog([], [])),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { resolveCatalogSources } from './sourceComposition.js';
|
||||
import { createWeatherClock } from '../layers/weather/clock.js';
|
||||
import { createWeatherLayer } from '../layers/weather/index.js';
|
||||
import { createCyclonesLayer } from '../layers/cyclones/index.js';
|
||||
import { createApplicationCyclones } from './layers/cyclones.js';
|
||||
import { createWindLayer } from '../layers/wind/index.js';
|
||||
import { createLayerCatalog } from './catalog.js';
|
||||
import { LAYER_STATE_REGISTRY } from '../data/layerState.js';
|
||||
@@ -30,6 +30,7 @@ import { createApplicationFirePerimeters } from './layers/perimeters.js';
|
||||
import { createApplicationGnssIntegrity } from './layers/gnss.js';
|
||||
import { createApplicationGhostMaps } from './layers/ghostMaps.js';
|
||||
import { createApplicationDroneAirspace } from './layers/droneAirspace.js';
|
||||
import { createApplicationWildlifeSightings } from './layers/wildlifeSightings.js';
|
||||
import { createApplicationCables } from './layers/submarineCables.js';
|
||||
import { createOsmOverlayLayers } from '../data/osmOverlays.js';
|
||||
import { createApplicationStreetLevel } from './layers/streetLevel.js';
|
||||
@@ -137,6 +138,10 @@ export function createApplicationCatalog({
|
||||
createApplicationDroneAirspace({
|
||||
source: sources['drone-airspace'],
|
||||
}),
|
||||
createApplicationWildlifeSightings({
|
||||
source: sources['wildlife-sightings'],
|
||||
groundFloor: surface.groundFloor,
|
||||
}),
|
||||
createApplicationAlpr({ surface, source: sources.alpr }),
|
||||
createApplicationStreetLevel({
|
||||
surface,
|
||||
@@ -177,7 +182,7 @@ export function createApplicationCatalog({
|
||||
id: 'weather-lightning',
|
||||
clock: weatherClock,
|
||||
}),
|
||||
createCyclonesLayer({ feed: sources.cyclones }),
|
||||
createApplicationCyclones({ feed: sources.cyclones }),
|
||||
...createOsmOverlayLayers(),
|
||||
...createInfrastructureLayers(localGeoJsonServices),
|
||||
createApplicationCables({ source: sources.cables }),
|
||||
|
||||
@@ -40,7 +40,7 @@ test('catalogs construct distinct layers and classification from their supplied
|
||||
signal: b.signal,
|
||||
surface: fixtureSurface(b.signal),
|
||||
});
|
||||
assert.equal(first.layers.length, 37);
|
||||
assert.equal(first.layers.length, 38);
|
||||
assert.ok(first.get('local-adsb'), 'Local ADS-B is registered');
|
||||
assert.deepEqual(
|
||||
first.metadata.find(({ id }) => id === 'osm-places'),
|
||||
@@ -161,7 +161,7 @@ test('Mapillary can be omitted or replaced without changing catalog membership',
|
||||
surface: fixtureSurface(lifetime.signal),
|
||||
};
|
||||
const catalog = createApplicationCatalog(options);
|
||||
assert.equal(catalog.layers.length, 37);
|
||||
assert.equal(catalog.layers.length, 38);
|
||||
assert.deepEqual(catalog.get('street-level').providerIds, []);
|
||||
assert.equal(
|
||||
catalogControlServices(catalog).streetLevelLayer,
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { createCyclonesLayer } from '../../layers/cyclones/index.js';
|
||||
import * as context from '../../data/contextStore.js';
|
||||
|
||||
/** Wire cyclone selection to the application shared context. */
|
||||
export function createApplicationCyclones(options) {
|
||||
return createCyclonesLayer({
|
||||
context: typeof window !== 'undefined' ? context : null,
|
||||
...options,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { createWildlifeSightingsLayer } from '../../layers/wildlifeSightings/index.js';
|
||||
import * as picking from '../../data/pickRegistry.js';
|
||||
import * as overlays from '../../overlays/worldOverlay.js';
|
||||
import * as sprites from '../../data/spriteOrder.js';
|
||||
import * as render from '../../renderGovernor.js';
|
||||
import * as context from '../../data/contextStore.js';
|
||||
import * as focus from '../../worldFocus.js';
|
||||
import { isPointerFree } from '../../data/inputOwnership.js';
|
||||
|
||||
/** Bind the wildlife layer to the application's scene and selection owners. */
|
||||
export function createApplicationWildlifeSightings(options) {
|
||||
return createWildlifeSightingsLayer({
|
||||
overlayHost: {
|
||||
setEntries: overlays.setOverlayEntries,
|
||||
setVisible: overlays.setOverlaySourceVisible,
|
||||
clearSource: overlays.clearOverlaySource,
|
||||
hitTest: overlays.hitTestWorldOverlay,
|
||||
},
|
||||
screenSpaceEventHandlerFactory: (viewer) =>
|
||||
new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas),
|
||||
picking,
|
||||
pointer: { isPointerFree },
|
||||
sprites,
|
||||
render,
|
||||
context,
|
||||
focus,
|
||||
...options,
|
||||
});
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { createApplicationOperations } from './operations.js';
|
||||
import * as Cesium from 'cesium';
|
||||
import {
|
||||
createApplicationViewer,
|
||||
installFreeLookHeightLimit,
|
||||
installTrackpadPinchZoom,
|
||||
} from '../app/viewer.js';
|
||||
import { registerDataCredits } from '../data/dataCredits.js';
|
||||
@@ -58,6 +59,7 @@ export async function createApplicationScene({
|
||||
if (!viewer.isDestroyed()) viewer.destroy();
|
||||
});
|
||||
defer(installTrackpadPinchZoom(viewer));
|
||||
defer(installFreeLookHeightLimit(viewer));
|
||||
registerDataCredits(viewer, credits);
|
||||
configureCreditKeyboardAccess(document);
|
||||
defer(onLocaleChange(() => configureCreditKeyboardAccess(document)));
|
||||
|
||||
@@ -50,6 +50,7 @@ export const CATALOG_SOURCE_CONTRACTS = Object.freeze({
|
||||
'gnss-integrity': contract(['gnss-integrity'], ['getSnapshot']),
|
||||
's2-ghostmaps': contract(['s2-ghostmaps'], ['getCatalog', 'getFile']),
|
||||
'drone-airspace': contract(['drone-airspace'], ['getSnapshot']),
|
||||
'wildlife-sightings': contract(['wildlife-sightings'], ['getSnapshot']),
|
||||
cables: contract(['telegeography-submarine-cables'], ['fetch']),
|
||||
transit: contract(['transit'], ['requestSnapshot', 'getHistory']),
|
||||
});
|
||||
|
||||
@@ -37,7 +37,7 @@ test('every acquisition source can be omitted without losing catalog membership'
|
||||
|
||||
test('all acquisition sources may be absent, and enable fails before initializing or fetching', async (t) => {
|
||||
const actual = catalog({}, t);
|
||||
assert.equal(actual.layers.length, 37);
|
||||
assert.equal(actual.layers.length, 38);
|
||||
const manager = new LayerLifecycle(
|
||||
{},
|
||||
{ getSourceAvailability: actual.getSourceAvailability },
|
||||
|
||||
+15
-1
@@ -8,7 +8,13 @@ import { initImageryBoxTool } from '../ui/imageryBoxTool.js';
|
||||
import { createRecentImageryPanel } from '../ui/recentImagery.js';
|
||||
import { initGevVoiceCommands } from '../voice/gevRealtime.js';
|
||||
import { installViews, isEmbeddedInline } from './embed.js';
|
||||
import { installScopeMask, destroyScopeMask } from '../scopeMask.js';
|
||||
import {
|
||||
installScopeMask,
|
||||
destroyScopeMask,
|
||||
isScopeMaskEnabled,
|
||||
} from '../scopeMask.js';
|
||||
import { KEYHOLE_OUTER_RADIUS } from '../celestialRing.js';
|
||||
import { installGlobeInViewRecovery } from './viewer.js';
|
||||
import { installLayerReleaseTrim } from '../sceneCommandRelease.js';
|
||||
import {
|
||||
installRenderGovernor,
|
||||
@@ -44,6 +50,14 @@ export function createApplicationTools({
|
||||
.get('bhote-koshi-2026')
|
||||
?.module.attachSceneController(sceneDirector);
|
||||
defer(() => sceneDirector.destroy());
|
||||
defer(
|
||||
installGlobeInViewRecovery(viewer, {
|
||||
// The scope mask hides everything outside its keyhole.
|
||||
keyholeRadius: () => (isScopeMaskEnabled() ? KEYHOLE_OUTER_RADIUS : 0),
|
||||
// An authored scene frames its own shots, holds included.
|
||||
paused: () => sceneDirector.running,
|
||||
}),
|
||||
);
|
||||
onSceneDirector?.(sceneDirector);
|
||||
const annotations = initAnnotations({
|
||||
viewer,
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { applyModelAtmosphereWorkaround } from './atmosphereCompat.js';
|
||||
import { StarrySky } from '../sky/starrySky.js';
|
||||
import {
|
||||
FREE_LOOK_MAX_HEIGHT_METERS,
|
||||
globeOutOfFrame,
|
||||
isFreeGlobeCamera,
|
||||
} from '../globeInView.js';
|
||||
|
||||
const PINCH_ZOOM_MULTIPLIER = 8;
|
||||
const MAX_PINCH_PIXEL_DELTA = 120;
|
||||
@@ -103,6 +108,231 @@ export function installTrackpadPinchZoom(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Allow Cesium's free look only below `maxHeight` and return its disposer.
|
||||
* Orbit, tilt and zoom stay available at every height. A value other code
|
||||
* sets later is kept as the one to allow below that height, and restored on
|
||||
* dispose; a `false` written while the camera is above `maxHeight` cannot be
|
||||
* told apart from this limit's own write, so dispose restores the earlier
|
||||
* value then. `enableLook` gates the Shift+drag binding only: below its trackball
|
||||
* height Cesium also turns the camera in place for a drag that starts on the
|
||||
* stars. That height is lowered to `maxHeight` too, so above it such a drag
|
||||
* orbits the globe, as it does above Cesium's own 7,500 km.
|
||||
*/
|
||||
export function installFreeLookHeightLimit(
|
||||
viewer,
|
||||
maxHeight = FREE_LOOK_MAX_HEIGHT_METERS,
|
||||
) {
|
||||
const scene = viewer?.scene;
|
||||
const controller = scene?.screenSpaceCameraController;
|
||||
const camera = viewer?.camera;
|
||||
if (!controller || !camera || !scene?.preUpdate)
|
||||
throw new TypeError('A complete Cesium viewer is required');
|
||||
|
||||
const originalTrackBallHeight = controller.minimumTrackBallHeight;
|
||||
const trackBallHeight = Math.min(originalTrackBallHeight, maxHeight);
|
||||
controller.minimumTrackBallHeight = trackBallHeight;
|
||||
let wanted = controller.enableLook;
|
||||
let written;
|
||||
const sync = () => {
|
||||
if (written !== undefined && controller.enableLook !== written)
|
||||
wanted = controller.enableLook;
|
||||
const height = camera.positionCartographic?.height;
|
||||
written = wanted && !(Number.isFinite(height) && height > maxHeight);
|
||||
controller.enableLook = written;
|
||||
};
|
||||
sync();
|
||||
const removeListener = scene.preUpdate.addEventListener(sync);
|
||||
|
||||
let active = true;
|
||||
return () => {
|
||||
if (!active) return;
|
||||
active = false;
|
||||
removeListener();
|
||||
if (controller.enableLook === written) controller.enableLook = wanted;
|
||||
if (controller.minimumTrackBallHeight === trackBallHeight)
|
||||
controller.minimumTrackBallHeight = originalTrackBallHeight;
|
||||
};
|
||||
}
|
||||
|
||||
// How often a moving camera is sampled to see whether it has come to rest, how
|
||||
// far a pose may drift between samples and still count as at rest, and how
|
||||
// long after the camera last moved the check gives up if Cesium never raises
|
||||
// moveEnd.
|
||||
const SETTLE_SAMPLE_MS = 250;
|
||||
const SETTLE_POSITION_TOLERANCE_M = 1e-3;
|
||||
const SETTLE_DIRECTION_TOLERANCE = 1e-9;
|
||||
const SETTLE_GIVE_UP_MS = 5 * 60_000;
|
||||
|
||||
/**
|
||||
* Turn the camera back to the planet when a move ends above `minHeight` with
|
||||
* no surface on screen (`globeOutOfFrame`: limb glow and slivers up to about
|
||||
* one sample cell thick can count as none), and return the disposer. Views with
|
||||
* more surface, such as an oblique orbital shot, are left as they are. This
|
||||
* catches a saved or shared view that looked away, or any other move that ends
|
||||
* looking away from the planet.
|
||||
* Heading and position are kept. Tracked cameras, locked ones (non-identity
|
||||
* transform), a camera flight in progress, a pointer held down on the canvas
|
||||
* (a drag held still), the 2D and Columbus modes, and any time `paused`
|
||||
* returns true (an authored scene playing) are left alone; a check they defer
|
||||
* runs once they end, however long they last.
|
||||
* `keyholeRadius`
|
||||
* returns the radius of the visible circle when a scope mask hides the rest of
|
||||
* the frame (see `globeFrameShare`), or 0; it is read at every check.
|
||||
*
|
||||
* Cesium's `moveEnd` alone is not enough: Cesium compares the camera with the
|
||||
* last frame's to the last rounding error, and at some of these poses the
|
||||
* camera drifts by a rounding error every frame, so Cesium never sees it stop.
|
||||
* So each `moveStart` also starts a check of its own that samples the pose
|
||||
* every 250 ms, with a tolerance for that drift, and once the pose has held
|
||||
* for half a second after moving runs the same recovery. It keeps watching
|
||||
* until Cesium raises `moveEnd`, since under that drift Cesium raises no new
|
||||
* `moveStart` for a later move, and gives up five minutes after the camera
|
||||
* last moved.
|
||||
* `setTimeout` and `clearTimeout` can be injected for tests.
|
||||
*/
|
||||
export function installGlobeInViewRecovery(
|
||||
viewer,
|
||||
{
|
||||
minHeight = FREE_LOOK_MAX_HEIGHT_METERS,
|
||||
keyholeRadius = () => 0,
|
||||
paused = () => false,
|
||||
setTimeout: schedule = globalThis.setTimeout,
|
||||
clearTimeout: unschedule = globalThis.clearTimeout,
|
||||
} = {},
|
||||
) {
|
||||
const scene = viewer?.scene;
|
||||
const camera = viewer?.camera;
|
||||
if (!scene || !camera?.moveEnd || !camera?.moveStart)
|
||||
throw new TypeError('A complete Cesium viewer is required');
|
||||
|
||||
// A pointer held on the globe is a gesture in progress, even when it holds
|
||||
// still for half a second: the check waits until every pointer pressed on
|
||||
// it (each finger of a pinch) is released. A release the page never sees,
|
||||
// outside the window, is taken as given when the window loses focus, the
|
||||
// page is hidden, the canvas loses the pointer's capture, or the pointer
|
||||
// moves with no button down.
|
||||
const canvas = scene.canvas;
|
||||
const releaseTarget = canvas?.ownerDocument ?? canvas;
|
||||
const releaseWindow = canvas?.ownerDocument?.defaultView;
|
||||
const heldPointers = new Set();
|
||||
const onPointerDown = (event) => heldPointers.add(event?.pointerId);
|
||||
const onPointerUp = (event) => heldPointers.delete(event?.pointerId);
|
||||
const onPointerMove = (event) => {
|
||||
if (event?.buttons === 0) heldPointers.delete(event.pointerId);
|
||||
};
|
||||
const releaseAll = () => heldPointers.clear();
|
||||
const onVisibilityChange = () => {
|
||||
if (canvas.ownerDocument.visibilityState === 'hidden') releaseAll();
|
||||
};
|
||||
const listeners = [
|
||||
[canvas, 'pointerdown', onPointerDown],
|
||||
[canvas, 'lostpointercapture', onPointerUp],
|
||||
[releaseTarget, 'pointerup', onPointerUp],
|
||||
[releaseTarget, 'pointercancel', onPointerUp],
|
||||
[releaseTarget, 'pointermove', onPointerMove],
|
||||
[canvas?.ownerDocument, 'visibilitychange', onVisibilityChange],
|
||||
[releaseWindow, 'blur', releaseAll],
|
||||
];
|
||||
for (const [target, type, listener] of listeners)
|
||||
target?.addEventListener?.(type, listener);
|
||||
|
||||
// Returns false while a flight, a paused scene or a held pointer defers the
|
||||
// check, so the caller can try again once it ends.
|
||||
const recover = () => {
|
||||
if (!isFreeGlobeCamera(viewer)) return true;
|
||||
// Cesium has no public running-flight flag.
|
||||
if (camera._currentFlight || paused() || heldPointers.size > 0)
|
||||
return false;
|
||||
const height = camera.positionCartographic?.height;
|
||||
if (
|
||||
!(height > minHeight) ||
|
||||
!globeOutOfFrame(camera, { keyholeRadius: keyholeRadius() })
|
||||
)
|
||||
return true;
|
||||
camera.setView({
|
||||
orientation: {
|
||||
heading: camera.heading,
|
||||
pitch: -Cesium.Math.PI_OVER_TWO,
|
||||
roll: 0,
|
||||
},
|
||||
});
|
||||
scene.requestRender?.();
|
||||
return true;
|
||||
};
|
||||
|
||||
const lastPosition = new Cesium.Cartesian3();
|
||||
const lastDirection = new Cesium.Cartesian3();
|
||||
let settleTimer = null;
|
||||
let settleElapsed = 0;
|
||||
let restingSamples = 0;
|
||||
let movedSinceCheck = false;
|
||||
const stopSettling = () => {
|
||||
if (settleTimer !== null) unschedule(settleTimer);
|
||||
settleTimer = null;
|
||||
};
|
||||
const sample = () => {
|
||||
settleTimer = null;
|
||||
settleElapsed += SETTLE_SAMPLE_MS;
|
||||
const atRest =
|
||||
Cesium.Cartesian3.equalsEpsilon(
|
||||
camera.positionWC,
|
||||
lastPosition,
|
||||
0,
|
||||
SETTLE_POSITION_TOLERANCE_M,
|
||||
) &&
|
||||
Cesium.Cartesian3.equalsEpsilon(
|
||||
camera.directionWC,
|
||||
lastDirection,
|
||||
0,
|
||||
SETTLE_DIRECTION_TOLERANCE,
|
||||
);
|
||||
restingSamples = atRest ? restingSamples + 1 : 0;
|
||||
if (!atRest) {
|
||||
movedSinceCheck = true;
|
||||
settleElapsed = 0;
|
||||
}
|
||||
// Two quiet samples in a row after a move: at rest for half a second, as
|
||||
// for moveEnd.
|
||||
if (restingSamples >= 2 && movedSinceCheck) {
|
||||
if (recover()) movedSinceCheck = false;
|
||||
// A flight or a playing scene bounds its own deferral.
|
||||
else settleElapsed = 0;
|
||||
}
|
||||
if (settleElapsed >= SETTLE_GIVE_UP_MS) return;
|
||||
Cesium.Cartesian3.clone(camera.positionWC, lastPosition);
|
||||
Cesium.Cartesian3.clone(camera.directionWC, lastDirection);
|
||||
settleTimer = schedule(sample, SETTLE_SAMPLE_MS);
|
||||
};
|
||||
const startSettling = () => {
|
||||
stopSettling();
|
||||
settleElapsed = 0;
|
||||
restingSamples = 0;
|
||||
movedSinceCheck = true;
|
||||
Cesium.Cartesian3.clone(camera.positionWC, lastPosition);
|
||||
Cesium.Cartesian3.clone(camera.directionWC, lastDirection);
|
||||
settleTimer = schedule(sample, SETTLE_SAMPLE_MS);
|
||||
};
|
||||
const removeMoveEnd = camera.moveEnd.addEventListener(() => {
|
||||
if (recover()) stopSettling();
|
||||
// Deferred: keep sampling so the check runs once the flight or scene ends.
|
||||
else if (settleTimer === null) startSettling();
|
||||
});
|
||||
const removeMoveStart = camera.moveStart.addEventListener(startSettling);
|
||||
|
||||
let active = true;
|
||||
return () => {
|
||||
if (!active) return;
|
||||
active = false;
|
||||
stopSettling();
|
||||
removeMoveEnd();
|
||||
removeMoveStart();
|
||||
for (const [target, type, listener] of listeners)
|
||||
target?.removeEventListener?.(type, listener);
|
||||
heldPointers.clear();
|
||||
};
|
||||
}
|
||||
|
||||
/** Cesium.Viewer options of the standard globe viewer. */
|
||||
export function applicationViewerOptions(creditContainer) {
|
||||
return {
|
||||
|
||||
@@ -13,8 +13,16 @@ import {
|
||||
moveCamera,
|
||||
} from './cameraVerbs.js';
|
||||
import { getRenderGovernorDiagnostics } from './renderGovernor.js';
|
||||
import {
|
||||
MIN_GLOBE_FRAME_SHARE,
|
||||
globeFrameShare,
|
||||
globeOutOfFrame,
|
||||
} from './globeInView.js';
|
||||
import { KEYHOLE_OUTER_RADIUS } from './celestialRing.js';
|
||||
import { setScopeMaskEnabled } from './scopeMask.js';
|
||||
|
||||
const radians = Cesium.Math.toRadians;
|
||||
const degrees = Cesium.Math.toDegrees;
|
||||
const clone = (value) => Cesium.Cartesian3.clone(value);
|
||||
const longitude = (camera) =>
|
||||
Cesium.Cartographic.fromCartesian(camera.positionWC).longitude;
|
||||
@@ -1347,3 +1355,664 @@ test('close-up ultrawide refusal does not offer an unfit reset or move the camer
|
||||
samePose(h.camera, before);
|
||||
assert.equal(hasRenderHold(), false);
|
||||
});
|
||||
|
||||
/**
|
||||
* An oracle independent of the code under test: the share of an 80×45 grid of
|
||||
* Cesium pick rays across the canvas that hit the ellipsoid surface. With
|
||||
* `keyhole`, only the pixels inside the scope's visible circle count.
|
||||
*/
|
||||
function surfacePixelShare(camera, width, height, { keyhole = false } = {}) {
|
||||
const radius = (KEYHOLE_OUTER_RADIUS * height) / 2;
|
||||
let hits = 0;
|
||||
let counted = 0;
|
||||
for (let row = 0; row < 45; row += 1) {
|
||||
for (let column = 0; column < 80; column += 1) {
|
||||
const x = ((column + 0.5) * width) / 80;
|
||||
const y = ((row + 0.5) * height) / 45;
|
||||
if (keyhole && Math.hypot(x - width / 2, y - height / 2) > radius)
|
||||
continue;
|
||||
counted += 1;
|
||||
const ray = camera.getPickRay(new Cesium.Cartesian2(x, y));
|
||||
if (Cesium.IntersectionTests.rayEllipsoid(ray, Cesium.Ellipsoid.WGS84))
|
||||
hits += 1;
|
||||
}
|
||||
}
|
||||
return hits / counted;
|
||||
}
|
||||
|
||||
/** Run a test with the scope mask on or off, restoring the default after. */
|
||||
function withScope(t, enabled) {
|
||||
setScopeMaskEnabled(enabled);
|
||||
t.after(() => setScopeMaskEnabled(true));
|
||||
}
|
||||
|
||||
// The frame limit is measured on a coarser grid than the oracle.
|
||||
const SHARE_SLACK = 0.02;
|
||||
// Far-out poses, height in metres and pitch in degrees, where a few tilts up
|
||||
// are accepted before the limit refuses the next one.
|
||||
const FAR_OUT_POSES = [
|
||||
[35_884_000, -86.26],
|
||||
[35_884_000, -89],
|
||||
[30_000_000, -86],
|
||||
[25_000_000, -80],
|
||||
[40_000_000, -88],
|
||||
];
|
||||
|
||||
test('voice tilt from orbit stops with the planet still on screen and then says so', (t) => {
|
||||
withScope(t, false); // the whole frame is visible
|
||||
// Slightly off nadir: from straight down a tilt is clamped before it moves.
|
||||
const { camera, finishOnce } = harness(t, {
|
||||
pitch: -80,
|
||||
viewportWidth: 1600,
|
||||
viewportHeight: 900,
|
||||
});
|
||||
const results = [];
|
||||
for (let i = 0; i < 4; i += 1) {
|
||||
const result = moveCamera({
|
||||
motion: 'tilt',
|
||||
direction: 'up',
|
||||
mode: 'once',
|
||||
});
|
||||
results.push(result);
|
||||
if (result.ok) finishOnce();
|
||||
assert.ok(
|
||||
surfacePixelShare(camera, 1600, 900) >=
|
||||
MIN_GLOBE_FRAME_SHARE - SHARE_SLACK,
|
||||
`after tilt ${i + 1}`,
|
||||
);
|
||||
}
|
||||
// From 18,000 km a 16:9 frame keeps a tenth of its area on the planet down
|
||||
// to about -67°: the first tilt stops there, and later ones are refused.
|
||||
assert.deepEqual(
|
||||
results.map((result) => result.ok),
|
||||
[true, false, false, false],
|
||||
);
|
||||
assert.match(results[1].error, /globe out of view/);
|
||||
assert.ok(camera.pitch > radians(-68.5) && camera.pitch < radians(-66));
|
||||
});
|
||||
|
||||
// The frame limit judges a tilt by the way its pitch moves, not by the
|
||||
// direction word: a tilt down or to a steeper angle turns toward the planet.
|
||||
for (const args of [{ direction: 'down' }, { pitchDeg: -80 }]) {
|
||||
test(`a tilt ${JSON.stringify(args)} from the frame limit is judged as the look down it is`, (t) => {
|
||||
withScope(t, false);
|
||||
const { camera, finishOnce } = harness(t, {
|
||||
pitch: -80,
|
||||
viewportWidth: 1600,
|
||||
viewportHeight: 900,
|
||||
});
|
||||
assert.equal(moveCamera({ motion: 'tilt', direction: 'up' }).ok, true);
|
||||
finishOnce();
|
||||
const atLimit = camera.pitch;
|
||||
const result = moveCamera({ motion: 'tilt', ...args });
|
||||
assert.equal(result.ok, true, result.error);
|
||||
finishOnce();
|
||||
assert.ok(camera.pitch < atLimit - radians(5), 'the tilt turned down');
|
||||
});
|
||||
}
|
||||
|
||||
for (const args of [{ pitchDeg: -10 }, {}]) {
|
||||
test(`a tilt ${JSON.stringify(args)} up from the frame limit is refused, not reported as done`, (t) => {
|
||||
withScope(t, false);
|
||||
const { camera, finishOnce } = harness(t, {
|
||||
pitch: -80,
|
||||
viewportWidth: 1600,
|
||||
viewportHeight: 900,
|
||||
});
|
||||
assert.equal(moveCamera({ motion: 'tilt', direction: 'up' }).ok, true);
|
||||
finishOnce();
|
||||
const atLimit = camera.pitch;
|
||||
// A bare tilt means -45°: up from the -67° limit.
|
||||
const result = moveCamera({ motion: 'tilt', ...args });
|
||||
assert.equal(result.ok, false);
|
||||
assert.match(result.error, /globe out of view/);
|
||||
assert.equal(camera.pitch, atLimit);
|
||||
});
|
||||
}
|
||||
|
||||
// A tilt to an angle, or a bare tilt (-45°), from orbit turns in place like
|
||||
// a nudge and must keep the planet in frame the whole way, or the globe
|
||||
// recovery would snap the view back down.
|
||||
for (const [label, args, options] of [
|
||||
['a bare tilt', { motion: 'tilt' }, { pitch: -90 }],
|
||||
['a tilt to -10°', { motion: 'tilt', pitchDeg: -10 }, { pitch: -90 }],
|
||||
[
|
||||
'a continuous tilt up',
|
||||
{ motion: 'tilt', direction: 'up', mode: 'continuous' },
|
||||
{ pitch: -90 },
|
||||
],
|
||||
[
|
||||
'a tilt to -5° from 2,000 km',
|
||||
{ motion: 'tilt', pitchDeg: -5 },
|
||||
{ pitch: -90, height: 2_000_000 },
|
||||
],
|
||||
]) {
|
||||
test(`${label} from top-down never turns the planet out of frame`, (t) => {
|
||||
withScope(t, false);
|
||||
const { camera, tick } = harness(t, {
|
||||
...options,
|
||||
viewportWidth: 1600,
|
||||
viewportHeight: 900,
|
||||
});
|
||||
const startPitch = camera.pitch;
|
||||
const result = moveCamera(args);
|
||||
for (let i = 0; i < 600 && getActiveCameraMotion(); i += 1) {
|
||||
tick();
|
||||
assert.ok(
|
||||
surfacePixelShare(camera, 1600, 900) >=
|
||||
MIN_GLOBE_FRAME_SHARE - SHARE_SLACK,
|
||||
`frame ${i}: the planet left the frame`,
|
||||
);
|
||||
}
|
||||
assert.equal(getActiveCameraMotion(), null, 'the tilt must stop');
|
||||
assert.equal(globeOutOfFrame(camera), false, 'no snap-back');
|
||||
if (result.ok) assert.ok(camera.pitch > startPitch, 'the tilt moved');
|
||||
});
|
||||
}
|
||||
|
||||
// The frame limit stops a tilt to an angle short of it: the reply names the
|
||||
// angle the camera reaches, not the one asked for (a tilt to -10° from
|
||||
// top-down reported -10° and ended at -66.5°).
|
||||
for (const [label, args, options] of [
|
||||
['a bare tilt', { motion: 'tilt' }, { pitch: -90 }],
|
||||
['a tilt to -10°', { motion: 'tilt', pitchDeg: -10 }, { pitch: -90 }],
|
||||
[
|
||||
'a tilt to -10° from -80°',
|
||||
{ motion: 'tilt', pitchDeg: -10 },
|
||||
{ pitch: -80 },
|
||||
],
|
||||
[
|
||||
'a tilt to -5° from 2,000 km',
|
||||
{ motion: 'tilt', pitchDeg: -5 },
|
||||
{ pitch: -90, height: 2_000_000 },
|
||||
],
|
||||
// A rolled camera's look turns its pitch by less than the look angle:
|
||||
// reported -25°, ended at -27.3°.
|
||||
[
|
||||
'a tilt to -5° from 2,000 km rolled 30°',
|
||||
{ motion: 'tilt', pitchDeg: -5 },
|
||||
{ pitch: -70, roll: 30, height: 2_000_000 },
|
||||
],
|
||||
]) {
|
||||
test(`${label} from orbit reports the angle it reaches`, (t) => {
|
||||
withScope(t, false);
|
||||
const { camera, tick } = harness(t, {
|
||||
...options,
|
||||
viewportWidth: 1600,
|
||||
viewportHeight: 900,
|
||||
});
|
||||
const result = moveCamera(args);
|
||||
assert.equal(result.ok, true, result.error);
|
||||
for (let i = 0; i < 600 && getActiveCameraMotion(); i += 1) tick();
|
||||
assert.equal(getActiveCameraMotion(), null, 'the tilt must stop');
|
||||
const reached = degrees(camera.pitch);
|
||||
assert.ok(
|
||||
Math.abs(reached - result.pitchDeg) < 1,
|
||||
`reported ${result.pitchDeg}°, reached ${reached}°`,
|
||||
);
|
||||
const asked = args.pitchDeg ?? -45;
|
||||
if (Math.abs(reached - asked) >= 1)
|
||||
assert.match(result.note ?? '', /globe out of view/);
|
||||
else assert.equal(result.note, undefined);
|
||||
});
|
||||
}
|
||||
|
||||
// A continuous tilt promises no angle: it runs until stopped or the frame
|
||||
// limit ends it.
|
||||
test('a continuous tilt up from orbit claims no angle it may not reach', (t) => {
|
||||
withScope(t, false);
|
||||
const { tick } = harness(t, {
|
||||
pitch: -90,
|
||||
viewportWidth: 1600,
|
||||
viewportHeight: 900,
|
||||
});
|
||||
const result = moveCamera({
|
||||
motion: 'tilt',
|
||||
direction: 'up',
|
||||
mode: 'continuous',
|
||||
});
|
||||
assert.equal(result.ok, true, result.error);
|
||||
assert.equal('pitchDeg' in result, false);
|
||||
assert.equal(result.mode, 'continuous');
|
||||
for (let i = 0; i < 600 && getActiveCameraMotion(); i += 1) tick();
|
||||
assert.equal(getActiveCameraMotion(), null, 'the frame limit ends it');
|
||||
});
|
||||
|
||||
test('voice turn in place from orbit stops with the planet still on screen', (t) => {
|
||||
withScope(t, false); // the whole frame is visible
|
||||
const { camera, tick } = harness(t, { pitch: -75 });
|
||||
assert.equal(
|
||||
moveCamera({ motion: 'rotate', direction: 'left', mode: 'continuous' }).ok,
|
||||
true,
|
||||
);
|
||||
const heading = camera.heading;
|
||||
for (let i = 0; i < 400 && getActiveCameraMotion(); i += 1) {
|
||||
tick();
|
||||
assert.ok(
|
||||
surfacePixelShare(camera, 1280, 800) >=
|
||||
MIN_GLOBE_FRAME_SHARE - SHARE_SLACK,
|
||||
`tick ${i + 1}`,
|
||||
);
|
||||
}
|
||||
assert.equal(getActiveCameraMotion(), null, 'the turn stops at the limit');
|
||||
assert.notEqual(camera.heading, heading, 'the turn moved before stopping');
|
||||
});
|
||||
|
||||
test('a long voice turn below the limit keeps the share it started with', (t) => {
|
||||
withScope(t, false); // the whole frame is visible
|
||||
// About 2% of a 16:9 frame on the planet: the limit is that share, less two
|
||||
// grid cells of sampling noise, for the whole motion rather than per step.
|
||||
const { camera, tick } = harness(t, {
|
||||
pitch: -60,
|
||||
viewportWidth: 1600,
|
||||
viewportHeight: 900,
|
||||
});
|
||||
const start = globeFrameShare(camera);
|
||||
assert.ok(start > 0 && start < MIN_GLOBE_FRAME_SHARE);
|
||||
assert.equal(
|
||||
moveCamera({ motion: 'rotate', direction: 'left', mode: 'continuous' }).ok,
|
||||
true,
|
||||
);
|
||||
for (let i = 0; i < 400 && getActiveCameraMotion(); i += 1) {
|
||||
tick();
|
||||
assert.ok(
|
||||
globeFrameShare(camera) >= start - 2 / (32 * 18) - 1e-9,
|
||||
`tick ${i + 1}`,
|
||||
);
|
||||
}
|
||||
assert.equal(getActiveCameraMotion(), null, 'the turn stops at the limit');
|
||||
assert.ok(surfacePixelShare(camera, 1600, 900) > 0);
|
||||
});
|
||||
|
||||
test('repeated voice turns from one spot cannot each take a little more', (t) => {
|
||||
withScope(t, false); // the whole frame is visible
|
||||
// About 2% of a 16:9 frame on the planet. Every turn from this position is
|
||||
// measured against the same baseline, so asking again stops being allowed.
|
||||
const { camera, finishOnce, tick } = harness(t, {
|
||||
pitch: -60,
|
||||
viewportWidth: 1600,
|
||||
viewportHeight: 900,
|
||||
});
|
||||
const start = globeFrameShare(camera);
|
||||
const floor = start - 2 / (32 * 18) - 1e-9;
|
||||
const results = [];
|
||||
for (const mode of ['once', 'once', 'continuous', 'once', 'continuous']) {
|
||||
for (let i = 0; i < 4; i += 1) {
|
||||
const result = moveCamera({ motion: 'rotate', direction: 'left', mode });
|
||||
results.push(result.ok);
|
||||
if (!result.ok) continue;
|
||||
if (mode === 'once') finishOnce();
|
||||
else for (let j = 0; j < 400 && getActiveCameraMotion(); j += 1) tick();
|
||||
assert.ok(globeFrameShare(camera) >= floor, `${mode} turn ${i + 1}`);
|
||||
}
|
||||
}
|
||||
assert.ok(results.includes(false), 'a turn at the limit is refused');
|
||||
assert.equal(results.at(-1), false);
|
||||
assert.ok(surfacePixelShare(camera, 1600, 900) > 0);
|
||||
});
|
||||
|
||||
test('a voice turn after something else turned the camera starts over', (t) => {
|
||||
withScope(t, false); // the whole frame is visible
|
||||
// The same spot, first framed straight down, then turned to about 2% of the
|
||||
// frame by something other than voice: a turn left is measured from there.
|
||||
const { camera, finishOnce } = harness(t, {
|
||||
pitch: -90,
|
||||
viewportWidth: 1600,
|
||||
viewportHeight: 900,
|
||||
});
|
||||
assert.equal(
|
||||
moveCamera({ motion: 'rotate', direction: 'left', mode: 'once' }).ok,
|
||||
true,
|
||||
);
|
||||
finishOnce();
|
||||
camera.setView({
|
||||
orientation: { heading: 0, pitch: radians(-60), roll: 0 },
|
||||
});
|
||||
const start = globeFrameShare(camera);
|
||||
assert.ok(start > 0 && start < MIN_GLOBE_FRAME_SHARE);
|
||||
assert.equal(
|
||||
moveCamera({ motion: 'rotate', direction: 'left', mode: 'once' }).ok,
|
||||
true,
|
||||
);
|
||||
finishOnce();
|
||||
assert.ok(globeFrameShare(camera) >= start - 2 / (32 * 18) - 1e-9);
|
||||
});
|
||||
|
||||
test('nudging the camera between voice turns cannot take the planet below the 1% floor', (t) => {
|
||||
withScope(t, false); // the whole frame is visible
|
||||
// A wheel notch or an orbit drag between requests starts each turn afresh;
|
||||
// the 1% floor still holds, and below it a turn may only keep what it has.
|
||||
for (const [nudge, pitch] of [
|
||||
[(camera) => camera.moveBackward(2), -60],
|
||||
[(camera) => camera.rotateRight(radians(0.2)), -60],
|
||||
[(camera) => camera.moveBackward(2), -58],
|
||||
]) {
|
||||
const { camera, tick } = harness(t, {
|
||||
pitch,
|
||||
viewportWidth: 1600,
|
||||
viewportHeight: 900,
|
||||
});
|
||||
const start = globeFrameShare(camera);
|
||||
const floor = Math.min(0.01, start) - 1e-9;
|
||||
for (let i = 0; i < 12; i += 1) {
|
||||
nudge(camera);
|
||||
if (
|
||||
moveCamera({ motion: 'rotate', direction: 'left', mode: 'continuous' })
|
||||
.ok
|
||||
)
|
||||
for (let j = 0; j < 400 && getActiveCameraMotion(); j += 1) tick();
|
||||
assert.ok(
|
||||
globeFrameShare(camera) >= floor,
|
||||
`start ${start}, turn ${i + 1}`,
|
||||
);
|
||||
}
|
||||
assert.ok(surfacePixelShare(camera, 1600, 900) > 0);
|
||||
}
|
||||
});
|
||||
|
||||
test('after the frame changes size, a voice turn toward the planet is allowed', (t) => {
|
||||
withScope(t, false); // the whole frame is visible
|
||||
// A tilt stops at its limit; then the window widens at the same pose, so the
|
||||
// frame shows less than the carried limit. Toward the planet must still go.
|
||||
const { camera, canvas, viewer, finishOnce } = harness(t, {
|
||||
pitch: -80,
|
||||
viewportWidth: 1600,
|
||||
viewportHeight: 900,
|
||||
});
|
||||
assert.equal(
|
||||
moveCamera({ motion: 'tilt', direction: 'up', mode: 'once' }).ok,
|
||||
true,
|
||||
);
|
||||
finishOnce();
|
||||
canvas.clientWidth = canvas.width = viewer.scene.drawingBufferWidth = 3200;
|
||||
camera.frustum.aspectRatio = 3200 / 900;
|
||||
const narrowed = globeFrameShare(camera);
|
||||
assert.ok(narrowed < MIN_GLOBE_FRAME_SHARE, `frame now shows ${narrowed}`);
|
||||
|
||||
const down = moveCamera({ motion: 'tilt', direction: 'down', mode: 'once' });
|
||||
assert.equal(down.ok, true, down.error);
|
||||
finishOnce();
|
||||
assert.ok(globeFrameShare(camera) > narrowed);
|
||||
assert.equal(
|
||||
moveCamera({ motion: 'tilt', direction: 'down', mode: 'once' }).ok,
|
||||
true,
|
||||
);
|
||||
finishOnce();
|
||||
const up = moveCamera({ motion: 'tilt', direction: 'up', mode: 'once' });
|
||||
if (up.ok) finishOnce();
|
||||
assert.ok(surfacePixelShare(camera, 3200, 900) > 0);
|
||||
});
|
||||
|
||||
test('with the scope on, a voice tilt stops while the keyhole still shows the planet', (t) => {
|
||||
withScope(t, true);
|
||||
const { camera, finishOnce } = harness(t, {
|
||||
pitch: -80,
|
||||
viewportWidth: 1600,
|
||||
viewportHeight: 900,
|
||||
});
|
||||
const results = [];
|
||||
for (let i = 0; i < 4; i += 1) {
|
||||
const result = moveCamera({
|
||||
motion: 'tilt',
|
||||
direction: 'up',
|
||||
mode: 'once',
|
||||
});
|
||||
results.push(result.ok);
|
||||
if (result.ok) finishOnce();
|
||||
assert.ok(
|
||||
surfacePixelShare(camera, 1600, 900, { keyhole: true }) >=
|
||||
MIN_GLOBE_FRAME_SHARE - SHARE_SLACK,
|
||||
`after tilt ${i + 1}`,
|
||||
);
|
||||
}
|
||||
assert.equal(results[0], true);
|
||||
assert.equal(results.at(-1), false, 'a tilt past the limit is refused');
|
||||
});
|
||||
|
||||
test('with the scope on, a voice turn cannot leave the planet only in the masked corners', (t) => {
|
||||
// An oblique orbit view: turning in place swings the planet toward a corner,
|
||||
// where the full frame still shows it but the scope mask hides it.
|
||||
for (const [height, pitch] of [
|
||||
[5_000_000, -70],
|
||||
[3_000_000, -75],
|
||||
[18_000_000, -80],
|
||||
]) {
|
||||
withScope(t, true);
|
||||
const { camera, tick } = harness(t, {
|
||||
height,
|
||||
pitch,
|
||||
viewportWidth: 1600,
|
||||
viewportHeight: 900,
|
||||
});
|
||||
assert.equal(
|
||||
moveCamera({ motion: 'rotate', direction: 'left', mode: 'continuous' })
|
||||
.ok,
|
||||
true,
|
||||
);
|
||||
for (let i = 0; i < 600 && getActiveCameraMotion(); i += 1) tick();
|
||||
assert.equal(getActiveCameraMotion(), null, 'the turn stops at the limit');
|
||||
assert.ok(
|
||||
surfacePixelShare(camera, 1600, 900, { keyhole: true }) >=
|
||||
MIN_GLOBE_FRAME_SHARE - SHARE_SLACK,
|
||||
`${height / 1000} km at ${pitch}°`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('far out, a voice tilt is either refused or really moves, at display frame rates', (t) => {
|
||||
// A small planet disc: its sampled share wobbles as it crosses the grid.
|
||||
// Whatever the limit decides, "done" must mean the camera turned, and some
|
||||
// of these tilts must be accepted for that to be checked at all.
|
||||
let accepted = 0;
|
||||
for (const [height, pitch] of FAR_OUT_POSES) {
|
||||
withScope(t, true);
|
||||
const { camera, tick } = harness(t, {
|
||||
latitude: 45.37,
|
||||
longitudeDeg: -33.33,
|
||||
height,
|
||||
heading: 89.9,
|
||||
pitch,
|
||||
viewportWidth: 1280,
|
||||
viewportHeight: 1024,
|
||||
});
|
||||
for (let i = 0; i < 4; i += 1) {
|
||||
const before = camera.pitch;
|
||||
const result = moveCamera({
|
||||
motion: 'tilt',
|
||||
direction: 'up',
|
||||
mode: 'once',
|
||||
});
|
||||
if (!result.ok) {
|
||||
assert.match(result.error, /zoom in first/);
|
||||
continue;
|
||||
}
|
||||
accepted += 1;
|
||||
for (let j = 0; j < 600 && getActiveCameraMotion(); j += 1) tick(1 / 60);
|
||||
assert.ok(
|
||||
camera.pitch - before > radians(0.5) - 1e-9,
|
||||
`${height / 1000} km at ${pitch}°: tilt ${i + 1} reported done but moved ${Cesium.Math.toDegrees(camera.pitch - before)}°`,
|
||||
);
|
||||
}
|
||||
assert.ok(surfacePixelShare(camera, 1280, 1024, { keyhole: true }) > 0);
|
||||
}
|
||||
assert.ok(accepted > 0, 'no far-out tilt was accepted');
|
||||
});
|
||||
|
||||
test('a voice turn reported as done turns at least the half degree its check proved', (t) => {
|
||||
// A grazing sliver: the first frame's small step lands in a dip of the
|
||||
// sampled share, which used to stop the turn before it moved at all.
|
||||
for (const scope of [true, false]) {
|
||||
withScope(t, scope);
|
||||
const { camera, tick } = harness(t, {
|
||||
latitude: 20,
|
||||
longitudeDeg: 10,
|
||||
height: 18_000_000,
|
||||
pitch: -58,
|
||||
heading: 37,
|
||||
viewportWidth: 1600,
|
||||
viewportHeight: 900,
|
||||
});
|
||||
const before = Cesium.Cartesian3.clone(camera.directionWC);
|
||||
const result = moveCamera({
|
||||
motion: 'rotate',
|
||||
direction: 'left',
|
||||
mode: 'once',
|
||||
});
|
||||
if (!result.ok) {
|
||||
assert.match(result.error, /globe out of view/);
|
||||
continue;
|
||||
}
|
||||
for (let i = 0; i < 600 && getActiveCameraMotion(); i += 1) tick(1 / 60);
|
||||
const turned = Cesium.Cartesian3.angleBetween(before, camera.directionWC);
|
||||
assert.ok(
|
||||
turned >= radians(0.5) - 1e-9,
|
||||
`scope ${scope ? 'on' : 'off'}: turned ${Cesium.Math.toDegrees(turned)}°`,
|
||||
);
|
||||
assert.ok(surfacePixelShare(camera, 1600, 900, { keyhole: scope }) > 0);
|
||||
}
|
||||
});
|
||||
|
||||
/** Track an entity below `camera`, locking it into that entity's frame. */
|
||||
function trackFrom(viewer, camera) {
|
||||
viewer.trackedEntity = { id: 'high-satellite' };
|
||||
const below = Cesium.Cartesian3.fromDegrees(10, 20, 400_000);
|
||||
camera.lookAtTransform(Cesium.Transforms.eastNorthUpToFixedFrame(below));
|
||||
}
|
||||
|
||||
test('a voice turn that releases a tracked camera is checked as released', (t) => {
|
||||
// Voice releases the follow camera inside the navigation that runs the
|
||||
// motion, so the request is judged by the pose the release will leave.
|
||||
let accepted = 0;
|
||||
for (const [scope, direction, motion] of [
|
||||
[true, 'up', 'tilt'],
|
||||
[false, 'up', 'tilt'],
|
||||
[false, 'left', 'rotate'],
|
||||
]) {
|
||||
withScope(t, scope);
|
||||
const { camera, viewer, tick } = harness(t, {
|
||||
latitude: 20,
|
||||
longitudeDeg: 10,
|
||||
height: 18_000_000,
|
||||
pitch: -58,
|
||||
heading: 37,
|
||||
viewportWidth: 1600,
|
||||
viewportHeight: 900,
|
||||
});
|
||||
trackFrom(viewer, camera);
|
||||
const releaseThenRun = (start) => {
|
||||
viewer.trackedEntity = undefined;
|
||||
camera.lookAtTransform(Cesium.Matrix4.IDENTITY);
|
||||
return start();
|
||||
};
|
||||
const before = Cesium.Cartesian3.clone(camera.directionWC);
|
||||
const result = moveCamera(
|
||||
{ motion, direction, mode: 'once' },
|
||||
releaseThenRun,
|
||||
);
|
||||
const label = `${motion} ${direction}, scope ${scope ? 'on' : 'off'}`;
|
||||
if (!result.ok) {
|
||||
assert.match(result.error, /globe out of view/, label);
|
||||
continue;
|
||||
}
|
||||
accepted += 1;
|
||||
for (let i = 0; i < 600 && getActiveCameraMotion(); i += 1) tick(1 / 60);
|
||||
const turned = Cesium.Cartesian3.angleBetween(before, camera.directionWC);
|
||||
assert.ok(turned >= radians(0.5) - 1e-9, `${label}: turned ${turned}`);
|
||||
assert.ok(
|
||||
surfacePixelShare(camera, 1600, 900, { keyhole: scope }) > 0,
|
||||
`${label}: the planet stays on screen`,
|
||||
);
|
||||
}
|
||||
assert.ok(accepted > 0, 'no released turn was accepted');
|
||||
});
|
||||
|
||||
test('a refused voice turn leaves a tracked camera tracking', (t) => {
|
||||
for (const scope of [true, false]) {
|
||||
withScope(t, scope);
|
||||
const { camera, viewer, tick } = harness(t, {
|
||||
pitch: -80,
|
||||
viewportWidth: 1600,
|
||||
viewportHeight: 900,
|
||||
});
|
||||
// Tilt up to the limit untracked, then track from there.
|
||||
for (let i = 0; i < 40; i += 1) {
|
||||
if (!moveCamera({ motion: 'tilt', direction: 'up', mode: 'once' }).ok)
|
||||
break;
|
||||
for (let j = 0; j < 600 && getActiveCameraMotion(); j += 1) tick(1 / 60);
|
||||
}
|
||||
trackFrom(viewer, camera);
|
||||
const tracked = viewer.trackedEntity;
|
||||
const transform = Cesium.Matrix4.clone(camera.transform);
|
||||
let released = false;
|
||||
const result = moveCamera(
|
||||
{ motion: 'tilt', direction: 'up', mode: 'once' },
|
||||
(start) => {
|
||||
released = true;
|
||||
viewer.trackedEntity = undefined;
|
||||
camera.lookAtTransform(Cesium.Matrix4.IDENTITY);
|
||||
return start();
|
||||
},
|
||||
);
|
||||
const label = `scope ${scope ? 'on' : 'off'}`;
|
||||
assert.equal(result.ok, false, label);
|
||||
assert.match(result.error, /globe out of view/, label);
|
||||
assert.equal(released, false, label);
|
||||
assert.equal(viewer.trackedEntity, tracked, label);
|
||||
assert.ok(Cesium.Matrix4.equals(camera.transform, transform), label);
|
||||
}
|
||||
});
|
||||
|
||||
test('a turn proven at one position is checked again after a release moves the camera', (t) => {
|
||||
withScope(t, false);
|
||||
const { camera, tick } = harness(t, {
|
||||
pitch: -80,
|
||||
viewportWidth: 1600,
|
||||
viewportHeight: 900,
|
||||
});
|
||||
// Tilt up to the limit, then check from 4,000 km closer along the same view
|
||||
// direction, where the next tilt fits; the release moves the camera back.
|
||||
for (let i = 0; i < 40; i += 1) {
|
||||
if (!moveCamera({ motion: 'tilt', direction: 'up', mode: 'once' }).ok)
|
||||
break;
|
||||
for (let j = 0; j < 600 && getActiveCameraMotion(); j += 1) tick(1 / 60);
|
||||
}
|
||||
camera.moveForward(4_000_000);
|
||||
const result = moveCamera(
|
||||
{ motion: 'tilt', direction: 'up', mode: 'once' },
|
||||
(start) => {
|
||||
camera.moveBackward(4_000_000);
|
||||
return start();
|
||||
},
|
||||
);
|
||||
assert.equal(result.ok, false);
|
||||
assert.match(result.error, /globe out of view/);
|
||||
});
|
||||
|
||||
test('the voice frame limit leaves Columbus view alone', (t) => {
|
||||
// At its limit in 3D a further tilt up is refused; the same tilt in Columbus
|
||||
// view is not limited by the frame.
|
||||
withScope(t, false);
|
||||
const { viewer, finishOnce } = harness(t, {
|
||||
pitch: -80,
|
||||
viewportWidth: 1600,
|
||||
viewportHeight: 900,
|
||||
});
|
||||
assert.equal(
|
||||
moveCamera({ motion: 'tilt', direction: 'up', mode: 'once' }).ok,
|
||||
true,
|
||||
);
|
||||
finishOnce();
|
||||
const refused = moveCamera({ motion: 'tilt', direction: 'up', mode: 'once' });
|
||||
assert.match(refused.error || '', /globe out of view/);
|
||||
viewer.scene.mode = Cesium.SceneMode.COLUMBUS_VIEW;
|
||||
const result = moveCamera({ motion: 'tilt', direction: 'up', mode: 'once' });
|
||||
assert.doesNotMatch(result.error || '', /globe out of view/);
|
||||
});
|
||||
|
||||
test('voice tilt below the free-look height is not limited by the frame', (t) => {
|
||||
const { camera, finishOnce } = harness(t, { height: 500_000, pitch: -60 });
|
||||
const result = moveCamera({ motion: 'tilt', direction: 'up', mode: 'once' });
|
||||
assert.equal(result.ok, true);
|
||||
finishOnce();
|
||||
near(camera.pitch, radians(-45), 1e-6, 'full 15° tilt');
|
||||
});
|
||||
@@ -223,11 +223,20 @@ test('validated voice camera destinations share the UI navigation authority faca
|
||||
'move camera',
|
||||
);
|
||||
ordered(move, [
|
||||
"if (!['orbit', 'pan', 'tilt', 'rotate', 'rotate_globe'].includes(motion))",
|
||||
'if (argsError) return argsError;',
|
||||
'const start = () => {',
|
||||
/const preserveCameraFlight =\s*motion === 'orbit'/,
|
||||
'runNavigation(start, { preserveCameraFlight })',
|
||||
], 'move validation before handoff');
|
||||
const moveArgs = body(
|
||||
cameraVerbs,
|
||||
/export function moveCameraArgsError\(args = \{\}\) \{([\s\S]*?)\n\}/,
|
||||
'move camera argument checks',
|
||||
);
|
||||
assert.ok(
|
||||
moveArgs.includes("if (!['orbit', 'pan', 'tilt', 'rotate', 'rotate_globe'].includes(motion))"),
|
||||
'unknown motions are refused before handoff',
|
||||
);
|
||||
|
||||
const route = body(
|
||||
cameraVerbs,
|
||||
|
||||
@@ -0,0 +1,773 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import * as Cesium from 'cesium';
|
||||
import {
|
||||
adjustOrbitRange,
|
||||
getActiveCameraMotion,
|
||||
initCameraVerbs,
|
||||
interruptCameraMotion,
|
||||
moveCamera,
|
||||
} from './cameraVerbs.js';
|
||||
import { createGevActionRunner } from './voice/gevActions.js';
|
||||
|
||||
const radians = Cesium.Math.toRadians;
|
||||
const degrees = Cesium.Math.toDegrees;
|
||||
|
||||
/** Real Cesium camera over London with no renderer: the voice fly-to frame. */
|
||||
function harness(
|
||||
t,
|
||||
{ height = 4000, heading = 0, pitch = -90, pivot = true } = {},
|
||||
) {
|
||||
const realPerformance = globalThis.performance;
|
||||
let nowMs = 0;
|
||||
globalThis.performance = { now: () => nowMs };
|
||||
const canvas = Object.assign(new EventTarget(), {
|
||||
clientWidth: 1280,
|
||||
clientHeight: 800,
|
||||
width: 1280,
|
||||
height: 800,
|
||||
});
|
||||
const ellipsoid = Cesium.Ellipsoid.WGS84;
|
||||
const scene = {
|
||||
canvas,
|
||||
drawingBufferWidth: 1280,
|
||||
drawingBufferHeight: 800,
|
||||
frameState: { mode: Cesium.SceneMode.SCENE3D },
|
||||
mapProjection: new Cesium.GeographicProjection(ellipsoid),
|
||||
globe: { ellipsoid },
|
||||
mapMode2D: Cesium.MapMode2D.INFINITE_SCROLL,
|
||||
mode: Cesium.SceneMode.SCENE3D,
|
||||
pixelRatio: 1,
|
||||
tweens: [],
|
||||
requestRender() {},
|
||||
};
|
||||
const camera = new Cesium.Camera(scene);
|
||||
scene.camera = camera;
|
||||
camera.setView({
|
||||
destination: Cesium.Cartesian3.fromDegrees(-0.1276, 51.5072, height),
|
||||
orientation: { heading: radians(heading), pitch: radians(pitch), roll: 0 },
|
||||
});
|
||||
const viewer = {
|
||||
camera,
|
||||
scene,
|
||||
trackedEntity: undefined,
|
||||
trackedEntityChanged: new Cesium.Event(),
|
||||
clock: { onTick: new Cesium.Event() },
|
||||
};
|
||||
// The app's view target: the ground point under the screen centre.
|
||||
const viewTarget = () => {
|
||||
const ray = new Cesium.Ray(camera.positionWC, camera.directionWC);
|
||||
const hit = Cesium.IntersectionTests.rayEllipsoid(ray, ellipsoid);
|
||||
return hit ? Cesium.Ray.getPoint(ray, hit.start) : undefined;
|
||||
};
|
||||
const target = viewTarget();
|
||||
initCameraVerbs(viewer, pivot ? viewTarget : () => undefined);
|
||||
t.after(() => {
|
||||
interruptCameraMotion('test-cleanup');
|
||||
globalThis.performance = realPerformance;
|
||||
});
|
||||
const tick = (seconds = 1 / 30) => {
|
||||
nowMs += seconds * 1000;
|
||||
viewer.clock.onTick.raiseEvent(viewer.clock);
|
||||
};
|
||||
const finish = () => {
|
||||
for (let i = 0; i < 600 && getActiveCameraMotion(); i += 1) tick();
|
||||
assert.equal(getActiveCameraMotion(), null, 'tilt must finish');
|
||||
};
|
||||
return { viewer, camera, target, tick, finish, viewTarget };
|
||||
}
|
||||
|
||||
const pitchDeg = (camera) => degrees(camera.pitch);
|
||||
/** Tick until done() holds; a regression fails here instead of hanging. */
|
||||
const tickUntil = (h, done, frames = 600) => {
|
||||
for (let i = 0; i < frames && !done(); i += 1) h.tick();
|
||||
assert.ok(done(), `not reached in ${frames} frames`);
|
||||
};
|
||||
const headingDeg = (camera) =>
|
||||
degrees(Cesium.Math.negativePiToPi(camera.heading));
|
||||
|
||||
/** Metres between the original centre and the ground now under the crosshair. */
|
||||
function centreDrift(h) {
|
||||
return Cesium.Cartesian3.distance(h.viewTarget(), h.target);
|
||||
}
|
||||
|
||||
test('tilt up leaves a top-down view and keeps the place centred', (t) => {
|
||||
const h = harness(t);
|
||||
const result = moveCamera({ motion: 'tilt', direction: 'up' });
|
||||
assert.equal(result.ok, true);
|
||||
h.finish();
|
||||
assert.ok(
|
||||
Math.abs(pitchDeg(h.camera) - -75) < 0.5,
|
||||
`pitch ${pitchDeg(h.camera)}`,
|
||||
);
|
||||
assert.ok(centreDrift(h) < 5, `centre drifted ${centreDrift(h)} m`);
|
||||
assert.ok(Cesium.Matrix4.equals(h.camera.transform, Cesium.Matrix4.IDENTITY));
|
||||
});
|
||||
|
||||
test('continuous tilt up from top-down keeps moving', (t) => {
|
||||
const h = harness(t);
|
||||
assert.equal(
|
||||
moveCamera({ motion: 'tilt', direction: 'up', mode: 'continuous' }).ok,
|
||||
true,
|
||||
);
|
||||
for (let i = 0; i < 30; i += 1) h.tick();
|
||||
assert.equal(getActiveCameraMotion()?.kind, 'tilt');
|
||||
assert.ok(pitchDeg(h.camera) > -82, `pitch ${pitchDeg(h.camera)}`);
|
||||
});
|
||||
|
||||
test('a requested angle tilts to that pitch', (t) => {
|
||||
const h = harness(t);
|
||||
const result = moveCamera({ motion: 'tilt', pitchDeg: -45 });
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.direction, 'up');
|
||||
assert.equal(result.pitchDeg, -45);
|
||||
h.finish();
|
||||
assert.ok(
|
||||
Math.abs(pitchDeg(h.camera) - -45) < 0.5,
|
||||
`pitch ${pitchDeg(h.camera)}`,
|
||||
);
|
||||
assert.ok(centreDrift(h) < 5, `centre drifted ${centreDrift(h)} m`);
|
||||
});
|
||||
|
||||
test('a bare tilt settles on the default oblique angle', (t) => {
|
||||
const h = harness(t);
|
||||
const result = moveCamera({ motion: 'tilt' });
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.pitchDeg, -45);
|
||||
h.finish();
|
||||
assert.ok(Math.abs(pitchDeg(h.camera) - -45) < 0.5);
|
||||
});
|
||||
|
||||
test('a requested angle can tilt back down toward top-down', (t) => {
|
||||
const h = harness(t, { pitch: -30 });
|
||||
const result = moveCamera({ motion: 'tilt', pitchDeg: -80 });
|
||||
assert.equal(result.direction, 'down');
|
||||
h.finish();
|
||||
assert.ok(Math.abs(pitchDeg(h.camera) - -80) < 0.5);
|
||||
});
|
||||
|
||||
test('requested angles clamp to the tilt limits', (t) => {
|
||||
const h = harness(t);
|
||||
assert.equal(moveCamera({ motion: 'tilt', pitchDeg: -2 }).pitchDeg, -5);
|
||||
h.finish();
|
||||
assert.ok(Math.abs(pitchDeg(h.camera) - -5) < 0.5);
|
||||
});
|
||||
|
||||
test('a positive angle is read as the same angle below the horizon', (t) => {
|
||||
const h = harness(t);
|
||||
assert.equal(moveCamera({ motion: 'tilt', pitchDeg: 45 }).pitchDeg, -45);
|
||||
h.finish();
|
||||
assert.ok(Math.abs(pitchDeg(h.camera) - -45) < 0.5);
|
||||
});
|
||||
|
||||
for (const pitch of ['', null, false, ' ', 'steep'])
|
||||
test(`a blank or non-numeric angle ${JSON.stringify(pitch)} is ignored`, (t) => {
|
||||
harness(t);
|
||||
const result = moveCamera({ motion: 'tilt', pitchDeg: pitch });
|
||||
// No angle and no direction: the default oblique view, never the horizon.
|
||||
assert.equal(result.pitchDeg, -45);
|
||||
});
|
||||
|
||||
test('a requested angle the camera already holds is a no-op', (t) => {
|
||||
harness(t, { pitch: -45 });
|
||||
const result = moveCamera({ motion: 'tilt', pitchDeg: -45 });
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.unchanged, true);
|
||||
assert.equal(getActiveCameraMotion(), null);
|
||||
});
|
||||
|
||||
for (const pitch of [-90, -89]) {
|
||||
test(`a top-down view asked for ${pitch}° stays put`, (t) => {
|
||||
// The tool description calls -89 top-down; the clamp must not tilt a
|
||||
// -90° view up 1° and report it as a move up.
|
||||
const h = harness(t);
|
||||
const before = pitchDeg(h.camera);
|
||||
const result = moveCamera({ motion: 'tilt', pitchDeg: pitch });
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.unchanged, true);
|
||||
assert.equal(result.pitchDeg, Math.round(before));
|
||||
assert.equal(getActiveCameraMotion(), null);
|
||||
assert.equal(pitchDeg(h.camera), before);
|
||||
});
|
||||
}
|
||||
|
||||
test('tilt down from top-down still refuses honestly', (t) => {
|
||||
harness(t);
|
||||
const result = moveCamera({ motion: 'tilt', direction: 'down' });
|
||||
assert.equal(result.ok, false);
|
||||
assert.match(result.error, /straight down/);
|
||||
});
|
||||
|
||||
test('a tilt up nudge stops at the horizon limit', (t) => {
|
||||
const h = harness(t, { pitch: -12 });
|
||||
assert.equal(moveCamera({ motion: 'tilt', direction: 'up' }).ok, true);
|
||||
h.finish();
|
||||
assert.ok(Math.abs(pitchDeg(h.camera) - -5) < 0.5);
|
||||
});
|
||||
|
||||
test('without a ground pivot a tilt still leaves top-down in place', (t) => {
|
||||
const h = harness(t, { pivot: false });
|
||||
const before = Cesium.Cartesian3.clone(h.camera.positionWC);
|
||||
assert.equal(moveCamera({ motion: 'tilt', direction: 'up' }).ok, true);
|
||||
h.finish();
|
||||
assert.ok(Math.abs(pitchDeg(h.camera) - -75) < 0.5);
|
||||
assert.ok(Cesium.Cartesian3.distance(h.camera.positionWC, before) < 1e-6);
|
||||
});
|
||||
|
||||
for (const heading of [0, 30, -120])
|
||||
test(`tilt from top-down keeps the ${heading}° heading`, (t) => {
|
||||
const h = harness(t, { heading, height: 67000 });
|
||||
// Top-down float noise must not pick a new heading for the pivot.
|
||||
assert.equal(moveCamera({ motion: 'tilt', pitchDeg: -45 }).ok, true);
|
||||
h.finish();
|
||||
assert.ok(
|
||||
Math.abs(headingDeg(h.camera) - heading) < 1,
|
||||
`heading ${headingDeg(h.camera)}`,
|
||||
);
|
||||
assert.ok(centreDrift(h) < 50, `centre drifted ${centreDrift(h)} m`);
|
||||
});
|
||||
|
||||
test('an oblique tilt keeps its heading toward the pivot', (t) => {
|
||||
const h = harness(t, { heading: 75, pitch: -60 });
|
||||
assert.equal(moveCamera({ motion: 'tilt', pitchDeg: -30 }).ok, true);
|
||||
h.finish();
|
||||
assert.ok(Math.abs(headingDeg(h.camera) - 75) < 0.5);
|
||||
assert.ok(centreDrift(h) < 5, `centre drifted ${centreDrift(h)} m`);
|
||||
});
|
||||
|
||||
test('a pivoting tilt yields when tracking takes the camera', (t) => {
|
||||
const h = harness(t);
|
||||
assert.equal(moveCamera({ motion: 'tilt', pitchDeg: -45 }).ok, true);
|
||||
h.tick();
|
||||
const entity = { id: 'tracked' };
|
||||
h.viewer.trackedEntity = entity;
|
||||
h.viewer.trackedEntityChanged.raiseEvent(entity);
|
||||
assert.equal(getActiveCameraMotion(), null);
|
||||
});
|
||||
|
||||
/** Metres between the camera and the point it pivots about. */
|
||||
const pivotRange = (h) =>
|
||||
Cesium.Cartesian3.distance(h.camera.positionWC, h.target);
|
||||
const heightM = (camera) =>
|
||||
Cesium.Cartographic.fromCartesian(camera.positionWC).height;
|
||||
|
||||
test('a zoom during a pivoting tilt changes the range it holds', (t) => {
|
||||
const h = harness(t);
|
||||
assert.equal(
|
||||
moveCamera({ motion: 'tilt', direction: 'up', mode: 'continuous' }).ok,
|
||||
true,
|
||||
);
|
||||
h.tick();
|
||||
const before = pivotRange(h);
|
||||
assert.deepEqual(
|
||||
adjustOrbitRange(0.5),
|
||||
{ kind: 'tilt', changed: true },
|
||||
'the tilt must take the zoom',
|
||||
);
|
||||
for (let i = 0; i < 5; i += 1) h.tick();
|
||||
// The next frame's lookAt must not put the old range back.
|
||||
assert.ok(
|
||||
Math.abs(pivotRange(h) - before / 2) < 1,
|
||||
`range ${pivotRange(h)} m, wanted ${before / 2} m`,
|
||||
);
|
||||
});
|
||||
|
||||
test('a zoom with no pivot to hold is left to the camera', (t) => {
|
||||
harness(t, { pivot: false });
|
||||
moveCamera({ motion: 'tilt', direction: 'up', mode: 'continuous' });
|
||||
assert.equal(adjustOrbitRange(0.5), null);
|
||||
});
|
||||
|
||||
test('a pivot tilt toward the horizon keeps its clearance above the ground', (t) => {
|
||||
// A close top-down view: swinging 250 m of range to -5° would end ~22 m up.
|
||||
const h = harness(t, { height: 250 });
|
||||
assert.equal(moveCamera({ motion: 'tilt', pitchDeg: -5 }).ok, true);
|
||||
let lowest = Infinity;
|
||||
for (let i = 0; i < 600 && getActiveCameraMotion(); i += 1) {
|
||||
h.tick();
|
||||
lowest = Math.min(lowest, heightM(h.camera));
|
||||
}
|
||||
assert.equal(getActiveCameraMotion(), null, 'tilt must finish');
|
||||
assert.ok(lowest > 149, `camera dropped to ${lowest} m`);
|
||||
assert.ok(
|
||||
Math.abs(pitchDeg(h.camera) - -5) < 0.5,
|
||||
`pitch ${pitchDeg(h.camera)}`,
|
||||
);
|
||||
assert.ok(Cesium.Matrix4.equals(h.camera.transform, Cesium.Matrix4.IDENTITY));
|
||||
});
|
||||
|
||||
test('a continuous tilt up from close in keeps its clearance', (t) => {
|
||||
const h = harness(t, { height: 250 });
|
||||
moveCamera({ motion: 'tilt', direction: 'up', mode: 'continuous' });
|
||||
for (let i = 0; i < 600 && getActiveCameraMotion(); i += 1) {
|
||||
h.tick();
|
||||
assert.ok(heightM(h.camera) > 149, `camera at ${heightM(h.camera)} m`);
|
||||
}
|
||||
assert.ok(Math.abs(pitchDeg(h.camera) - -5) < 0.5);
|
||||
});
|
||||
|
||||
test('a city-scale pivot tilt keeps the place centred to the horizon limit', (t) => {
|
||||
const h = harness(t);
|
||||
moveCamera({ motion: 'tilt', pitchDeg: -5 });
|
||||
h.finish();
|
||||
// 4 km of range clears the floor at every pitch: all pivot, no drift.
|
||||
assert.ok(centreDrift(h) < 5, `centre drifted ${centreDrift(h)} m`);
|
||||
});
|
||||
|
||||
test('pitchDeg against a contradicting direction is refused', (t) => {
|
||||
harness(t, { pitch: -45 });
|
||||
// "tilt down 10 degrees": -10° is an absolute angle, which is up from -45°.
|
||||
const result = moveCamera({
|
||||
motion: 'tilt',
|
||||
direction: 'down',
|
||||
pitchDeg: 10,
|
||||
});
|
||||
assert.equal(result.ok, false);
|
||||
assert.match(result.error, /absolute angle/);
|
||||
assert.equal(getActiveCameraMotion(), null);
|
||||
});
|
||||
|
||||
test('pitchDeg with a direction that agrees still tilts', (t) => {
|
||||
const h = harness(t, { pitch: -45 });
|
||||
const result = moveCamera({
|
||||
motion: 'tilt',
|
||||
direction: 'down',
|
||||
pitchDeg: -80,
|
||||
});
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.direction, 'down');
|
||||
h.finish();
|
||||
assert.ok(Math.abs(pitchDeg(h.camera) - -80) < 0.5);
|
||||
});
|
||||
|
||||
test('the pitchDeg and direction check uses the camera pitch', (t) => {
|
||||
// At regional range the Earth curves away and the pivot pitch is a degree
|
||||
// or two shallower than the camera's (-30°); -29° is still up from it.
|
||||
harness(t, { height: 150000, pitch: -30 });
|
||||
const down = moveCamera({ motion: 'tilt', direction: 'down', pitchDeg: -29 });
|
||||
assert.equal(down.ok, false);
|
||||
assert.match(down.error, /tilts up, not down/);
|
||||
const up = moveCamera({ motion: 'tilt', direction: 'up', pitchDeg: -29 });
|
||||
assert.equal(up.ok, true);
|
||||
assert.equal(up.direction, 'up');
|
||||
});
|
||||
|
||||
test('after a zoom in, a pivoting tilt never lowers the camera further', (t) => {
|
||||
const h = harness(t, { height: 1000 });
|
||||
moveCamera({ motion: 'tilt', direction: 'up', mode: 'continuous' });
|
||||
tickUntil(h, () => pitchDeg(h.camera) >= -30);
|
||||
adjustOrbitRange(1 / 2.4);
|
||||
adjustOrbitRange(1 / 2.4);
|
||||
h.tick();
|
||||
const afterZoom = heightM(h.camera);
|
||||
for (let i = 0; i < 600 && getActiveCameraMotion(); i += 1) {
|
||||
h.tick();
|
||||
assert.ok(
|
||||
heightM(h.camera) > afterZoom - 1,
|
||||
`camera fell from ${afterZoom} m to ${heightM(h.camera)} m`,
|
||||
);
|
||||
}
|
||||
assert.ok(Math.abs(pitchDeg(h.camera) - -5) < 0.5);
|
||||
});
|
||||
|
||||
test('a zoom in during a close pivot tilt never pushes the camera out', (t) => {
|
||||
// 62 m of range, under the 80 m zoom floor.
|
||||
const h = harness(t, { height: 40, pitch: -40 });
|
||||
moveCamera({ motion: 'tilt', direction: 'down', mode: 'continuous' });
|
||||
h.tick();
|
||||
const before = pivotRange(h);
|
||||
assert.ok(before < 80, `range ${before} m`);
|
||||
adjustOrbitRange(1 / 1.6);
|
||||
h.tick();
|
||||
assert.ok(pivotRange(h) <= before + 0.01, `range grew to ${pivotRange(h)} m`);
|
||||
});
|
||||
|
||||
test('a zoom out during a far orbit never pulls the camera in', (t) => {
|
||||
// 15,000 km up, beyond the 5,000 km zoom cap.
|
||||
const h = harness(t, { height: 15_000_000 });
|
||||
assert.equal(moveCamera({ motion: 'orbit', mode: 'continuous' }).ok, true);
|
||||
h.tick();
|
||||
const before = pivotRange(h);
|
||||
assert.ok(before > 5_000_000, `range ${before} m`);
|
||||
assert.deepEqual(adjustOrbitRange(2.4), { kind: 'orbit', changed: false });
|
||||
h.tick();
|
||||
assert.ok(pivotRange(h) >= before - 1, `range fell to ${pivotRange(h)} m`);
|
||||
});
|
||||
|
||||
test('an out-of-range pitchDeg is clamped to the tilt limits', (t) => {
|
||||
// The schema carries no range; 120 reads as 120° below the horizon.
|
||||
const h = harness(t, { pitch: -45 });
|
||||
assert.equal(moveCamera({ motion: 'tilt', pitchDeg: 120 }).ok, true);
|
||||
h.finish();
|
||||
assert.ok(
|
||||
Math.abs(pitchDeg(h.camera) - -89) < 0.5,
|
||||
`pitch ${pitchDeg(h.camera)}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('a zoom in during a pivot tilt stops at the clearance', (t) => {
|
||||
// 400 m of range at -40°, then "zoom in a lot" twice: unfloored, that left
|
||||
// the camera about 107 m up, then at the 80 m zoom floor.
|
||||
const h = harness(t, { height: 400 });
|
||||
moveCamera({ motion: 'tilt', direction: 'up', mode: 'continuous' });
|
||||
tickUntil(h, () => pitchDeg(h.camera) >= -40);
|
||||
assert.deepEqual(adjustOrbitRange(1 / 2.4), { kind: 'tilt', changed: true });
|
||||
// At -40° the floor is 150 / sin 40° ≈ 233 m, which the first zoom reached.
|
||||
assert.deepEqual(adjustOrbitRange(1 / 2.4), { kind: 'tilt', changed: false });
|
||||
// The camera must take that range, not just report it.
|
||||
h.tick();
|
||||
const floor = 150 / Math.sin(radians(40));
|
||||
assert.ok(
|
||||
Math.abs(pivotRange(h) - floor) < floor * 0.02,
|
||||
`range ${pivotRange(h)} m, wanted about ${floor} m`,
|
||||
);
|
||||
for (let i = 0; i < 600 && getActiveCameraMotion(); i += 1) {
|
||||
h.tick();
|
||||
assert.ok(heightM(h.camera) > 149, `camera at ${heightM(h.camera)} m`);
|
||||
}
|
||||
assert.ok(Math.abs(pitchDeg(h.camera) - -5) < 0.5);
|
||||
});
|
||||
|
||||
test('a voice zoom during a pivot tilt says what it changed', async (t) => {
|
||||
const h = harness(t, { height: 400 });
|
||||
const runner = createGevActionRunner({
|
||||
viewer: h.viewer,
|
||||
styleManager: {},
|
||||
dataManager: { layers: new Map(), getAll: () => [] },
|
||||
});
|
||||
moveCamera({ motion: 'tilt', direction: 'up', mode: 'continuous' });
|
||||
h.tick();
|
||||
const zoom = { direction: 'in', amount: 'lot' };
|
||||
const first = await runner('adjust_camera_zoom', zoom);
|
||||
assert.equal(first.ok, true);
|
||||
assert.equal(first.tiltRangeAdjusted, true);
|
||||
assert.equal('orbitRadiusAdjusted' in first, false);
|
||||
// 400 m → 167 m → the 150 m clearance; a third zoom in moves nothing, and
|
||||
// says so.
|
||||
assert.equal((await runner('adjust_camera_zoom', zoom)).ok, true);
|
||||
const third = await runner('adjust_camera_zoom', zoom);
|
||||
assert.equal(third.ok, false);
|
||||
assert.match(third.error, /tilt is already at its closest range/);
|
||||
});
|
||||
|
||||
test('from globe range a tilt happens in place', (t) => {
|
||||
// A pivot 2,000 km out would swing the camera thousands of km.
|
||||
const h = harness(t, { height: 2_000_000 });
|
||||
const before = Cesium.Cartesian3.clone(h.camera.positionWC);
|
||||
assert.equal(moveCamera({ motion: 'tilt', pitchDeg: -45 }).ok, true);
|
||||
h.finish();
|
||||
assert.ok(Math.abs(pitchDeg(h.camera) - -45) < 0.5);
|
||||
assert.ok(Cesium.Cartesian3.distance(h.camera.positionWC, before) < 1e-3);
|
||||
});
|
||||
|
||||
for (const height of [100_000, 400_000])
|
||||
test(`from ${height / 1000} km a tilt to -5° ends with the camera at -5°`, (t) => {
|
||||
// The pivot pitch is shallower than the camera's by up to 4° at this
|
||||
// range; the camera reached only -5.9° and -8.6° when the swing stopped
|
||||
// at a pivot pitch of -5°.
|
||||
const h = harness(t, { height });
|
||||
const result = moveCamera({ motion: 'tilt', pitchDeg: 5 });
|
||||
assert.equal(result.pitchDeg, -5);
|
||||
h.finish();
|
||||
assert.ok(
|
||||
Math.abs(pitchDeg(h.camera) - -5) < 0.5,
|
||||
`pitch ${pitchDeg(h.camera)}`,
|
||||
);
|
||||
assert.ok(heightM(h.camera) > 149, `camera at ${heightM(h.camera)} m`);
|
||||
const nudge = moveCamera({ motion: 'tilt', direction: 'up' });
|
||||
assert.equal(nudge.ok, false);
|
||||
assert.match(nudge.error, /upper tilt limit/);
|
||||
});
|
||||
|
||||
// From 550 km out the camera's -5° lies beyond the place's own horizon: the
|
||||
// swing ran into its clearance, let go of the pivot and tilted the rest in
|
||||
// place, which put the crosshair on the sky (28 to 63 km up), and a return
|
||||
// to top-down then landed 600 to 900 km from the place.
|
||||
for (const height of [600_000, 700_000, 900_000]) {
|
||||
test(`from ${height / 1000} km a tilt to -5° keeps the place centred`, (t) => {
|
||||
const h = harness(t, { height });
|
||||
const result = moveCamera({ motion: 'tilt', pitchDeg: 5 });
|
||||
assert.equal(result.ok, true);
|
||||
h.finish();
|
||||
assert.ok(h.viewTarget(), 'the crosshair is on the sky');
|
||||
assert.ok(centreDrift(h) < 100, `centre drifted ${centreDrift(h)} m`);
|
||||
assert.ok(heightM(h.camera) > 149, `camera at ${heightM(h.camera)} m`);
|
||||
// The reply names the angle the camera reached, not the one asked for.
|
||||
assert.ok(result.pitchDeg < -5, `reported ${result.pitchDeg}°`);
|
||||
assert.ok(
|
||||
Math.abs(pitchDeg(h.camera) - result.pitchDeg) < 0.5,
|
||||
`camera at ${pitchDeg(h.camera)}°, reported ${result.pitchDeg}°`,
|
||||
);
|
||||
assert.match(result.note, /horizon/);
|
||||
const nudge = moveCamera({ motion: 'tilt', direction: 'up' });
|
||||
assert.equal(nudge.ok, false);
|
||||
assert.match(nudge.error, /upper tilt limit/);
|
||||
assert.equal(moveCamera({ motion: 'tilt', pitchDeg: -89 }).ok, true);
|
||||
h.finish();
|
||||
assert.ok(centreDrift(h) < 100, `centre drifted ${centreDrift(h)} m`);
|
||||
// Back over the place at the range it began with (-89° is ~1° off nadir).
|
||||
const range = Cesium.Cartesian3.distance(h.camera.positionWC, h.target);
|
||||
assert.ok(
|
||||
Math.abs(range - height) < height * 0.01,
|
||||
`top-down ${range} m from the place`,
|
||||
);
|
||||
});
|
||||
|
||||
test(`a continuous tilt up from ${height / 1000} km stops with the place centred`, (t) => {
|
||||
const h = harness(t, { height });
|
||||
moveCamera({ motion: 'tilt', direction: 'up', mode: 'continuous' });
|
||||
for (let i = 0; i < 1200 && getActiveCameraMotion(); i += 1) {
|
||||
h.tick();
|
||||
assert.ok(h.viewTarget(), `the crosshair is on the sky at frame ${i}`);
|
||||
}
|
||||
assert.equal(getActiveCameraMotion(), null, 'tilt must stop at its limit');
|
||||
assert.ok(centreDrift(h) < 100, `centre drifted ${centreDrift(h)} m`);
|
||||
});
|
||||
}
|
||||
|
||||
test('a pitchDeg tilt toward the horizon limit says it is already there', (t) => {
|
||||
const h = harness(t, { height: 900_000 });
|
||||
moveCamera({ motion: 'tilt', pitchDeg: 5 });
|
||||
h.finish();
|
||||
const again = moveCamera({ motion: 'tilt', pitchDeg: 5 });
|
||||
assert.equal(again.ok, false);
|
||||
assert.match(again.error, /upper tilt limit/);
|
||||
});
|
||||
|
||||
test('a regional tilt-up nudge goes to the camera horizon limit', (t) => {
|
||||
// 20 km up at -7°: the pivot 186 km out sits at -5.3°, but the camera can
|
||||
// still tilt up 2°.
|
||||
const h = harness(t, { height: 20000, pitch: -7 });
|
||||
assert.equal(moveCamera({ motion: 'tilt', direction: 'up' }).ok, true);
|
||||
h.finish();
|
||||
assert.ok(
|
||||
Math.abs(pitchDeg(h.camera) - -5) < 0.5,
|
||||
`pitch ${pitchDeg(h.camera)}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('a voice tilt from a tracked view judges its limit from the camera pitch', (t) => {
|
||||
const h = harness(t, { height: 20000, pitch: -5.2 });
|
||||
h.viewer.trackedEntity = { id: 'tracked' };
|
||||
// Voice releases the follow camera before the motion starts.
|
||||
const release = (start) => {
|
||||
h.viewer.trackedEntity = undefined;
|
||||
return start();
|
||||
};
|
||||
const result = moveCamera({ motion: 'tilt', direction: 'up' }, release);
|
||||
assert.equal(result.ok, false);
|
||||
assert.match(result.error, /upper tilt limit/);
|
||||
});
|
||||
|
||||
test('a zoom out during a pivoting tilt stops at the pivot range limit', (t) => {
|
||||
// 900 km top-down, "tilt to 10°", then "zoom out a lot" twice: capped at
|
||||
// the orbit's 5,000 km, the swing reached 2,386 km up at a real -44°.
|
||||
const h = harness(t, { height: 900_000 });
|
||||
assert.equal(moveCamera({ motion: 'tilt', pitchDeg: -10 }).ok, true);
|
||||
h.tick();
|
||||
assert.deepEqual(adjustOrbitRange(2.4), { kind: 'tilt', changed: true });
|
||||
assert.deepEqual(adjustOrbitRange(2.4), { kind: 'tilt', changed: false });
|
||||
let farthest = 0;
|
||||
for (let i = 0; i < 600 && getActiveCameraMotion(); i += 1) {
|
||||
h.tick();
|
||||
farthest = Math.max(farthest, pivotRange(h));
|
||||
}
|
||||
assert.equal(getActiveCameraMotion(), null, 'tilt must finish');
|
||||
assert.ok(farthest <= 1_000_001, `swung ${farthest} m from the pivot`);
|
||||
assert.ok(
|
||||
Math.abs(pitchDeg(h.camera) - -10) < 0.5,
|
||||
`pitch ${pitchDeg(h.camera)}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('an unchanged angle still stops a running motion through navigation', (t) => {
|
||||
const h = harness(t);
|
||||
moveCamera({ motion: 'tilt', direction: 'up', mode: 'continuous' });
|
||||
tickUntil(h, () => pitchDeg(h.camera) >= -45.3);
|
||||
let handoffs = 0;
|
||||
const navigate = (start) => {
|
||||
handoffs += 1;
|
||||
return start();
|
||||
};
|
||||
const result = moveCamera({ motion: 'tilt', pitchDeg: -45 }, navigate);
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.unchanged, true);
|
||||
assert.equal(handoffs, 1, 'the request must take navigation');
|
||||
assert.equal(getActiveCameraMotion(), null, 'the tilt must stop');
|
||||
const held = pitchDeg(h.camera);
|
||||
for (let i = 0; i < 30; i += 1) h.tick();
|
||||
assert.equal(pitchDeg(h.camera), held);
|
||||
});
|
||||
|
||||
for (const direction of ['left', 'right'])
|
||||
test(`a tilt ${direction} is refused`, (t) => {
|
||||
harness(t, { pitch: -45 });
|
||||
const result = moveCamera({ motion: 'tilt', direction });
|
||||
assert.equal(result.ok, false);
|
||||
assert.match(result.error, /tilt needs up or down/);
|
||||
const angled = moveCamera({ motion: 'tilt', direction, pitchDeg: -30 });
|
||||
assert.equal(angled.ok, false);
|
||||
assert.match(angled.error, /pitchDeg alone/);
|
||||
assert.equal(getActiveCameraMotion(), null);
|
||||
});
|
||||
|
||||
// The 1,000 km pivot cutoff includes its boundary: at exactly 1,000 km the
|
||||
// pivot range came back a hair over it, so the tilt ran in place and put the
|
||||
// crosshair on the sky.
|
||||
test('from exactly 1,000 km a tilt to -5° still pivots about the place', (t) => {
|
||||
const h = harness(t, { height: 1_000_000 });
|
||||
const result = moveCamera({ motion: 'tilt', pitchDeg: 5 });
|
||||
assert.equal(result.ok, true);
|
||||
h.finish();
|
||||
assert.ok(h.viewTarget(), 'the crosshair is on the sky');
|
||||
assert.ok(centreDrift(h) < 100, `centre drifted ${centreDrift(h)} m`);
|
||||
assert.ok(
|
||||
Math.abs(pitchDeg(h.camera) - result.pitchDeg) < 0.5,
|
||||
`camera at ${pitchDeg(h.camera)}°, reported ${result.pitchDeg}°`,
|
||||
);
|
||||
assert.match(result.note, /horizon/);
|
||||
});
|
||||
|
||||
// A zoom mid-tilt moves the pivot's horizon limit: the zoom's reply names the
|
||||
// angle the tilt now ends at, and the camera ends there.
|
||||
test('a zoom out during a tilt to an angle reports the angle it now ends at', (t) => {
|
||||
const h = harness(t, { height: 400_000 });
|
||||
const result = moveCamera({ motion: 'tilt', pitchDeg: -5 });
|
||||
assert.equal(result.pitchDeg, -5);
|
||||
assert.equal(result.note, undefined);
|
||||
for (let i = 0; i < 10; i += 1) h.tick();
|
||||
const zoom = adjustOrbitRange(2.5);
|
||||
assert.equal(zoom.kind, 'tilt');
|
||||
assert.equal(zoom.changed, true);
|
||||
h.finish();
|
||||
assert.ok(h.viewTarget(), 'the crosshair is on the sky');
|
||||
assert.ok(zoom.pitchDeg < -5, `zoom reported ${zoom.pitchDeg}°`);
|
||||
assert.ok(
|
||||
Math.abs(pitchDeg(h.camera) - zoom.pitchDeg) < 0.5,
|
||||
`camera at ${pitchDeg(h.camera)}°, zoom reported ${zoom.pitchDeg}°`,
|
||||
);
|
||||
assert.match(zoom.note, /horizon/);
|
||||
});
|
||||
|
||||
test('a zoom in during a horizon-limited tilt reports the angle it now reaches', (t) => {
|
||||
const h = harness(t, { height: 900_000 });
|
||||
const result = moveCamera({ motion: 'tilt', pitchDeg: -5 });
|
||||
assert.ok(result.pitchDeg < -5, `reported ${result.pitchDeg}°`);
|
||||
for (let i = 0; i < 10; i += 1) h.tick();
|
||||
const zoom = adjustOrbitRange(1 / 2.4);
|
||||
assert.equal(zoom.changed, true);
|
||||
h.finish();
|
||||
assert.equal(zoom.pitchDeg, -5);
|
||||
assert.ok(
|
||||
Math.abs(pitchDeg(h.camera) - -5) < 0.5,
|
||||
`camera at ${pitchDeg(h.camera)}°`,
|
||||
);
|
||||
});
|
||||
|
||||
test('a voice zoom during a tilt to an angle passes on the new end angle', async (t) => {
|
||||
const h = harness(t, { height: 400_000 });
|
||||
const runner = createGevActionRunner({
|
||||
viewer: h.viewer,
|
||||
styleManager: {},
|
||||
dataManager: { layers: new Map(), getAll: () => [] },
|
||||
});
|
||||
moveCamera({ motion: 'tilt', pitchDeg: -5 });
|
||||
for (let i = 0; i < 10; i += 1) h.tick();
|
||||
const zoom = await runner('adjust_camera_zoom', {
|
||||
direction: 'out',
|
||||
amount: 'lot',
|
||||
});
|
||||
assert.equal(zoom.ok, true);
|
||||
assert.equal(zoom.tiltRangeAdjusted, true);
|
||||
h.finish();
|
||||
assert.ok(
|
||||
Math.abs(pitchDeg(h.camera) - zoom.pitchDeg) < 0.5,
|
||||
`camera at ${pitchDeg(h.camera)}°, zoom reported ${zoom.pitchDeg}°`,
|
||||
);
|
||||
assert.match(zoom.note, /horizon/);
|
||||
});
|
||||
|
||||
// Late in the tilt the camera is already past the angle the new range's
|
||||
// horizon allows (from 400 km at -8°, a zoom out x2.5 reported -8°): past it
|
||||
// the place is behind the Earth, so the camera steps down to that limit with
|
||||
// the zoom, then holds it with no further jump, and the zoom's reply names
|
||||
// the angle it ends at, not the one it stood at.
|
||||
test('a late zoom out during a tilt steps to the new limit and reports it', (t) => {
|
||||
const h = harness(t, { height: 400_000 });
|
||||
moveCamera({ motion: 'tilt', pitchDeg: -5 });
|
||||
for (let i = 0; i < 70; i += 1) h.tick();
|
||||
const zoom = adjustOrbitRange(2.5);
|
||||
assert.equal(zoom.changed, true);
|
||||
h.tick();
|
||||
assert.equal(zoom.pitchDeg, Math.round(pitchDeg(h.camera)));
|
||||
let last = pitchDeg(h.camera);
|
||||
let maxStep = 0;
|
||||
for (let i = 0; i < 600 && getActiveCameraMotion(); i += 1) {
|
||||
h.tick();
|
||||
maxStep = Math.max(maxStep, Math.abs(pitchDeg(h.camera) - last));
|
||||
last = pitchDeg(h.camera);
|
||||
}
|
||||
assert.equal(getActiveCameraMotion(), null, 'tilt must finish');
|
||||
assert.ok(maxStep < 0.1, `pitch jumped ${maxStep}° in one frame`);
|
||||
assert.ok(h.viewTarget(), 'the crosshair is on the sky');
|
||||
// Replies give whole degrees: the reported angle is the reached one rounded.
|
||||
assert.equal(
|
||||
zoom.pitchDeg,
|
||||
Math.round(pitchDeg(h.camera)),
|
||||
`camera at ${pitchDeg(h.camera)}°, zoom reported ${zoom.pitchDeg}°`,
|
||||
);
|
||||
assert.match(zoom.note, /horizon/);
|
||||
});
|
||||
|
||||
// Easing from the camera's old pitch at the zoomed-out range put the camera
|
||||
// below the place's horizon: the first frame's crosshair hit the ground
|
||||
// 186 km from London, and London stayed hidden for ~11 frames.
|
||||
test('a late zoom out during a tilt keeps the place under the crosshair', (t) => {
|
||||
const h = harness(t, { height: 400_000 });
|
||||
moveCamera({ motion: 'tilt', pitchDeg: -5 });
|
||||
for (let i = 0; i < 70; i += 1) h.tick();
|
||||
const zoom = adjustOrbitRange(2.5);
|
||||
assert.equal(zoom.changed, true);
|
||||
for (let i = 0; i < 600 && getActiveCameraMotion(); i += 1) {
|
||||
h.tick();
|
||||
const hit = h.viewTarget();
|
||||
assert.ok(hit, `frame ${i}: the crosshair is on the sky`);
|
||||
const drift = centreDrift(h);
|
||||
assert.ok(
|
||||
drift < 100,
|
||||
`frame ${i}: crosshair ${Math.round(drift)} m off the place`,
|
||||
);
|
||||
}
|
||||
assert.equal(getActiveCameraMotion(), null, 'tilt must finish');
|
||||
assert.ok(
|
||||
Math.abs(pivotRange(h) - 1_000_000) < 1,
|
||||
`range ${pivotRange(h)} m`,
|
||||
);
|
||||
assert.equal(
|
||||
zoom.pitchDeg,
|
||||
Math.round(pitchDeg(h.camera)),
|
||||
`camera at ${pitchDeg(h.camera)}°, zoom reported ${zoom.pitchDeg}°`,
|
||||
);
|
||||
assert.match(zoom.note, /horizon/);
|
||||
});
|
||||
|
||||
// The zoom out left the pivot pitch level with the place, so the zoom in's
|
||||
// clearance floor came out infinite and it was refused at 1,000 km.
|
||||
test('a zoom in right after a late zoom out brings the tilt back in', (t) => {
|
||||
const h = harness(t, { height: 400_000 });
|
||||
moveCamera({ motion: 'tilt', pitchDeg: -5 });
|
||||
for (let i = 0; i < 70; i += 1) h.tick();
|
||||
const range = pivotRange(h);
|
||||
assert.equal(adjustOrbitRange(2.5).changed, true);
|
||||
const zoomIn = adjustOrbitRange(0.4);
|
||||
assert.equal(zoomIn.changed, true, 'the zoom in must not be refused');
|
||||
h.finish();
|
||||
assert.ok(
|
||||
Math.abs(pivotRange(h) - range) < 1,
|
||||
`range ${pivotRange(h)} m, wanted ${range} m`,
|
||||
);
|
||||
assert.ok(
|
||||
centreDrift(h) < 100,
|
||||
`crosshair ${centreDrift(h)} m off the place`,
|
||||
);
|
||||
});
|
||||
+748
-83
@@ -20,11 +20,19 @@
|
||||
|
||||
import * as Cesium from 'cesium';
|
||||
import { GLOBE_VIEW } from './locations.js';
|
||||
import {
|
||||
globeFrameShare,
|
||||
isFreeGlobeCamera,
|
||||
lookAngleWithinFrame,
|
||||
lookStepLosesGlobe,
|
||||
} from './globeInView.js';
|
||||
import {
|
||||
projectEarthDiscToViewport,
|
||||
earthDiscScreenRadius,
|
||||
getKeyholeGeometry,
|
||||
KEYHOLE_OUTER_RADIUS,
|
||||
} from './celestialRing.js';
|
||||
import { isScopeMaskEnabled } from './scopeMask.js';
|
||||
import {
|
||||
holdContinuousRender,
|
||||
releaseContinuousRender,
|
||||
@@ -48,6 +56,33 @@ const ONCE = {
|
||||
};
|
||||
const PITCH_MIN = Cesium.Math.toRadians(-89);
|
||||
const PITCH_MAX = Cesium.Math.toRadians(-5);
|
||||
/** A bare "tilt the camera" / "oblique view" settles here. */
|
||||
const TILT_DEFAULT_PITCH_DEG = -45;
|
||||
/** °/s for a tilt to a requested angle; short ones keep the once duration. */
|
||||
const TILT_TO_DEG_S = 30;
|
||||
/**
|
||||
* A pivoting tilt keeps the range, so its height above the pivot is
|
||||
* range × sin|pitch|. It swings no lower than this; the rest tilts in place.
|
||||
*/
|
||||
const TILT_MIN_CLEARANCE_M = 150;
|
||||
/**
|
||||
* Beyond this range a pivot would swing the camera thousands of km about the
|
||||
* crosshair point, so the tilt happens in place instead.
|
||||
*/
|
||||
const TILT_PIVOT_MAX_RANGE_M = 1_000_000;
|
||||
/**
|
||||
* A pivot range rebuilt from the camera's position comes back a hair off the
|
||||
* range it was set at: a range this close past the cutoff still counts as on
|
||||
* it.
|
||||
*/
|
||||
const TILT_PIVOT_RANGE_SLACK_M = 1;
|
||||
/**
|
||||
* The shallowest a pivot swing goes when its clearance is not what stops it.
|
||||
* From several hundred km the camera's own horizon limit lies past the
|
||||
* place's horizon; tilting the rest in place there would lift the crosshair
|
||||
* onto the sky, so the swing stops with the place this far above it.
|
||||
*/
|
||||
const TILT_MIN_PIVOT_ELEVATION = Cesium.Math.toRadians(0.5);
|
||||
|
||||
/* ── Route dolly: cinematic tuning ──────────────────────────────────────────
|
||||
* Every knob an owner may want to retune lives in this block. The shaping is
|
||||
@@ -966,6 +1001,17 @@ let _trackingRemover = null;
|
||||
let _inputRemovers = [];
|
||||
/** Monotonic id for the motion currently running; 0 means "no motion". */
|
||||
let _motionSeq = 0;
|
||||
/** Canvas grabs, touches and wheel turns since load. */
|
||||
let _manualInputCount = 0;
|
||||
|
||||
/**
|
||||
* Lets a caller holding a motion back (behind a fly-to) see whether the user
|
||||
* took the camera meanwhile; Cesium does not cancel a flight on input.
|
||||
* @returns {number} Manual camera inputs so far.
|
||||
*/
|
||||
export function manualCameraInputCount() {
|
||||
return _manualInputCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Install a motion and stamp it with a fresh id. The id is what lets a caller
|
||||
@@ -1008,6 +1054,187 @@ export function interruptCameraMotionIfActive(
|
||||
return interruptCameraMotion(reason);
|
||||
}
|
||||
|
||||
// The frame share that in-place voice turns measure their limit against, with
|
||||
// the pose the last voice turn left the camera in. A turn that starts where
|
||||
// the last one left off keeps that baseline (raised if the planet now fills
|
||||
// more of the frame), so repeating a request cannot take a little more each
|
||||
// time. Once anything else has moved or turned the camera, a turn starts over
|
||||
// from what is on screen.
|
||||
let _lookBaseline = null;
|
||||
|
||||
// The pre-check's proof that a half-degree turn from this pose keeps the planet
|
||||
// in view: which way, from which position and direction. A motion that starts from that
|
||||
// pose turns at least that far, so a request reported as done really moves
|
||||
// even when a smaller first step lands in a dip of the sampled share.
|
||||
const PROVEN_TURN_RAD = Cesium.Math.toRadians(0.5);
|
||||
let _provenLook = null;
|
||||
|
||||
/** The part of the frame a viewer can see: the scope keyhole when it is on. */
|
||||
function visibleFrame() {
|
||||
return { keyholeRadius: isScopeMaskEnabled() ? KEYHOLE_OUTER_RADIUS : 0 };
|
||||
}
|
||||
|
||||
/** The frame share an in-place turn from `cam`'s pose is limited against. */
|
||||
function lookBaselineShare(cam) {
|
||||
const share = globeFrameShare(cam, visibleFrame());
|
||||
const continues =
|
||||
_lookBaseline &&
|
||||
Cesium.Cartesian3.equalsEpsilon(
|
||||
_lookBaseline.position,
|
||||
cam.positionWC,
|
||||
0,
|
||||
1,
|
||||
) &&
|
||||
Cesium.Cartesian3.equalsEpsilon(
|
||||
_lookBaseline.direction,
|
||||
cam.directionWC,
|
||||
0,
|
||||
1e-9,
|
||||
);
|
||||
_lookBaseline = {
|
||||
position: Cesium.Cartesian3.clone(cam.positionWC),
|
||||
direction: Cesium.Cartesian3.clone(cam.directionWC),
|
||||
share: continues ? Math.max(_lookBaseline.share, share) : share,
|
||||
};
|
||||
return _lookBaseline.share;
|
||||
}
|
||||
|
||||
/**
|
||||
* One in-place look step of motion `m`. From orbit a free globe camera turns
|
||||
* only as far as keeps the planet in frame, measured against the baseline the
|
||||
* turn started from, then the motion ends there.
|
||||
*/
|
||||
function lookWithinFrame(cam, m, look, angle) {
|
||||
let allowed = angle;
|
||||
if (isFreeGlobeCamera(_viewer)) {
|
||||
if (m.globeShareAtStart === undefined) {
|
||||
m.globeShareAtStart = lookBaselineShare(cam);
|
||||
m.provenTurnRad = lookProven(cam, look) ? PROVEN_TURN_RAD : 0;
|
||||
m.turnedRad = 0;
|
||||
}
|
||||
_provenLook = null;
|
||||
allowed = lookAngleWithinFrame(cam, look, angle, {
|
||||
...visibleFrame(),
|
||||
startShare: m.globeShareAtStart,
|
||||
});
|
||||
const owed = Math.min(angle, m.provenTurnRad - m.turnedRad);
|
||||
if (allowed < owed) allowed = owed;
|
||||
m.turnedRad += allowed;
|
||||
}
|
||||
if (allowed > 0) cam[look](allowed);
|
||||
// The pose this turn leaves, for a turn that continues from it.
|
||||
if (_lookBaseline)
|
||||
Cesium.Cartesian3.clone(cam.directionWC, _lookBaseline.direction);
|
||||
if (allowed < angle) interruptCameraMotion('globe-out-of-frame');
|
||||
}
|
||||
|
||||
/**
|
||||
* The in-place look a pitch change from `fromPitch` to `goalPitch` makes:
|
||||
* 'lookUp', 'lookDown', or null when it does not move. A tilt is judged by
|
||||
* the way its pitch moves, whatever form or direction word it came in.
|
||||
*/
|
||||
function tiltLook(fromPitch, goalPitch) {
|
||||
if (!Number.isFinite(fromPitch) || !Number.isFinite(goalPitch)) return null;
|
||||
if (goalPitch > fromPitch) return 'lookUp';
|
||||
return goalPitch < fromPitch ? 'lookDown' : null;
|
||||
}
|
||||
|
||||
/** Tilt `m` in place to `goalPitch`, as far as the frame limit allows. */
|
||||
function tiltInPlaceWithinFrame(cam, m, goalPitch) {
|
||||
const look = tiltLook(cam.pitch, goalPitch);
|
||||
if (look) lookWithinFrame(cam, m, look, Math.abs(goalPitch - cam.pitch));
|
||||
}
|
||||
|
||||
/**
|
||||
* The pitch an in-place tilt from `cam`'s pose toward `goalPitch` reaches
|
||||
* before the frame limit stops it, judged as its steps will be: `goalPitch`
|
||||
* itself when the planet stays in frame the whole way, or the camera is not a
|
||||
* free globe camera. The steps run on a copy of the camera, each looking by
|
||||
* the pitch still to go as the tilt's frames do, because a rolled camera's
|
||||
* look turns its pitch by less than the look angle.
|
||||
*/
|
||||
function tiltPitchWithinFrame(cam, goalPitch) {
|
||||
const look = tiltLook(cam.pitch, goalPitch);
|
||||
if (!look || !isFreeGlobeCamera(_viewer)) return goalPitch;
|
||||
const frame = { ...visibleFrame(), startShare: lookBaselineShare(cam) };
|
||||
// A proven half degree is turned whatever the sampled share says.
|
||||
let owed = lookProven(cam, look) ? PROVEN_TURN_RAD : 0;
|
||||
const probe = Cesium.Camera.clone(cam);
|
||||
for (let i = 0; i < 32; i += 1) {
|
||||
if (tiltLook(probe.pitch, goalPitch) !== look) return goalPitch;
|
||||
const angle = Math.abs(goalPitch - probe.pitch);
|
||||
if (angle < 1e-6) return goalPitch;
|
||||
const allowed = Math.max(
|
||||
lookAngleWithinFrame(probe, look, angle, frame),
|
||||
Math.min(angle, owed),
|
||||
);
|
||||
owed -= allowed;
|
||||
if (allowed > 0) probe[look](allowed);
|
||||
if (allowed < angle) return probe.pitch;
|
||||
}
|
||||
return probe.pitch;
|
||||
}
|
||||
|
||||
/** The reply's note for a tilt the frame limit stops short of its angle. */
|
||||
function tiltFrameNote(pitch) {
|
||||
return `Stopped at ${Math.round(Cesium.Math.toDegrees(pitch))}°: from this height a shallower angle would turn the globe out of view. Zoom in to tilt further.`;
|
||||
}
|
||||
|
||||
/** True when the pre-check proved a half-degree `look` from `cam`'s pose. */
|
||||
function lookProven(cam, look) {
|
||||
return (
|
||||
_provenLook?.look === look &&
|
||||
Cesium.Cartesian3.equalsEpsilon(
|
||||
_provenLook.position,
|
||||
cam.positionWC,
|
||||
0,
|
||||
1,
|
||||
) &&
|
||||
Cesium.Cartesian3.equalsEpsilon(
|
||||
_provenLook.direction,
|
||||
cam.directionWC,
|
||||
0,
|
||||
1e-9,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The check a voice in-place turn (`look`: 'lookUp', 'lookDown', 'lookLeft'
|
||||
* or 'lookRight') must pass from orbit: refused when even half a degree would
|
||||
* cross the frame limit, otherwise proven for that half degree from this pose.
|
||||
* With `released`, the turn runs through a navigation that first releases a
|
||||
* tracked or locked camera and keeps its world pose. The frame measure and the
|
||||
* turn are the same in any reference frame, so such a camera is judged as it
|
||||
* is, before the release, and a refusal leaves tracking as it was. Otherwise a
|
||||
* camera that is not a free globe camera is not checked. Returns the refusal,
|
||||
* or null.
|
||||
*/
|
||||
function checkLookFromOrbit(look, { released = false } = {}) {
|
||||
_provenLook = null;
|
||||
if (_viewer.scene.mode !== Cesium.SceneMode.SCENE3D) return null;
|
||||
const cam = _viewer.camera;
|
||||
if (!released && !isFreeGlobeCamera(_viewer)) return null;
|
||||
if (
|
||||
lookStepLosesGlobe(cam, look, PROVEN_TURN_RAD, {
|
||||
...visibleFrame(),
|
||||
startShare: lookBaselineShare(cam),
|
||||
})
|
||||
)
|
||||
return {
|
||||
ok: false,
|
||||
action: 'move_camera',
|
||||
error:
|
||||
'From this height that would turn the globe out of view — zoom in first, or turn the other way.',
|
||||
};
|
||||
_provenLook = {
|
||||
look,
|
||||
position: Cesium.Cartesian3.clone(cam.positionWC),
|
||||
direction: Cesium.Cartesian3.clone(cam.directionWC),
|
||||
};
|
||||
return null;
|
||||
}
|
||||
|
||||
function clearLookAt() {
|
||||
try {
|
||||
_viewer.camera.lookAtTransform(Cesium.Matrix4.IDENTITY);
|
||||
@@ -1052,7 +1279,9 @@ export function interruptCameraMotion(reason = 'interrupt') {
|
||||
if (
|
||||
wasActive &&
|
||||
!tracking &&
|
||||
(_active.kind === 'orbit' || _active.kind === 'route')
|
||||
(_active.kind === 'orbit' ||
|
||||
_active.kind === 'route' ||
|
||||
(_active.kind === 'tilt' && _active.hpr))
|
||||
)
|
||||
clearLookAt();
|
||||
// Instant, not eased: the user grabbed the camera, or the flight is over.
|
||||
@@ -1104,7 +1333,7 @@ function onTick() {
|
||||
let scale = 1;
|
||||
if (m.mode === 'once') {
|
||||
m.elapsed = (m.elapsed || 0) + dt;
|
||||
const t = Math.min(1, m.elapsed / ONCE.durationS);
|
||||
const t = Math.min(1, m.elapsed / (m.durationS || ONCE.durationS));
|
||||
scale = 1 - (1 - t) * (1 - t); // ease-out progress share
|
||||
if (t >= 1) {
|
||||
interruptCameraMotion('once-complete');
|
||||
@@ -1173,30 +1402,20 @@ 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' ||
|
||||
m.kind === 'rotate_globe'
|
||||
) {
|
||||
} else if (m.kind === 'tilt') {
|
||||
tickTilt(cam, m, scale, dt);
|
||||
} else if (m.kind === 'rotate' || m.kind === 'rotate_globe') {
|
||||
const degS = TILT_DEG_S[m.speed];
|
||||
const stepRad =
|
||||
m.mode === 'once'
|
||||
? Cesium.Math.toRadians(
|
||||
m.kind === 'tilt' ? ONCE.tiltDeg : ONCE.rotateDeg,
|
||||
) *
|
||||
(scale - (m.lastScale || 0))
|
||||
? Cesium.Math.toRadians(ONCE.rotateDeg) * (scale - (m.lastScale || 0))
|
||||
: Cesium.Math.toRadians(degS) * dt;
|
||||
m.lastScale = scale;
|
||||
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)
|
||||
) {
|
||||
if (!isFreeGlobeCamera(_viewer) || _isCockpitActive?.()) {
|
||||
interruptCameraMotion('globe-camera-owner-changed');
|
||||
return;
|
||||
}
|
||||
@@ -1218,18 +1437,14 @@ function onTick() {
|
||||
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);
|
||||
if (next > PITCH_MAX || next < PITCH_MIN) {
|
||||
interruptCameraMotion('tilt-clamp');
|
||||
return;
|
||||
}
|
||||
if (up) cam.lookUp(stepRad);
|
||||
else cam.lookDown(stepRad);
|
||||
} else if (m.direction === 'left') cam.lookLeft(stepRad);
|
||||
else cam.lookRight(stepRad);
|
||||
} else {
|
||||
lookWithinFrame(
|
||||
cam,
|
||||
m,
|
||||
m.direction === 'left' ? 'lookLeft' : 'lookRight',
|
||||
stepRad,
|
||||
);
|
||||
}
|
||||
} else if (m.kind === 'route') {
|
||||
// The dolly's shaping lives in advanceRouteFlight(); the tick only
|
||||
// applies the pose and honours the same completion path as before.
|
||||
@@ -1252,6 +1467,186 @@ function onTick() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One frame of tilt. With a ground pivot the camera swings about it (lookAt),
|
||||
* so the place under the crosshair stays centred; without one it looks up or
|
||||
* down in place. Every angle and limit is the camera's own pitch, which at
|
||||
* regional range is several degrees steeper than the pivot's (the Earth
|
||||
* curves away), so the swing solves for the pivot pitch that gives it. Pitch
|
||||
* is clamped to [-89°, -5°] only in the direction of travel: a top-down view
|
||||
* sits at -90°, below the limit, and must still be able to tilt up out of it.
|
||||
* A pivot swing toward the horizon lowers the camera, so it stops at
|
||||
* TILT_MIN_CLEARANCE_M and the rest tilts in place. Farther out it stops with
|
||||
* the camera TILT_MIN_PIVOT_ELEVATION above the place's horizon instead, and
|
||||
* keeps the place centred: tilting in place there would lift the crosshair
|
||||
* onto the sky.
|
||||
*/
|
||||
function tickTilt(cam, m, scale, dt) {
|
||||
const from = cam.pitch;
|
||||
const want =
|
||||
m.mode === 'once'
|
||||
? m.startPitch + (m.goalPitch - m.startPitch) * scale
|
||||
: from +
|
||||
Cesium.Math.toRadians(TILT_DEG_S[m.speed]) *
|
||||
dt *
|
||||
(m.direction === 'up' ? 1 : -1);
|
||||
const up = want > from;
|
||||
const limited = up
|
||||
? Math.min(want, Math.max(from, PITCH_MAX))
|
||||
: Math.max(want, Math.min(from, PITCH_MIN));
|
||||
let atLimit = false;
|
||||
if (m.hpr) {
|
||||
// Apply a range a zoom set since the last frame, even when the pitch
|
||||
// then has nowhere to go (a zoom in to the clearance ends the pivot).
|
||||
cam.lookAt(m.target, m.hpr);
|
||||
// Up toward the horizon the swing lowers the camera: hold the clearance,
|
||||
// and keep the place below the horizon.
|
||||
const cap = up
|
||||
? Math.max(m.hpr.pitch, tiltPivotPitchMax(m.hpr.range))
|
||||
: Infinity;
|
||||
for (let i = 0; i < 3 && Math.abs(limited - cam.pitch) > 1e-6; i += 1) {
|
||||
const pitch = Math.min(
|
||||
cap,
|
||||
Math.max(-Cesium.Math.PI_OVER_TWO, m.hpr.pitch + limited - cam.pitch),
|
||||
);
|
||||
if (pitch === m.hpr.pitch) break;
|
||||
m.hpr.pitch = pitch;
|
||||
cam.lookAt(m.target, m.hpr);
|
||||
}
|
||||
// A once motion that just completed released the lookAt frame before
|
||||
// this last step re-took it.
|
||||
if (_active !== m) clearLookAt();
|
||||
if (up && m.hpr.pitch >= cap && cam.pitch < limited - 1e-6) {
|
||||
if (tiltClearanceBinds(m.hpr.range)) {
|
||||
// Down to its clearance: let go of the pivot and tilt the rest in
|
||||
// place, which keeps the height.
|
||||
if (_active === m) clearLookAt();
|
||||
m.hpr = null;
|
||||
m.target = null;
|
||||
} else {
|
||||
// The place is at its horizon limit: stop here, still centred.
|
||||
atLimit = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!m.hpr) tiltInPlaceWithinFrame(cam, m, limited);
|
||||
if (m.mode !== 'once' && (limited !== want || atLimit))
|
||||
interruptCameraMotion('tilt-clamp');
|
||||
}
|
||||
|
||||
/** The refusal for a tilt up the camera has no room left for. */
|
||||
function tiltUpLimitRefusal() {
|
||||
return {
|
||||
ok: false,
|
||||
action: 'move_camera',
|
||||
error:
|
||||
'Already at the upper tilt limit (near the horizon) — tilt down, or zoom/pan instead.',
|
||||
};
|
||||
}
|
||||
|
||||
/** Shallowest pivot pitch that keeps the clearance at this range. */
|
||||
function tiltPivotPitchMax(range) {
|
||||
return Math.min(
|
||||
-Math.asin(Math.min(1, TILT_MIN_CLEARANCE_M / range)),
|
||||
-TILT_MIN_PIVOT_ELEVATION,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the clearance, not the horizon, stops a swing at this range. Only
|
||||
* then does the rest of the tilt happen in place: that close in (under ~17
|
||||
* km) the camera's -5° limit stays well below its own horizon.
|
||||
*/
|
||||
function tiltClearanceBinds(range) {
|
||||
return TILT_MIN_CLEARANCE_M / range >= Math.sin(TILT_MIN_PIVOT_ELEVATION);
|
||||
}
|
||||
|
||||
/**
|
||||
* The shallowest camera pitch a tilt starting from this frame can reach with
|
||||
* the place under the crosshair still in view: PITCH_MAX, or less where a
|
||||
* far pivot meets its horizon limit first.
|
||||
*/
|
||||
function tiltUpLimit({ target, pivot }) {
|
||||
if (!pivot || tiltClearanceBinds(pivot.range)) return PITCH_MAX;
|
||||
return Math.min(
|
||||
PITCH_MAX,
|
||||
pivotCameraPitch(
|
||||
target,
|
||||
pivot,
|
||||
Math.max(pivot.pitch, tiltPivotPitchMax(pivot.range)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/** The camera pitch lookAt gives for this pivot at the given pivot pitch. */
|
||||
function pivotCameraPitch(target, { heading, range }, pitch) {
|
||||
const offset = new Cesium.Cartesian3(
|
||||
-range * Math.cos(pitch) * Math.sin(heading),
|
||||
-range * Math.cos(pitch) * Math.cos(heading),
|
||||
-range * Math.sin(pitch),
|
||||
);
|
||||
const eye = Cesium.Matrix4.multiplyByPoint(
|
||||
Cesium.Transforms.eastNorthUpToFixedFrame(target),
|
||||
offset,
|
||||
new Cesium.Cartesian3(),
|
||||
);
|
||||
const look = Cesium.Cartesian3.subtract(target, eye, new Cesium.Cartesian3());
|
||||
Cesium.Cartesian3.normalize(look, look);
|
||||
const ellipsoid = _viewer.scene.globe?.ellipsoid ?? Cesium.Ellipsoid.WGS84;
|
||||
const upAtEye = ellipsoid.geodeticSurfaceNormal(eye, new Cesium.Cartesian3());
|
||||
return Math.asin(
|
||||
Cesium.Math.clamp(Cesium.Cartesian3.dot(look, upAtEye), -1, 1),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a tilt starts. It swings about the ground under the crosshair so the
|
||||
* place stays centred. It tilts in place with no ground there, from globe
|
||||
* range, or while an entity is tracked (the follow camera owns that frame;
|
||||
* voice releases it before moving, so a voice tilt still pivots).
|
||||
*/
|
||||
function tiltStartFrame(cam) {
|
||||
const target = _viewer.trackedEntity ? null : _getTarget?.(_viewer);
|
||||
let pivot = target ? tiltPivot(cam, target) : null;
|
||||
if (pivot && !tiltPivotInReach(pivot.range)) pivot = null;
|
||||
return { target, pivot, from: cam.pitch };
|
||||
}
|
||||
|
||||
/** Whether a tilt may pivot at this range: up to and including the cutoff. */
|
||||
function tiltPivotInReach(range) {
|
||||
return range <= TILT_PIVOT_MAX_RANGE_M + TILT_PIVOT_RANGE_SLACK_M;
|
||||
}
|
||||
|
||||
/** The reply's note for a tilt the place's horizon stops short of its angle. */
|
||||
function tiltHorizonNote(pitch) {
|
||||
return `Stopped at ${Math.round(Cesium.Math.toDegrees(pitch))}°: from this range a shallower angle puts the place past the horizon. Zoom in to tilt further.`;
|
||||
}
|
||||
|
||||
/** Ground pivot for a tilt: heading, pitch and range in the target's own frame. */
|
||||
function tiltPivot(cam, target) {
|
||||
const local = Cesium.Matrix4.multiplyByPoint(
|
||||
Cesium.Matrix4.inverseTransformation(
|
||||
Cesium.Transforms.eastNorthUpToFixedFrame(target),
|
||||
new Cesium.Matrix4(),
|
||||
),
|
||||
cam.positionWC,
|
||||
new Cesium.Cartesian3(),
|
||||
);
|
||||
const range = Cesium.Cartesian3.magnitude(local);
|
||||
if (!(range > 1) || local.z <= 0) return null;
|
||||
// Near straight overhead the offset's heading is float noise (it flipped a
|
||||
// north-up London view to south); keep the camera's own.
|
||||
const heading =
|
||||
Math.hypot(local.x, local.y) > range * 0.01
|
||||
? Math.atan2(-local.x, -local.y)
|
||||
: cam.heading;
|
||||
return new Cesium.HeadingPitchRange(
|
||||
heading,
|
||||
-Math.asin(Math.min(1, local.z / range)),
|
||||
range,
|
||||
);
|
||||
}
|
||||
|
||||
/** Rigid origin-centred rotation stays outside the actual ellipsoid's bounding sphere. */
|
||||
function globeRotationHasClearance(position) {
|
||||
const ellipsoid = _viewer.scene.globe?.ellipsoid ?? Cesium.Ellipsoid.WGS84;
|
||||
@@ -1413,6 +1808,7 @@ export function initCameraVerbs(
|
||||
_isCockpitActive =
|
||||
typeof isCockpitActive === 'function' ? isCockpitActive : null;
|
||||
_viewer = viewer;
|
||||
_lookBaseline = null;
|
||||
if (_tickRemover) _tickRemover();
|
||||
_tickRemover = viewer.clock.onTick.addEventListener(onTick);
|
||||
if (_trackingRemover) _trackingRemover();
|
||||
@@ -1421,18 +1817,108 @@ export function initCameraVerbs(
|
||||
_trackingRemover = viewer.trackedEntityChanged?.addEventListener((entity) => {
|
||||
if (entity && _active?.kind === 'rotate_globe')
|
||||
interruptCameraMotion('globe-camera-owner-changed');
|
||||
// A pivoting tilt's lookAt would fight the follow camera every frame.
|
||||
else if (entity && _active?.kind === 'tilt' && _active.hpr)
|
||||
interruptCameraMotion('tilt-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', 'touchstart', 'wheel']) {
|
||||
const h = () => interruptCameraMotion('manual-input');
|
||||
const h = () => {
|
||||
_manualInputCount += 1;
|
||||
interruptCameraMotion('manual-input');
|
||||
};
|
||||
canvas.addEventListener(evt, h, { passive: true });
|
||||
_inputRemovers.push(() => canvas.removeEventListener(evt, h));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A tilt's angle: the one asked for, the default oblique view when a bare
|
||||
* "tilt the camera" names neither angle nor direction, or null for a nudge.
|
||||
*/
|
||||
function requestedTiltPitch(args, direction) {
|
||||
// Only a real number or numeric text: Number('') and Number(null) are 0,
|
||||
// which would swing the camera to the horizon.
|
||||
const raw = args.pitchDeg;
|
||||
const requested =
|
||||
typeof raw === 'number' || (typeof raw === 'string' && raw.trim() !== '')
|
||||
? Number(raw)
|
||||
: NaN;
|
||||
// The camera never looks above the horizon, so a positive angle can only
|
||||
// mean the same angle below it ("tilt 45 degrees" sent as 45).
|
||||
if (Number.isFinite(requested))
|
||||
return Cesium.Math.clamp(
|
||||
Cesium.Math.toRadians(-Math.abs(requested)),
|
||||
PITCH_MIN,
|
||||
PITCH_MAX,
|
||||
);
|
||||
return direction === null
|
||||
? Cesium.Math.toRadians(TILT_DEFAULT_PITCH_DEG)
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Argument errors move_camera refuses whatever the camera is doing, so a
|
||||
* caller holding a motion back behind a flight can refuse it before waiting.
|
||||
* @param {Object} args
|
||||
* @returns {Object|null} The refusal, or null when the arguments are usable.
|
||||
*/
|
||||
export function moveCameraArgsError(args = {}) {
|
||||
const motion = String(args.motion || '').toLowerCase();
|
||||
const direction = args.direction
|
||||
? String(args.direction).toLowerCase()
|
||||
: null;
|
||||
if (motion === 'stop') return null;
|
||||
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, rotate_globe, or stop.`,
|
||||
};
|
||||
}
|
||||
if (
|
||||
motion !== 'orbit' &&
|
||||
motion !== 'rotate_globe' &&
|
||||
(motion !== 'tilt' || requestedTiltPitch(args, direction) === null) &&
|
||||
!['left', 'right', 'up', 'down'].includes(direction)
|
||||
) {
|
||||
return {
|
||||
ok: false,
|
||||
action: 'move_camera',
|
||||
error: `${motion} needs a direction (${motion === 'tilt' ? 'up/down' : motion === 'rotate' ? 'left/right' : 'left/right/up/down'}).`,
|
||||
};
|
||||
}
|
||||
if (motion === 'tilt' && (direction === 'left' || direction === 'right')) {
|
||||
return {
|
||||
ok: false,
|
||||
action: 'move_camera',
|
||||
error: 'tilt needs up or down, or pitchDeg alone for an angle.',
|
||||
};
|
||||
}
|
||||
if (motion === 'rotate' && !['left', 'right'].includes(direction)) {
|
||||
return {
|
||||
ok: false,
|
||||
action: 'move_camera',
|
||||
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.',
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* move_camera implementation. Returns the house result shape; rejections are
|
||||
* plain-English `ok:false` errors.
|
||||
@@ -1465,54 +1951,66 @@ export function moveCamera(args = {}, runNavigation = null) {
|
||||
};
|
||||
return typeof runNavigation === 'function' ? runNavigation(stop) : stop();
|
||||
}
|
||||
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, rotate_globe, or stop.`,
|
||||
};
|
||||
}
|
||||
if (
|
||||
motion !== 'orbit' &&
|
||||
motion !== 'rotate_globe' &&
|
||||
!['left', 'right', 'up', 'down'].includes(direction)
|
||||
) {
|
||||
return {
|
||||
ok: false,
|
||||
action: 'move_camera',
|
||||
error: `${motion} needs a direction (left/right${motion !== 'rotate' ? '/up/down' : ''}).`,
|
||||
};
|
||||
}
|
||||
if (motion === 'rotate' && !['left', 'right'].includes(direction)) {
|
||||
return {
|
||||
ok: false,
|
||||
action: 'move_camera',
|
||||
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).
|
||||
const pitch = _viewer.camera.pitch;
|
||||
if (direction === 'up' && pitch >= PITCH_MAX - Cesium.Math.toRadians(0.5)) {
|
||||
const argsError = moveCameraArgsError(args);
|
||||
if (argsError) return argsError;
|
||||
const tiltToPitch =
|
||||
motion === 'tilt' ? requestedTiltPitch(args, direction) : null;
|
||||
if (motion === 'tilt' && tiltToPitch !== null) {
|
||||
// Angles and limits are the camera's own pitch, whatever the swing pivots
|
||||
// about; releasing the follow camera does not change it.
|
||||
const from = _viewer.camera.pitch;
|
||||
// -89 or -90 means top-down, which a -90° view already is: the clamp
|
||||
// must not tilt it up 1°.
|
||||
const atTopDown = tiltToPitch <= PITCH_MIN && from <= PITCH_MIN;
|
||||
if (
|
||||
atTopDown ||
|
||||
Math.abs(tiltToPitch - from) < Cesium.Math.toRadians(0.5)
|
||||
) {
|
||||
// Still a camera request: it stops a running motion and releases the
|
||||
// follow camera, as a tilt that moves would.
|
||||
const hold = () => {
|
||||
interruptCameraMotion('replaced');
|
||||
return {
|
||||
ok: true,
|
||||
action: 'move_camera',
|
||||
motion,
|
||||
pitchDeg: Math.round(
|
||||
Cesium.Math.toDegrees(atTopDown ? from : tiltToPitch),
|
||||
),
|
||||
unchanged: true,
|
||||
};
|
||||
};
|
||||
return typeof runNavigation === 'function' ? runNavigation(hold) : hold();
|
||||
}
|
||||
// pitchDeg is an absolute angle, not a step: "tilt down 10 degrees" sent
|
||||
// as {direction:'down', pitchDeg:10} would tilt up from -45°.
|
||||
const towards = tiltToPitch > from ? 'up' : 'down';
|
||||
if ((direction === 'up' || direction === 'down') && direction !== towards) {
|
||||
const target = Math.round(Cesium.Math.toDegrees(tiltToPitch));
|
||||
const now = Math.round(Cesium.Math.toDegrees(from));
|
||||
return {
|
||||
ok: false,
|
||||
action: 'move_camera',
|
||||
error:
|
||||
'Already at the upper tilt limit (near the horizon) — tilt down, or zoom/pan instead.',
|
||||
error: `pitchDeg is an absolute angle, not a step: ${target}° from ${now}° tilts ${towards}, not ${direction}. Send direction alone for a nudge, or pitchDeg alone for an angle.`,
|
||||
};
|
||||
}
|
||||
if (
|
||||
towards === 'up' &&
|
||||
from >=
|
||||
tiltUpLimit(tiltStartFrame(_viewer.camera)) - Cesium.Math.toRadians(0.5)
|
||||
)
|
||||
return tiltUpLimitRefusal();
|
||||
} else 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).
|
||||
const pitch = _viewer.camera.pitch;
|
||||
if (
|
||||
direction === 'up' &&
|
||||
pitch >=
|
||||
tiltUpLimit(tiltStartFrame(_viewer.camera)) - Cesium.Math.toRadians(0.5)
|
||||
) {
|
||||
return tiltUpLimitRefusal();
|
||||
}
|
||||
if (
|
||||
direction === 'down' &&
|
||||
pitch <= PITCH_MIN + Cesium.Math.toRadians(0.5)
|
||||
@@ -1524,6 +2022,34 @@ export function moveCamera(args = {}, runNavigation = null) {
|
||||
};
|
||||
}
|
||||
}
|
||||
// A tilt is judged by the way its pitch will move: toward its angle, or a
|
||||
// nudge up or down; a rotate turns left only when told to, as it runs.
|
||||
const look =
|
||||
motion === 'tilt'
|
||||
? tiltLook(
|
||||
_viewer.camera.pitch,
|
||||
tiltToPitch ??
|
||||
_viewer.camera.pitch +
|
||||
Cesium.Math.toRadians(ONCE.tiltDeg) *
|
||||
(direction === 'up' ? 1 : -1),
|
||||
)
|
||||
: motion === 'rotate'
|
||||
? direction === 'left'
|
||||
? 'lookLeft'
|
||||
: 'lookRight'
|
||||
: null;
|
||||
// Honest limits from orbit too: a stopped turn rests within 0.05° of its
|
||||
// limit, so one that cannot go half a degree further is refused rather than
|
||||
// run and reported as done, and one that can is then turned at least that
|
||||
// far. Near the limit the sampled share wobbles as the planet's edge crosses
|
||||
// the grid, so a turn that would keep the planet can occasionally be refused
|
||||
// too.
|
||||
// A camera that is tracked or locked now is judged as the navigation that
|
||||
// runs the turn will release it, so a refusal keeps the tracking.
|
||||
const lookRefusal =
|
||||
look &&
|
||||
checkLookFromOrbit(look, { released: typeof runNavigation === 'function' });
|
||||
if (lookRefusal) return lookRefusal;
|
||||
if (
|
||||
(motion === 'rotate_globe' ||
|
||||
(motion === 'orbit' && typeof runNavigation !== 'function')) &&
|
||||
@@ -1607,14 +2133,70 @@ export function moveCamera(args = {}, runNavigation = null) {
|
||||
error: 'Globe rotation requires a clear ellipsoid bounding sphere.',
|
||||
};
|
||||
}
|
||||
// The angle a tilt to an angle stops at short of it, and why.
|
||||
let stoppedAt = null;
|
||||
let stopNote = null;
|
||||
const start = () => {
|
||||
interruptCameraMotion('replaced');
|
||||
// Checked again only if the release left the camera somewhere other than
|
||||
// where the check above expected it.
|
||||
if (look && !lookProven(_viewer.camera, look)) {
|
||||
const refusal = checkLookFromOrbit(look);
|
||||
if (refusal) return refusal;
|
||||
}
|
||||
const state = {
|
||||
kind: motion,
|
||||
direction: direction || 'right',
|
||||
speed,
|
||||
mode,
|
||||
};
|
||||
if (motion === 'tilt') {
|
||||
const frame = tiltStartFrame(_viewer.camera);
|
||||
const { target, pivot, from } = frame;
|
||||
if (pivot) {
|
||||
state.target = target;
|
||||
state.hpr = pivot;
|
||||
}
|
||||
state.startPitch = from;
|
||||
const upLimit = tiltUpLimit(frame);
|
||||
if (tiltToPitch !== null) {
|
||||
// From far out the place reaches its horizon before the camera
|
||||
// reaches the asked angle: stop there, and say so.
|
||||
let goal =
|
||||
tiltToPitch > from
|
||||
? Math.min(tiltToPitch, Math.max(from, upLimit))
|
||||
: tiltToPitch;
|
||||
if (goal !== tiltToPitch) stopNote = tiltHorizonNote(goal);
|
||||
// From orbit an in-place tilt stops where the planet would leave the
|
||||
// frame: aim it there, and say so.
|
||||
if (!pivot) {
|
||||
const framed = tiltPitchWithinFrame(_viewer.camera, goal);
|
||||
if (framed !== goal) {
|
||||
goal = framed;
|
||||
stopNote = tiltFrameNote(goal);
|
||||
}
|
||||
}
|
||||
state.mode = 'once';
|
||||
state.direction = goal > from ? 'up' : 'down';
|
||||
state.goalPitch = goal;
|
||||
// A zoom mid-tilt moves the horizon limit; it re-judges the goal
|
||||
// against the angle asked for.
|
||||
state.askedPitch = tiltToPitch;
|
||||
state.durationS = Math.max(
|
||||
ONCE.durationS,
|
||||
Math.abs(Cesium.Math.toDegrees(goal - from)) / TILT_TO_DEG_S,
|
||||
);
|
||||
if (goal !== tiltToPitch) stoppedAt = goal;
|
||||
} else {
|
||||
// A nudge stops at the limit it travels toward.
|
||||
const step =
|
||||
Cesium.Math.toRadians(ONCE.tiltDeg) * (direction === 'up' ? 1 : -1);
|
||||
state.goalPitch =
|
||||
step > 0
|
||||
? Math.min(from + step, Math.max(from, upLimit))
|
||||
: Math.max(from + step, Math.min(from, PITCH_MIN));
|
||||
}
|
||||
}
|
||||
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.
|
||||
@@ -1633,9 +2215,11 @@ export function moveCamera(args = {}, runNavigation = null) {
|
||||
}
|
||||
if (motion === 'orbit') {
|
||||
if (_viewer.scene.tweens.length > 0) {
|
||||
// A camera flight is in progress ("fly to X and orbit it") — ALWAYS arm
|
||||
// and capture at the destination; a target existing right now is
|
||||
// meaningless (the globe is always under the crosshair).
|
||||
// A camera flight is in progress — ALWAYS arm and capture at the
|
||||
// destination; a target existing right now is meaningless (the globe
|
||||
// is always under the crosshair). The voice runner already holds an
|
||||
// orbit until its own fly-to lands; this covers any other
|
||||
// flight.
|
||||
setActiveMotion({
|
||||
kind: 'orbit',
|
||||
direction: direction || 'right',
|
||||
@@ -1699,7 +2283,11 @@ export function moveCamera(args = {}, runNavigation = null) {
|
||||
motion,
|
||||
direction: state.direction,
|
||||
speed,
|
||||
mode,
|
||||
mode: state.mode,
|
||||
...(tiltToPitch !== null && {
|
||||
pitchDeg: Math.round(Cesium.Math.toDegrees(stoppedAt ?? tiltToPitch)),
|
||||
}),
|
||||
...(stopNote !== null && { note: stopNote }),
|
||||
};
|
||||
};
|
||||
const preserveCameraFlight =
|
||||
@@ -1714,16 +2302,93 @@ export function moveCamera(args = {}, runNavigation = null) {
|
||||
/**
|
||||
* Zoom while orbiting: scale the orbit RADIUS instead of letting the zoom
|
||||
* fight the per-frame lookAt (field test: "zoom out did literally nothing").
|
||||
* A tilt swinging about its pivot holds a range the same way.
|
||||
* @param {number} factor >1 zooms out (radius grows), <1 zooms in.
|
||||
* @returns {boolean} true when an orbit consumed the zoom.
|
||||
* @returns {{kind: string, changed: boolean, pitchDeg?: number,
|
||||
* note?: string}|null} the motion that consumed the zoom, and whether its
|
||||
* range moved; null when none did. A tilt to an angle whose end the new
|
||||
* range moves also names the angle it now ends at (pitchDeg), with a note.
|
||||
*/
|
||||
export function adjustOrbitRange(factor) {
|
||||
if (!_active || _active.kind !== 'orbit' || !_active.hpr) return false;
|
||||
_active.hpr.range = Math.max(
|
||||
80,
|
||||
Math.min(5_000_000, _active.hpr.range * factor),
|
||||
if (!_active || !['orbit', 'tilt'].includes(_active.kind) || !_active.hpr)
|
||||
return null;
|
||||
// A tilt's zoom in stops at the range that keeps its clearance. The limits
|
||||
// never move a view the wrong way: a zoom in leaves a closer view in place,
|
||||
// and a zoom out leaves a farther one.
|
||||
const { kind, hpr } = _active;
|
||||
const floor =
|
||||
kind === 'tilt'
|
||||
? Math.max(80, TILT_MIN_CLEARANCE_M / Math.abs(Math.sin(hpr.pitch)))
|
||||
: 80;
|
||||
// A tilt pivots only within TILT_PIVOT_MAX_RANGE_M; farther out the swing
|
||||
// would carry the camera thousands of km.
|
||||
const ceiling = kind === 'tilt' ? TILT_PIVOT_MAX_RANGE_M : 5_000_000;
|
||||
const range = hpr.range;
|
||||
hpr.range = Math.max(
|
||||
Math.min(floor, range),
|
||||
Math.min(Math.max(ceiling, range), range * factor),
|
||||
);
|
||||
return true;
|
||||
// Under half a metre counts as not moved, as for a plain zoom.
|
||||
const changed = Math.abs(hpr.range - range) >= 0.5;
|
||||
return { kind, changed, ...(changed && retargetTiltAfterZoom(_active)) };
|
||||
}
|
||||
|
||||
/**
|
||||
* A zoom changed the range a tilt to an angle holds, and with it the place's
|
||||
* horizon limit: aim the tilt at the angle asked for or the new limit,
|
||||
* whichever comes first, from where the camera is now. The pivot pitch is
|
||||
* re-solved so the new range keeps the camera's pitch, and a limit the camera
|
||||
* is already past is eased down to rather than jumped to. Returns the end
|
||||
* angle and a note for the reply when that end moved, otherwise nothing.
|
||||
*/
|
||||
function retargetTiltAfterZoom(m) {
|
||||
if (m.kind !== 'tilt' || m.mode !== 'once' || m.askedPitch === undefined)
|
||||
return {};
|
||||
let from = _viewer.camera.pitch;
|
||||
if (!(m.askedPitch > from)) return {};
|
||||
if (m.hpr) {
|
||||
// At the same pivot pitch a longer range looks down more steeply (the
|
||||
// Earth curves away): find the pivot pitch that holds this camera pitch.
|
||||
// It stays below the place's horizon limit (or where the pivot already
|
||||
// was): past it the place drops behind the Earth, so a camera the zoom
|
||||
// leaves past that limit steps down to it with the zoom.
|
||||
const cap = Math.max(m.hpr.pitch, tiltPivotPitchMax(m.hpr.range));
|
||||
for (let i = 0; i < 4; i += 1) {
|
||||
const error = from - pivotCameraPitch(m.target, m.hpr, m.hpr.pitch);
|
||||
if (Math.abs(error) < 1e-7) break;
|
||||
const pitch = Cesium.Math.clamp(
|
||||
m.hpr.pitch + error,
|
||||
-Cesium.Math.PI_OVER_TWO,
|
||||
cap,
|
||||
);
|
||||
if (pitch === m.hpr.pitch) break;
|
||||
m.hpr.pitch = pitch;
|
||||
}
|
||||
from = Math.min(from, pivotCameraPitch(m.target, m.hpr, m.hpr.pitch));
|
||||
}
|
||||
const limit =
|
||||
m.hpr && !tiltClearanceBinds(m.hpr.range)
|
||||
? Math.min(
|
||||
PITCH_MAX,
|
||||
pivotCameraPitch(m.target, m.hpr, tiltPivotPitchMax(m.hpr.range)),
|
||||
)
|
||||
: PITCH_MAX;
|
||||
const goal = Math.min(m.askedPitch, limit);
|
||||
if (Math.abs(goal - m.goalPitch) < 1e-6) return {};
|
||||
m.goalPitch = goal;
|
||||
m.startPitch = from;
|
||||
m.elapsed = 0;
|
||||
m.durationS = Math.max(
|
||||
ONCE.durationS,
|
||||
Math.abs(Cesium.Math.toDegrees(goal - from)) / TILT_TO_DEG_S,
|
||||
);
|
||||
const limited = goal !== m.askedPitch;
|
||||
return {
|
||||
pitchDeg: Math.round(Cesium.Math.toDegrees(goal)),
|
||||
note: limited
|
||||
? tiltHorizonNote(goal)
|
||||
: `The tilt now reaches ${Math.round(Cesium.Math.toDegrees(goal))}°.`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1306,12 +1306,12 @@ test('voice Cockpit entry honours a requested contact layer before it enters', (
|
||||
ui.indexOf("if (normalized === 'exit') {"),
|
||||
);
|
||||
assert.ok(branch, 'cockpit enter branch is missing');
|
||||
const gateIndex = branch.indexOf('isEntryAllowed?.()');
|
||||
const gateIndex = branch.indexOf('this._cockpitEntryRefusal()');
|
||||
const retargetIndex = branch.indexOf('_retargetCockpitEntryLayer(');
|
||||
const enterIndex = branch.indexOf('enterCockpitWithTracking({');
|
||||
assert.ok(
|
||||
gateIndex >= 0,
|
||||
'entry consults the same gate the manual chip uses',
|
||||
'entry refuses on the gate it can check before tracking',
|
||||
);
|
||||
assert.ok(
|
||||
retargetIndex > gateIndex,
|
||||
@@ -1362,9 +1362,21 @@ test('voice Cockpit entry refuses when the entry gate is shut', () => {
|
||||
ui.indexOf("if (normalized === 'enter') {"),
|
||||
ui.indexOf("if (normalized === 'exit') {"),
|
||||
);
|
||||
assert.match(branch, /if \(!this\.cockpitView\.isEntryAllowed\?\.\(\)\) \{/);
|
||||
assert.match(branch, /Contacts must be active to enter Cockpit/);
|
||||
assert.match(branch, /Contacts is still starting up/);
|
||||
// Contacts is not required: only a Context change in flight or no aircraft
|
||||
// layer to ride shuts the gate before tracking, and the entry itself then
|
||||
// applies the manual chip's gate to the aircraft it rides.
|
||||
assert.match(
|
||||
branch,
|
||||
/const refusal = this\._cockpitEntryRefusal\(\);\s*if \(refusal\) \{/,
|
||||
);
|
||||
assert.doesNotMatch(branch, /Contacts must be active/);
|
||||
const refusal = ui.slice(
|
||||
ui.indexOf(' _cockpitEntryRefusal() {'),
|
||||
ui.indexOf(' getCockpitState() {'),
|
||||
);
|
||||
assert.match(refusal, /Context is still changing/);
|
||||
assert.match(refusal, /No aircraft layer is on/);
|
||||
assert.doesNotMatch(refusal, /[Cc]ontacts/);
|
||||
});
|
||||
|
||||
test('cockpit state cannot report entryAllowed while already active', () => {
|
||||
@@ -1382,7 +1394,8 @@ test('cockpit state cannot report entryAllowed while already active', () => {
|
||||
);
|
||||
assert.match(state, /entryBlockedReason:/, 'and a refusal can be explained');
|
||||
assert.match(state, /'contacts-starting'/);
|
||||
assert.match(state, /'contacts-inactive'/);
|
||||
assert.match(state, /'subject-layer-off'/);
|
||||
assert.doesNotMatch(state, /'contacts-inactive'/, 'Contacts is not required');
|
||||
assert.match(state, /'no-tracked-aircraft'/);
|
||||
});
|
||||
|
||||
|
||||
@@ -147,6 +147,40 @@ const snapshot = (distanceM = 2000) => ({
|
||||
],
|
||||
});
|
||||
|
||||
test('a feed-off row leads the rebuilt rows and the list stays five long', async (t) => {
|
||||
const f = fixture(t);
|
||||
const crowded = {
|
||||
...snapshot(),
|
||||
cohorts: [
|
||||
{
|
||||
id: 'flights',
|
||||
count: 6,
|
||||
nearest: [1, 2, 3, 4, 5, 6].map((n) => ({
|
||||
id: `n${n}`,
|
||||
label: `N${n}`,
|
||||
distanceM: n * 1000,
|
||||
})),
|
||||
},
|
||||
],
|
||||
};
|
||||
updateCockpitSignals.call(f.owner, crowded, 0, {
|
||||
title: 'MILITARY FEED OFF · PARTIAL PICTURE',
|
||||
detail: 'NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR',
|
||||
});
|
||||
await nextTick();
|
||||
assert.equal(f.owner.signalItems.length, 5);
|
||||
assert.equal(f.owner.signalItems[0].key, 'feed-status');
|
||||
assert.equal(f.owner.signalItems[0].tone, 'warning');
|
||||
assert.equal(f.owner.signalItems[0].target, null);
|
||||
assert.match(f.list.textContent, /MILITARY FEED OFF · PARTIAL PICTURE/);
|
||||
updateCockpitSignals.call(f.owner, crowded, 0);
|
||||
assert.equal(
|
||||
f.owner.signalItems.some((item) => item.key === 'feed-status'),
|
||||
false,
|
||||
'with the feed back, the row goes',
|
||||
);
|
||||
});
|
||||
|
||||
test('rows render the signals the controller presents', async (t) => {
|
||||
const f = fixture(t);
|
||||
await render(f, [
|
||||
|
||||
+23
-16
@@ -411,30 +411,37 @@ export function contextRestoreLayerIds(snapshot = {}) {
|
||||
return restored;
|
||||
}
|
||||
|
||||
/** The layers an aircraft Cockpit subject can come from. */
|
||||
export const COCKPIT_AIRCRAFT_LAYER_IDS = Object.freeze([
|
||||
'flights',
|
||||
'military',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Cockpit entry belongs to the operational Contacts context bundle. A tracked
|
||||
* aircraft alone is not sufficient: both observed-flight feeds must still be
|
||||
* active so the cockpit's surrounding-contact picture is not presented as a
|
||||
* complete context view when one source is disabled.
|
||||
* Cockpit entry needs only the aircraft it rides: that aircraft's own layer
|
||||
* (Live Flights for a civilian, Military Flights for a military aircraft) must
|
||||
* be on. Contacts and the other feeds are not required. When a source the
|
||||
* surrounding picture uses is off, the Cockpit's Contact card and Live
|
||||
* Signals say the picture is partial instead of blocking entry, so a partial
|
||||
* picture is never presented as complete. A Context mode change in flight
|
||||
* still owns the camera, so entry waits it out.
|
||||
*
|
||||
* @param {object} input Current context and dependency visibility.
|
||||
* @param {string|null} input.contextMode Active Context mode.
|
||||
* @param {boolean} input.contextModeChanging Whether the Context transaction is unsettled.
|
||||
* @param {boolean} input.flightsEnabled Whether Live Flights is enabled.
|
||||
* @param {boolean} input.militaryEnabled Whether Military Flights is enabled.
|
||||
* @param {object} input Current subject and Context state.
|
||||
* @param {string|null} input.subjectLayerId Layer of the tracked aircraft
|
||||
* ('flights' or 'military'), or null with none tracked.
|
||||
* @param {boolean} input.subjectLayerEnabled Whether that layer is enabled.
|
||||
* @param {boolean} input.contextModeChanging Whether a Context transaction is unsettled.
|
||||
* @returns {boolean} Whether cockpit entry may be offered or activated.
|
||||
*/
|
||||
export function cockpitEntryAllowed({
|
||||
contextMode,
|
||||
subjectLayerId,
|
||||
subjectLayerEnabled,
|
||||
contextModeChanging,
|
||||
flightsEnabled,
|
||||
militaryEnabled,
|
||||
}) {
|
||||
return (
|
||||
contextMode === 'flights' &&
|
||||
!contextModeChanging &&
|
||||
Boolean(flightsEnabled) &&
|
||||
Boolean(militaryEnabled)
|
||||
COCKPIT_AIRCRAFT_LAYER_IDS.includes(subjectLayerId) &&
|
||||
Boolean(subjectLayerEnabled) &&
|
||||
!contextModeChanging
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -400,37 +400,32 @@ test('explicit layer intent finishing during restore overrides the stale queued
|
||||
}
|
||||
});
|
||||
|
||||
test('cockpit entry requires Contacts context with both aircraft feeds enabled', () => {
|
||||
test("cockpit entry needs only the tracked aircraft's own layer", () => {
|
||||
assert.equal(cockpitEntryAllowed({
|
||||
contextMode: 'flights',
|
||||
subjectLayerId: 'flights',
|
||||
subjectLayerEnabled: true,
|
||||
contextModeChanging: false,
|
||||
flightsEnabled: true,
|
||||
militaryEnabled: true,
|
||||
}), true);
|
||||
}), true, 'a civilian needs Live Flights alone: no Contacts, no Military');
|
||||
assert.equal(cockpitEntryAllowed({
|
||||
contextMode: 'flights',
|
||||
subjectLayerId: 'military',
|
||||
subjectLayerEnabled: true,
|
||||
contextModeChanging: false,
|
||||
}), true, 'a military aircraft needs Military Flights alone');
|
||||
assert.equal(cockpitEntryAllowed({
|
||||
subjectLayerId: 'flights',
|
||||
subjectLayerEnabled: true,
|
||||
contextModeChanging: true,
|
||||
flightsEnabled: true,
|
||||
militaryEnabled: true,
|
||||
}), false, 'Cockpit remains unavailable until Contacts activation settles');
|
||||
}), false, 'Cockpit remains unavailable until a Context mode change settles');
|
||||
assert.equal(cockpitEntryAllowed({
|
||||
contextMode: null,
|
||||
subjectLayerId: 'military',
|
||||
subjectLayerEnabled: false,
|
||||
contextModeChanging: false,
|
||||
flightsEnabled: true,
|
||||
militaryEnabled: true,
|
||||
}), false, 'ordinary layer selection is not an operational Contacts context');
|
||||
}), false, "the ridden aircraft's own layer is required");
|
||||
assert.equal(cockpitEntryAllowed({
|
||||
contextMode: 'flights',
|
||||
subjectLayerId: null,
|
||||
subjectLayerEnabled: false,
|
||||
contextModeChanging: false,
|
||||
flightsEnabled: true,
|
||||
militaryEnabled: false,
|
||||
}), false, 'the military contact feed is required');
|
||||
assert.equal(cockpitEntryAllowed({
|
||||
contextMode: 'flights',
|
||||
contextModeChanging: false,
|
||||
flightsEnabled: false,
|
||||
militaryEnabled: true,
|
||||
}), false, 'the civilian contact feed is required');
|
||||
}), false, 'there is no aircraft to ride');
|
||||
});
|
||||
|
||||
test('context teardown guard restores coordination state after success and failure', async () => {
|
||||
|
||||
@@ -67,6 +67,8 @@ export const _extractPickedCameraIdForTest =
|
||||
export const _setCctvCoverageStateForTest = layer._setCctvCoverageStateForTest;
|
||||
export const focusCctvRecord = layer.focusCctvRecord;
|
||||
export const maybeAutoHop = layer.maybeAutoHop;
|
||||
export const openingCameraId = layer.openingCameraId;
|
||||
export const reopenNearestCamera = layer.reopenNearestCamera;
|
||||
export const cctvCycleIndex = layer.cctvCycleIndex;
|
||||
export {
|
||||
CCTV_CALIBRATION_STORAGE_KEY_V1,
|
||||
|
||||
@@ -61,6 +61,8 @@ import cctvLayer, {
|
||||
materializeCctvActiveCoverageEntities,
|
||||
materializeCctvVisibleCoverageEntities,
|
||||
maybeAutoHop,
|
||||
openingCameraId,
|
||||
reopenNearestCamera,
|
||||
prioritizeActiveCctvGeometryRecord,
|
||||
processCctvGeometryDrainBatch,
|
||||
processCctvGeometryQueueBatch,
|
||||
@@ -1296,6 +1298,120 @@ test('CCTV null-active coverage, auto-hop, cycling, and panel targets stay hones
|
||||
}
|
||||
});
|
||||
|
||||
test('CCTV opens on the camera nearest the viewer, not the first in the catalog', () => {
|
||||
// QA 2026-11-05: at King's Cross, turning CCTV on showed a Sydney camera,
|
||||
// the first catalog record, instead of the TfL camera 0.3 km away.
|
||||
const viewer = makeDeselectViewer();
|
||||
viewer.camera.positionCartographic = Cesium.Cartographic.fromDegrees(
|
||||
-0.1209,
|
||||
51.5295,
|
||||
187,
|
||||
);
|
||||
viewer.camera.positionWC = Cesium.Cartesian3.fromDegrees(
|
||||
-0.1209,
|
||||
51.5295,
|
||||
187,
|
||||
);
|
||||
const sydney = makeDeselectRecord('au-sydney');
|
||||
sydney.camera = { ...sydney.camera, lat: -34.03, lon: 151.11 };
|
||||
const london = makeDeselectRecord('gb-kings-cross');
|
||||
london.camera = { ...london.camera, lat: 51.5318, lon: -0.1209 };
|
||||
try {
|
||||
_setCctvCoverageStateForTest({
|
||||
viewer,
|
||||
records: [sydney, london],
|
||||
activeCameraId: null,
|
||||
enabled: false,
|
||||
});
|
||||
assert.equal(openingCameraId(), 'gb-kings-cross');
|
||||
_setCctvCoverageStateForTest({
|
||||
viewer: null,
|
||||
records: [sydney, london],
|
||||
activeCameraId: null,
|
||||
enabled: false,
|
||||
});
|
||||
assert.equal(
|
||||
openingCameraId(),
|
||||
'au-sydney',
|
||||
'with no viewer position the first record is the fallback',
|
||||
);
|
||||
_setCctvCoverageStateForTest({ viewer, records: [], enabled: false });
|
||||
assert.equal(openingCameraId(), null);
|
||||
} finally {
|
||||
_setCctvCoverageStateForTest({ enabled: false });
|
||||
}
|
||||
});
|
||||
|
||||
test('the opening camera follows a shared view that lands after CCTV turned on, until someone picks one', () => {
|
||||
// A share link turns CCTV on before its flight lands, so enable() picks
|
||||
// from the startup view; the flight's settled move re-picks near London.
|
||||
const viewer = makeDeselectViewer();
|
||||
viewer.camera.positionCartographic = Cesium.Cartographic.fromDegrees(
|
||||
-0.1209,
|
||||
51.5295,
|
||||
187,
|
||||
);
|
||||
const sydney = makeDeselectRecord('au-sydney');
|
||||
sydney.camera = { ...sydney.camera, lat: -34.03, lon: 151.11 };
|
||||
const london = makeDeselectRecord('gb-kings-cross');
|
||||
london.camera = { ...london.camera, lat: 51.5318, lon: -0.1209 };
|
||||
for (const record of [sydney, london])
|
||||
Object.assign(record, {
|
||||
groundResolved: {},
|
||||
groundSamples: {},
|
||||
groundPrior: 0,
|
||||
frustumGeometry: null,
|
||||
frustumPositions: null,
|
||||
probeClampRangeM: null,
|
||||
});
|
||||
_setCctvOverlayHostForTest({
|
||||
setEntries() {},
|
||||
setVisible() {},
|
||||
clearSource() {},
|
||||
});
|
||||
// Activation builds the projection canvas and frame image.
|
||||
const originalDocument = globalThis.document;
|
||||
const originalImage = globalThis.Image;
|
||||
globalThis.document = {
|
||||
createElement: () => ({ getContext: () => null }),
|
||||
};
|
||||
globalThis.Image = class {};
|
||||
try {
|
||||
_setCctvCoverageStateForTest({
|
||||
viewer,
|
||||
records: [sydney, london],
|
||||
activeCameraId: 'au-sydney',
|
||||
enabled: true,
|
||||
openingCameraProvisional: true,
|
||||
});
|
||||
assert.equal(reopenNearestCamera(), true);
|
||||
assert.equal(cctvLayer.getUIState().activeCameraId, 'gb-kings-cross');
|
||||
assert.equal(reopenNearestCamera(), false, 'already the nearest');
|
||||
|
||||
// Someone chooses a camera: settled moves leave it alone.
|
||||
setActiveCamera('au-sydney');
|
||||
assert.equal(reopenNearestCamera(), false);
|
||||
assert.equal(cctvLayer.getUIState().activeCameraId, 'au-sydney');
|
||||
|
||||
// A deselect is a choice too.
|
||||
_setCctvCoverageStateForTest({
|
||||
viewer,
|
||||
records: [sydney, london],
|
||||
activeCameraId: 'au-sydney',
|
||||
enabled: true,
|
||||
openingCameraProvisional: true,
|
||||
});
|
||||
deactivateActiveCamera();
|
||||
assert.equal(reopenNearestCamera(), false);
|
||||
assert.equal(cctvLayer.getUIState().activeCameraId, null);
|
||||
} finally {
|
||||
globalThis.document = originalDocument;
|
||||
globalThis.Image = originalImage;
|
||||
_setCctvOverlayHostForTest();
|
||||
_setCctvCoverageStateForTest({ enabled: false });
|
||||
}
|
||||
});
|
||||
|
||||
test('CCTV disable clears and hides its shared-host source through the real layer lifecycle', () => {
|
||||
const calls = [];
|
||||
_setCctvOverlayHostForTest({
|
||||
|
||||
@@ -0,0 +1,429 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { createCctvCatalog } from '../../server/providers/cctv/catalog.js';
|
||||
import {
|
||||
DEFAULT_ICELAND_MAX_SOURCES,
|
||||
ICELAND_CAMERAS_URL,
|
||||
ICELAND_IMAGE_ORIGIN,
|
||||
ICELAND_MAX_CATALOG_BYTES,
|
||||
} from '../../server/providers/cctv/constants.js';
|
||||
import { fetchWithinHost } from '../../server/providers/cctv/media.js';
|
||||
import { isLikelyIcelandCoordinate } from '../../server/providers/cctv/normalize.js';
|
||||
import {
|
||||
icelandCameraToSource,
|
||||
icelandHeadingFromDescription,
|
||||
loadIcelandSourcesFromOpenData,
|
||||
} from '../../server/providers/cctv/sources.js';
|
||||
|
||||
/** Set (or, for `undefined`, delete) environment variables for one test. */
|
||||
function withEnv(t, env) {
|
||||
for (const [name, value] of Object.entries(env)) {
|
||||
const previous = process.env[name];
|
||||
t.after(() => {
|
||||
if (previous === undefined) delete process.env[name];
|
||||
else process.env[name] = previous;
|
||||
});
|
||||
if (value === undefined) delete process.env[name];
|
||||
else process.env[name] = value;
|
||||
}
|
||||
}
|
||||
|
||||
/** One row shaped like the live `vefmyndavelar2014_1` payload. */
|
||||
const row = (over = {}) => ({
|
||||
Maelist_nr: 7001,
|
||||
Myndavel: 'Hellisheiði',
|
||||
Vegheiti: 'Hringvegur',
|
||||
NrVegur: '1',
|
||||
Skyring: 'Hellisheiði séð til vesturs',
|
||||
Slod: `${ICELAND_IMAGE_ORIGIN}hellisheidi_1.jpg`,
|
||||
PntX: 395000,
|
||||
PntY: 390000,
|
||||
Breidd: 64.0136,
|
||||
Lengd: -21.4,
|
||||
...over,
|
||||
});
|
||||
|
||||
/** A live response body plus a flag that flips when the stream is cancelled. */
|
||||
const streamingResponse = (init = {}) => {
|
||||
const state = { cancelled: false };
|
||||
const body = new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode('['));
|
||||
},
|
||||
cancel() {
|
||||
state.cancelled = true;
|
||||
},
|
||||
});
|
||||
return { response: new Response(body, init), state };
|
||||
};
|
||||
|
||||
const json = (value) =>
|
||||
new Response(JSON.stringify(value), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
|
||||
test('an Iceland row maps to a source on the pinned frame directory', () => {
|
||||
const source = icelandCameraToSource(row());
|
||||
assert.equal(source.id, 'is-vegagerdin-hellisheidi_1');
|
||||
assert.equal(source.name, 'Hellisheiði séð til vesturs');
|
||||
assert.equal(source.city, 'Iceland');
|
||||
assert.equal(source.cityId, 'iceland');
|
||||
assert.equal(source.provider, 'Vegagerðin (IRCA)');
|
||||
assert.equal(source.lat, 64.0136);
|
||||
assert.equal(source.lon, -21.4);
|
||||
assert.equal(source.headingDeg, 270);
|
||||
assert.equal(source.headingConfidence, 'high');
|
||||
assert.equal(source.pitchDeg, -24);
|
||||
assert.equal(source.fovDeg, 56);
|
||||
assert.equal(source.rangeM, 210);
|
||||
assert.equal(source.mountHeightM, 10);
|
||||
assert.equal(source.feedType, 'image');
|
||||
assert.equal(source.sourceKind, 'iceland-vegagerdin');
|
||||
assert.equal(source.code, 'HELLISHEIÐI');
|
||||
assert.match(source.license, /CC BY 4\.0/);
|
||||
assert.ok(source.url.startsWith(ICELAND_IMAGE_ORIGIN));
|
||||
assert.equal(source.snapshotUrl, source.url);
|
||||
});
|
||||
|
||||
test('a view that states no facing takes the low-confidence id-hash pose', () => {
|
||||
const source = icelandCameraToSource(
|
||||
row({
|
||||
Skyring: 'Hellisheiði séð niður á veg',
|
||||
Slod: `${ICELAND_IMAGE_ORIGIN}hellisheidi_2.jpg`,
|
||||
}),
|
||||
);
|
||||
assert.equal(source.headingConfidence, 'low');
|
||||
assert.equal(source.headingDeg % 22.5, 0);
|
||||
assert.equal(
|
||||
source.headingDeg,
|
||||
icelandCameraToSource(
|
||||
row({ Slod: `${ICELAND_IMAGE_ORIGIN}hellisheidi_2.jpg` }),
|
||||
).headingDeg,
|
||||
'the fallback depends on the id, not on the text',
|
||||
);
|
||||
});
|
||||
|
||||
test('the label collapses the feed’s doubled spaces and repeated words', () => {
|
||||
assert.equal(
|
||||
icelandCameraToSource(
|
||||
row({ Skyring: 'Strandarheiði séð niður á veg til til austurs' }),
|
||||
).name,
|
||||
'Strandarheiði séð niður á veg til austurs',
|
||||
);
|
||||
const label = (Skyring) => icelandCameraToSource(row({ Skyring })).name;
|
||||
// Repeats of words with Icelandic letters collapse too.
|
||||
assert.equal(
|
||||
label('Hellisheiði séð séð til austurs'),
|
||||
'Hellisheiði séð til austurs',
|
||||
);
|
||||
assert.equal(label('Brú á á Jökulsá'), 'Brú á Jökulsá');
|
||||
// A word ending in a letter the next word starts with is not a repeat.
|
||||
for (const text of [
|
||||
'Þverá Árnessýslu',
|
||||
'brú Úlfarsá',
|
||||
'Vesturlandsvegur við Elliðaá Ártúnsbrekku',
|
||||
])
|
||||
assert.equal(label(text), text);
|
||||
});
|
||||
|
||||
test('a row with no label, station or station number is named by its frame', () => {
|
||||
const source = icelandCameraToSource(
|
||||
row({ Skyring: '', Myndavel: '', Maelist_nr: undefined }),
|
||||
);
|
||||
assert.equal(source.name, 'Vegagerðin hellisheidi_1');
|
||||
});
|
||||
|
||||
test('only an explicit facing sets a heading', () => {
|
||||
const cases = [
|
||||
['Hellisheiði séð til vesturs', 270],
|
||||
['Holtavörðuheiði til norðurs', 0],
|
||||
['Klettsháls í suður', 180],
|
||||
['Arnarneshæð séð austur', 90],
|
||||
['Bústaðabrú séð norður Kringlumýrarbraut', 0],
|
||||
['Staður séð til norðausturs', 45],
|
||||
['Staður séð til suðausturs', 135],
|
||||
['Staður séð í suðvestur', 225],
|
||||
['Staður séð til norðvesturs', 315],
|
||||
['STAÐUR SÉÐ TIL VESTURS', 270],
|
||||
['Brú séð til austurs og Selfoss', 90], // "og" then a place, not a facing
|
||||
['Staður séð til austurs og Vesturlandsvegur', 90],
|
||||
];
|
||||
for (const [text, heading] of cases) {
|
||||
assert.equal(icelandHeadingFromDescription(text), heading, text);
|
||||
}
|
||||
for (const text of [
|
||||
'',
|
||||
null,
|
||||
'Gautsdalur niður á veg',
|
||||
'Miklabraut austur', // a road direction, not a facing
|
||||
'Biskupsháls - norður',
|
||||
'Hringtorg norðan Selfoss',
|
||||
'Norðurárdalur niður á veg', // a place name that contains a cardinal
|
||||
'Staður séð til suðurlands', // a longer word, not "suður"
|
||||
'Staður séð til norðurs og til suðurs', // two facings: ambiguous
|
||||
'Staður séð til austurs og vesturs', // the second facing shares "til"
|
||||
'Staður séð í norður og suður',
|
||||
'Staður séð í norður og í suður',
|
||||
'Norðfjarðargöng séð til Eskifjarðar',
|
||||
]) {
|
||||
assert.ok(Number.isNaN(icelandHeadingFromDescription(text)), String(text));
|
||||
}
|
||||
});
|
||||
|
||||
test('rows off the pinned directory, or with unusable frames or places, are dropped', () => {
|
||||
const bad = [
|
||||
{ Slod: 'https://example.com/vgdata/vefmyndavelar/a_1.jpg' },
|
||||
{ Slod: 'http://www.vegagerdin.is/vgdata/vefmyndavelar/a_1.jpg' },
|
||||
{ Slod: 'https://user:[email protected]/vgdata/vefmyndavelar/a_1.jpg' },
|
||||
{ Slod: 'https://www.vegagerdin.is:8443/vgdata/vefmyndavelar/a_1.jpg' },
|
||||
{ Slod: 'https://www.vegagerdin.is/vgdata/other/a_1.jpg' },
|
||||
{ Slod: 'https://www.vegagerdin.is/vgdata/vefmyndavelar/../x/a_1.jpg' },
|
||||
{ Slod: 'https://www.vegagerdin.is/vgdata/vefmyndavelar/a%2Fb_1.jpg' },
|
||||
{ Slod: 'https://www.vegagerdin.is/vgdata/vefmyndavelar/a_1.php' },
|
||||
{ Slod: 'https://www.vegagerdin.is/vgdata/vefmyndavelar/a_1' },
|
||||
{ Slod: 'https://www.vegagerdin.is/vgdata/vefmyndavelar/' },
|
||||
{ Slod: 'https://www.vegagerdin.is/vgdata/vefmyndavelar/a b_1.jpg' },
|
||||
{ Slod: '' },
|
||||
{ Slod: null },
|
||||
{ Breidd: 0, Lengd: 0 },
|
||||
{ Breidd: null, Lengd: null },
|
||||
{ Breidd: '64.01', Lengd: '-21.4' },
|
||||
{ Breidd: '64.01' }, // each coordinate must be a number on its own
|
||||
{ Lengd: '-21.4' },
|
||||
{ Breidd: 59.9, Lengd: 10.7 }, // Oslo
|
||||
{ Breidd: 64.01, Lengd: 21.4 }, // wrong hemisphere
|
||||
];
|
||||
for (const over of bad) {
|
||||
assert.equal(icelandCameraToSource(row(over)), null, JSON.stringify(over));
|
||||
}
|
||||
assert.equal(icelandCameraToSource(null), null);
|
||||
assert.equal(icelandCameraToSource({}), null);
|
||||
assert.equal(isLikelyIcelandCoordinate(64.1466, -21.9426), true);
|
||||
assert.equal(isLikelyIcelandCoordinate(66.3, -14.0), true);
|
||||
});
|
||||
|
||||
test('the Iceland loader reads the list with redirects refused and keeps the nearest cameras', async (t) => {
|
||||
t.mock.method(console, 'log', () => {});
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
withEnv(t, { CCTV_ICELAND_MAX_SOURCES: '8' });
|
||||
const rows = Array.from({ length: 12 }, (_, i) =>
|
||||
row({
|
||||
Maelist_nr: 8000 + i,
|
||||
Slod: `${ICELAND_IMAGE_ORIGIN}far_${i}.jpg`,
|
||||
Skyring: `Far ${i}`,
|
||||
Breidd: 66.2,
|
||||
Lengd: -15.9 + i * 0.01,
|
||||
}),
|
||||
);
|
||||
// Reykjavík cameras come last in the feed but first in the priority order.
|
||||
// A frame the feed repeats keeps its last row, as the other packs do.
|
||||
rows.push(row({ Slod: `${ICELAND_IMAGE_ORIGIN}rvk_1.jpg`, Skyring: 'Old' }));
|
||||
rows.push(
|
||||
row({
|
||||
Slod: `${ICELAND_IMAGE_ORIGIN}rvk_1.jpg`,
|
||||
Skyring: 'Kringlumýrarbraut',
|
||||
Breidd: 64.1466,
|
||||
Lengd: -21.9426,
|
||||
}),
|
||||
);
|
||||
const seen = [];
|
||||
t.mock.method(globalThis, 'fetch', async (url, init) => {
|
||||
seen.push([String(url), init.redirect]);
|
||||
return json(rows);
|
||||
});
|
||||
const sources = await loadIcelandSourcesFromOpenData();
|
||||
assert.deepEqual(seen, [[ICELAND_CAMERAS_URL, 'manual']]);
|
||||
assert.equal(sources.length, 8);
|
||||
assert.equal(sources[0].id, 'is-vegagerdin-rvk_1');
|
||||
assert.equal(sources[0].name, 'Kringlumýrarbraut');
|
||||
assert.equal(sources[0].lat, 64.1466);
|
||||
assert.equal(
|
||||
new Set(sources.map((s) => s.id)).size,
|
||||
sources.length,
|
||||
'a frame the feed repeats collapses to one camera',
|
||||
);
|
||||
assert.equal(sources.available, 13);
|
||||
});
|
||||
|
||||
test('the default Iceland cap is DEFAULT_ICELAND_MAX_SOURCES', async (t) => {
|
||||
t.mock.method(console, 'log', () => {});
|
||||
withEnv(t, { CCTV_ICELAND_MAX_SOURCES: undefined });
|
||||
const rows = Array.from(
|
||||
{ length: DEFAULT_ICELAND_MAX_SOURCES + 40 },
|
||||
(_, i) => row({ Slod: `${ICELAND_IMAGE_ORIGIN}cam_${i}.jpg` }),
|
||||
);
|
||||
t.mock.method(globalThis, 'fetch', async () => json(rows));
|
||||
assert.equal(
|
||||
(await loadIcelandSourcesFromOpenData()).length,
|
||||
DEFAULT_ICELAND_MAX_SOURCES,
|
||||
);
|
||||
});
|
||||
|
||||
test('CCTV_ICELAND_MAX_SOURCES is clamped to 8..600 and ignored when not a number', async (t) => {
|
||||
t.mock.method(console, 'log', () => {});
|
||||
withEnv(t, { CCTV_ICELAND_MAX_SOURCES: undefined });
|
||||
const rows = Array.from({ length: 650 }, (_, i) =>
|
||||
row({ Slod: `${ICELAND_IMAGE_ORIGIN}cam_${i}.jpg` }),
|
||||
);
|
||||
t.mock.method(globalThis, 'fetch', async () => json(rows));
|
||||
for (const [value, expected] of [
|
||||
['0', 8],
|
||||
['3', 8],
|
||||
['1000', 600],
|
||||
['abc', DEFAULT_ICELAND_MAX_SOURCES],
|
||||
]) {
|
||||
process.env.CCTV_ICELAND_MAX_SOURCES = value;
|
||||
assert.equal(
|
||||
(await loadIcelandSourcesFromOpenData()).length,
|
||||
expected,
|
||||
value,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('a redirect on the Iceland frame host stays inside vefmyndavelar/', async () => {
|
||||
const frame = `${ICELAND_IMAGE_ORIGIN}hellisheidi_1.jpg`;
|
||||
const redirectTo = (location) => async (url) =>
|
||||
url === frame
|
||||
? new Response(null, { status: 302, headers: { location } })
|
||||
: new Response('frame', { headers: { 'content-type': 'image/jpeg' } });
|
||||
for (const location of [
|
||||
'/vgdata/other/x.jpg',
|
||||
'/vgdata/vefmyndavelar/../x.jpg',
|
||||
'/',
|
||||
])
|
||||
assert.equal(
|
||||
await fetchWithinHost(frame, {}, redirectTo(location)),
|
||||
null,
|
||||
location,
|
||||
);
|
||||
const followed = await fetchWithinHost(
|
||||
frame,
|
||||
{},
|
||||
redirectTo('/vgdata/vefmyndavelar/hellisheidi_1b.jpg'),
|
||||
);
|
||||
assert.equal(await followed.text(), 'frame');
|
||||
});
|
||||
|
||||
test('the Iceland loader fails soft on HTTP errors, bad payloads and network errors', async (t) => {
|
||||
t.mock.method(console, 'log', () => {});
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
t.mock.method(
|
||||
globalThis,
|
||||
'fetch',
|
||||
async () => new Response('x', { status: 503 }),
|
||||
);
|
||||
assert.deepEqual(await loadIcelandSourcesFromOpenData(), []);
|
||||
t.mock.restoreAll();
|
||||
t.mock.method(console, 'log', () => {});
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
t.mock.method(globalThis, 'fetch', async () => json({ rows: [] }));
|
||||
assert.deepEqual(await loadIcelandSourcesFromOpenData(), []);
|
||||
t.mock.method(
|
||||
globalThis,
|
||||
'fetch',
|
||||
async () => new Response('<html>', { status: 200 }),
|
||||
);
|
||||
assert.deepEqual(await loadIcelandSourcesFromOpenData(), []);
|
||||
t.mock.method(globalThis, 'fetch', async () => {
|
||||
throw new Error('network down');
|
||||
});
|
||||
assert.deepEqual(await loadIcelandSourcesFromOpenData(), []);
|
||||
});
|
||||
|
||||
test('the Iceland catalog fetch refuses redirects and oversized bodies', async (t) => {
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
const redirected = streamingResponse({
|
||||
status: 302,
|
||||
headers: {
|
||||
location: 'http://gagnaveita.vegagerdin.is/api/vefmyndavelar2014_1',
|
||||
},
|
||||
});
|
||||
t.mock.method(globalThis, 'fetch', async () => redirected.response);
|
||||
assert.deepEqual(await loadIcelandSourcesFromOpenData(), []);
|
||||
assert.equal(
|
||||
redirected.state.cancelled,
|
||||
true,
|
||||
'the redirect body is cancelled',
|
||||
);
|
||||
|
||||
t.mock.restoreAll();
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
const failed = streamingResponse({ status: 500 });
|
||||
t.mock.method(globalThis, 'fetch', async () => failed.response);
|
||||
assert.deepEqual(await loadIcelandSourcesFromOpenData(), []);
|
||||
assert.equal(failed.state.cancelled, true, 'the error body is cancelled');
|
||||
|
||||
const valid = JSON.stringify([row()]);
|
||||
t.mock.restoreAll();
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
t.mock.method(globalThis, 'fetch', async () => {
|
||||
return new Response(valid, {
|
||||
headers: { 'content-length': String(ICELAND_MAX_CATALOG_BYTES + 1) },
|
||||
});
|
||||
});
|
||||
assert.deepEqual(await loadIcelandSourcesFromOpenData(), []);
|
||||
|
||||
t.mock.restoreAll();
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
t.mock.method(globalThis, 'fetch', async () => {
|
||||
const padded = valid + ' '.repeat(ICELAND_MAX_CATALOG_BYTES + 1024);
|
||||
return new Response(
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode(padded));
|
||||
controller.close();
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
assert.deepEqual(await loadIcelandSourcesFromOpenData(), []);
|
||||
});
|
||||
|
||||
test('CCTV catalog merges Iceland cameras and CCTV_ICELAND_ENABLED=0 skips the request', async (t) => {
|
||||
t.mock.method(console, 'log', () => {});
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
const sourceRoot = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), 'gev-cctv-catalog-iceland-'),
|
||||
);
|
||||
t.after(() => fs.rmSync(sourceRoot, { recursive: true, force: true }));
|
||||
withEnv(t, {
|
||||
CCTV_SOURCES_FILE: undefined,
|
||||
CCTV_SOURCES_JSON: undefined,
|
||||
CCTV_FORCE_AUSTIN: undefined,
|
||||
CCTV_PREFER_AUSTIN: undefined,
|
||||
CCTV_MAX_SOURCES: undefined,
|
||||
CCTV_CALTRANS_DISTRICTS: undefined,
|
||||
CCTV_TFL_ENABLED: undefined,
|
||||
CCTV_ICELAND_ENABLED: undefined,
|
||||
CCTV_ICELAND_MAX_SOURCES: undefined,
|
||||
});
|
||||
const requested = [];
|
||||
t.mock.method(globalThis, 'fetch', async (url, init) => {
|
||||
requested.push([String(url), init.redirect]);
|
||||
if (String(url) !== ICELAND_CAMERAS_URL) {
|
||||
return new Response('unavailable', { status: 503 });
|
||||
}
|
||||
return json([row()]);
|
||||
});
|
||||
|
||||
const sources = await createCctvCatalog({ sourceRoot })();
|
||||
assert.deepEqual(
|
||||
sources.map((source) => source.id),
|
||||
['is-vegagerdin-hellisheidi_1'],
|
||||
);
|
||||
|
||||
process.env.CCTV_ICELAND_ENABLED = '0';
|
||||
requested.length = 0;
|
||||
assert.deepEqual(await createCctvCatalog({ sourceRoot })(), []);
|
||||
assert.ok(requested.length > 0, 'the other live packs still load');
|
||||
assert.equal(
|
||||
requested.some(([url]) => url === ICELAND_CAMERAS_URL),
|
||||
false,
|
||||
'a disabled pack makes no request',
|
||||
);
|
||||
});
|
||||
@@ -34,6 +34,14 @@ export const OPENMAPTILES_CREDIT = {
|
||||
html: 'Vector tiles: <a href="https://openfreemap.org" target="_blank" rel="noopener">OpenFreeMap</a> © <a href="https://openmaptiles.org" target="_blank" rel="noopener">OpenMapTiles</a>',
|
||||
};
|
||||
|
||||
/** Conditional Salish Sea coverage; registered when its sightings first render. */
|
||||
export const ACARTIA_CREDIT = Object.freeze({
|
||||
key: 'acartia',
|
||||
html:
|
||||
'Wildlife sightings: <a href="https://acartia.io" target="_blank" rel="noopener">Acartia</a> (CC BY 4.0; each provider credited per sighting) · ' +
|
||||
'<a href="https://www.bewhalewise.org" target="_blank" rel="noopener">Be Whale Wise</a>',
|
||||
});
|
||||
|
||||
const osmDisplays = new WeakMap();
|
||||
|
||||
function updateOsmDisplay(viewer, state) {
|
||||
@@ -188,6 +196,10 @@ export const DATA_CREDITS = [
|
||||
'<a href="https://adds-faa.opendata.arcgis.com/" target="_blank" rel="noopener">FAA Special Use Airspace</a>' +
|
||||
' (prohibited and restricted zones; informational only)',
|
||||
},
|
||||
{
|
||||
key: 'inaturalist',
|
||||
html: 'Wildlife sightings: <a href="https://www.inaturalist.org" target="_blank" rel="noopener">iNaturalist</a> contributors (licences per record; photos credited on each card)',
|
||||
},
|
||||
OSM_CREDIT,
|
||||
OPENMAPTILES_CREDIT,
|
||||
{
|
||||
@@ -309,6 +321,15 @@ export const DATA_CREDITS = [
|
||||
'(Llicència oberta d’ús d’informació - Catalunya); some cameras are supplied by partners ' +
|
||||
'(Ajuntament de Barcelona, Ajuntament de Terrassa), each named in the CCTV panel',
|
||||
},
|
||||
{
|
||||
key: 'iceland-cctv',
|
||||
html:
|
||||
'CCTV cameras & frames (Iceland): road camera images, fetched live, from the ' +
|
||||
'<a href="https://www.vegagerdin.is/" target="_blank" rel="noopener">Icelandic Road and Coastal Administration (Vegagerðin)</a> — ' +
|
||||
'<a href="https://creativecommons.org/licenses/by/4.0/" target="_blank" rel="noopener">CC BY 4.0</a> ' +
|
||||
'(<a href="https://www.vegagerdin.is/vegagerdin/gagnasafn/vefthjonustur/terms-and-conditions" target="_blank" rel="noopener">terms</a>); ' +
|
||||
'no endorsement by IRCA is implied',
|
||||
},
|
||||
{
|
||||
key: 'kingcounty-cctv',
|
||||
html:
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
# Test fixtures
|
||||
|
||||
- `photon-moscow-search.json` - Photon responses for `Москва`, captured
|
||||
2026-11-05 with and without a proximity bias near the Tajikistan namesake.
|
||||
The fixture is trimmed to the fields the keyless geocoder consumes and is
|
||||
used only by offline search tests. Data from OpenStreetMap contributors,
|
||||
ODbL 1.0 (https://www.openstreetmap.org/copyright).
|
||||
- `tomtom-flow-austin-12-935-1686.pbf` — one real TomTom traffic-flow vector
|
||||
tile (Mapbox Vector Tile protobuf, layer `"Traffic flow"`), downtown Austin
|
||||
z12 x935 y1686, captured 2026-07-16 from
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
{
|
||||
"biased": {
|
||||
"features": [
|
||||
{
|
||||
"geometry": {
|
||||
"type": "Point",
|
||||
"coordinates": [71.8339931, 38.948412]
|
||||
},
|
||||
"properties": {
|
||||
"name": "Москва",
|
||||
"country": "Тоҷикистон"
|
||||
}
|
||||
},
|
||||
{
|
||||
"geometry": {
|
||||
"type": "Point",
|
||||
"coordinates": [37.6063916, 55.625578]
|
||||
},
|
||||
"properties": {
|
||||
"name": "Москва",
|
||||
"country": "Россия"
|
||||
}
|
||||
},
|
||||
{
|
||||
"geometry": {
|
||||
"type": "Point",
|
||||
"coordinates": [37.6174782, 55.7505412]
|
||||
},
|
||||
"properties": {
|
||||
"name": "Москва",
|
||||
"country": "Россия"
|
||||
}
|
||||
},
|
||||
{
|
||||
"geometry": {
|
||||
"type": "Point",
|
||||
"coordinates": [37.0536908, 55.7098009]
|
||||
},
|
||||
"properties": {
|
||||
"name": "Москва",
|
||||
"country": "Россия"
|
||||
}
|
||||
},
|
||||
{
|
||||
"geometry": {
|
||||
"type": "Point",
|
||||
"coordinates": [32.163334, 56.917778]
|
||||
},
|
||||
"properties": {
|
||||
"name": "Москва",
|
||||
"country": "Россия"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"unbiased": {
|
||||
"features": [
|
||||
{
|
||||
"geometry": {
|
||||
"type": "Point",
|
||||
"coordinates": [37.6063916, 55.625578]
|
||||
},
|
||||
"properties": {
|
||||
"name": "Москва",
|
||||
"country": "Россия"
|
||||
}
|
||||
},
|
||||
{
|
||||
"geometry": {
|
||||
"type": "Point",
|
||||
"coordinates": [37.6174782, 55.7505412]
|
||||
},
|
||||
"properties": {
|
||||
"name": "Москва",
|
||||
"country": "Россия"
|
||||
}
|
||||
},
|
||||
{
|
||||
"geometry": {
|
||||
"type": "Point",
|
||||
"coordinates": [37.0536908, 55.7098009]
|
||||
},
|
||||
"properties": {
|
||||
"name": "Москва",
|
||||
"country": "Россия"
|
||||
}
|
||||
},
|
||||
{
|
||||
"geometry": {
|
||||
"type": "Point",
|
||||
"coordinates": [32.163334, 56.917778]
|
||||
},
|
||||
"properties": {
|
||||
"name": "Москва",
|
||||
"country": "Россия"
|
||||
}
|
||||
},
|
||||
{
|
||||
"geometry": {
|
||||
"type": "Point",
|
||||
"coordinates": [71.8339931, 38.948412]
|
||||
},
|
||||
"properties": {
|
||||
"name": "Москва",
|
||||
"country": "Тоҷикистон"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -82,7 +82,7 @@ test('infrastructure factory preserves identity and creates independent state wi
|
||||
|
||||
test('dataset URLs still name the complete bundled sources', () => {
|
||||
for (const [file, count] of [
|
||||
['datacenters', 4351],
|
||||
['datacenters', 4352],
|
||||
['dams', 704],
|
||||
]) {
|
||||
const lines = readFileSync(
|
||||
|
||||
@@ -312,12 +312,32 @@ const OPTION_GROUPS = Object.freeze({
|
||||
high: 'h',
|
||||
}),
|
||||
]),
|
||||
'wildlife-sightings': Object.freeze([
|
||||
booleanOption('confirmed', 'c', false),
|
||||
enumOption(
|
||||
'taxa',
|
||||
't',
|
||||
'all',
|
||||
['all', 'Aves', 'Mammalia', 'whales', 'Insecta', 'Plantae'],
|
||||
{
|
||||
all: 'a',
|
||||
Aves: 'b',
|
||||
Mammalia: 'm',
|
||||
whales: 'w',
|
||||
Insecta: 'i',
|
||||
Plantae: 'p',
|
||||
},
|
||||
),
|
||||
]),
|
||||
traffic: Object.freeze([
|
||||
enumOption('roadMode', 'r', null, ['tomtom', 'osm', 'hybrid'], {
|
||||
tomtom: 't',
|
||||
osm: 'o',
|
||||
hybrid: 'h',
|
||||
}),
|
||||
// Street Traffic row toggles: flow-colored road lines, moving dots.
|
||||
booleanOption('lines', 'l', false),
|
||||
booleanOption('dots', 'd', true),
|
||||
]),
|
||||
'weather-lightning': Object.freeze([
|
||||
enumOption('opacity', 'o', 'strong', ['light', 'strong'], {
|
||||
@@ -733,6 +753,12 @@ export const LAYER_STATE_REGISTRY = Object.freeze([
|
||||
disposition: 'enabled+options',
|
||||
optionOwner: 'weather-satellite',
|
||||
}),
|
||||
Object.freeze({
|
||||
id: 'wildlife-sightings',
|
||||
token: '9',
|
||||
disposition: 'enabled+options',
|
||||
optionOwner: 'wildlife-sightings',
|
||||
}),
|
||||
Object.freeze({
|
||||
id: 'wind',
|
||||
token: 'k',
|
||||
|
||||
@@ -203,8 +203,8 @@ function encode(state) {
|
||||
|
||||
test('production registry is exact, canonical, and rejects incomplete contracts', async () => {
|
||||
assert.equal(validateLayerStateRegistry(), true);
|
||||
assert.equal(REGISTERED_LAYER_IDS.length, 35);
|
||||
assert.equal(new Set(REGISTERED_LAYER_IDS).size, 35);
|
||||
assert.equal(REGISTERED_LAYER_IDS.length, 36);
|
||||
assert.equal(new Set(REGISTERED_LAYER_IDS).size, 36);
|
||||
assert.ok(REGISTERED_LAYER_IDS.includes('transit'));
|
||||
assert.deepEqual(REGISTERED_LAYER_IDS, [...REGISTERED_LAYER_IDS].sort());
|
||||
assert.deepEqual(LEGACY_LAYER_STATE_TOKENS, {
|
||||
@@ -243,7 +243,7 @@ test('production registry is exact, canonical, and rejects incomplete contracts'
|
||||
for (const [id, token] of Object.entries(LEGACY_LAYER_STATE_TOKENS)) {
|
||||
assert.equal(LAYER_STATE_TOKEN_RESERVATIONS[id], token);
|
||||
}
|
||||
assert.equal(nextLayerStateToken(), '9');
|
||||
assert.equal(nextLayerStateToken(), '00');
|
||||
assert.equal(
|
||||
nextLayerStateToken({
|
||||
...LAYER_STATE_TOKEN_RESERVATIONS,
|
||||
@@ -306,8 +306,8 @@ test('production registry is exact, canonical, and rejects incomplete contracts'
|
||||
assert.equal(
|
||||
validateLayerStateAllocations(LAYER_STATE_TOKEN_RESERVATIONS, {
|
||||
...LAYER_STATE_TOKEN_RESERVATIONS,
|
||||
future: '9',
|
||||
next: '00',
|
||||
future: '00',
|
||||
next: '01',
|
||||
}),
|
||||
true,
|
||||
);
|
||||
@@ -315,12 +315,13 @@ test('production registry is exact, canonical, and rejects incomplete contracts'
|
||||
() =>
|
||||
validateLayerStateAllocations(LAYER_STATE_TOKEN_RESERVATIONS, {
|
||||
...LAYER_STATE_TOKEN_RESERVATIONS,
|
||||
future: '00',
|
||||
future: '01',
|
||||
}),
|
||||
/next free token 9/,
|
||||
/next free token 00/,
|
||||
);
|
||||
const beforeLastDigit = { ...digitsExhausted };
|
||||
delete beforeLastDigit['prior-9'];
|
||||
delete beforeLastDigit['wildlife-sightings'];
|
||||
assert.equal(
|
||||
validateLayerStateAllocations(beforeLastDigit, {
|
||||
...beforeLastDigit,
|
||||
@@ -2695,6 +2696,32 @@ test('the recent-imagery split is share-link only: never stored locally, and a s
|
||||
);
|
||||
});
|
||||
|
||||
test('wildlife confirmed and every taxa preset round-trip through the full share codec', () => {
|
||||
assert.equal(
|
||||
LAYER_STATE_REGISTRY.find(({ id }) => id === 'wildlife-sightings').token,
|
||||
'9',
|
||||
);
|
||||
for (const taxa of [
|
||||
'all',
|
||||
'Aves',
|
||||
'Mammalia',
|
||||
'whales',
|
||||
'Insecta',
|
||||
'Plantae',
|
||||
]) {
|
||||
const state = normalizeLayerState({
|
||||
enabledLayerIds: ['wildlife-sightings'],
|
||||
options: { 'wildlife-sightings': { confirmed: true, taxa } },
|
||||
});
|
||||
const encoded = encode(state);
|
||||
assert.doesNotMatch(encoded, /undefined/);
|
||||
assert.deepEqual(
|
||||
decodeLayerStateParams(new URLSearchParams(encoded)),
|
||||
state,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('drone restrictions uses digit 8 without colliding with fire perimeters', () => {
|
||||
const decoded = decodeLayerStateParams(new URLSearchParams('v=2&l=8.2'));
|
||||
assert.deepEqual(decoded.enabledLayerIds, [
|
||||
|
||||
@@ -97,6 +97,10 @@ function withPublishedBase(
|
||||
}
|
||||
}
|
||||
|
||||
const preWildlifeRows = reservationRows.filter(
|
||||
([id]) => id !== 'wildlife-sightings',
|
||||
);
|
||||
|
||||
function writeCodecFixture(cwd, rows, entries) {
|
||||
const providerCodec = path.join(cwd, 'src/data/idSwitches.js');
|
||||
mkdirSync(path.dirname(providerCodec), { recursive: true });
|
||||
@@ -104,7 +108,12 @@ function writeCodecFixture(cwd, rows, entries) {
|
||||
providerCodec,
|
||||
readFileSync(new URL('./idSwitches.js', import.meta.url)),
|
||||
);
|
||||
const source = readFileSync(CODEC_SOURCE_PATH, 'utf8');
|
||||
let source = readFileSync(CODEC_SOURCE_PATH, 'utf8');
|
||||
if (!rows.some(([id]) => id === 'wildlife-sightings'))
|
||||
source = source.replace(
|
||||
/ Object\.freeze\(\{\s*id: 'wildlife-sightings',[\s\S]*?\}\),\n/,
|
||||
'',
|
||||
);
|
||||
const registryEnd = ']);\n\nexport const REGISTERED_LAYER_IDS';
|
||||
assert.equal(source.split(registryEnd).length, 2);
|
||||
const additions = entries
|
||||
@@ -128,7 +137,7 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed
|
||||
{ ...parseLayerStateTokenReservations(reservationRows) },
|
||||
{ ...LAYER_STATE_TOKEN_RESERVATIONS },
|
||||
);
|
||||
assert.equal(Object.keys(LAYER_STATE_TOKEN_RESERVATIONS).length, 35);
|
||||
assert.equal(Object.keys(LAYER_STATE_TOKEN_RESERVATIONS).length, 36);
|
||||
assert.deepEqual(
|
||||
{ ...LAYER_STATE_TOKEN_RESERVATIONS },
|
||||
{
|
||||
@@ -140,6 +149,7 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed
|
||||
'sea-marks': '6',
|
||||
'ski-pistes': '7',
|
||||
'drone-airspace': '8',
|
||||
'wildlife-sightings': '9',
|
||||
},
|
||||
);
|
||||
assert.throws(
|
||||
@@ -165,9 +175,9 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed
|
||||
/Duplicate layer-state token reservation/,
|
||||
);
|
||||
assert.equal(
|
||||
parseLayerStateTokenReservations([...reservationRows, ['future', '9']])
|
||||
parseLayerStateTokenReservations([...reservationRows, ['future', '00']])
|
||||
.future,
|
||||
'9',
|
||||
'00',
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
@@ -185,7 +195,7 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed
|
||||
|
||||
test('published retired digits and competing PRs advance the merge-time allocation', () => {
|
||||
withPublishedBase(
|
||||
{ rows: [...reservationRows, ['retired-layer', '9']] },
|
||||
{ rows: [...preWildlifeRows, ['retired-layer', '9']] },
|
||||
(cwd) => {
|
||||
const published = readPublishedLayerStateReservations('HEAD', cwd);
|
||||
assert.equal(nextLayerStateToken(published), '00');
|
||||
@@ -217,7 +227,7 @@ test('published retired digits and competing PRs advance the merge-time allocati
|
||||
});
|
||||
|
||||
test('PR B manually replaces provisional 9 with 00 after PR A publishes 9', () => {
|
||||
withPublishedBase({ rows: [...reservationRows, ['pr-a', '9']] }, (cwd) => {
|
||||
withPublishedBase({ rows: [...preWildlifeRows, ['pr-a', '9']] }, (cwd) => {
|
||||
mkdirSync(path.join(cwd, 'scripts'));
|
||||
const fixtureChecker = path.join(
|
||||
cwd,
|
||||
@@ -238,7 +248,7 @@ test('PR B manually replaces provisional 9 with 00 after PR A publishes 9', () =
|
||||
|
||||
writeCodecFixture(
|
||||
cwd,
|
||||
[...reservationRows, ['pr-b', '9']],
|
||||
[...preWildlifeRows, ['pr-b', '9']],
|
||||
[{ id: 'pr-b', token: '9' }],
|
||||
);
|
||||
const beforeRebase = runCheck();
|
||||
@@ -247,7 +257,7 @@ test('PR B manually replaces provisional 9 with 00 after PR A publishes 9', () =
|
||||
|
||||
writeCodecFixture(
|
||||
cwd,
|
||||
[...reservationRows, ['pr-a', '9'], ['pr-b', '9']],
|
||||
[...preWildlifeRows, ['pr-a', '9'], ['pr-b', '9']],
|
||||
[
|
||||
{ id: 'pr-a', token: '9' },
|
||||
{ id: 'pr-b', token: '9' },
|
||||
@@ -259,7 +269,7 @@ test('PR B manually replaces provisional 9 with 00 after PR A publishes 9', () =
|
||||
|
||||
const published = readPublishedLayerStateReservations('HEAD', cwd);
|
||||
assert.equal(nextLayerStateToken(published), '00');
|
||||
const validBaselineRows = [...reservationRows, ['pr-a', '9']];
|
||||
const validBaselineRows = [...preWildlifeRows, ['pr-a', '9']];
|
||||
writeCodecFixture(cwd, validBaselineRows, [{ id: 'pr-a', token: '9' }]);
|
||||
const helper = spawnSync(
|
||||
process.execPath,
|
||||
@@ -275,7 +285,7 @@ test('PR B manually replaces provisional 9 with 00 after PR A publishes 9', () =
|
||||
);
|
||||
writeCodecFixture(
|
||||
cwd,
|
||||
[...reservationRows, ['pr-a', '9'], ['pr-b', '00']],
|
||||
[...preWildlifeRows, ['pr-a', '9'], ['pr-b', '00']],
|
||||
[
|
||||
{ id: 'pr-a', token: '9' },
|
||||
{ id: 'pr-b', token: '00' },
|
||||
@@ -290,7 +300,7 @@ test('PR B manually replaces provisional 9 with 00 after PR A publishes 9', () =
|
||||
test('allocation batches cross the last digit and base-36 pair boundaries in order', () => {
|
||||
const digitRows = [...'9'].map((digit) => [`retired-${digit}`, digit]);
|
||||
const allDigits = parseLayerStateTokenReservations([
|
||||
...reservationRows,
|
||||
...preWildlifeRows,
|
||||
...digitRows,
|
||||
]);
|
||||
const beforeNine = { ...allDigits };
|
||||
@@ -349,7 +359,7 @@ test('an isolated valid two-character fixture round-trips an l field beyond the
|
||||
token,
|
||||
}));
|
||||
const fixtureRows = [
|
||||
...reservationRows,
|
||||
...preWildlifeRows,
|
||||
...priorDigits,
|
||||
...fixtureLayers.map(({ id, token }) => [id, token]),
|
||||
];
|
||||
@@ -358,13 +368,13 @@ test('an isolated valid two-character fixture round-trips an l field beyond the
|
||||
assert.equal(codec.validateLayerStateRegistry(), true);
|
||||
assert.equal(
|
||||
nextLayerStateToken(
|
||||
parseLayerStateTokenReservations([...reservationRows, ...priorDigits]),
|
||||
parseLayerStateTokenReservations([...preWildlifeRows, ...priorDigits]),
|
||||
),
|
||||
'00',
|
||||
);
|
||||
assert.equal(
|
||||
validateLayerStateAllocations(
|
||||
LAYER_STATE_TOKEN_RESERVATIONS,
|
||||
parseLayerStateTokenReservations(preWildlifeRows),
|
||||
codec.LAYER_STATE_TOKEN_RESERVATIONS,
|
||||
),
|
||||
true,
|
||||
@@ -395,7 +405,7 @@ test('checker reads a complete future base ledger and rejects retired-token reus
|
||||
},
|
||||
(cwd) => {
|
||||
const published = readPublishedLayerStateReservations('HEAD', cwd);
|
||||
assert.equal(Object.keys(published).length, 36);
|
||||
assert.equal(Object.keys(published).length, 37);
|
||||
assert.equal(published['retired-layer'], '00');
|
||||
assert.throws(
|
||||
() =>
|
||||
|
||||
@@ -33,5 +33,6 @@
|
||||
["weather-lightning", "l"],
|
||||
["weather-radar", "v"],
|
||||
["weather-satellite", "o"],
|
||||
["wildlife-sightings", "9"],
|
||||
["wind", "k"]
|
||||
]
|
||||
@@ -5,7 +5,7 @@ layer.
|
||||
|
||||
- Source: OpenStreetMap contributors
|
||||
- License: Open Database License (ODbL) 1.0
|
||||
- Feature count: 4,351
|
||||
- Feature count: 4,352
|
||||
- Runtime file: `datacenters.geojsonl`
|
||||
|
||||
The public-release snapshot removes contact-oriented tags such as email, phone,
|
||||
@@ -16,3 +16,12 @@ ODbL 1.0 with the required OpenStreetMap attribution.
|
||||
|
||||
The original extraction date and query were not recorded alongside this
|
||||
snapshot. Future refreshes should record both before replacing the file.
|
||||
|
||||
## Norway refresh
|
||||
|
||||
Features inside Geofabrik's Norway boundary (`norway.poly`, with Svalbard) come
|
||||
from a newer extract than the rest of the file. Contact tags are dropped as above.
|
||||
|
||||
- Extract: Geofabrik `norway-261005.osm.pbf`, OSM data
|
||||
2026-10-05T20:21:35Z, MD5 `1d2cfdeb98980fce11b8891d0f9146c0`
|
||||
- Filter: `osmium tags-filter nwr/telecom=data_center nwr/building=data_center`
|
||||
@@ -3770,15 +3770,15 @@
|
||||
{"id":939340882,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[21.021364183,52.315373019],[21.02232433,52.315435883],[21.022363641,52.315209656],[21.021403578,52.315146875],[21.021364183,52.315373019]]]},"properties":{"id":11407,"tags":{"name":"T-Mobile Warszawa-Szlachecka","telecom":"data_center","building":"office","building:levels":"3"},"type":"data_center","osm_id":939340882}}
|
||||
{"id":1005973518,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[21.021325375,52.315595978],[21.022285857,52.315658423],[21.02232433,52.315435883],[21.021364183,52.315373019],[21.021325375,52.315595978]]]},"properties":{"id":11976,"tags":{"telecom":"data_center","building":"office","building:levels":"3"},"type":"data_center","osm_id":1005973518}}
|
||||
{"id":1156823776,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[21.115130857,52.235385942],[21.115189866,52.236349777],[21.115232027,52.236667954],[21.115818174,52.237174556],[21.116175578,52.237468677],[21.117262124,52.236985628],[21.11728157,52.236976576],[21.117262795,52.236960985],[21.117340495,52.236926033],[21.117360276,52.2369433],[21.117447532,52.236904491],[21.11804097,52.236654459],[21.118030996,52.236645742],[21.118041054,52.236641719],[21.116603055,52.235624072],[21.116518984,52.235676375],[21.115986231,52.235696659],[21.115954966,52.235361551],[21.115130857,52.235385942]]]},"properties":{"id":15342,"tags":{"name":"Atman Data Center Warsaw-1","telecom":"data_center","website":"https://www.atman.pl"},"type":"data_center","osm_id":1156823776}}
|
||||
{"id":7405586142,"type":"Feature","geometry":{"type":"Point","coordinates":[5.598350034,58.860111293]},"properties":{"id":24849,"tags":{"name":"Maan Group","telecom":"data_center","operator":"Privat"},"type":"data_center","osm_id":7405586142}}
|
||||
{"id":7405586142,"type":"Feature","geometry":{"type":"Point","coordinates":[5.5983501,58.8601113]},"properties":{"id":24849,"tags":{"name":"Maan Group","opening_hours":"24/7","operator":"Privat","telecom":"data_center"},"type":"data_center","osm_id":7405586142}}
|
||||
{"id":481615983,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[9.572510149,56.481777951],[9.574069938,56.484921751],[9.57465357,56.48483349],[9.574639572,56.484805243],[9.574700676,56.484795939],[9.574714925,56.484824857],[9.575298557,56.484736595],[9.57454888,56.483225422],[9.574609229,56.483216369],[9.574510155,56.483016629],[9.574449722,56.483025849],[9.573738769,56.481592795],[9.573155137,56.481680972],[9.573170643,56.481712321],[9.573109539,56.481721625],[9.573093781,56.48168969],[9.572510149,56.481777951]]]},"properties":{"id":7881,"tags":{"telecom":"data_center","building":"data_center","operator":"Apple","wikidata":"Q19721053","wikipedia":"en:Foulum Data Center","description":"Apple Datacenter"},"type":"data_center","osm_id":481615983}}
|
||||
{"id":618756435,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[10.500360092,57.318668738],[10.500399236,57.318671672],[10.500382891,57.318734871],[10.500552038,57.318744175],[10.500545249,57.318787677],[10.500501495,57.318785666],[10.500458999,57.318957746],[10.500970295,57.31899647],[10.501009187,57.318847189],[10.501084959,57.318852888],[10.50111966,57.318719197],[10.500843896,57.318698159],[10.500865521,57.318615178],[10.500859235,57.318614759],[10.500869461,57.318575699],[10.500954537,57.318582153],[10.501022347,57.31832223],[10.500736691,57.318300437],[10.500669133,57.318559689],[10.500674162,57.318560025],[10.500664272,57.318598498],[10.500383981,57.318577124],[10.500360092,57.318668738]]]},"properties":{"id":8842,"tags":{"ref":"AAL01-01","name":"Prime Data Centers - Saeby Campus AAL01","telecom":"data_center","website":"https://primedatacenters.com/locations/saeby/","building":"industrial","operator":"Prime Data Centers"},"type":"data_center","osm_id":618756435}}
|
||||
{"id":-14367602,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[11.002243225,59.633471864],[11.003903261,59.633446551],[11.007046726,59.633399696],[11.006983443,59.632420941],[11.006982772,59.63227057],[11.006911023,59.632265289],[11.006852936,59.632253973],[11.006765848,59.632228576],[11.00671782,59.632205275],[11.006119687,59.632213573],[11.006119352,59.632202592],[11.006118933,59.63219245],[11.005930424,59.632194378],[11.005923635,59.632120869],[11.005980799,59.632120031],[11.005979542,59.632104524],[11.005977027,59.63206865],[11.00597225,59.631998242],[11.005412171,59.632035541],[11.005286945,59.632043839],[11.00527848,59.632044342],[11.003707962,59.632147272],[11.003107483,59.63219178],[11.002741864,59.632235533],[11.002729375,59.63223788],[11.002345987,59.632309294],[11.002308352,59.632316251],[11.002302066,59.632477351],[11.002277088,59.632913881],[11.002243225,59.633471864]]]},"properties":{"id":107,"tags":{"name":"STACK Infrastructure OSL04","type":"multipolygon","telecom":"data_center","website":"https://www.stackinfra.com","operator":"Stack Infrastructure","operator:wikidata":"Q115477173"},"type":"data_center","osm_id":-14367602}}
|
||||
{"id":-14367602,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[11.0022433,59.6334719],[11.0022771,59.6329139],[11.0023021,59.6324774],[11.0023084,59.6323163],[11.002346,59.6323093],[11.0027294,59.6322379],[11.0027419,59.6322356],[11.0031075,59.6321918],[11.003708,59.6321473],[11.0052785,59.6320444],[11.005287,59.6320439],[11.0054122,59.6320356],[11.0059723,59.6319983],[11.0059771,59.6320687],[11.0059796,59.6321046],[11.0059808,59.6321201],[11.0059237,59.6321209],[11.0059305,59.6321944],[11.006119,59.6321925],[11.0061194,59.6322026],[11.0061197,59.6322136],[11.0067179,59.6322053],[11.0067659,59.6322286],[11.006853,59.632254],[11.0069111,59.6322653],[11.0069828,59.6322706],[11.0069835,59.632421],[11.0070468,59.6333997],[11.0039033,59.6334466],[11.0022433,59.6334719]]]},"properties":{"id":107,"tags":{"landuse":"industrial","name":"STACK Infrastructure OSL04","operator":"Stack Infrastructure","operator:wikidata":"Q115477173","telecom":"data_center","website":"https://www.stackinfra.com","type":"multipolygon"},"type":"data_center","osm_id":-14367602}}
|
||||
{"id":1028687251,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[11.004111886,59.632578437],[11.004142983,59.633037262],[11.004275166,59.633034999],[11.004274998,59.63303173],[11.00505133,59.633019157],[11.005056443,59.633099372],[11.005224416,59.63309669],[11.005190637,59.632562344],[11.005060885,59.632564439],[11.005060969,59.632567457],[11.004587978,59.632575419],[11.00458538,59.632536527],[11.004459819,59.632538623],[11.004462418,59.632576844],[11.004242476,59.6325807],[11.004242141,59.632576174],[11.004111886,59.632578437]]]},"properties":{"id":12226,"tags":{"building":"data_center","ref:bygningsnr":"300783262"},"type":"data_center","osm_id":1028687251}}
|
||||
{"id":1164840308,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[11.002600126,59.632599392],[11.002631223,59.633058217],[11.00276349,59.633055954],[11.002763238,59.633052685],[11.00353957,59.633040196],[11.003544683,59.633120327],[11.00371274,59.633117645],[11.003678961,59.632583382],[11.003549042,59.632585478],[11.003549293,59.632588495],[11.003076135,59.632596458],[11.00307362,59.632557566],[11.002948143,59.632559661],[11.002950657,59.632597883],[11.002730716,59.632601571],[11.002730465,59.632597129],[11.002600126,59.632599392]]]},"properties":{"id":15459,"tags":{"building":"data_center","ref:bygningsnr":"300783262"},"type":"data_center","osm_id":1164840308}}
|
||||
{"id":1164840309,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[11.005613672,59.632723444],[11.005644769,59.633182269],[11.005776951,59.633179922],[11.005776784,59.633176737],[11.006553199,59.633164164],[11.006558229,59.633244463],[11.006726286,59.633241781],[11.006692423,59.632707351],[11.006562671,59.632709446],[11.006562839,59.632712464],[11.006089764,59.632720426],[11.006087166,59.632681534],[11.005961772,59.632683797],[11.005964287,59.632721851],[11.005744262,59.632725623],[11.005743927,59.632721181],[11.005613672,59.632723444]]]},"properties":{"id":15460,"tags":{"building":"data_center","ref:bygningsnr":"300783262"},"type":"data_center","osm_id":1164840309}}
|
||||
{"id":935092287,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[10.991467366,59.759024056],[10.992780978,59.759406942],[10.992773937,59.759423957],[10.992948197,59.759474248],[10.993073842,59.759363859],[10.993022293,59.759348939],[10.993291688,59.759112653],[10.993128827,59.759065547],[10.993181885,59.759019027],[10.993083062,59.758990445],[10.992976947,59.759083484],[10.992845519,59.759045346],[10.992813584,59.759073342],[10.9927876,59.759065798],[10.993409789,59.758524243],[10.99263002,59.758297094],[10.991938848,59.758898663],[10.991693594,59.758827165],[10.991467366,59.759024056]]]},"properties":{"id":11377,"tags":{"name":"Green Mountain Ytre Enebakk","telecom":"data_center","website":"https://greenmountain.no/","building":"warehouse","ref:bygningsnr":"300752944"},"type":"data_center","osm_id":935092287}}
|
||||
{"id":1161764012,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[10.992826324,59.757448761],[10.994530281,59.757601396],[10.994634888,59.757303168],[10.992928332,59.757152545],[10.992826324,59.757448761]]]},"properties":{"id":15426,"tags":{"name":"Green Mountain Ytre Enebakk","telecom":"data_center","website":"https://greenmountain.no/","building":"industrial"},"type":"data_center","osm_id":1161764012}}
|
||||
{"id":935092287,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[10.9914674,59.7590241],[10.9916936,59.7588272],[10.9919389,59.7588987],[10.9926301,59.7582971],[10.9934098,59.7585243],[10.9927876,59.7590658],[10.9928136,59.7590734],[10.9928456,59.7590454],[10.992977,59.7590835],[10.9930831,59.7589905],[10.9931819,59.7590191],[10.9931289,59.7590656],[10.9932917,59.7591127],[10.9930223,59.759349],[10.9930739,59.7593639],[10.9929482,59.7594743],[10.992774,59.759424],[10.992781,59.759407],[10.9914674,59.7590241]]]},"properties":{"id":11377,"tags":{"building":"warehouse","name":"Green Mountain Ytre Enebakk","ref:bygningsnr":"300752944","source":"Norway Orthophoto 2024;https://greenmountain.no/data-center/osl1-enebakk/","telecom":"data_center","website":"https://greenmountain.no/"},"type":"data_center","osm_id":935092287}}
|
||||
{"id":1161764012,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[10.9928264,59.7574488],[10.9929284,59.7571526],[10.9946349,59.7573032],[10.9945303,59.7576014],[10.9928264,59.7574488]]]},"properties":{"id":15426,"tags":{"building":"industrial","name":"Green Mountain Ytre Enebakk","source":"Norway Orthophoto 2024;https://greenmountain.no/data-center/osl1-enebakk/","telecom":"data_center","website":"https://greenmountain.no/"},"type":"data_center","osm_id":1161764012}}
|
||||
{"id":30724580,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[10.808790995,59.924984985],[10.808893925,59.925031923],[10.80893793,59.92500594],[10.808982941,59.925025972],[10.809042955,59.92501298],[10.809083943,59.925031923],[10.809012948,59.925077856],[10.80926583,59.925189755],[10.80955115,59.925025972],[10.809676543,59.925081293],[10.81032899,59.924701928],[10.809890365,59.924508138],[10.80982155,59.924515347],[10.809691631,59.924457847],[10.808820667,59.924557592],[10.809099365,59.924699581],[10.808806418,59.924865291],[10.808918149,59.924914828],[10.808875988,59.924937962],[10.808790995,59.924984985]]]},"properties":{"id":261,"tags":{"name":"Stack Infrastructure OSLO1A","telecom":"data_center","building":"office","operator":"Stack Infrastructure","ref:bygningsnr":"80877293","building:levels":"4","operator:wikidata":"Q115477173"},"type":"data_center","osm_id":30724580}}
|
||||
{"id":80871917,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[10.83408892,59.938588981],[10.834305927,59.938720996],[10.834575992,59.938720996],[10.834574987,59.938736],[10.834581943,59.938736922],[10.834579932,59.938771958],[10.834736925,59.938771958],[10.834734997,59.93893792],[10.836717988,59.938944961],[10.836717988,59.938983937],[10.836817984,59.938983937],[10.83681899,59.938944961],[10.836844974,59.938944961],[10.836846985,59.938774976],[10.836841956,59.938774976],[10.836842962,59.938744968],[10.836849919,59.938744968],[10.836851931,59.938653941],[10.836683957,59.938652935],[10.836684125,59.938640278],[10.836686975,59.938414973],[10.83490297,59.938406926],[10.834899953,59.938646984],[10.834812949,59.938645978],[10.83481496,59.938551933],[10.834579932,59.938550927],[10.834580938,59.938564925],[10.834567946,59.938564925],[10.834567946,59.938551933],[10.834089926,59.938549922],[10.83408892,59.938588981]]]},"properties":{"id":1464,"tags":{"name":"Bulk Data Centers Oslo Internet Exchange","telecom":"data_center","website":"https://bulkinfrastructure.com/data-centers/locations/os-ix","building":"industrial","ref:bygningsnr":"80709943"},"type":"data_center","osm_id":80871917}}
|
||||
{"id":12158409495,"type":"Feature","geometry":{"type":"Point","coordinates":[9.689363435,62.59439936]},"properties":{"id":28723,"tags":{"name":"VisBook AS","telecom":"data_center","website":"https://visbook.com/"},"type":"data_center","osm_id":12158409495}}
|
||||
@@ -4349,3 +4349,4 @@
|
||||
{"id":540895898,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[101.71747909,56.303855963],[101.71782015,56.304420065],[101.721113819,56.303806929],[101.720772759,56.303242994],[101.71747909,56.303855963]]]},"properties":{"id":8271,"tags":{"name":"Битривер Рус","name:en":"Bitriver Rus","name:ru":"Битривер Рус","telecom":"data_center","website":"https://bitriver.farm/","building":"data_center"},"type":"data_center","osm_id":540895898}}
|
||||
{"id":1082803064,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[123.948021193,47.340264299],[123.948354625,47.340268239],[123.948490663,47.340269831],[123.948488568,47.340349292],[123.948809762,47.340353064],[123.948811942,47.340267485],[123.948918643,47.340268658],[123.948922918,47.340137817],[123.948023959,47.340126082],[123.948021193,47.340264299]]]},"properties":{"id":13377,"tags":{"name":"联通云数据中心·齐齐哈尔","name:en":"Unicom Data Center of Qiqihar","name:zh":"联通云数据中心·齐齐哈尔","telecom":"data_center","building":"yes","name:zh-Hans":"联通云数据中心·齐齐哈尔","name:zh-Hant":"聯通雲數據中心·齊齊哈爾"},"type":"data_center","osm_id":1082803064}}
|
||||
{"id":442479059,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[142.76019226,46.910096898],[142.760192427,46.910325724],[142.760447656,46.910328155],[142.760460397,46.910102263],[142.76019226,46.910096898]]]},"properties":{"id":7471,"tags":{"telecom":"data_center","building":"yes"},"type":"data_center","osm_id":442479059}}
|
||||
{"id":1551029155,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[7.1069147,62.8627625],[7.1071207,62.8621617],[7.1075917,62.8616616],[7.1088647,62.8620476],[7.1106308,62.862645],[7.1098655,62.8632827],[7.1089604,62.8630042],[7.1086734,62.8632156],[7.1069147,62.8627625]]]},"properties":{"id":29471,"tags":{"layer":"-1","loc_name":"Troll Housing Klempertåsen","name":"Troll Housing","operator":"Troll Housing AS","telecom":"data_center","website":"https://www.trollhousing.no/"},"type":"data_center","osm_id":1551029155}}
|
||||
@@ -98,6 +98,7 @@ function installAwarenessRuntime({
|
||||
// status its module never emits guards nothing. See the settled-but-fetching
|
||||
// tests below, which use militaryInstallations' genuine first-fetch readout.
|
||||
layerStats = {},
|
||||
subscribe = undefined,
|
||||
} = {}) {
|
||||
const originalWindow = globalThis.window;
|
||||
const originalDocument = globalThis.document;
|
||||
@@ -215,6 +216,7 @@ function installAwarenessRuntime({
|
||||
setEnabled,
|
||||
getLayerLifecycleState,
|
||||
layers,
|
||||
...(subscribe ? { subscribe } : {}),
|
||||
});
|
||||
militaryAwarenessLayer.setParams({ passive: true });
|
||||
militaryAwarenessLayer.init(viewer);
|
||||
@@ -383,6 +385,54 @@ test('Contacts does not claim a dependency already enabling for the user', async
|
||||
}
|
||||
});
|
||||
|
||||
test('a dependency the user adopts stays on when Contacts turns off', async () => {
|
||||
// A direct selection, or the Cockpit riding one of its aircraft, promotes a
|
||||
// layer Contacts turned on into the user's own (adoptLayerVisibility). The
|
||||
// coordinator must give it up, or its OFF release tears the layer down —
|
||||
// and the track or the Cockpit with it — before Context restores it.
|
||||
const calls = [];
|
||||
const subscribers = new Set();
|
||||
const runtime = installAwarenessRuntime({
|
||||
isEnabled: () => false,
|
||||
isEffectivelyEnabled: () => false,
|
||||
setEnabled: (layerId, enabled) => {
|
||||
calls.push({ layerId, enabled });
|
||||
return Promise.resolve(true);
|
||||
},
|
||||
subscribe: (listener) => {
|
||||
subscribers.add(listener);
|
||||
return () => subscribers.delete(listener);
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
militaryAwarenessLayer.setParams({ passive: false });
|
||||
await nextTurn();
|
||||
for (const listener of subscribers)
|
||||
listener({
|
||||
type: 'visibility',
|
||||
layerId: 'flights',
|
||||
enabled: true,
|
||||
origin: 'user',
|
||||
adopted: true,
|
||||
adoptedFromSelection: true,
|
||||
});
|
||||
await militaryAwarenessLayer.disable();
|
||||
const released = calls
|
||||
.filter(({ enabled }) => !enabled)
|
||||
.map(({ layerId }) => layerId)
|
||||
.sort();
|
||||
assert.deepEqual(
|
||||
released,
|
||||
AWARENESS_DEPENDENCIES.filter((layerId) => layerId !== 'flights').sort(),
|
||||
'the adopted layer is the user\'s now; the rest are still released',
|
||||
);
|
||||
} finally {
|
||||
runtime.restore();
|
||||
}
|
||||
assert.equal(subscribers.size, 0, 'destroy drops the manager subscription');
|
||||
});
|
||||
|
||||
test('operational awareness becomes ready after aircraft while deferred sources keep loading', async () => {
|
||||
let enabled = false;
|
||||
let releaseAircraft;
|
||||
|
||||
+19
-9
@@ -11,17 +11,16 @@ const FOLD = {
|
||||
};
|
||||
|
||||
/**
|
||||
* Comparable form of a place name, shared by the bundled packs, the
|
||||
* OpenFreeMap street and grounds matchers and the Nominatim server: case
|
||||
* folded, accents removed from Latin letters only, punctuation and symbols
|
||||
* collapsed to single spaces, and a leading "the" dropped. Other scripts keep
|
||||
* their marks, so "कुल मार्ग" and "काली मार्ग" stay distinct while "São Paulo",
|
||||
* "sao paulo" and "SÃO PAULO!" meet.
|
||||
* Script-aware fold of a place name: case folded, accents removed from Latin
|
||||
* letters only, punctuation and symbols collapsed to single spaces. Other
|
||||
* scripts keep their marks, so "कुल मार्ग" and "काली मार्ग" stay distinct while
|
||||
* "São Paulo", "sao paulo" and "SÃO PAULO!" meet. Every word is kept: "The
|
||||
* Hague" is not "Hague".
|
||||
*
|
||||
* @param {unknown} value
|
||||
* @returns {string}
|
||||
*/
|
||||
export function foldPlaceName(value) {
|
||||
export function foldPlaceText(value) {
|
||||
return String(value ?? '')
|
||||
.toLowerCase()
|
||||
.replace(/[ßøæłđıœþ]/g, (c) => FOLD[c])
|
||||
@@ -29,8 +28,19 @@ export function foldPlaceName(value) {
|
||||
.replace(/(\p{Script=Latin})\p{M}+/gu, '$1')
|
||||
.normalize('NFKC')
|
||||
.replace(/[^\p{L}\p{M}\p{N}]+/gu, ' ')
|
||||
.trim()
|
||||
.replace(/^the /, '');
|
||||
.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Comparable form of a place name, shared by the bundled packs, the
|
||||
* OpenFreeMap street and grounds matchers and the Nominatim server:
|
||||
* `foldPlaceText` with a leading "the" dropped.
|
||||
*
|
||||
* @param {unknown} value
|
||||
* @returns {string}
|
||||
*/
|
||||
export function foldPlaceName(value) {
|
||||
return foldPlaceText(value).replace(/^the /, '');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -85,7 +85,7 @@ test('Warendorf webcam display introduces the shared OSM credit', (t) => {
|
||||
const v = viewer(t), empty = new Proxy({}, { get: () => noop });
|
||||
const lifecycle = createCctvLifecycle({ state: { _viewer: v, _records: [{ camera: { id: 'warendorf-marktplatz-rathaus', cityId: 'warendorf' } }], _recordById: new Map(), _cctvOverlayHost: overlayHost },
|
||||
services: { credits: { showOsmCredit, hideOsmCredit }, sprites: empty, activation: empty, picking: empty, focus: empty, render: empty },
|
||||
parts: { selection: empty, geometryQueue: empty, rendering: empty, projection: empty, cards: empty, presentation: empty }, source: {} });
|
||||
parts: { selection: empty, geometryQueue: empty, rendering: empty, projection: empty, cards: empty, presentation: empty, navigation: empty }, source: {} });
|
||||
lifecycle.methods.enable(); assertCredit(v); lifecycle.methods.disable();
|
||||
assert.equal(v.credits.filter(c => c.showOnScreen).length, 0);
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
export const SPRITE_LAYER_ORDER = Object.freeze([
|
||||
'cctv',
|
||||
'firms',
|
||||
'wildlife-sightings',
|
||||
'bikeshare',
|
||||
'transit',
|
||||
'transit-motion',
|
||||
|
||||
@@ -30,6 +30,28 @@ test('a superseded flow fetch is not an outage', () => {
|
||||
assert.equal(deriveTrafficFlowError(undefined), null);
|
||||
});
|
||||
|
||||
test('hosted account limits and service failures name their own reason', () => {
|
||||
const failure = (status, code) =>
|
||||
deriveTrafficFlowError(
|
||||
Object.assign(new Error(`TomTom flow unavailable (HTTP ${status})`), {
|
||||
status,
|
||||
code,
|
||||
}),
|
||||
);
|
||||
assert.equal(
|
||||
failure(429, 'USAGE_LIMIT_REACHED'),
|
||||
'Monthly traffic allowance used',
|
||||
);
|
||||
assert.equal(failure(429, 'RATE_LIMITED'), 'Traffic rate limit reached');
|
||||
assert.equal(failure(429, 'budget'), 'TomTom daily budget reached');
|
||||
assert.equal(failure(503, 'no_key'), 'TomTom key unavailable');
|
||||
assert.equal(failure(503, null), 'TomTom key unavailable');
|
||||
assert.equal(
|
||||
failure(503, 'USAGE_UNAVAILABLE'),
|
||||
'Traffic service unavailable',
|
||||
);
|
||||
});
|
||||
|
||||
test('flow failures map onto short, specific reasons', () => {
|
||||
const reason = (message) => deriveTrafficFlowError(new Error(message));
|
||||
assert.equal(reason('flow tile 12/1/1: HTTP 503'), 'TomTom key unavailable');
|
||||
|
||||
@@ -1020,6 +1020,14 @@ test('the voice TOOL SCHEMA matches the pinned contract — the mission mapping
|
||||
'noir',
|
||||
]);
|
||||
visualStyle.enum = [...visualStyle.enum, 'snow'];
|
||||
// Tilt to an angle adds pitchDeg and rewords direction; restore
|
||||
// the legacy camera fields to keep the pin.
|
||||
const camera = legacyTools.find((tool) => tool.name === 'move_camera')
|
||||
.parameters.properties;
|
||||
assert.equal(camera.pitchDeg.type, 'number');
|
||||
delete camera.pitchDeg;
|
||||
camera.direction.description =
|
||||
'Required for pan/tilt/rotate; orbit/rotate_globe default right.';
|
||||
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
|
||||
@@ -1039,10 +1047,11 @@ test('the voice TOOL SCHEMA matches the pinned contract — the mission mapping
|
||||
// The Utilities panel adds one set_panel_open target; Sea Marks and Ski
|
||||
// Pistes extend the generated layer enums and alias hint; voice geometry
|
||||
// adds the analyst area scopes and the osm-places layer.
|
||||
assert.equal(block.length, 31318, 'serialized tool schema length drifted');
|
||||
// control_cockpit states the own-layer entry rule (Contacts not required).
|
||||
assert.equal(block.length, 31372, 'serialized tool schema length drifted');
|
||||
assert.equal(
|
||||
crypto.createHash('sha256').update(block).digest('hex'),
|
||||
'f5b42f41c2f575838f979e88a1dbff615a77ccd85fdf8fcd0a0a47d02f7d3ddc',
|
||||
'7f593e78c232db972b04441f830b6a60f93916a6e5bc5a68eca7422cd1035508',
|
||||
'the first-run missions must ride EXISTING tools: no schema edit, no cache bust',
|
||||
);
|
||||
const instructions = fs.readFileSync(
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
import * as Cesium from 'cesium';
|
||||
|
||||
// Cesium's free look (Shift+drag) turns the camera in place. Near the ground
|
||||
// that surveys a city; from orbit a few drags turn the whole planet out of
|
||||
// frame and leave only stars.
|
||||
export const FREE_LOOK_MAX_HEIGHT_METERS = 1_000_000;
|
||||
|
||||
// A voice turn from orbit stops when about this share of the frame still shows
|
||||
// the planet's surface.
|
||||
export const MIN_GLOBE_FRAME_SHARE = 0.1;
|
||||
|
||||
/**
|
||||
* A free globe camera: the viewer is in 3D, follows no tracked entity, and its
|
||||
* camera is not locked to a reference frame.
|
||||
*/
|
||||
export function isFreeGlobeCamera(viewer) {
|
||||
return (
|
||||
viewer.scene.mode === Cesium.SceneMode.SCENE3D &&
|
||||
!viewer.trackedEntity &&
|
||||
Cesium.Matrix4.equals(viewer.camera.transform, Cesium.Matrix4.IDENTITY)
|
||||
);
|
||||
}
|
||||
|
||||
const GRID_COLUMNS = 32;
|
||||
const GRID_ROWS = 18;
|
||||
// A turn across a sliver of planet moves its edge through sample points, so
|
||||
// the share wobbles by a cell or two without the planet leaving.
|
||||
const GRID_NOISE_CELLS = 2;
|
||||
// Whatever the baseline, a voice turn never takes the planet below this share
|
||||
// of the frame; a view that already shows less may only keep what it has.
|
||||
const LOOK_ABSOLUTE_FLOOR_SHARE = 0.01;
|
||||
const sampleRay = new Cesium.Ray();
|
||||
const sampleOffset = new Cesium.Cartesian3();
|
||||
|
||||
/**
|
||||
* Cast a 32×18 grid of view rays, one through the centre of each cell, at the
|
||||
* WGS84 ellipsoid. With `keyholeRadius` (a radius around the frame centre, in
|
||||
* units of half the frame height) only the cells inside that circle count: the
|
||||
* scope mask hides the rest. Returns the surface hits and the cells counted, or
|
||||
* null for a frustum other than a perspective one.
|
||||
*/
|
||||
function sampleFrame(camera, keyholeRadius) {
|
||||
const frustum = camera.frustum;
|
||||
if (!(frustum instanceof Cesium.PerspectiveFrustum)) return null;
|
||||
const aspect = frustum.aspectRatio;
|
||||
const tanY = Math.tan(frustum.fovy / 2);
|
||||
const tanX = tanY * aspect;
|
||||
const keyholeSquared = keyholeRadius > 0 ? keyholeRadius * keyholeRadius : 0;
|
||||
Cesium.Cartesian3.clone(camera.positionWC, sampleRay.origin);
|
||||
let hits = 0;
|
||||
let cells = 0;
|
||||
for (let row = 0; row < GRID_ROWS; row += 1) {
|
||||
const rowY = (2 * (row + 0.5)) / GRID_ROWS - 1;
|
||||
for (let column = 0; column < GRID_COLUMNS; column += 1) {
|
||||
const columnX = (2 * (column + 0.5)) / GRID_COLUMNS - 1;
|
||||
if (
|
||||
keyholeSquared &&
|
||||
(columnX * aspect) ** 2 + rowY * rowY > keyholeSquared
|
||||
)
|
||||
continue;
|
||||
cells += 1;
|
||||
Cesium.Cartesian3.multiplyByScalar(
|
||||
camera.rightWC,
|
||||
columnX * tanX,
|
||||
sampleOffset,
|
||||
);
|
||||
Cesium.Cartesian3.add(
|
||||
camera.directionWC,
|
||||
sampleOffset,
|
||||
sampleRay.direction,
|
||||
);
|
||||
Cesium.Cartesian3.multiplyByScalar(
|
||||
camera.upWC,
|
||||
rowY * tanY,
|
||||
sampleOffset,
|
||||
);
|
||||
Cesium.Cartesian3.add(
|
||||
sampleRay.direction,
|
||||
sampleOffset,
|
||||
sampleRay.direction,
|
||||
);
|
||||
Cesium.Cartesian3.normalize(sampleRay.direction, sampleRay.direction);
|
||||
if (
|
||||
Cesium.IntersectionTests.rayEllipsoid(sampleRay, Cesium.Ellipsoid.WGS84)
|
||||
)
|
||||
hits += 1;
|
||||
}
|
||||
}
|
||||
return { hits, cells };
|
||||
}
|
||||
|
||||
/**
|
||||
* Share of the frame, from 0 to 1, that shows the planet's surface: the
|
||||
* fraction of a 32×18 grid of view rays, one through the centre of each cell,
|
||||
* that hit the WGS84 ellipsoid. With `keyholeRadius` (see `sampleFrame`) it is
|
||||
* the share of the visible circle instead. A frustum other than a perspective
|
||||
* one counts as fully covered.
|
||||
*/
|
||||
export function globeFrameShare(camera, { keyholeRadius = 0 } = {}) {
|
||||
const sample = sampleFrame(camera, keyholeRadius);
|
||||
if (!sample || sample.cells === 0) return 1;
|
||||
return sample.hits / sample.cells;
|
||||
}
|
||||
|
||||
/** True when none of the planet's surface is in `camera`'s visible frame. */
|
||||
export function globeOutOfFrame(camera, options) {
|
||||
return globeFrameShare(camera, options) === 0;
|
||||
}
|
||||
|
||||
const lookProbes = new WeakMap();
|
||||
const LOOK_EDGE_TOLERANCE = Cesium.Math.toRadians(0.05);
|
||||
|
||||
/**
|
||||
* How much of one in-place look step (`lookUp`, `lookDown`, `lookLeft` or
|
||||
* `lookRight` by a non-negative `angle` in radians) keeps the planet in frame
|
||||
* from orbit. Above the free-look height the step may not leave less than
|
||||
* `MIN_GLOBE_FRAME_SHARE` of the visible frame on the planet, or less than
|
||||
* `startShare` when that is lower, give or take two grid cells of sampling
|
||||
* noise. However the baseline was taken, it never leaves less than 1% of the
|
||||
* frame, and a view that already shows less may only keep or gain what it has.
|
||||
* `startShare` is the baseline the turn is measured against (the current share
|
||||
* by default): callers pass one value to every step of a motion, and to every
|
||||
* turn that continues from where the last one left off, so the limit cannot
|
||||
* slide down a step or a command at a time. `keyholeRadius` limits the measure
|
||||
* to the scope's visible circle, as in `globeFrameShare`.
|
||||
* With nothing on screen the limit is nothing either, so the step is left to
|
||||
* the move-end recovery. Returns `angle` when the whole step fits, otherwise a
|
||||
* smaller angle that fits, found by bisection to within 0.05°, so a stopped
|
||||
* motion rests at the limit. Steps are tried on a copy; `camera` is not moved.
|
||||
*/
|
||||
export function lookAngleWithinFrame(
|
||||
camera,
|
||||
look,
|
||||
angle,
|
||||
{ startShare, keyholeRadius = 0 } = {},
|
||||
) {
|
||||
if (
|
||||
!(angle > 0) ||
|
||||
!(camera.positionCartographic?.height > FREE_LOOK_MAX_HEIGHT_METERS)
|
||||
)
|
||||
return angle;
|
||||
const sample = sampleFrame(camera, keyholeRadius);
|
||||
if (!sample || sample.cells === 0) return angle;
|
||||
const share = sample.hits / sample.cells;
|
||||
const baseline = startShare ?? share;
|
||||
// Capped at what is on screen now, so a step that keeps or gains surface is
|
||||
// never refused, even when the frame has shrunk since the baseline was taken
|
||||
// (a resized window or a turned phone).
|
||||
const floor = Math.min(
|
||||
share,
|
||||
Math.max(
|
||||
Math.min(MIN_GLOBE_FRAME_SHARE, baseline) -
|
||||
GRID_NOISE_CELLS / sample.cells,
|
||||
Math.min(LOOK_ABSOLUTE_FLOOR_SHARE, baseline),
|
||||
),
|
||||
);
|
||||
const fits = (step) => {
|
||||
const probe = Cesium.Camera.clone(camera, lookProbes.get(camera));
|
||||
lookProbes.set(camera, probe);
|
||||
probe[look](step);
|
||||
// Shares are whole grid cells; the epsilon keeps rounding in `floor` from
|
||||
// costing a cell.
|
||||
return globeFrameShare(probe, { keyholeRadius }) >= floor - 1e-9;
|
||||
};
|
||||
if (fits(angle)) return angle;
|
||||
let inside = 0;
|
||||
let outside = angle;
|
||||
while (outside - inside > LOOK_EDGE_TOLERANCE) {
|
||||
const middle = (inside + outside) / 2;
|
||||
if (fits(middle)) inside = middle;
|
||||
else outside = middle;
|
||||
}
|
||||
return inside;
|
||||
}
|
||||
|
||||
/** True when an in-place look step of `angle` would lose part of its turn. */
|
||||
export function lookStepLosesGlobe(camera, look, angle, options) {
|
||||
return lookAngleWithinFrame(camera, look, angle, options) < angle;
|
||||
}
|
||||
+96
-20
@@ -301,6 +301,8 @@
|
||||
"AVAILABLE MISSIONS": "المهام المتاحة",
|
||||
"SELECT A MISSION TO INSPECT": "اختر مهمة لفحصها",
|
||||
"LOADING 30-DAY MISSION INDEX": "جارٍ تحميل فهرس مهام 30 يومًا",
|
||||
"Space Missions unavailable: the mission index could not load. Try again later.": "المهام الفضائية غير متاحة: تعذّر تحميل فهرس المهام. حاول مرة أخرى لاحقًا.",
|
||||
"30-DAY MISSION INDEX UNAVAILABLE": "فهرس مهام 30 يومًا غير متاح",
|
||||
"RADIO": "الراديو",
|
||||
"ENABLE": "تفعيل",
|
||||
"Previous filtered radio station": "محطة الراديو السابقة في القائمة المصفّاة",
|
||||
@@ -1747,8 +1749,16 @@
|
||||
"TomTom roads first; OpenStreetMap fills the rest, simulated": "طرق TomTom أولًا؛ ويكمل OpenStreetMap الباقي بتدفق محاكى",
|
||||
"TomTom roads first; OpenStreetMap fills the rest, simulated (needs a TomTom key)": "طرق TomTom أولًا؛ ويكمل OpenStreetMap الباقي بتدفق محاكى (يحتاج إلى مفتاح TomTom)",
|
||||
"Hybrid": "هجين",
|
||||
"Lines": "خطوط",
|
||||
"Dots": "نقاط",
|
||||
"Road lines colored by live flow: green to red": "خطوط الطرق ملوّنة حسب التدفق المباشر: من الأخضر إلى الأحمر",
|
||||
"Moving dots colored by live flow": "نقاط متحركة ملوّنة حسب التدفق المباشر",
|
||||
"Moving dots": "نقاط متحركة",
|
||||
"TomTom key unavailable": "مفتاح TomTom غير متاح",
|
||||
"TomTom daily budget reached": "بلغت الحصة اليومية لـ TomTom",
|
||||
"Monthly traffic allowance used": "تم استهلاك حصة حركة المرور الشهرية",
|
||||
"Traffic rate limit reached": "تم بلوغ حد طلبات حركة المرور",
|
||||
"Traffic service unavailable": "خدمة حركة المرور غير متاحة",
|
||||
"TomTom upstream unreachable": "تعذّر الوصول إلى خادم TomTom",
|
||||
"TomTom flow unavailable": "تدفق TomTom غير متاح",
|
||||
"SIMULATED — {reason}": "محاكى — {reason}",
|
||||
@@ -2187,6 +2197,9 @@
|
||||
"{value} KM/H": "{value} كم/س",
|
||||
"{label} · {radius} AIR/SEA WINDOW": "{label} · نطاق جو/بحر {radius}",
|
||||
"{scope} · INSTALLATIONS {coverage}": "{scope} · المنشآت {coverage}",
|
||||
"MILITARY FEED OFF · PARTIAL PICTURE": "مصدر البيانات العسكرية متوقف · صورة جزئية",
|
||||
"FLIGHTS FEED OFF · PARTIAL PICTURE": "مصدر الرحلات متوقف · صورة جزئية",
|
||||
"NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR": "قائمة الطائرات القريبة غير مكتملة · لا يعني أن الوضع آمن",
|
||||
"SQUAWK {code} · {status}": "SQUAWK {code} · {status}",
|
||||
"ADS-B PRIORITY · {status}": "أولوية ADS-B · {status}",
|
||||
"ADS-B EMERGENCY · {status}": "طوارئ ADS-B · {status}",
|
||||
@@ -3220,7 +3233,7 @@
|
||||
"Every detection — many of the strongest fires are rated low confidence": "كل عمليات الرصد — كثير من أقوى الحرائق مصنّفة بثقة منخفضة",
|
||||
"Nominal and high confidence detections": "عمليات الرصد ذات الثقة المتوسطة والعالية",
|
||||
"High confidence detections only": "عمليات الرصد ذات الثقة العالية فقط",
|
||||
"Fire intensity (FRP)": "الشدة (FRP)",
|
||||
"Fire intensity (FRP)": "شدة الحريق (FRP)",
|
||||
"≥ {value} MW": "{value} MW على الأقل",
|
||||
"Confidence": "الثقة",
|
||||
"Minimum fire radiative power, megawatts": "الحد الأدنى للقدرة الإشعاعية للحريق، بالميغاواط",
|
||||
@@ -3238,15 +3251,15 @@
|
||||
"Directions, drawing and your own files": "الاتجاهات والرسم وملفاتك الخاصة",
|
||||
"Voice place search (needs an Overpass server)": "البحث الصوتي عن الأماكن (يتطلب خادم Overpass)",
|
||||
"OSM Places": "أماكن OSM",
|
||||
"state or province": "ولاية أو مقاطعة",
|
||||
"county or district": "محافظة أو قضاء",
|
||||
"state or province": "ولاية أو إقليم",
|
||||
"county or district": "مقاطعة أو قضاء",
|
||||
"neighbourhood": "حي",
|
||||
"natural region": "منطقة طبيعية",
|
||||
"Which {place}?": "أي {place} تقصد؟",
|
||||
"Which place?": "أي مكان تقصد؟",
|
||||
"Not drawn: {parts}": "لم يُرسم: {parts}",
|
||||
"Not drawn: {parts}": "لم يُرسم المخطط: {parts}",
|
||||
"approximate": "تقريبي",
|
||||
"{pct}% cloud": "غيوم {pct}%",
|
||||
"{pct}% cloud": "الغيوم {pct}%",
|
||||
"Over {area}": "فوق {area}",
|
||||
"{count} of {total} acquisitions match": {
|
||||
"few": "الصور المطابقة: {count} من {total}",
|
||||
@@ -3257,12 +3270,12 @@
|
||||
"other": "الصور المطابقة: {count} من {total}"
|
||||
},
|
||||
"{n} acquisitions searched": {
|
||||
"few": "الصور التي جرى البحث فيها: {n}",
|
||||
"many": "الصور التي جرى البحث فيها: {n}",
|
||||
"one": "الصور التي جرى البحث فيها: {n}",
|
||||
"two": "الصور التي جرى البحث فيها: {n}",
|
||||
"zero": "الصور التي جرى البحث فيها: {n}",
|
||||
"other": "الصور التي جرى البحث فيها: {n}"
|
||||
"zero": "الصور المفحوصة: {n}",
|
||||
"one": "الصور المفحوصة: {n}",
|
||||
"two": "الصور المفحوصة: {n}",
|
||||
"few": "الصور المفحوصة: {n}",
|
||||
"many": "الصور المفحوصة: {n}",
|
||||
"other": "الصور المفحوصة: {n}"
|
||||
},
|
||||
"No matching imagery": "لا توجد صور مطابقة",
|
||||
"Unknown number of {kind}": "{kind}: العدد غير معروف",
|
||||
@@ -3270,10 +3283,10 @@
|
||||
"At least {count} {kind}": "{kind}: {count} على الأقل",
|
||||
"{count} {kind}": "{kind}: {count}",
|
||||
"In {area}": "في {area}",
|
||||
"Area around {place}": "المنطقة المحيطة بـ{place}",
|
||||
"Area around landmark": "المنطقة المحيطة بمعلم",
|
||||
"Outline {place}": "تحديد مخطط {place}",
|
||||
"Outline area": "تحديد مخطط منطقة",
|
||||
"Area around {place}": "المنطقة حول {place}",
|
||||
"Area around landmark": "المنطقة حول المعلم",
|
||||
"Outline {place}": "رسم مخطط {place}",
|
||||
"Outline area": "رسم مخطط منطقة",
|
||||
"Find {place}": "البحث عن {place}",
|
||||
"Find area": "البحث عن منطقة",
|
||||
"Find recent imagery": "البحث عن صور حديثة",
|
||||
@@ -3316,9 +3329,9 @@
|
||||
"zero": "التكرارات المدمجة: {count}"
|
||||
},
|
||||
"listed the first {limit}": "عُرض أول {limit}",
|
||||
"counted over the bounding box of {area}, not its outline": "عُدّ ضمن المستطيل المحيط ({area})، لا ضمن الحدود",
|
||||
"counted over the bounding box of {area}, not its outline": "جرى العدّ ضمن المستطيل المحيط بمنطقة {area}، لا ضمن حدودها الفعلية",
|
||||
"Search cancelled": "أُلغي البحث",
|
||||
"Sea Marks": "علامات بحرية",
|
||||
"Sea Marks": "العلامات البحرية",
|
||||
"Ski Pistes": "مسارات التزلج",
|
||||
"Fly to Tokyo": "انتقل إلى طوكيو",
|
||||
"Show live flights": "اعرض تتبّع الرحلات المباشر",
|
||||
@@ -3393,7 +3406,7 @@
|
||||
"Too many chat requests. Try again in {seconds} s.": "طلبات دردشة كثيرة جدًا. أعد المحاولة بعد {seconds} ث.",
|
||||
"The provider refused access for this key. Check the key's project, region or permissions.": "رفض المزوّد الوصول باستخدام هذا المفتاح. تحقّق من مشروع المفتاح أو منطقته أو أذوناته.",
|
||||
"The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "نفد رصيد حساب المزوّد. تحقّق من الفوترة، أو اختر مزوّد ذكاء اصطناعي آخر في الإعدادات.",
|
||||
"{lower} to {upper}": "{lower} إلى {upper}",
|
||||
"{lower} to {upper}": "من {lower} إلى {upper}",
|
||||
"Up to {upper}": "حتى {upper}",
|
||||
"From {lower}": "من {lower}",
|
||||
"Informational only · not for flight planning": "للعلم فقط · لا يصلح لتخطيط الرحلات",
|
||||
@@ -3401,8 +3414,71 @@
|
||||
"Restricted airspace source unavailable": "مصدر المجال الجوي المقيّد غير متاح",
|
||||
"Prohibited (P)": "محظور (P)",
|
||||
"Restricted (R)": "مقيّد (R)",
|
||||
"FAA prohibited and restricted special-use airspace only, not the UAS Facility Map or UAS flight restrictions. Informational only — not a substitute for official UAS flight-planning tools (B4UFLY / LAANC).": "المجال الجوي الخاص المحظور والمقيّد لدى FAA فقط، ولا يشمل UAS Facility Map أو قيود طيران UAS. للعلم فقط — ولا يغني عن الأدوات الرسمية لتخطيط رحلات UAS (B4UFLY / LAANC).",
|
||||
"FAA prohibited and restricted special-use airspace only, not the UAS Facility Map or UAS flight restrictions. Informational only — not a substitute for official UAS flight-planning tools (B4UFLY / LAANC).": "المجال الجوي المحظور والمقيّد ذو الاستخدام الخاص لدى FAA فقط، ولا يشمل UAS Facility Map ولا قيود طيران UAS. للعلم فقط — ولا يغني عن الأدوات الرسمية لتخطيط رحلات UAS (B4UFLY / LAANC).",
|
||||
"Prohibited": "محظور",
|
||||
"Restricted": "مقيّد",
|
||||
"Prohibited & Restricted Airspace": "المجال الجوي المحظور والمقيّد"
|
||||
"Prohibited & Restricted Airspace": "المجال الجوي المحظور والمقيّد",
|
||||
"SIGHTING": "مشاهدة",
|
||||
"Show {name} sighting": "إظهار مشاهدة {name}",
|
||||
"seen {age} ago": "شوهد قبل {age}",
|
||||
"observed {age} ago": "رُصد قبل {age}",
|
||||
"Be Whale Wise": "راقب الحيتان بمسؤولية",
|
||||
"Reported by {name} ({entity})": "أبلغ عنه {name} ({entity})",
|
||||
"Reported by {name}": "أبلغ عنه {name}",
|
||||
"Salish Sea": "بحر ساليش",
|
||||
"Birds": "الطيور",
|
||||
"Mammals": "الثدييات",
|
||||
"Reptiles": "الزواحف",
|
||||
"Amphibians": "البرمائيات",
|
||||
"Ray-finned fishes": "الأسماك شعاعية الزعانف",
|
||||
"Insects": "الحشرات",
|
||||
"Arachnids": "العنكبوتيات",
|
||||
"Plants": "النباتات",
|
||||
"Fungi": "الفطريات",
|
||||
"Other animals": "حيوانات أخرى",
|
||||
"Other or unknown": "أخرى أو غير معروفة",
|
||||
"{label} {detail} — click to retry": "{label} {detail} — انقر لإعادة المحاولة",
|
||||
"Wildlife sightings source unavailable": "مصدر مشاهدات الحياة البرية غير متاح",
|
||||
"Show only community-confirmed (research-grade) sightings": "إظهار المشاهدات المؤكدة مجتمعياً (بدرجة بحثية) فقط",
|
||||
"No recent sightings in view": "لا توجد مشاهدات حديثة في العرض",
|
||||
"Acartia feed unavailable": "تعذر الوصول إلى مصدر Acartia",
|
||||
"Show all taxa": "إظهار جميع المجموعات التصنيفية",
|
||||
"BIRDS": "الطيور",
|
||||
"Show only birds": "إظهار الطيور فقط",
|
||||
"MAMMALS": "الثدييات",
|
||||
"Show only mammals": "إظهار الثدييات فقط",
|
||||
"WHALES": "الحيتان",
|
||||
"Show only whales and dolphins (7 days)": "إظهار الحيتان والدلافين فقط (7 أيام)",
|
||||
"INSECTS": "الحشرات",
|
||||
"Show only insects": "إظهار الحشرات فقط",
|
||||
"PLANTS": "النباتات",
|
||||
"Show only plants": "إظهار النباتات فقط",
|
||||
"All rights reserved": "جميع الحقوق محفوظة",
|
||||
"Cetacean": "حوتي",
|
||||
"Wildlife Sightings": "مشاهدات الحياة البرية",
|
||||
"Exit cockpit to fly to a sighting": "اخرج من قمرة القيادة للانتقال إلى مشاهدة",
|
||||
"Exit cockpit to fly to a sighting area": "اخرج من قمرة القيادة للانتقال إلى منطقة مشاهدة",
|
||||
"Wildlife": "الحياة البرية",
|
||||
"Zoom in for local sightings": "قرّب لرؤية المشاهدات المحلية",
|
||||
"Loading photo...": "جارٍ تحميل الصورة…",
|
||||
"Photo unavailable": "الصورة غير متاحة",
|
||||
"Location obscured to a 0.2° cell": "الموقع مخفي ضمن خلية بمقدار 0.2°",
|
||||
"Photo of {name}": "صورة {name}",
|
||||
"Acartia": "Acartia",
|
||||
"CC BY 4.0": "CC BY 4.0",
|
||||
"{count} sightings in view": "{count} مشاهدة ضمن النطاق",
|
||||
"Close sighting": "إغلاق المشاهدة",
|
||||
"Wildlife observed within 48 h (whales 7 d). Color and badge glyph show the taxon group. Sensitive species are shown only as 0.2° cells; Salish Sea marine mammal reports via Acartia are rounded to 0.01°. Not a census.": "حياة برية شوهدت خلال 48 ساعة (الحيتان 7 أيام). يوضح اللون ورمز الشارة المجموعة التصنيفية. تُعرض الأنواع الحساسة في خلايا بحجم 0.2° فقط؛ وتُقرّب مشاهدات الثدييات البحرية في بحر ساليش عبر Acartia إلى 0.01°. ليست إحصاءً شاملاً.",
|
||||
"Open {name} on iNaturalist": "فتح {name} على iNaturalist",
|
||||
"Open {name} on Acartia": "فتح {name} على Acartia",
|
||||
"iNaturalist": "iNaturalist",
|
||||
"iNaturalist contributors · licences per record": "مساهمو iNaturalist · التراخيص لكل سجل",
|
||||
"iNaturalist contributors · Acartia {count} (Salish Sea) · licences per record": "مساهمو iNaturalist · Acartia {count} (بحر ساليش) · التراخيص لكل سجل",
|
||||
"{days}d": "{days}ي",
|
||||
"All markers": "كل العلامات",
|
||||
"Show markers of every age": "عرض العلامات من جميع الأعمار",
|
||||
"Maximum event age: {days}d; undated markers are kept": "الحد الأقصى لعمر الحدث: {days} يوم؛ تُحفظ العلامات غير المؤرخة",
|
||||
"event age filter|All": "الكل",
|
||||
"Event age": "عمر الحدث",
|
||||
"Event age: {days}d · undated kept": "عمر الحدث: {days} يوم · الاحتفاظ بغير المؤرخ"
|
||||
}
|
||||
+94
-18
@@ -760,6 +760,8 @@
|
||||
"AVAILABLE MISSIONS": "VERFÜGBARE MISSIONEN",
|
||||
"SELECT A MISSION TO INSPECT": "MISSION ZUM ANSEHEN WÄHLEN",
|
||||
"LOADING 30-DAY MISSION INDEX": "30-TAGE-MISSIONSINDEX WIRD GELADEN",
|
||||
"Space Missions unavailable: the mission index could not load. Try again later.": "Raummissionen nicht verfügbar: Der Missionsindex konnte nicht geladen werden. Später erneut versuchen.",
|
||||
"30-DAY MISSION INDEX UNAVAILABLE": "30-TAGE-MISSIONSINDEX NICHT VERFÜGBAR",
|
||||
"TAB PREVIEWS · ENTER / SPACE SELECTS": "TAB: VORSCHAU · ENTER / LEERTASTE: AUSWAHL",
|
||||
"Internet radio and local SDR": "Internetradio und lokales SDR",
|
||||
"STATION TAG": "KATEGORIE",
|
||||
@@ -1665,8 +1667,16 @@
|
||||
"TomTom roads first; OpenStreetMap fills the rest, simulated": "Zuerst TomTom-Straßen; OpenStreetMap füllt den Rest, simuliert",
|
||||
"TomTom roads first; OpenStreetMap fills the rest, simulated (needs a TomTom key)": "Zuerst TomTom-Straßen; OpenStreetMap füllt den Rest, simuliert (braucht einen TomTom-Schlüssel)",
|
||||
"Hybrid": "Hybrid",
|
||||
"Lines": "Linien",
|
||||
"Dots": "Punkte",
|
||||
"Road lines colored by live flow: green to red": "Straßenlinien nach Live-Verkehrsfluss gefärbt: grün bis rot",
|
||||
"Moving dots colored by live flow": "Bewegte Punkte nach Live-Verkehrsfluss gefärbt",
|
||||
"Moving dots": "Bewegte Punkte",
|
||||
"TomTom key unavailable": "TomTom-Schlüssel nicht verfügbar",
|
||||
"TomTom daily budget reached": "TomTom-Tagesbudget erreicht",
|
||||
"Monthly traffic allowance used": "Monatliches Verkehrskontingent aufgebraucht",
|
||||
"Traffic rate limit reached": "Verkehrs-Anfragelimit erreicht",
|
||||
"Traffic service unavailable": "Verkehrsdienst nicht verfügbar",
|
||||
"TomTom upstream unreachable": "TomTom-Upstream nicht erreichbar",
|
||||
"TomTom flow unavailable": "TomTom-Verkehrsfluss nicht verfügbar",
|
||||
"SIMULATED — {reason}": "SIMULIERT – {reason}",
|
||||
@@ -2087,6 +2097,9 @@
|
||||
"{value} KM/H": "{value} KM/H",
|
||||
"{label} · {radius} AIR/SEA WINDOW": "{label} · {radius} LUFT-/SEEFENSTER",
|
||||
"{scope} · INSTALLATIONS {coverage}": "{scope} · ANLAGEN {coverage}",
|
||||
"MILITARY FEED OFF · PARTIAL PICTURE": "MILITÄR-FEED AUS · UNVOLLSTÄNDIGES LAGEBILD",
|
||||
"FLIGHTS FEED OFF · PARTIAL PICTURE": "FLUG-FEED AUS · UNVOLLSTÄNDIGES LAGEBILD",
|
||||
"NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR": "FLUGZEUGE IN DER NÄHE UNVOLLSTÄNDIG · KEINE ENTWARNUNG",
|
||||
"SQUAWK {code} · {status}": "SQUAWK {code} · {status}",
|
||||
"ADS-B PRIORITY · {status}": "ADS-B-PRIORITÄT · {status}",
|
||||
"ADS-B EMERGENCY · {status}": "ADS-B-NOTFALL · {status}",
|
||||
@@ -2912,7 +2925,7 @@
|
||||
"From {lower}": "Ab {lower}",
|
||||
"Informational only · not for flight planning": "Nur zur Information · nicht für die Flugplanung",
|
||||
"Unnamed zone": "Unbenannte Zone",
|
||||
"Restricted airspace source unavailable": "Quelle für Sperrgebiete nicht verfügbar",
|
||||
"Restricted airspace source unavailable": "Quelle für Sperr- und Flugbeschränkungsgebiete nicht verfügbar",
|
||||
"Prohibited (P)": "Sperrgebiet (P)",
|
||||
"Restricted (R)": "Flugbeschränkungsgebiet (R)",
|
||||
"FAA prohibited and restricted special-use airspace only, not the UAS Facility Map or UAS flight restrictions. Informational only — not a substitute for official UAS flight-planning tools (B4UFLY / LAANC).": "Nur Sperr- und Flugbeschränkungsgebiete der FAA (Luftraum mit besonderer Nutzung), nicht die UAS Facility Map oder UAS-Flugbeschränkungen. Nur zur Information – kein Ersatz für offizielle UAS-Flugplanungstools (B4UFLY / LAANC).",
|
||||
@@ -2949,13 +2962,13 @@
|
||||
"fire confidence filter|All": "Alle",
|
||||
"fire confidence filter|Nominal+": "Mittel+",
|
||||
"fire confidence filter|High": "Hoch",
|
||||
"Every detection — many of the strongest fires are rated low confidence": "Alle Erkennungen — viele der stärksten Brände haben eine niedrige Konfidenz",
|
||||
"Every detection — many of the strongest fires are rated low confidence": "Alle Erkennungen – viele der stärksten Brände haben eine niedrige Konfidenz",
|
||||
"Nominal and high confidence detections": "Erkennungen mit mittlerer und hoher Konfidenz",
|
||||
"High confidence detections only": "Nur Erkennungen mit hoher Konfidenz",
|
||||
"Fire intensity (FRP)": "Intensität (FRP)",
|
||||
"≥ {value} MW": "≥ {value} MW",
|
||||
"Confidence": "Konfidenz",
|
||||
"Minimum fire radiative power, megawatts": "Minimale Strahlungsleistung des Brandes in Megawatt",
|
||||
"Minimum fire radiative power, megawatts": "Mindestwert der Strahlungsleistung des Brandes in Megawatt",
|
||||
"fire intensity filter|All": "Alle",
|
||||
"Detection confidence": "Konfidenz der Erkennung",
|
||||
"{shown} of {total} shown": "{shown} von {total} angezeigt",
|
||||
@@ -2995,9 +3008,9 @@
|
||||
"{count} {kind}": "{kind}: {count}",
|
||||
"In {area}": "Gebiet: {area}",
|
||||
"Area around {place}": "Gebiet um {place}",
|
||||
"Area around landmark": "Gebiet um ein Wahrzeichen",
|
||||
"Outline {place}": "{place} umreißen",
|
||||
"Outline area": "Gebiet umreißen",
|
||||
"Area around landmark": "Gebiet um einen Ort",
|
||||
"Outline {place}": "Umriss von {place} zeichnen",
|
||||
"Outline area": "Umriss zeichnen",
|
||||
"Find {place}": "{place} finden",
|
||||
"Find area": "Gebiet finden",
|
||||
"Find recent imagery": "Aktuelle Bilddaten finden",
|
||||
@@ -3008,27 +3021,27 @@
|
||||
"Loading imagery": "Bilddaten werden geladen",
|
||||
"Searching OpenStreetMap": "OpenStreetMap wird durchsucht",
|
||||
"voice area|Area": "Gebiet",
|
||||
"buffer sized to the place": "Puffer in Größe des Ortes",
|
||||
"buffer sized to the place": "Puffer entsprechend der Ortsgröße",
|
||||
"enclosing mapped area": "umschließendes kartiertes Gebiet",
|
||||
"the map was cleared while looking it up": "die Karte wurde während der Suche geleert",
|
||||
"OpenStreetMap coverage varies by place": "Die Abdeckung von OpenStreetMap ist je nach Ort unterschiedlich",
|
||||
"the map was cleared while looking it up": "die Zeichenfläche wurde während der Suche geleert",
|
||||
"OpenStreetMap coverage varies by place": "die Abdeckung von OpenStreetMap ist je nach Ort unterschiedlich",
|
||||
"the catalog covers the last 30 days": "der Katalog umfasst die letzten 30 Tage",
|
||||
"there are more": "es gibt weitere",
|
||||
"{radius} m around the landmark": "{radius} m um das Wahrzeichen",
|
||||
"{radius} m around the landmark": "{radius} m um den Ort",
|
||||
"{count} parts": {
|
||||
"one": "Teile: {count}",
|
||||
"other": "Teile: {count}"
|
||||
"one": "{count} Teil",
|
||||
"other": "{count} Teile"
|
||||
},
|
||||
"{drawn} of {total} parts drawn": {
|
||||
"one": "Gezeichnete Teile: {drawn} von {total}",
|
||||
"other": "Gezeichnete Teile: {drawn} von {total}"
|
||||
"one": "{drawn} von {total} Teil gezeichnet",
|
||||
"other": "{drawn} von {total} Teilen gezeichnet"
|
||||
},
|
||||
"{count} duplicate mappings merged": {
|
||||
"one": "Zusammengeführte Doppelkartierungen: {count}",
|
||||
"other": "Zusammengeführte Doppelkartierungen: {count}"
|
||||
"one": "{count} Duplikat zusammengeführt",
|
||||
"other": "{count} Duplikate zusammengeführt"
|
||||
},
|
||||
"listed the first {limit}": "die ersten {limit} aufgelistet",
|
||||
"counted over the bounding box of {area}, not its outline": "über das umgebende Rechteck gezählt ({area}), nicht über den Umriss",
|
||||
"counted over the bounding box of {area}, not its outline": "im umgebenden Rechteck von {area} gezählt, nicht innerhalb des Umrisses",
|
||||
"Search cancelled": "Suche abgebrochen",
|
||||
"Sea Marks": "Seezeichen",
|
||||
"Ski Pistes": "Skipisten",
|
||||
@@ -3104,5 +3117,68 @@
|
||||
"The provider rejected the API key. Check it in Provider Settings.": "Der Anbieter hat den API-Schlüssel abgelehnt. Den Schlüssel in den Anbieter-Einstellungen prüfen.",
|
||||
"Too many chat requests. Try again in {seconds} s.": "Zu viele Chat-Anfragen. In {seconds} s erneut versuchen.",
|
||||
"The provider refused access for this key. Check the key's project, region or permissions.": "Der Anbieter hat den Zugriff für diesen Schlüssel verweigert. Projekt, Region oder Berechtigungen des Schlüssels prüfen.",
|
||||
"The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "Das Anbieterkonto hat kein Guthaben mehr. Die Abrechnung prüfen oder in den Einstellungen einen anderen KI-Anbieter wählen."
|
||||
"The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "Das Anbieterkonto hat kein Guthaben mehr. Die Abrechnung prüfen oder in den Einstellungen einen anderen KI-Anbieter wählen.",
|
||||
"SIGHTING": "BEOBACHTUNG",
|
||||
"Show {name} sighting": "Beobachtung von {name} anzeigen",
|
||||
"seen {age} ago": "vor {age} gesichtet",
|
||||
"observed {age} ago": "vor {age} beobachtet",
|
||||
"Be Whale Wise": "Wale respektvoll beobachten",
|
||||
"Reported by {name} ({entity})": "Gemeldet von {name} ({entity})",
|
||||
"Reported by {name}": "Gemeldet von {name}",
|
||||
"Salish Sea": "Salish-See",
|
||||
"Birds": "Vögel",
|
||||
"Mammals": "Säugetiere",
|
||||
"Reptiles": "Reptilien",
|
||||
"Amphibians": "Amphibien",
|
||||
"Ray-finned fishes": "Strahlenflosser",
|
||||
"Insects": "Insekten",
|
||||
"Arachnids": "Spinnentiere",
|
||||
"Plants": "Pflanzen",
|
||||
"Fungi": "Pilze",
|
||||
"Other animals": "Andere Tiere",
|
||||
"Other or unknown": "Andere oder unbekannt",
|
||||
"{label} {detail} — click to retry": "{label} {detail} — zum Wiederholen klicken",
|
||||
"Wildlife sightings source unavailable": "Quelle für Naturbeobachtungen nicht verfügbar",
|
||||
"Show only community-confirmed (research-grade) sightings": "Nur gemeinschaftlich bestätigte Beobachtungen (Forschungsqualität) anzeigen",
|
||||
"No recent sightings in view": "Keine aktuellen Beobachtungen im Ausschnitt",
|
||||
"Acartia feed unavailable": "Acartia-Datenstrom nicht verfügbar",
|
||||
"Show all taxa": "Alle Artengruppen anzeigen",
|
||||
"BIRDS": "VÖGEL",
|
||||
"Show only birds": "Nur Vögel anzeigen",
|
||||
"MAMMALS": "SÄUGETIERE",
|
||||
"Show only mammals": "Nur Säugetiere anzeigen",
|
||||
"WHALES": "WALE",
|
||||
"Show only whales and dolphins (7 days)": "Nur Wale und Delfine anzeigen (7 Tage)",
|
||||
"INSECTS": "INSEKTEN",
|
||||
"Show only insects": "Nur Insekten anzeigen",
|
||||
"PLANTS": "PFLANZEN",
|
||||
"Show only plants": "Nur Pflanzen anzeigen",
|
||||
"All rights reserved": "Alle Rechte vorbehalten",
|
||||
"Cetacean": "Wal oder Delfin",
|
||||
"Wildlife Sightings": "Naturbeobachtungen",
|
||||
"Exit cockpit to fly to a sighting": "Cockpit verlassen, um zu einer Beobachtung zu fliegen",
|
||||
"Exit cockpit to fly to a sighting area": "Cockpit verlassen, um zu einem Beobachtungsgebiet zu fliegen",
|
||||
"Wildlife": "Tierwelt",
|
||||
"Zoom in for local sightings": "Für lokale Beobachtungen vergrößern",
|
||||
"Loading photo...": "Foto wird geladen…",
|
||||
"Photo unavailable": "Foto nicht verfügbar",
|
||||
"Location obscured to a 0.2° cell": "Standort auf eine 0,2°-Rasterzelle verschleiert",
|
||||
"Photo of {name}": "Foto von {name}",
|
||||
"Acartia": "Acartia",
|
||||
"CC BY 4.0": "CC BY 4.0",
|
||||
"{count} sightings in view": "{count} Sichtungen im Ausschnitt",
|
||||
"Close sighting": "Sichtung schließen",
|
||||
"Wildlife observed within 48 h (whales 7 d). Color and badge glyph show the taxon group. Sensitive species are shown only as 0.2° cells; Salish Sea marine mammal reports via Acartia are rounded to 0.01°. Not a census.": "In den letzten 48 h beobachtete Wildtiere (Wale 7 Tage). Farbe und Abzeichensymbol zeigen die Artengruppe. Empfindliche Arten erscheinen nur als 0,2°-Zellen; Acartia-Meldungen zu Meeressäugern aus der Salish-See werden auf 0,01° gerundet. Keine Bestandszählung.",
|
||||
"Open {name} on iNaturalist": "{name} auf iNaturalist öffnen",
|
||||
"Open {name} on Acartia": "{name} auf Acartia öffnen",
|
||||
"iNaturalist": "iNaturalist",
|
||||
"iNaturalist contributors · licences per record": "iNaturalist-Beitragende · Lizenzen je Eintrag",
|
||||
"iNaturalist contributors · Acartia {count} (Salish Sea) · licences per record": "iNaturalist-Beitragende · Acartia {count} (Salish-See) · Lizenzen je Eintrag",
|
||||
"{days}d": "{days}T",
|
||||
"All markers": "Alle Markierungen",
|
||||
"Show markers of every age": "Markierungen jeden Alters anzeigen",
|
||||
"Maximum event age: {days}d; undated markers are kept": "Maximales Ereignisalter: {days} Tage; undatierte Markierungen bleiben",
|
||||
"event age filter|All": "Alle",
|
||||
"Event age": "Ereignisalter",
|
||||
"Event age: {days}d · undated kept": "Ereignisalter: {days} Tage · undatierte bleiben"
|
||||
}
|
||||
+91
-15
@@ -192,6 +192,8 @@
|
||||
"AVAILABLE MISSIONS": "MISIONES DISPONIBLES",
|
||||
"SELECT A MISSION TO INSPECT": "SELECCIONA UNA MISIÓN PARA INSPECCIONAR",
|
||||
"LOADING 30-DAY MISSION INDEX": "CARGANDO ÍNDICE DE MISIONES DE 30 DÍAS",
|
||||
"Space Missions unavailable: the mission index could not load. Try again later.": "Misiones espaciales no disponibles: no se pudo cargar el índice de misiones. Inténtalo más tarde.",
|
||||
"30-DAY MISSION INDEX UNAVAILABLE": "ÍNDICE DE MISIONES DE 30 DÍAS NO DISPONIBLE",
|
||||
"RADIO": "RADIO",
|
||||
"ENABLE": "ACTIVAR",
|
||||
"Previous filtered radio station": "Emisora de radio filtrada anterior",
|
||||
@@ -1920,6 +1922,9 @@
|
||||
"N/A": "N/D",
|
||||
"{label} · {radius} AIR/SEA WINDOW": "{label} · VENTANA AIRE/MAR {radius}",
|
||||
"{scope} · INSTALLATIONS {coverage}": "{scope} · INSTALACIONES: {coverage}",
|
||||
"MILITARY FEED OFF · PARTIAL PICTURE": "FUENTE MILITAR DESACTIVADA · PANORAMA PARCIAL",
|
||||
"FLIGHTS FEED OFF · PARTIAL PICTURE": "FUENTE DE VUELOS DESACTIVADA · PANORAMA PARCIAL",
|
||||
"NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR": "AERONAVES CERCANAS INCOMPLETAS · NO IMPLICA TODO DESPEJADO",
|
||||
"{source} · LOCATION QUERY · RECENT": "{source} · CONSULTA POR UBICACIÓN · RECIENTE",
|
||||
"ft": "ft",
|
||||
"kts": "kn",
|
||||
@@ -2271,6 +2276,9 @@
|
||||
"SIMULATED — {reason}": "SIMULADO — {reason}",
|
||||
"TomTom key unavailable": "Clave de TomTom no disponible",
|
||||
"TomTom daily budget reached": "Se alcanzó el límite diario de TomTom",
|
||||
"Monthly traffic allowance used": "Cupo mensual de tráfico agotado",
|
||||
"Traffic rate limit reached": "Límite de solicitudes de tráfico alcanzado",
|
||||
"Traffic service unavailable": "Servicio de tráfico no disponible",
|
||||
"TomTom upstream unreachable": "Servidor de TomTom inaccesible",
|
||||
"TomTom flow unavailable": "Flujo de TomTom no disponible",
|
||||
"API key rejected — check AISSTREAM_API_KEY": "Clave de API rechazada — revisa AISSTREAM_API_KEY",
|
||||
@@ -2691,6 +2699,11 @@
|
||||
"Zoom in for vector tile coverage": "Acércate para cargar las teselas vectoriales",
|
||||
"Camera coverage unavailable": "Cobertura de cámaras no disponible",
|
||||
"Hybrid": "Híbrido",
|
||||
"Lines": "Líneas",
|
||||
"Dots": "Puntos",
|
||||
"Road lines colored by live flow: green to red": "Líneas de vías coloreadas por el flujo en vivo: de verde a rojo",
|
||||
"Moving dots colored by live flow": "Puntos en movimiento coloreados por el flujo en vivo",
|
||||
"Moving dots": "Puntos en movimiento",
|
||||
"TomTom roads only, each with its live flow": "Solo vías de TomTom, cada una con su flujo en vivo",
|
||||
"TomTom roads only, each with its live flow (needs a TomTom key)": "Solo vías de TomTom, cada una con su flujo en vivo (necesita una clave de TomTom)",
|
||||
"OpenStreetMap roads, with TomTom flow matched when available": "Vías de OpenStreetMap, con el flujo de TomTom emparejado cuando está disponible",
|
||||
@@ -2986,7 +2999,7 @@
|
||||
"fire confidence filter|All": "Todas",
|
||||
"fire confidence filter|Nominal+": "Nominal+",
|
||||
"fire confidence filter|High": "Alta",
|
||||
"Every detection — many of the strongest fires are rated low confidence": "Todas las detecciones — muchos de los incendios más intensos tienen confianza baja",
|
||||
"Every detection — many of the strongest fires are rated low confidence": "Todas las detecciones — muchos de los incendios más intensos se clasifican con confianza baja",
|
||||
"Nominal and high confidence detections": "Detecciones de confianza nominal y alta",
|
||||
"High confidence detections only": "Solo detecciones de confianza alta",
|
||||
"Fire intensity (FRP)": "Intensidad (FRP)",
|
||||
@@ -2995,8 +3008,8 @@
|
||||
"Minimum fire radiative power, megawatts": "Potencia radiativa mínima del fuego, en megavatios",
|
||||
"fire intensity filter|All": "Todo",
|
||||
"Detection confidence": "Confianza de la detección",
|
||||
"{shown} of {total} shown": "{shown} de {total} mostrados",
|
||||
"{lower} to {upper}": "{lower} a {upper}",
|
||||
"{shown} of {total} shown": "Mostrando {shown} de {total}",
|
||||
"{lower} to {upper}": "De {lower} a {upper}",
|
||||
"Up to {upper}": "Hasta {upper}",
|
||||
"From {lower}": "Desde {lower}",
|
||||
"Informational only · not for flight planning": "Solo informativo · no apto para planificar vuelos",
|
||||
@@ -3004,9 +3017,9 @@
|
||||
"Restricted airspace source unavailable": "Fuente de espacio aéreo restringido no disponible",
|
||||
"Prohibited (P)": "Prohibido (P)",
|
||||
"Restricted (R)": "Restringido (R)",
|
||||
"FAA prohibited and restricted special-use airspace only, not the UAS Facility Map or UAS flight restrictions. Informational only — not a substitute for official UAS flight-planning tools (B4UFLY / LAANC).": "Solo espacio aéreo de uso especial prohibido y restringido de la FAA, sin el UAS Facility Map ni las restricciones de vuelo de UAS. Solo informativo: no sustituye a las herramientas oficiales de planificación de vuelos de UAS (B4UFLY / LAANC).",
|
||||
"Prohibited": "Prohibido",
|
||||
"Restricted": "Restringido",
|
||||
"FAA prohibited and restricted special-use airspace only, not the UAS Facility Map or UAS flight restrictions. Informational only — not a substitute for official UAS flight-planning tools (B4UFLY / LAANC).": "Solo espacio aéreo de uso especial prohibido y restringido de la FAA; no incluye el UAS Facility Map ni las restricciones de vuelo de UAS. Solo informativo — no sustituye a las herramientas oficiales de planificación de vuelos de UAS (B4UFLY / LAANC).",
|
||||
"Prohibited": "Zona prohibida",
|
||||
"Restricted": "Zona restringida",
|
||||
"Prohibited & Restricted Airspace": "Espacio aéreo prohibido y restringido",
|
||||
"routing busy — too many routes at once": "servicio de rutas ocupado: demasiadas rutas a la vez",
|
||||
"route proxy error": "error del proxy de rutas",
|
||||
@@ -3044,11 +3057,11 @@
|
||||
"county or district": "condado o distrito",
|
||||
"neighbourhood": "barrio",
|
||||
"natural region": "región natural",
|
||||
"Which {place}?": "¿Cuál {place}?",
|
||||
"Which {place}?": "¿Qué {place}?",
|
||||
"Which place?": "¿Qué lugar?",
|
||||
"Not drawn: {parts}": "Sin dibujar: {parts}",
|
||||
"approximate": "aproximado",
|
||||
"{pct}% cloud": "{pct} % de nubes",
|
||||
"approximate": "aproximada",
|
||||
"{pct}% cloud": "{pct}% de nubes",
|
||||
"Over {area}": "Zona: {area}",
|
||||
"{count} of {total} acquisitions match": {
|
||||
"many": "Adquisiciones coincidentes: {count} de {total}",
|
||||
@@ -3062,7 +3075,7 @@
|
||||
},
|
||||
"No matching imagery": "No hay imágenes que coincidan",
|
||||
"Unknown number of {kind}": "{kind}: número desconocido",
|
||||
"About {count} {kind}": "{kind}: unos {count}",
|
||||
"About {count} {kind}": "{kind}: aproximadamente {count}",
|
||||
"At least {count} {kind}": "{kind}: al menos {count}",
|
||||
"{count} {kind}": "{kind}: {count}",
|
||||
"In {area}": "En {area}",
|
||||
@@ -3081,12 +3094,12 @@
|
||||
"Searching OpenStreetMap": "Buscando en OpenStreetMap",
|
||||
"voice area|Area": "Área",
|
||||
"buffer sized to the place": "zona aproximada del tamaño del lugar",
|
||||
"enclosing mapped area": "zona cartografiada que lo rodea",
|
||||
"enclosing mapped area": "zona mapeada que contiene el punto de interés",
|
||||
"the map was cleared while looking it up": "el mapa se borró durante la búsqueda",
|
||||
"OpenStreetMap coverage varies by place": "La cobertura de OpenStreetMap varía según el lugar",
|
||||
"OpenStreetMap coverage varies by place": "la cobertura de OpenStreetMap varía según el lugar",
|
||||
"the catalog covers the last 30 days": "el catálogo cubre los últimos 30 días",
|
||||
"there are more": "hay más",
|
||||
"{radius} m around the landmark": "{radius} m alrededor del lugar",
|
||||
"{radius} m around the landmark": "{radius} m alrededor del punto de interés",
|
||||
"{count} parts": {
|
||||
"many": "Partes: {count}",
|
||||
"one": "Partes: {count}",
|
||||
@@ -3103,7 +3116,7 @@
|
||||
"other": "Duplicados fusionados: {count}"
|
||||
},
|
||||
"listed the first {limit}": "se listaron los primeros {limit}",
|
||||
"counted over the bounding box of {area}, not its outline": "contado en el rectángulo que lo rodea ({area}), no en su contorno",
|
||||
"counted over the bounding box of {area}, not its outline": "se contó en el rectángulo que abarca {area}, no dentro de su contorno",
|
||||
"Search cancelled": "Búsqueda cancelada",
|
||||
"Sea Marks": "Señales marítimas",
|
||||
"Ski Pistes": "Pistas de esquí",
|
||||
@@ -3179,5 +3192,68 @@
|
||||
"The provider rejected the API key. Check it in Provider Settings.": "El proveedor rechazó la clave de API. Revísala en Ajustes de proveedores.",
|
||||
"Too many chat requests. Try again in {seconds} s.": "Demasiadas solicitudes de chat. Vuelve a intentarlo en {seconds} s.",
|
||||
"The provider refused access for this key. Check the key's project, region or permissions.": "El proveedor denegó el acceso a esta clave. Revisa el proyecto, la región o los permisos de la clave.",
|
||||
"The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "La cuenta del proveedor no tiene crédito. Revisa la facturación o elige otro proveedor de IA en Ajustes."
|
||||
"The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "La cuenta del proveedor no tiene crédito. Revisa la facturación o elige otro proveedor de IA en Ajustes.",
|
||||
"SIGHTING": "AVISTAMIENTO",
|
||||
"Show {name} sighting": "Mostrar avistamiento de {name}",
|
||||
"seen {age} ago": "visto hace {age}",
|
||||
"observed {age} ago": "observado hace {age}",
|
||||
"Be Whale Wise": "Observa cetáceos con respeto",
|
||||
"Reported by {name} ({entity})": "Reportado por {name} ({entity})",
|
||||
"Reported by {name}": "Reportado por {name}",
|
||||
"Salish Sea": "Mar de Salish",
|
||||
"Birds": "Aves",
|
||||
"Mammals": "Mamíferos",
|
||||
"Reptiles": "Reptiles",
|
||||
"Amphibians": "Anfibios",
|
||||
"Ray-finned fishes": "Peces de aletas radiadas",
|
||||
"Insects": "Insectos",
|
||||
"Arachnids": "Arácnidos",
|
||||
"Plants": "Plantas",
|
||||
"Fungi": "Hongos",
|
||||
"Other animals": "Otros animales",
|
||||
"Other or unknown": "Otros o desconocidos",
|
||||
"{label} {detail} — click to retry": "{label} {detail} — haz clic para reintentar",
|
||||
"Wildlife sightings source unavailable": "Fuente de avistamientos no disponible",
|
||||
"Show only community-confirmed (research-grade) sightings": "Mostrar solo observaciones confirmadas por la comunidad (grado de investigación)",
|
||||
"No recent sightings in view": "No hay avistamientos recientes en la vista",
|
||||
"Acartia feed unavailable": "Fuente Acartia no disponible",
|
||||
"Show all taxa": "Mostrar todos los grupos taxonómicos",
|
||||
"BIRDS": "AVES",
|
||||
"Show only birds": "Mostrar solo aves",
|
||||
"MAMMALS": "MAMÍFEROS",
|
||||
"Show only mammals": "Mostrar solo mamíferos",
|
||||
"WHALES": "CETÁCEOS",
|
||||
"Show only whales and dolphins (7 days)": "Mostrar solo ballenas y delfines (7 días)",
|
||||
"INSECTS": "INSECTOS",
|
||||
"Show only insects": "Mostrar solo insectos",
|
||||
"PLANTS": "PLANTAS",
|
||||
"Show only plants": "Mostrar solo plantas",
|
||||
"All rights reserved": "Todos los derechos reservados",
|
||||
"Cetacean": "Cetáceo",
|
||||
"Wildlife Sightings": "Avistamientos de fauna y flora",
|
||||
"Exit cockpit to fly to a sighting": "Sal de la cabina para volar a un avistamiento",
|
||||
"Exit cockpit to fly to a sighting area": "Sal de la cabina para volar a un área de avistamiento",
|
||||
"Wildlife": "Vida silvestre",
|
||||
"Zoom in for local sightings": "Acerca para ver avistamientos locales",
|
||||
"Loading photo...": "Cargando foto…",
|
||||
"Photo unavailable": "Foto no disponible",
|
||||
"Location obscured to a 0.2° cell": "Ubicación oculta en una celda de 0,2°",
|
||||
"Photo of {name}": "Foto de {name}",
|
||||
"Acartia": "Acartia",
|
||||
"CC BY 4.0": "CC BY 4.0",
|
||||
"{count} sightings in view": "{count} avistamientos en la vista",
|
||||
"Close sighting": "Cerrar avistamiento",
|
||||
"Wildlife observed within 48 h (whales 7 d). Color and badge glyph show the taxon group. Sensitive species are shown only as 0.2° cells; Salish Sea marine mammal reports via Acartia are rounded to 0.01°. Not a census.": "Fauna observada en las últimas 48 h (cetáceos 7 días). El color y el símbolo de la insignia indican el grupo taxonómico. Las especies sensibles solo se muestran en celdas de 0,2°; los informes de mamíferos marinos de Acartia en el mar de Salish se redondean a 0,01°. No es un censo.",
|
||||
"Open {name} on iNaturalist": "Abrir {name} en iNaturalist",
|
||||
"Open {name} on Acartia": "Abrir {name} en Acartia",
|
||||
"iNaturalist": "iNaturalist",
|
||||
"iNaturalist contributors · licences per record": "Colaboradores de iNaturalist · licencias por registro",
|
||||
"iNaturalist contributors · Acartia {count} (Salish Sea) · licences per record": "Colaboradores de iNaturalist · Acartia {count} (mar de Salish) · licencias por registro",
|
||||
"{days}d": "{days}d",
|
||||
"All markers": "Todos los marcadores",
|
||||
"Show markers of every age": "Mostrar marcadores de cualquier antigüedad",
|
||||
"Maximum event age: {days}d; undated markers are kept": "Antigüedad máxima: {days} días; se conservan los marcadores sin fecha",
|
||||
"event age filter|All": "Todos",
|
||||
"Event age": "Antigüedad",
|
||||
"Event age: {days}d · undated kept": "Antigüedad: {days} días · se conservan los marcadores sin fecha"
|
||||
}
|
||||
+104
-28
@@ -1103,8 +1103,16 @@
|
||||
"TomTom roads first; OpenStreetMap fills the rest, simulated": "Routes TomTom d'abord ; OpenStreetMap complète le reste, en simulation",
|
||||
"TomTom roads first; OpenStreetMap fills the rest, simulated (needs a TomTom key)": "Routes TomTom d'abord ; OpenStreetMap complète le reste, en simulation (nécessite une clé TomTom)",
|
||||
"Hybrid": "Hybride",
|
||||
"Lines": "Lignes",
|
||||
"Dots": "Points",
|
||||
"Road lines colored by live flow: green to red": "Routes colorées selon le trafic en direct : du vert au rouge",
|
||||
"Moving dots colored by live flow": "Points mobiles colorés selon le trafic en direct",
|
||||
"Moving dots": "Points mobiles",
|
||||
"TomTom key unavailable": "Clé TomTom indisponible",
|
||||
"TomTom daily budget reached": "Quota quotidien TomTom atteint",
|
||||
"Monthly traffic allowance used": "Quota mensuel de trafic épuisé",
|
||||
"Traffic rate limit reached": "Limite de requêtes de trafic atteinte",
|
||||
"Traffic service unavailable": "Service de trafic indisponible",
|
||||
"TomTom upstream unreachable": "Serveur TomTom injoignable",
|
||||
"TomTom flow unavailable": "Flux TomTom indisponible",
|
||||
"SIMULATED — {reason}": "SIMULÉ — {reason}",
|
||||
@@ -1660,6 +1668,9 @@
|
||||
"REGIONAL NEWS": "ACTUS RÉGIONALES",
|
||||
"{label} · {radius} AIR/SEA WINDOW": "{label} · AIR/MER {radius}",
|
||||
"{scope} · INSTALLATIONS {coverage}": "{scope} · INSTALLATIONS : {coverage}",
|
||||
"MILITARY FEED OFF · PARTIAL PICTURE": "FLUX MILITAIRE COUPÉ · SITUATION PARTIELLE",
|
||||
"FLIGHTS FEED OFF · PARTIAL PICTURE": "FLUX DES VOLS COUPÉ · SITUATION PARTIELLE",
|
||||
"NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR": "AÉRONEFS PROCHES INCOMPLETS · PAS UN FEU VERT",
|
||||
"CONTEXT STANDBY": "CONTEXTE EN VEILLE",
|
||||
"ENABLE GLOBAL CONTEXT FOR PROXIMITY PINGS": "ACTIVER LE CONTEXTE GLOBAL POUR LES PINGS DE PROXIMITÉ",
|
||||
"CONTACT LOST · LAST KNOWN READOUT · NOT AN ALL-CLEAR": "CONTACT PERDU · DERNIER RELEVÉ CONNU · PAS UN FEU VERT",
|
||||
@@ -2799,6 +2810,8 @@
|
||||
"AVAILABLE MISSIONS": "MISSIONS DISPONIBLES",
|
||||
"SELECT A MISSION TO INSPECT": "CHOISIR UNE MISSION À EXAMINER",
|
||||
"LOADING 30-DAY MISSION INDEX": "CHARGEMENT DE L'INDEX DES MISSIONS À 30 JOURS",
|
||||
"Space Missions unavailable: the mission index could not load. Try again later.": "Missions spatiales indisponibles : impossible de charger l’index des missions. Réessayez plus tard.",
|
||||
"30-DAY MISSION INDEX UNAVAILABLE": "INDEX DES MISSIONS À 30 JOURS INDISPONIBLE",
|
||||
"TAB PREVIEWS · ENTER / SPACE SELECTS": "TAB APERÇU · ENTER / SPACE SÉLECTIONNE",
|
||||
"Internet radio and local SDR": "Radio Internet et SDR local",
|
||||
"STATION TAG": "TAG DE STATION",
|
||||
@@ -2977,18 +2990,18 @@
|
||||
"Tip: the GEV MIC button in the dock lets you talk to the map.": "Astuce : le bouton GEV MIC du dock permet de parler à la carte.",
|
||||
"Malformed cyclone snapshot": "Instantané cyclonique mal formé",
|
||||
"Cyclone request timed out": "Délai dépassé pour la requête cyclonique",
|
||||
"{lower} to {upper}": "{lower} à {upper}",
|
||||
"Up to {upper}": "Jusqu’à {upper}",
|
||||
"{lower} to {upper}": "De {lower} à {upper}",
|
||||
"Up to {upper}": "Jusqu'à {upper}",
|
||||
"From {lower}": "À partir de {lower}",
|
||||
"Informational only · not for flight planning": "À titre informatif uniquement · ne pas utiliser pour la planification de vol",
|
||||
"Unnamed zone": "Zone sans nom",
|
||||
"Restricted airspace source unavailable": "Source de l’espace aérien réglementé indisponible",
|
||||
"Informational only · not for flight planning": "À titre indicatif · ne pas utiliser pour la préparation des vols",
|
||||
"Unnamed zone": "Sans nom",
|
||||
"Restricted airspace source unavailable": "Source de l'espace aérien réglementé indisponible",
|
||||
"Prohibited (P)": "Zone interdite (P)",
|
||||
"Restricted (R)": "Zone réglementée (R)",
|
||||
"FAA prohibited and restricted special-use airspace only, not the UAS Facility Map or UAS flight restrictions. Informational only — not a substitute for official UAS flight-planning tools (B4UFLY / LAANC).": "Uniquement les zones interdites et réglementées de la FAA (espace aérien à usage spécial), sans la UAS Facility Map ni les restrictions de vol des drones. À titre informatif uniquement — ne remplace pas les outils officiels de planification de vol des drones (B4UFLY / LAANC).",
|
||||
"FAA prohibited and restricted special-use airspace only, not the UAS Facility Map or UAS flight restrictions. Informational only — not a substitute for official UAS flight-planning tools (B4UFLY / LAANC).": "Uniquement les zones interdites et réglementées de la FAA (espace aérien à usage spécial), sans la UAS Facility Map ni les restrictions de vol des drones. À titre indicatif uniquement — ne remplace pas les outils officiels de préparation des vols de drones (B4UFLY / LAANC).",
|
||||
"Prohibited": "Zone interdite",
|
||||
"Restricted": "Zone réglementée",
|
||||
"Prohibited & Restricted Airspace": "Zones interdites et réglementées",
|
||||
"Prohibited & Restricted Airspace": "Espaces aériens interdits et réglementés",
|
||||
"scene|PLAY": "LIRE",
|
||||
"AUTHORING": "CRÉATION",
|
||||
"Create and edit scenes": "Créer et modifier des scènes",
|
||||
@@ -3019,7 +3032,7 @@
|
||||
"fire confidence filter|All": "Toutes",
|
||||
"fire confidence filter|Nominal+": "Nominale+",
|
||||
"fire confidence filter|High": "Élevée",
|
||||
"Every detection — many of the strongest fires are rated low confidence": "Toutes les détections — beaucoup des feux les plus intenses ont une confiance faible",
|
||||
"Every detection — many of the strongest fires are rated low confidence": "Toutes les détections — de nombreux feux parmi les plus intenses sont classés en confiance faible",
|
||||
"Nominal and high confidence detections": "Détections de confiance nominale et élevée",
|
||||
"High confidence detections only": "Détections de confiance élevée uniquement",
|
||||
"Fire intensity (FRP)": "Intensité (FRP)",
|
||||
@@ -3028,7 +3041,7 @@
|
||||
"Minimum fire radiative power, megawatts": "Puissance radiative minimale du feu, en mégawatts",
|
||||
"fire intensity filter|All": "Toutes",
|
||||
"Detection confidence": "Confiance de la détection",
|
||||
"{shown} of {total} shown": "{shown} sur {total} affichées",
|
||||
"{shown} of {total} shown": "Affichés : {shown} sur {total}",
|
||||
"Name of {name}": "Nom de {name}",
|
||||
"Change the colour of {name}": "Changer la couleur de {name}",
|
||||
"Colour of {name}": "Couleur de {name}",
|
||||
@@ -3038,7 +3051,7 @@
|
||||
"UTILITIES": "OUTILS",
|
||||
"Exit cockpit to fly to a drawing": "Quittez le cockpit pour rejoindre un dessin",
|
||||
"Directions, drawing and your own files": "Itinéraire, dessin et vos propres fichiers",
|
||||
"Voice place search (needs an Overpass server)": "Recherche de lieux par la voix (nécessite un serveur Overpass)",
|
||||
"Voice place search (needs an Overpass server)": "Recherche vocale de lieux (nécessite un serveur Overpass)",
|
||||
"OSM Places": "Lieux OSM",
|
||||
"state or province": "État ou province",
|
||||
"county or district": "comté ou district",
|
||||
@@ -3046,9 +3059,9 @@
|
||||
"natural region": "région naturelle",
|
||||
"Which {place}?": "Quel lieu nommé {place} ?",
|
||||
"Which place?": "Quel lieu ?",
|
||||
"Not drawn: {parts}": "Non dessiné : {parts}",
|
||||
"Not drawn: {parts}": "Contour non tracé : {parts}",
|
||||
"approximate": "approximatif",
|
||||
"{pct}% cloud": "{pct} % de nuages",
|
||||
"{pct}% cloud": "{pct} % de nuages",
|
||||
"Over {area}": "Zone : {area}",
|
||||
"{count} of {total} acquisitions match": {
|
||||
"many": "Acquisitions correspondantes : {count} sur {total}",
|
||||
@@ -3067,43 +3080,43 @@
|
||||
"{count} {kind}": "{kind} : {count}",
|
||||
"In {area}": "Zone : {area}",
|
||||
"Area around {place}": "Environs : {place}",
|
||||
"Area around landmark": "Environs d'un repère",
|
||||
"Area around landmark": "Environs d'un lieu",
|
||||
"Outline {place}": "Délimiter {place}",
|
||||
"Outline area": "Délimiter une zone",
|
||||
"Find {place}": "Trouver {place}",
|
||||
"Find area": "Trouver une zone",
|
||||
"Find recent imagery": "Trouver des images récentes",
|
||||
"Find {what}": "Trouver {what}",
|
||||
"Find {what}": "Trouver : {what}",
|
||||
"Find places": "Trouver des lieux",
|
||||
"Drawing outline": "Dessin du contour",
|
||||
"Searching imagery catalog": "Recherche dans le catalogue d'images",
|
||||
"Loading imagery": "Chargement des images",
|
||||
"Searching OpenStreetMap": "Recherche dans OpenStreetMap",
|
||||
"voice area|Area": "Zone",
|
||||
"buffer sized to the place": "zone tampon à la taille du lieu",
|
||||
"buffer sized to the place": "zone tampon adaptée à la taille du lieu",
|
||||
"enclosing mapped area": "zone cartographiée englobante",
|
||||
"the map was cleared while looking it up": "la carte a été effacée pendant la recherche",
|
||||
"OpenStreetMap coverage varies by place": "La couverture OpenStreetMap varie selon les lieux",
|
||||
"OpenStreetMap coverage varies by place": "la couverture OpenStreetMap varie selon les lieux",
|
||||
"the catalog covers the last 30 days": "le catalogue couvre les 30 derniers jours",
|
||||
"there are more": "il y en a d'autres",
|
||||
"{radius} m around the landmark": "{radius} m autour du lieu",
|
||||
"{count} parts": {
|
||||
"many": "Parties : {count}",
|
||||
"one": "Parties : {count}",
|
||||
"other": "Parties : {count}"
|
||||
"many": "Parties : {count}",
|
||||
"one": "Parties : {count}",
|
||||
"other": "Parties : {count}"
|
||||
},
|
||||
"{drawn} of {total} parts drawn": {
|
||||
"many": "Parties tracées : {drawn} sur {total}",
|
||||
"one": "Parties tracées : {drawn} sur {total}",
|
||||
"other": "Parties tracées : {drawn} sur {total}"
|
||||
"many": "Parties tracées : {drawn} sur {total}",
|
||||
"one": "Parties tracées : {drawn} sur {total}",
|
||||
"other": "Parties tracées : {drawn} sur {total}"
|
||||
},
|
||||
"{count} duplicate mappings merged": {
|
||||
"many": "Doublons fusionnés : {count}",
|
||||
"one": "Doublons fusionnés : {count}",
|
||||
"other": "Doublons fusionnés : {count}"
|
||||
"many": "Doublons fusionnés : {count}",
|
||||
"one": "Doublons fusionnés : {count}",
|
||||
"other": "Doublons fusionnés : {count}"
|
||||
},
|
||||
"listed the first {limit}": "les {limit} premiers sont listés",
|
||||
"counted over the bounding box of {area}, not its outline": "compté sur le rectangle englobant ({area}), pas sur le contour",
|
||||
"listed the first {limit}": "liste limitée à {limit}",
|
||||
"counted over the bounding box of {area}, not its outline": "compté dans le rectangle englobant ({area}), pas dans son contour exact",
|
||||
"Search cancelled": "Recherche annulée",
|
||||
"Sea Marks": "Balisage maritime",
|
||||
"Ski Pistes": "Pistes de ski",
|
||||
@@ -3179,5 +3192,68 @@
|
||||
"The provider rejected the API key. Check it in Provider Settings.": "Le fournisseur a refusé la clé API. Vérifiez-la dans les paramètres des fournisseurs.",
|
||||
"Too many chat requests. Try again in {seconds} s.": "Trop de requêtes de discussion. Réessayez dans {seconds} s.",
|
||||
"The provider refused access for this key. Check the key's project, region or permissions.": "Le fournisseur a refusé l'accès pour cette clé. Vérifiez le projet, la région ou les autorisations de la clé.",
|
||||
"The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "Le compte du fournisseur n'a plus de crédit. Vérifiez la facturation ou choisissez un autre fournisseur d'IA dans les paramètres."
|
||||
"The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "Le compte du fournisseur n'a plus de crédit. Vérifiez la facturation ou choisissez un autre fournisseur d'IA dans les paramètres.",
|
||||
"SIGHTING": "OBSERVATION",
|
||||
"Show {name} sighting": "Afficher l’observation de {name}",
|
||||
"seen {age} ago": "vu il y a {age}",
|
||||
"observed {age} ago": "observé il y a {age}",
|
||||
"Be Whale Wise": "Respectez les cétacés",
|
||||
"Reported by {name} ({entity})": "Signalé par {name} ({entity})",
|
||||
"Reported by {name}": "Signalé par {name}",
|
||||
"Salish Sea": "Mer des Salish",
|
||||
"Birds": "Oiseaux",
|
||||
"Mammals": "Mammifères",
|
||||
"Reptiles": "Reptiles",
|
||||
"Amphibians": "Amphibiens",
|
||||
"Ray-finned fishes": "Poissons à nageoires rayonnées",
|
||||
"Insects": "Insectes",
|
||||
"Arachnids": "Arachnides",
|
||||
"Plants": "Plantes",
|
||||
"Fungi": "Champignons",
|
||||
"Other animals": "Autres animaux",
|
||||
"Other or unknown": "Autres ou inconnus",
|
||||
"{label} {detail} — click to retry": "{label} {detail} — cliquer pour réessayer",
|
||||
"Wildlife sightings source unavailable": "Source des observations naturalistes indisponible",
|
||||
"Show only community-confirmed (research-grade) sightings": "Afficher uniquement les observations confirmées par la communauté (qualité recherche)",
|
||||
"No recent sightings in view": "Aucune observation récente dans la vue",
|
||||
"Acartia feed unavailable": "Flux Acartia indisponible",
|
||||
"Show all taxa": "Afficher tous les groupes taxonomiques",
|
||||
"BIRDS": "OISEAUX",
|
||||
"Show only birds": "Afficher uniquement les oiseaux",
|
||||
"MAMMALS": "MAMMIFÈRES",
|
||||
"Show only mammals": "Afficher uniquement les mammifères",
|
||||
"WHALES": "CÉTACÉS",
|
||||
"Show only whales and dolphins (7 days)": "Afficher uniquement les baleines et dauphins (7 jours)",
|
||||
"INSECTS": "INSECTES",
|
||||
"Show only insects": "Afficher uniquement les insectes",
|
||||
"PLANTS": "PLANTES",
|
||||
"Show only plants": "Afficher uniquement les plantes",
|
||||
"All rights reserved": "Tous droits réservés",
|
||||
"Cetacean": "Cétacé",
|
||||
"Wildlife Sightings": "Observations naturalistes",
|
||||
"Exit cockpit to fly to a sighting": "Quittez le cockpit pour rejoindre une observation",
|
||||
"Exit cockpit to fly to a sighting area": "Quittez le cockpit pour rejoindre une zone d’observation",
|
||||
"Wildlife": "Faune et flore",
|
||||
"Zoom in for local sightings": "Zoomez pour les observations locales",
|
||||
"Loading photo...": "Chargement de la photo…",
|
||||
"Photo unavailable": "Photo indisponible",
|
||||
"Location obscured to a 0.2° cell": "Localisation masquée dans une cellule de 0,2°",
|
||||
"Photo of {name}": "Photo de {name}",
|
||||
"Acartia": "Acartia",
|
||||
"CC BY 4.0": "CC BY 4.0",
|
||||
"{count} sightings in view": "{count} observations dans la vue",
|
||||
"Close sighting": "Fermer l’observation",
|
||||
"Wildlife observed within 48 h (whales 7 d). Color and badge glyph show the taxon group. Sensitive species are shown only as 0.2° cells; Salish Sea marine mammal reports via Acartia are rounded to 0.01°. Not a census.": "Faune observée au cours des dernières 48 h (cétacés 7 jours). La couleur et le pictogramme du badge indiquent le groupe taxonomique. Les espèces sensibles sont limitées à des cellules de 0,2° ; les signalements de mammifères marins Acartia en mer des Salish sont arrondis à 0,01°. Pas un recensement.",
|
||||
"Open {name} on iNaturalist": "Ouvrir {name} sur iNaturalist",
|
||||
"Open {name} on Acartia": "Ouvrir {name} sur Acartia",
|
||||
"iNaturalist": "iNaturalist",
|
||||
"iNaturalist contributors · licences per record": "Contributeurs iNaturalist · licences par observation",
|
||||
"iNaturalist contributors · Acartia {count} (Salish Sea) · licences per record": "Contributeurs iNaturalist · Acartia {count} (mer des Salish) · licences par observation",
|
||||
"{days}d": "{days}j",
|
||||
"All markers": "Tous les marqueurs",
|
||||
"Show markers of every age": "Afficher les marqueurs de tout âge",
|
||||
"Maximum event age: {days}d; undated markers are kept": "Ancienneté maximale : {days} j ; marqueurs sans date conservés",
|
||||
"event age filter|All": "Tous",
|
||||
"Event age": "Ancienneté",
|
||||
"Event age: {days}d · undated kept": "Ancienneté : {days} j · marqueurs sans date conservés"
|
||||
}
|
||||
+97
-21
@@ -1103,8 +1103,16 @@
|
||||
"TomTom roads first; OpenStreetMap fills the rest, simulated": "Prima le strade TomTom; OpenStreetMap completa il resto, simulato",
|
||||
"TomTom roads first; OpenStreetMap fills the rest, simulated (needs a TomTom key)": "Prima le strade TomTom; OpenStreetMap completa il resto, simulato (richiede una chiave TomTom)",
|
||||
"Hybrid": "Ibrido",
|
||||
"Lines": "Linee",
|
||||
"Dots": "Punti",
|
||||
"Road lines colored by live flow: green to red": "Strade colorate in base al traffico in tempo reale: dal verde al rosso",
|
||||
"Moving dots colored by live flow": "Punti in movimento colorati in base al traffico in tempo reale",
|
||||
"Moving dots": "Punti in movimento",
|
||||
"TomTom key unavailable": "Chiave TomTom non disponibile",
|
||||
"TomTom daily budget reached": "Budget giornaliero TomTom esaurito",
|
||||
"Monthly traffic allowance used": "Quota mensile del traffico esaurita",
|
||||
"Traffic rate limit reached": "Limite di richieste del traffico raggiunto",
|
||||
"Traffic service unavailable": "Servizio traffico non disponibile",
|
||||
"TomTom upstream unreachable": "Servizio TomTom irraggiungibile",
|
||||
"TomTom flow unavailable": "Flusso TomTom non disponibile",
|
||||
"SIMULATED — {reason}": "SIMULATO: {reason}",
|
||||
@@ -1660,6 +1668,9 @@
|
||||
"REGIONAL NEWS": "NOTIZIE REGIONALI",
|
||||
"{label} · {radius} AIR/SEA WINDOW": "{label} · ARIA/MARE {radius}",
|
||||
"{scope} · INSTALLATIONS {coverage}": "{scope} · SITI {coverage}",
|
||||
"MILITARY FEED OFF · PARTIAL PICTURE": "FEED MILITARE DISATTIVATO · QUADRO PARZIALE",
|
||||
"FLIGHTS FEED OFF · PARTIAL PICTURE": "FEED VOLI DISATTIVATO · QUADRO PARZIALE",
|
||||
"NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR": "AEREI VICINI INCOMPLETI · NON È UN VIA LIBERA",
|
||||
"CONTEXT STANDBY": "CONTESTO IN ATTESA",
|
||||
"ENABLE GLOBAL CONTEXT FOR PROXIMITY PINGS": "ATTIVA IL CONTESTO GLOBALE PER I PING DI PROSSIMITÀ",
|
||||
"CONTACT LOST · LAST KNOWN READOUT · NOT AN ALL-CLEAR": "CONTATTO PERSO · ULTIMA LETTURA NOTA · NON È UN VIA LIBERA",
|
||||
@@ -2799,6 +2810,8 @@
|
||||
"AVAILABLE MISSIONS": "MISSIONI DISPONIBILI",
|
||||
"SELECT A MISSION TO INSPECT": "SELEZIONA UNA MISSIONE DA ESAMINARE",
|
||||
"LOADING 30-DAY MISSION INDEX": "CARICAMENTO INDICE MISSIONI A 30 GIORNI",
|
||||
"Space Missions unavailable: the mission index could not load. Try again later.": "Missioni spaziali non disponibili: impossibile caricare l’indice delle missioni. Riprova più tardi.",
|
||||
"30-DAY MISSION INDEX UNAVAILABLE": "INDICE MISSIONI A 30 GIORNI NON DISPONIBILE",
|
||||
"TAB PREVIEWS · ENTER / SPACE SELECTS": "TAB: ANTEPRIMA · ENTER / SPACE: SELEZIONA",
|
||||
"Internet radio and local SDR": "Radio internet e SDR locale",
|
||||
"STATION TAG": "TAG STAZIONE",
|
||||
@@ -2977,18 +2990,18 @@
|
||||
"Tip: the GEV MIC button in the dock lets you talk to the map.": "Suggerimento: il pulsante GEV MIC nel dock ti permette di parlare con la mappa.",
|
||||
"Malformed cyclone snapshot": "Snapshot dei cicloni non valido",
|
||||
"Cyclone request timed out": "La richiesta dei cicloni è andata in timeout",
|
||||
"{lower} to {upper}": "da {lower} a {upper}",
|
||||
"{lower} to {upper}": "Da {lower} a {upper}",
|
||||
"Up to {upper}": "Fino a {upper}",
|
||||
"From {lower}": "Da {lower}",
|
||||
"Informational only · not for flight planning": "Solo a scopo informativo · non per la pianificazione del volo",
|
||||
"Unnamed zone": "Zona senza nome",
|
||||
"Unnamed zone": "Senza nome",
|
||||
"Restricted airspace source unavailable": "Fonte dello spazio aereo regolamentato non disponibile",
|
||||
"Prohibited (P)": "Zona proibita (P)",
|
||||
"Prohibited (P)": "Zona vietata (P)",
|
||||
"Restricted (R)": "Zona regolamentata (R)",
|
||||
"FAA prohibited and restricted special-use airspace only, not the UAS Facility Map or UAS flight restrictions. Informational only — not a substitute for official UAS flight-planning tools (B4UFLY / LAANC).": "Solo zone proibite e regolamentate della FAA (spazio aereo a uso speciale), non la UAS Facility Map né le restrizioni di volo UAS. Solo a scopo informativo — non sostituisce gli strumenti ufficiali di pianificazione del volo UAS (B4UFLY / LAANC).",
|
||||
"Prohibited": "Zona proibita",
|
||||
"FAA prohibited and restricted special-use airspace only, not the UAS Facility Map or UAS flight restrictions. Informational only — not a substitute for official UAS flight-planning tools (B4UFLY / LAANC).": "Solo zone vietate e regolamentate della FAA (spazio aereo a uso speciale), non la UAS Facility Map né le restrizioni di volo UAS. Solo a scopo informativo — non sostituisce gli strumenti ufficiali di pianificazione del volo UAS (B4UFLY / LAANC).",
|
||||
"Prohibited": "Zona vietata",
|
||||
"Restricted": "Zona regolamentata",
|
||||
"Prohibited & Restricted Airspace": "Zone proibite e regolamentate",
|
||||
"Prohibited & Restricted Airspace": "Spazio aereo vietato e regolamentato",
|
||||
"scene|PLAY": "AVVIA",
|
||||
"AUTHORING": "CREAZIONE",
|
||||
"Create and edit scenes": "Crea e modifica le scene",
|
||||
@@ -3019,7 +3032,7 @@
|
||||
"fire confidence filter|All": "Tutti",
|
||||
"fire confidence filter|Nominal+": "Nominale+",
|
||||
"fire confidence filter|High": "Alta",
|
||||
"Every detection — many of the strongest fires are rated low confidence": "Tutti i rilevamenti — molti degli incendi più intensi hanno confidenza bassa",
|
||||
"Every detection — many of the strongest fires are rated low confidence": "Tutti i rilevamenti: molti degli incendi più intensi hanno confidenza bassa",
|
||||
"Nominal and high confidence detections": "Rilevamenti con confidenza nominale e alta",
|
||||
"High confidence detections only": "Solo rilevamenti con confidenza alta",
|
||||
"Fire intensity (FRP)": "Intensità (FRP)",
|
||||
@@ -3028,7 +3041,7 @@
|
||||
"Minimum fire radiative power, megawatts": "Potenza radiativa minima dell'incendio, in megawatt",
|
||||
"fire intensity filter|All": "Tutti",
|
||||
"Detection confidence": "Confidenza del rilevamento",
|
||||
"{shown} of {total} shown": "{shown} di {total} mostrati",
|
||||
"{shown} of {total} shown": "Mostrati: {shown} su {total}",
|
||||
"Name of {name}": "Nome di {name}",
|
||||
"Change the colour of {name}": "Cambia il colore di {name}",
|
||||
"Colour of {name}": "Colore di {name}",
|
||||
@@ -3046,10 +3059,10 @@
|
||||
"natural region": "regione naturale",
|
||||
"Which {place}?": "Quale {place}?",
|
||||
"Which place?": "Quale luogo?",
|
||||
"Not drawn: {parts}": "Non disegnati: {parts}",
|
||||
"approximate": "approssimativo",
|
||||
"Not drawn: {parts}": "Contorno non disegnato: {parts}",
|
||||
"approximate": "approssimata",
|
||||
"{pct}% cloud": "{pct}% di nuvole",
|
||||
"Over {area}": "Zona: {area}",
|
||||
"Over {area}": "Area: {area}",
|
||||
"{count} of {total} acquisitions match": {
|
||||
"many": "Acquisizioni corrispondenti: {count} su {total}",
|
||||
"one": "Acquisizioni corrispondenti: {count} su {total}",
|
||||
@@ -3068,8 +3081,8 @@
|
||||
"In {area}": "Area: {area}",
|
||||
"Area around {place}": "Area intorno a {place}",
|
||||
"Area around landmark": "Area intorno a un punto di interesse",
|
||||
"Outline {place}": "Traccia contorno di {place}",
|
||||
"Outline area": "Traccia contorno area",
|
||||
"Outline {place}": "Traccia il contorno di {place}",
|
||||
"Outline area": "Traccia il contorno dell'area",
|
||||
"Find {place}": "Trova {place}",
|
||||
"Find area": "Trova area",
|
||||
"Find recent imagery": "Trova immagini recenti",
|
||||
@@ -3077,16 +3090,16 @@
|
||||
"Find places": "Trova luoghi",
|
||||
"Drawing outline": "Disegno del contorno",
|
||||
"Searching imagery catalog": "Ricerca nel catalogo immagini",
|
||||
"Loading imagery": "Caricamento immagini",
|
||||
"Loading imagery": "Caricamento delle immagini",
|
||||
"Searching OpenStreetMap": "Ricerca in OpenStreetMap",
|
||||
"voice area|Area": "Area",
|
||||
"buffer sized to the place": "zona tampone delle dimensioni del luogo",
|
||||
"enclosing mapped area": "area mappata circostante",
|
||||
"the map was cleared while looking it up": "la mappa è stata cancellata durante la ricerca",
|
||||
"OpenStreetMap coverage varies by place": "La copertura di OpenStreetMap varia da luogo a luogo",
|
||||
"enclosing mapped area": "area mappata che contiene il punto di interesse",
|
||||
"the map was cleared while looking it up": "la mappa è stata ripulita durante la ricerca",
|
||||
"OpenStreetMap coverage varies by place": "la copertura di OpenStreetMap varia da luogo a luogo",
|
||||
"the catalog covers the last 30 days": "il catalogo copre gli ultimi 30 giorni",
|
||||
"there are more": "ce ne sono altri",
|
||||
"{radius} m around the landmark": "{radius} m attorno al luogo",
|
||||
"{radius} m around the landmark": "{radius} m intorno al punto di interesse",
|
||||
"{count} parts": {
|
||||
"many": "Parti: {count}",
|
||||
"one": "Parti: {count}",
|
||||
@@ -3102,8 +3115,8 @@
|
||||
"one": "Duplicati uniti: {count}",
|
||||
"other": "Duplicati uniti: {count}"
|
||||
},
|
||||
"listed the first {limit}": "elencati i primi {limit}",
|
||||
"counted over the bounding box of {area}, not its outline": "contato sul riquadro di delimitazione ({area}), non sul contorno",
|
||||
"listed the first {limit}": "elenco limitato ai primi risultati: {limit}",
|
||||
"counted over the bounding box of {area}, not its outline": "conteggio nel riquadro di delimitazione di {area}, non entro il suo contorno",
|
||||
"Search cancelled": "Ricerca annullata",
|
||||
"Sea Marks": "Segnalamenti marittimi",
|
||||
"Ski Pistes": "Piste da sci",
|
||||
@@ -3179,5 +3192,68 @@
|
||||
"The provider rejected the API key. Check it in Provider Settings.": "Il provider ha rifiutato la chiave API. Controllala in Impostazioni provider.",
|
||||
"Too many chat requests. Try again in {seconds} s.": "Troppe richieste di chat. Riprova tra {seconds} s.",
|
||||
"The provider refused access for this key. Check the key's project, region or permissions.": "Il provider ha negato l'accesso per questa chiave. Controlla progetto, regione o autorizzazioni della chiave.",
|
||||
"The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "L'account del provider ha esaurito il credito. Controlla la fatturazione o scegli un altro provider IA in Impostazioni."
|
||||
"The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "L'account del provider ha esaurito il credito. Controlla la fatturazione o scegli un altro provider IA in Impostazioni.",
|
||||
"SIGHTING": "AVVISTAMENTO",
|
||||
"Show {name} sighting": "Mostra avvistamento di {name}",
|
||||
"seen {age} ago": "avvistato {age} fa",
|
||||
"observed {age} ago": "osservato {age} fa",
|
||||
"Be Whale Wise": "Rispetta i cetacei",
|
||||
"Reported by {name} ({entity})": "Segnalato da {name} ({entity})",
|
||||
"Reported by {name}": "Segnalato da {name}",
|
||||
"Salish Sea": "Mare dei Salish",
|
||||
"Birds": "Uccelli",
|
||||
"Mammals": "Mammiferi",
|
||||
"Reptiles": "Rettili",
|
||||
"Amphibians": "Anfibi",
|
||||
"Ray-finned fishes": "Pesci con pinne raggiate",
|
||||
"Insects": "Insetti",
|
||||
"Arachnids": "Aracnidi",
|
||||
"Plants": "Piante",
|
||||
"Fungi": "Funghi",
|
||||
"Other animals": "Altri animali",
|
||||
"Other or unknown": "Altri o sconosciuti",
|
||||
"{label} {detail} — click to retry": "{label} {detail} — fai clic per riprovare",
|
||||
"Wildlife sightings source unavailable": "Fonte degli avvistamenti non disponibile",
|
||||
"Show only community-confirmed (research-grade) sightings": "Mostra solo osservazioni confermate dalla comunità (grado di ricerca)",
|
||||
"No recent sightings in view": "Nessun avvistamento recente nella vista",
|
||||
"Acartia feed unavailable": "Feed Acartia non disponibile",
|
||||
"Show all taxa": "Mostra tutti i gruppi tassonomici",
|
||||
"BIRDS": "UCCELLI",
|
||||
"Show only birds": "Mostra solo uccelli",
|
||||
"MAMMALS": "MAMMIFERI",
|
||||
"Show only mammals": "Mostra solo mammiferi",
|
||||
"WHALES": "CETACEI",
|
||||
"Show only whales and dolphins (7 days)": "Mostra solo balene e delfini (7 giorni)",
|
||||
"INSECTS": "INSETTI",
|
||||
"Show only insects": "Mostra solo insetti",
|
||||
"PLANTS": "PIANTE",
|
||||
"Show only plants": "Mostra solo piante",
|
||||
"All rights reserved": "Tutti i diritti riservati",
|
||||
"Cetacean": "Cetaceo",
|
||||
"Wildlife Sightings": "Avvistamenti naturalistici",
|
||||
"Exit cockpit to fly to a sighting": "Esci dalla cabina per volare a un avvistamento",
|
||||
"Exit cockpit to fly to a sighting area": "Esci dalla cabina per volare a un’area di avvistamento",
|
||||
"Wildlife": "Natura",
|
||||
"Zoom in for local sightings": "Ingrandisci per gli avvistamenti locali",
|
||||
"Loading photo...": "Caricamento della foto…",
|
||||
"Photo unavailable": "Foto non disponibile",
|
||||
"Location obscured to a 0.2° cell": "Posizione oscurata in una cella di 0,2°",
|
||||
"Photo of {name}": "Foto di {name}",
|
||||
"Acartia": "Acartia",
|
||||
"CC BY 4.0": "CC BY 4.0",
|
||||
"{count} sightings in view": "{count} avvistamenti nella vista",
|
||||
"Close sighting": "Chiudi avvistamento",
|
||||
"Wildlife observed within 48 h (whales 7 d). Color and badge glyph show the taxon group. Sensitive species are shown only as 0.2° cells; Salish Sea marine mammal reports via Acartia are rounded to 0.01°. Not a census.": "Fauna osservata nelle ultime 48 h (cetacei 7 giorni). Colore e simbolo del contrassegno indicano il gruppo tassonomico. Le specie sensibili sono mostrate solo in celle di 0,2°; le segnalazioni di mammiferi marini di Acartia nel mar dei Salish sono arrotondate a 0,01°. Non è un censimento.",
|
||||
"Open {name} on iNaturalist": "Apri {name} su iNaturalist",
|
||||
"Open {name} on Acartia": "Apri {name} su Acartia",
|
||||
"iNaturalist": "iNaturalist",
|
||||
"iNaturalist contributors · licences per record": "Contributori di iNaturalist · licenze per osservazione",
|
||||
"iNaturalist contributors · Acartia {count} (Salish Sea) · licences per record": "Contributori di iNaturalist · Acartia {count} (Mare dei Salish) · licenze per osservazione",
|
||||
"{days}d": "{days}g",
|
||||
"All markers": "Tutti gli indicatori",
|
||||
"Show markers of every age": "Mostra gli indicatori di qualsiasi età",
|
||||
"Maximum event age: {days}d; undated markers are kept": "Età massima degli eventi: {days} giorni; indicatori senza data mantenuti",
|
||||
"event age filter|All": "Tutti",
|
||||
"Event age": "Età degli eventi",
|
||||
"Event age: {days}d · undated kept": "Età degli eventi: {days} giorni · senza data mantenuti"
|
||||
}
|
||||
+112
-36
@@ -1071,8 +1071,16 @@
|
||||
"TomTom roads first; OpenStreetMap fills the rest, simulated": "TomTomの道路を優先し、残りはOpenStreetMapで補完(シミュレーション)",
|
||||
"TomTom roads first; OpenStreetMap fills the rest, simulated (needs a TomTom key)": "TomTomの道路を優先し、残りはOpenStreetMapで補完(シミュレーション、TomTomキーが必要)",
|
||||
"Hybrid": "ハイブリッド",
|
||||
"Lines": "ライン",
|
||||
"Dots": "ドット",
|
||||
"Road lines colored by live flow: green to red": "ライブの交通流で色分けした道路ライン(緑から赤)",
|
||||
"Moving dots colored by live flow": "ライブの交通流で色分けした移動ドット",
|
||||
"Moving dots": "移動ドット",
|
||||
"TomTom key unavailable": "TomTomキーを利用できません",
|
||||
"TomTom daily budget reached": "TomTomの1日の上限に達しました",
|
||||
"Monthly traffic allowance used": "今月の交通情報の上限に達しました",
|
||||
"Traffic rate limit reached": "交通情報のリクエスト上限に達しました",
|
||||
"Traffic service unavailable": "交通情報サービスを利用できません",
|
||||
"TomTom upstream unreachable": "TomTomのサービスに接続できません",
|
||||
"TomTom flow unavailable": "TomTomの交通流を利用できません",
|
||||
"SIMULATED — {reason}": "シミュレーション:{reason}",
|
||||
@@ -1612,6 +1620,9 @@
|
||||
"REGIONAL NEWS": "地域ニュース",
|
||||
"{label} · {radius} AIR/SEA WINDOW": "{label} · 航空機/船舶の範囲 {radius}",
|
||||
"{scope} · INSTALLATIONS {coverage}": "{scope} · 施設 {coverage}",
|
||||
"MILITARY FEED OFF · PARTIAL PICTURE": "軍用フィードオフ · 部分的な状況",
|
||||
"FLIGHTS FEED OFF · PARTIAL PICTURE": "フライトフィードオフ · 部分的な状況",
|
||||
"NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR": "付近の航空機情報は不完全 · 安全の保証ではありません",
|
||||
"CONTEXT STANDBY": "コンテキスト待機",
|
||||
"ENABLE GLOBAL CONTEXT FOR PROXIMITY PINGS": "グローバルコンテキストを有効にすると近接ピングを表示",
|
||||
"CONTACT LOST · LAST KNOWN READOUT · NOT AN ALL-CLEAR": "コンタクト喪失 · 最終既知の値 · 安全の保証ではありません",
|
||||
@@ -1684,7 +1695,7 @@
|
||||
"Restore real aircraft": "実際の航空機に戻す",
|
||||
"Reclassify as TR-3B": "TR-3Bに再分類",
|
||||
"TRACK ACQUIRED": "追跡開始",
|
||||
"{label} · COURSE {heading}°": "{label} · 針路 {heading}°",
|
||||
"{label} · COURSE {heading}°": "{label} · 対地針路 {heading}°",
|
||||
"Context could not restore every layer; try again": "コンテキストがすべてのレイヤーを復元できませんでした。もう一度お試しください",
|
||||
"No selected data layers": "選択中のデータレイヤーはありません",
|
||||
"{count} data layers could not be cleared": {
|
||||
@@ -2657,6 +2668,8 @@
|
||||
"AVAILABLE MISSIONS": "利用可能なミッション",
|
||||
"SELECT A MISSION TO INSPECT": "ミッションを選択して詳細を表示",
|
||||
"LOADING 30-DAY MISSION INDEX": "30日間のミッション一覧を読込中",
|
||||
"Space Missions unavailable: the mission index could not load. Try again later.": "宇宙ミッションを利用できません:ミッション一覧を読み込めませんでした。後でもう一度お試しください。",
|
||||
"30-DAY MISSION INDEX UNAVAILABLE": "30日間のミッション一覧を取得できません",
|
||||
"TAB PREVIEWS · ENTER / SPACE SELECTS": "Tabでプレビュー · Enter / Spaceで選択",
|
||||
"Internet radio and local SDR": "インターネットラジオとローカルSDR",
|
||||
"STATION TAG": "局タグ",
|
||||
@@ -2837,18 +2850,18 @@
|
||||
"route proxy error": "ルート検索プロキシでエラーが発生しました",
|
||||
"Malformed cyclone snapshot": "サイクロンのスナップショットの形式が不正です",
|
||||
"Cyclone request timed out": "サイクロンのリクエストがタイムアウトしました",
|
||||
"{lower} to {upper}": "{lower}~{upper}",
|
||||
"{lower} to {upper}": "{lower}–{upper}",
|
||||
"Up to {upper}": "{upper}まで",
|
||||
"From {lower}": "{lower}から",
|
||||
"Informational only · not for flight planning": "参考情報のみ · 飛行計画には使用しないでください",
|
||||
"Unnamed zone": "名称なしのゾーン",
|
||||
"Unnamed zone": "名称不明の区域",
|
||||
"Restricted airspace source unavailable": "制限空域のデータソースを利用できません",
|
||||
"Prohibited (P)": "飛行禁止区域 (P)",
|
||||
"Restricted (R)": "制限区域 (R)",
|
||||
"FAA prohibited and restricted special-use airspace only, not the UAS Facility Map or UAS flight restrictions. Informational only — not a substitute for official UAS flight-planning tools (B4UFLY / LAANC).": "FAAの飛行禁止区域と制限区域(特別使用空域)のみで、UAS Facility MapやUAS飛行制限は含みません。参考情報のみ — 公式のUAS飛行計画ツール(B4UFLY / LAANC)の代わりにはなりません。",
|
||||
"Prohibited (P)": "飛行禁止区域(P)",
|
||||
"Restricted (R)": "飛行制限区域(R)",
|
||||
"FAA prohibited and restricted special-use airspace only, not the UAS Facility Map or UAS flight restrictions. Informational only — not a substitute for official UAS flight-planning tools (B4UFLY / LAANC).": "FAAの特別使用空域のうち、飛行禁止区域と飛行制限区域のみです。UAS Facility MapやUASの飛行制限は含みません。参考情報であり、公式のUAS飛行計画ツール(B4UFLY / LAANC)の代わりにはなりません。",
|
||||
"Prohibited": "飛行禁止区域",
|
||||
"Restricted": "制限区域",
|
||||
"Prohibited & Restricted Airspace": "飛行禁止区域と制限区域",
|
||||
"Restricted": "飛行制限区域",
|
||||
"Prohibited & Restricted Airspace": "飛行禁止・飛行制限区域",
|
||||
"scene|PLAY": "再生",
|
||||
"AUTHORING": "作成",
|
||||
"Create and edit scenes": "シーンを作成・編集",
|
||||
@@ -2879,10 +2892,10 @@
|
||||
"fire confidence filter|All": "すべて",
|
||||
"fire confidence filter|Nominal+": "中以上",
|
||||
"fire confidence filter|High": "高",
|
||||
"Every detection — many of the strongest fires are rated low confidence": "すべての検知 — 最も強い火災の多くは信頼度が低と判定されています",
|
||||
"Nominal and high confidence detections": "信頼度が中・高の検知",
|
||||
"High confidence detections only": "信頼度が高の検知のみ",
|
||||
"Fire intensity (FRP)": "強度(FRP)",
|
||||
"Every detection — many of the strongest fires are rated low confidence": "すべての検知。最も強い火災の多くは信頼度「低」と判定されています",
|
||||
"Nominal and high confidence detections": "信頼度「中」以上の検知",
|
||||
"High confidence detections only": "信頼度「高」の検知のみ",
|
||||
"Fire intensity (FRP)": "火災強度(FRP)",
|
||||
"≥ {value} MW": "≥ {value} MW",
|
||||
"Confidence": "信頼度",
|
||||
"Minimum fire radiative power, megawatts": "火災放射強度の最小値(メガワット)",
|
||||
@@ -2900,16 +2913,16 @@
|
||||
"Directions, drawing and your own files": "ルート案内、描画、自分のファイル",
|
||||
"Voice place search (needs an Overpass server)": "音声による場所検索(Overpassサーバーが必要)",
|
||||
"OSM Places": "OSMの場所",
|
||||
"state or province": "州・省",
|
||||
"state or province": "州・県",
|
||||
"county or district": "郡・地区",
|
||||
"neighbourhood": "近隣地区",
|
||||
"natural region": "自然地域",
|
||||
"Which {place}?": "どの{place}ですか?",
|
||||
"Which {place}?": "どの「{place}」ですか?",
|
||||
"Which place?": "どの場所ですか?",
|
||||
"Not drawn: {parts}": "未描画:{parts}",
|
||||
"approximate": "概算",
|
||||
"{pct}% cloud": "雲量{pct}%",
|
||||
"Over {area}": "{area}上空",
|
||||
"approximate": "近似",
|
||||
"{pct}% cloud": "雲量 {pct}%",
|
||||
"Over {area}": "範囲:{area}",
|
||||
"{count} of {total} acquisitions match": {
|
||||
"other": "一致した画像:{count}/{total}件"
|
||||
},
|
||||
@@ -2918,45 +2931,45 @@
|
||||
},
|
||||
"No matching imagery": "一致する画像はありません",
|
||||
"Unknown number of {kind}": "{kind}:件数不明",
|
||||
"About {count} {kind}": "{kind}:約{count}件",
|
||||
"At least {count} {kind}": "{kind}:{count}件以上",
|
||||
"{count} {kind}": "{kind}:{count}件",
|
||||
"About {count} {kind}": "{kind}:約{count}か所",
|
||||
"At least {count} {kind}": "{kind}:{count}か所以上",
|
||||
"{count} {kind}": "{kind}:{count}か所",
|
||||
"In {area}": "{area}内",
|
||||
"Area around {place}": "{place}周辺のエリア",
|
||||
"Area around landmark": "ランドマーク周辺のエリア",
|
||||
"Outline {place}": "{place}の輪郭を描く",
|
||||
"Outline area": "エリアの輪郭を描く",
|
||||
"Outline {place}": "{place}の輪郭を描画",
|
||||
"Outline area": "エリアの輪郭を描画",
|
||||
"Find {place}": "{place}を検索",
|
||||
"Find area": "エリアを検索",
|
||||
"Find recent imagery": "最近の衛星画像を検索",
|
||||
"Find {what}": "{what}を検索",
|
||||
"Find places": "場所を検索",
|
||||
"Drawing outline": "輪郭を作図中",
|
||||
"Drawing outline": "輪郭を地図に描画中",
|
||||
"Searching imagery catalog": "画像カタログを検索中",
|
||||
"Loading imagery": "画像を読み込み中",
|
||||
"Searching OpenStreetMap": "OpenStreetMapを検索中",
|
||||
"voice area|Area": "エリア",
|
||||
"buffer sized to the place": "場所の大きさに合わせたバッファ",
|
||||
"enclosing mapped area": "周囲の地図上のエリア",
|
||||
"the map was cleared while looking it up": "検索中に地図がクリアされました",
|
||||
"OpenStreetMap coverage varies by place": "OpenStreetMap の網羅度は場所によって異なります",
|
||||
"the catalog covers the last 30 days": "カタログは過去 30 日分です",
|
||||
"buffer sized to the place": "場所の大きさに合わせたバッファー",
|
||||
"enclosing mapped area": "ランドマークを含むマップ済みのエリア",
|
||||
"the map was cleared while looking it up": "検索中に地図が消去されました",
|
||||
"OpenStreetMap coverage varies by place": "OpenStreetMapの網羅度は場所によって異なります",
|
||||
"the catalog covers the last 30 days": "カタログの対象は過去30日間です",
|
||||
"there are more": "ほかにもあります",
|
||||
"{radius} m around the landmark": "ランドマークの周囲 {radius} m",
|
||||
"{radius} m around the landmark": "ランドマークから半径{radius} m",
|
||||
"{count} parts": {
|
||||
"other": "パーツ数: {count}"
|
||||
"other": "パーツ数:{count}"
|
||||
},
|
||||
"{drawn} of {total} parts drawn": {
|
||||
"other": "描画したパーツ: {drawn}/{total}"
|
||||
"other": "描画したパーツ:{drawn}/{total}"
|
||||
},
|
||||
"{count} duplicate mappings merged": {
|
||||
"other": "統合した重複: {count}"
|
||||
"other": "重複登録{count}件を統合"
|
||||
},
|
||||
"listed the first {limit}": "最初の {limit} 件を表示",
|
||||
"counted over the bounding box of {area}, not its outline": "外接矩形 ({area}) で数えた結果で、輪郭ではありません",
|
||||
"listed the first {limit}": "最初の{limit}件を表示",
|
||||
"counted over the bounding box of {area}, not its outline": "{area}の輪郭ではなく外接矩形の範囲で数えています",
|
||||
"Search cancelled": "検索をキャンセルしました",
|
||||
"Sea Marks": "航路標識",
|
||||
"Ski Pistes": "スキーゲレンデ",
|
||||
"Ski Pistes": "スキーコース",
|
||||
"Fly to Tokyo": "東京へ移動して",
|
||||
"Show live flights": "ライブフライトを表示して",
|
||||
"Reset to globe view": "地球全体ビューに戻して",
|
||||
@@ -3029,5 +3042,68 @@
|
||||
"The provider rejected the API key. Check it in Provider Settings.": "プロバイダーがAPIキーを拒否しました。プロバイダー設定で確認してください。",
|
||||
"Too many chat requests. Try again in {seconds} s.": "チャットのリクエストが多すぎます。{seconds}秒後にもう一度お試しください。",
|
||||
"The provider refused access for this key. Check the key's project, region or permissions.": "プロバイダーがこのキーでのアクセスを拒否しました。キーのプロジェクト、リージョン、権限を確認してください。",
|
||||
"The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "プロバイダーアカウントのクレジットが不足しています。課金状況を確認するか、設定で別のAIプロバイダーを選んでください。"
|
||||
"The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "プロバイダーアカウントのクレジットが不足しています。課金状況を確認するか、設定で別のAIプロバイダーを選んでください。",
|
||||
"SIGHTING": "目撃情報",
|
||||
"Show {name} sighting": "{name}の目撃情報を表示",
|
||||
"seen {age} ago": "{age}前に目撃",
|
||||
"observed {age} ago": "{age}前に観察",
|
||||
"Be Whale Wise": "クジラに配慮しましょう",
|
||||
"Reported by {name} ({entity})": "報告元 {name}({entity})",
|
||||
"Reported by {name}": "報告元 {name}",
|
||||
"Salish Sea": "セイリッシュ海",
|
||||
"Birds": "鳥類",
|
||||
"Mammals": "哺乳類",
|
||||
"Reptiles": "爬虫類",
|
||||
"Amphibians": "両生類",
|
||||
"Ray-finned fishes": "条鰭類",
|
||||
"Insects": "昆虫",
|
||||
"Arachnids": "クモ類",
|
||||
"Plants": "植物",
|
||||
"Fungi": "菌類",
|
||||
"Other animals": "その他の動物",
|
||||
"Other or unknown": "その他・不明",
|
||||
"{label} {detail} — click to retry": "{label} {detail} — クリックして再試行",
|
||||
"Wildlife sightings source unavailable": "野生生物の目撃情報源を利用できません",
|
||||
"Show only community-confirmed (research-grade) sightings": "コミュニティが確認した研究グレードの目撃情報のみ表示",
|
||||
"No recent sightings in view": "表示範囲に最近の目撃情報はありません",
|
||||
"Acartia feed unavailable": "Acartiaのフィードを利用できません",
|
||||
"Show all taxa": "すべての分類群を表示",
|
||||
"BIRDS": "鳥類",
|
||||
"Show only birds": "鳥類のみ表示",
|
||||
"MAMMALS": "哺乳類",
|
||||
"Show only mammals": "哺乳類のみ表示",
|
||||
"WHALES": "クジラ類",
|
||||
"Show only whales and dolphins (7 days)": "クジラとイルカのみ表示(7日間)",
|
||||
"INSECTS": "昆虫",
|
||||
"Show only insects": "昆虫のみ表示",
|
||||
"PLANTS": "植物",
|
||||
"Show only plants": "植物のみ表示",
|
||||
"All rights reserved": "無断転載禁止",
|
||||
"Cetacean": "クジラ類",
|
||||
"Wildlife Sightings": "野生生物の目撃情報",
|
||||
"Exit cockpit to fly to a sighting": "目撃地点に移動するにはコックピットを終了",
|
||||
"Exit cockpit to fly to a sighting area": "目撃範囲に移動するにはコックピットを終了",
|
||||
"Wildlife": "野生生物",
|
||||
"Zoom in for local sightings": "拡大して地域の目撃情報を表示",
|
||||
"Loading photo...": "写真を読み込み中…",
|
||||
"Photo unavailable": "写真を表示できません",
|
||||
"Location obscured to a 0.2° cell": "位置は0.2°のセル内に秘匿されています",
|
||||
"Photo of {name}": "{name}の写真",
|
||||
"Acartia": "Acartia",
|
||||
"CC BY 4.0": "CC BY 4.0",
|
||||
"{count} sightings in view": "表示範囲に{count}件の目撃情報",
|
||||
"Close sighting": "目撃情報を閉じる",
|
||||
"Wildlife observed within 48 h (whales 7 d). Color and badge glyph show the taxon group. Sensitive species are shown only as 0.2° cells; Salish Sea marine mammal reports via Acartia are rounded to 0.01°. Not a census.": "過去48時間以内に観察された野生生物(クジラ類は7日間)。色とバッジのアイコンは分類群を表します。保護が必要な種は0.2°の区画のみ表示し、セイリッシュ海のAcartia海生哺乳類目撃情報は0.01°に丸めます。個体数調査ではありません。",
|
||||
"Open {name} on iNaturalist": "iNaturalistで{name}を開く",
|
||||
"Open {name} on Acartia": "Acartiaで{name}を開く",
|
||||
"iNaturalist": "iNaturalist",
|
||||
"iNaturalist contributors · licences per record": "iNaturalist の投稿者 · ライセンスは記録ごと",
|
||||
"iNaturalist contributors · Acartia {count} (Salish Sea) · licences per record": "iNaturalist の投稿者 · Acartia {count}(セイリッシュ海) · ライセンスは記録ごと",
|
||||
"{days}d": "{days}日",
|
||||
"All markers": "すべてのマーカー",
|
||||
"Show markers of every age": "日付にかかわらずすべてのマーカーを表示",
|
||||
"Maximum event age: {days}d; undated markers are kept": "イベントの最大経過日数:{days}日。日付のないマーカーは保持",
|
||||
"event age filter|All": "すべて",
|
||||
"Event age": "経過日数",
|
||||
"Event age: {days}d · undated kept": "経過日数:{days}日以内 · 日付なしは保持"
|
||||
}
|
||||
+100
-24
@@ -1101,8 +1101,16 @@
|
||||
"TomTom roads first; OpenStreetMap fills the rest, simulated": "Primeiro as vias do TomTom; o OpenStreetMap completa o resto com fluxo simulado",
|
||||
"TomTom roads first; OpenStreetMap fills the rest, simulated (needs a TomTom key)": "Primeiro as vias do TomTom; o OpenStreetMap completa o resto com fluxo simulado (precisa de uma chave do TomTom)",
|
||||
"Hybrid": "Híbrido",
|
||||
"Lines": "Linhas",
|
||||
"Dots": "Pontos",
|
||||
"Road lines colored by live flow: green to red": "Vias coloridas pelo fluxo ao vivo: do verde ao vermelho",
|
||||
"Moving dots colored by live flow": "Pontos em movimento coloridos pelo fluxo ao vivo",
|
||||
"Moving dots": "Pontos em movimento",
|
||||
"TomTom key unavailable": "Chave do TomTom indisponível",
|
||||
"TomTom daily budget reached": "Limite diário do TomTom atingido",
|
||||
"Monthly traffic allowance used": "Cota mensal de trânsito esgotada",
|
||||
"Traffic rate limit reached": "Limite de solicitações de trânsito atingido",
|
||||
"Traffic service unavailable": "Serviço de trânsito indisponível",
|
||||
"TomTom upstream unreachable": "Servidor do TomTom inacessível",
|
||||
"TomTom flow unavailable": "Fluxo do TomTom indisponível",
|
||||
"SIMULATED — {reason}": "SIMULADO — {reason}",
|
||||
@@ -1660,6 +1668,9 @@
|
||||
"REGIONAL NEWS": "NOTÍCIAS DA ÁREA",
|
||||
"{label} · {radius} AIR/SEA WINDOW": "{label} · AR/MAR {radius}",
|
||||
"{scope} · INSTALLATIONS {coverage}": "{scope} · INSTALAÇÕES: {coverage}",
|
||||
"MILITARY FEED OFF · PARTIAL PICTURE": "FEED MILITAR DESLIGADO · QUADRO PARCIAL",
|
||||
"FLIGHTS FEED OFF · PARTIAL PICTURE": "FEED DE VOOS DESLIGADO · QUADRO PARCIAL",
|
||||
"NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR": "AERONAVES PRÓXIMAS INCOMPLETAS · NÃO GARANTE SEGURANÇA",
|
||||
"CONTEXT STANDBY": "CONTEXTO EM ESPERA",
|
||||
"ENABLE GLOBAL CONTEXT FOR PROXIMITY PINGS": "ATIVE O CONTEXTO GLOBAL PARA PINGS DE PROXIMIDADE",
|
||||
"CONTACT LOST · LAST KNOWN READOUT · NOT AN ALL-CLEAR": "CONTATO PERDIDO · ÚLTIMA LEITURA CONHECIDA · NÃO GARANTE SEGURANÇA",
|
||||
@@ -1736,7 +1747,7 @@
|
||||
"Restore real aircraft": "Restaurar a aeronave real",
|
||||
"Reclassify as TR-3B": "Reclassificar como TR-3B",
|
||||
"TRACK ACQUIRED": "RASTRO ADQUIRIDO",
|
||||
"{label} · COURSE {heading}°": "{label} · CURSO {heading}°",
|
||||
"{label} · COURSE {heading}°": "{label} · RUMO {heading}°",
|
||||
"Context could not restore every layer; try again": "O contexto não conseguiu restaurar todas as camadas; tente novamente",
|
||||
"No selected data layers": "Nenhuma camada de dados selecionada",
|
||||
"{count} data layers could not be cleared": {
|
||||
@@ -2801,6 +2812,8 @@
|
||||
"AVAILABLE MISSIONS": "MISSÕES DISPONÍVEIS",
|
||||
"SELECT A MISSION TO INSPECT": "SELECIONE UMA MISSÃO PARA INSPECIONAR",
|
||||
"LOADING 30-DAY MISSION INDEX": "CARREGANDO ÍNDICE DE MISSÕES DE 30 DIAS",
|
||||
"Space Missions unavailable: the mission index could not load. Try again later.": "Missões espaciais indisponíveis: não foi possível carregar o índice de missões. Tente novamente mais tarde.",
|
||||
"30-DAY MISSION INDEX UNAVAILABLE": "ÍNDICE DE MISSÕES DE 30 DIAS INDISPONÍVEL",
|
||||
"TAB PREVIEWS · ENTER / SPACE SELECTS": "TAB MOSTRA PRÉVIA · ENTER / SPACE SELECIONA",
|
||||
"Internet radio and local SDR": "Rádio pela internet e SDR local",
|
||||
"STATION TAG": "TAG DA ESTAÇÃO",
|
||||
@@ -2981,14 +2994,14 @@
|
||||
"Up to {upper}": "Até {upper}",
|
||||
"From {lower}": "A partir de {lower}",
|
||||
"Informational only · not for flight planning": "Apenas informativo · não usar para planejamento de voo",
|
||||
"Unnamed zone": "Zona sem nome",
|
||||
"Unnamed zone": "Sem nome",
|
||||
"Restricted airspace source unavailable": "Fonte de espaço aéreo restrito indisponível",
|
||||
"Prohibited (P)": "Área proibida (P)",
|
||||
"Restricted (R)": "Área restrita (R)",
|
||||
"FAA prohibited and restricted special-use airspace only, not the UAS Facility Map or UAS flight restrictions. Informational only — not a substitute for official UAS flight-planning tools (B4UFLY / LAANC).": "Apenas áreas proibidas e restritas da FAA (espaço aéreo de uso especial), sem o UAS Facility Map ou as restrições de voo de UAS. Apenas informativo — não substitui as ferramentas oficiais de planejamento de voo de UAS (B4UFLY / LAANC).",
|
||||
"FAA prohibited and restricted special-use airspace only, not the UAS Facility Map or UAS flight restrictions. Informational only — not a substitute for official UAS flight-planning tools (B4UFLY / LAANC).": "Somente o espaço aéreo de uso especial proibido e restrito da FAA; não inclui o UAS Facility Map nem as restrições de voo de UAS. Apenas informativo — não substitui as ferramentas oficiais de planejamento de voo de UAS (B4UFLY / LAANC).",
|
||||
"Prohibited": "Área proibida",
|
||||
"Restricted": "Área restrita",
|
||||
"Prohibited & Restricted Airspace": "Áreas proibidas e restritas",
|
||||
"Prohibited & Restricted Airspace": "Espaço aéreo proibido e restrito",
|
||||
"scene|PLAY": "INICIAR",
|
||||
"AUTHORING": "CRIAÇÃO",
|
||||
"Create and edit scenes": "Criar e editar cenas",
|
||||
@@ -3028,7 +3041,7 @@
|
||||
"Minimum fire radiative power, megawatts": "Potência radiativa mínima do fogo, em megawatts",
|
||||
"fire intensity filter|All": "Todas",
|
||||
"Detection confidence": "Confiança da detecção",
|
||||
"{shown} of {total} shown": "{shown} de {total} exibidas",
|
||||
"{shown} of {total} shown": "Detecções exibidas: {shown} de {total}",
|
||||
"Name of {name}": "Nome de {name}",
|
||||
"Change the colour of {name}": "Mudar a cor de {name}",
|
||||
"Colour of {name}": "Cor de {name}",
|
||||
@@ -3047,18 +3060,18 @@
|
||||
"Which {place}?": "Qual {place}?",
|
||||
"Which place?": "Qual lugar?",
|
||||
"Not drawn: {parts}": "Não desenhado: {parts}",
|
||||
"approximate": "aproximado",
|
||||
"approximate": "aproximada",
|
||||
"{pct}% cloud": "{pct}% de nuvens",
|
||||
"Over {area}": "Área: {area}",
|
||||
"{count} of {total} acquisitions match": {
|
||||
"many": "Aquisições correspondentes: {count} de {total}",
|
||||
"one": "Aquisições correspondentes: {count} de {total}",
|
||||
"other": "Aquisições correspondentes: {count} de {total}"
|
||||
"one": "Imagens correspondentes: {count} de {total}",
|
||||
"many": "Imagens correspondentes: {count} de {total}",
|
||||
"other": "Imagens correspondentes: {count} de {total}"
|
||||
},
|
||||
"{n} acquisitions searched": {
|
||||
"many": "Aquisições pesquisadas: {n}",
|
||||
"one": "Aquisições pesquisadas: {n}",
|
||||
"other": "Aquisições pesquisadas: {n}"
|
||||
"one": "Imagens pesquisadas: {n}",
|
||||
"many": "Imagens pesquisadas: {n}",
|
||||
"other": "Imagens pesquisadas: {n}"
|
||||
},
|
||||
"No matching imagery": "Nenhuma imagem correspondente",
|
||||
"Unknown number of {kind}": "{kind}: número desconhecido",
|
||||
@@ -3070,23 +3083,23 @@
|
||||
"Area around landmark": "Área ao redor de um ponto de referência",
|
||||
"Outline {place}": "Traçar contorno de {place}",
|
||||
"Outline area": "Traçar contorno da área",
|
||||
"Find {place}": "Encontrar {place}",
|
||||
"Find area": "Encontrar área",
|
||||
"Find recent imagery": "Encontrar imagens recentes",
|
||||
"Find {what}": "Encontrar {what}",
|
||||
"Find places": "Encontrar lugares",
|
||||
"Find {place}": "Buscar {place}",
|
||||
"Find area": "Buscar área",
|
||||
"Find recent imagery": "Buscar imagens recentes",
|
||||
"Find {what}": "Buscar {what}",
|
||||
"Find places": "Buscar lugares",
|
||||
"Drawing outline": "Desenhando contorno",
|
||||
"Searching imagery catalog": "Buscando no catálogo de imagens",
|
||||
"Loading imagery": "Carregando imagens",
|
||||
"Searching OpenStreetMap": "Buscando no OpenStreetMap",
|
||||
"voice area|Area": "Área",
|
||||
"buffer sized to the place": "zona de margem do tamanho do local",
|
||||
"enclosing mapped area": "área mapeada envolvente",
|
||||
"buffer sized to the place": "círculo do tamanho aproximado do lugar",
|
||||
"enclosing mapped area": "área mapeada que contém o ponto de referência",
|
||||
"the map was cleared while looking it up": "o mapa foi limpo durante a busca",
|
||||
"OpenStreetMap coverage varies by place": "A cobertura do OpenStreetMap varia de lugar para lugar",
|
||||
"the catalog covers the last 30 days": "o catálogo cobre os últimos 30 dias",
|
||||
"there are more": "há mais",
|
||||
"{radius} m around the landmark": "{radius} m ao redor do local",
|
||||
"{radius} m around the landmark": "{radius} m ao redor do ponto de referência",
|
||||
"{count} parts": {
|
||||
"many": "Partes: {count}",
|
||||
"one": "Partes: {count}",
|
||||
@@ -3102,10 +3115,10 @@
|
||||
"one": "Duplicatas mescladas: {count}",
|
||||
"other": "Duplicatas mescladas: {count}"
|
||||
},
|
||||
"listed the first {limit}": "listados os primeiros {limit}",
|
||||
"counted over the bounding box of {area}, not its outline": "contado na caixa delimitadora ({area}), não no contorno",
|
||||
"listed the first {limit}": "lista limitada a {limit}",
|
||||
"counted over the bounding box of {area}, not its outline": "contagem na caixa delimitadora de {area}, não no contorno",
|
||||
"Search cancelled": "Busca cancelada",
|
||||
"Sea Marks": "Sinais marítimos",
|
||||
"Sea Marks": "Sinalização náutica",
|
||||
"Ski Pistes": "Pistas de esqui",
|
||||
"Fly to Tokyo": "Voe para Tóquio",
|
||||
"Show live flights": "Mostre os voos ao vivo",
|
||||
@@ -3179,5 +3192,68 @@
|
||||
"The provider rejected the API key. Check it in Provider Settings.": "O provedor recusou a chave de API. Verifique-a em \"Configurações de provedores\".",
|
||||
"Too many chat requests. Try again in {seconds} s.": "Chat: limite de requisições atingido. Tente novamente em {seconds} s.",
|
||||
"The provider refused access for this key. Check the key's project, region or permissions.": "O provedor negou o acesso para esta chave. Verifique o projeto, a região ou as permissões da chave.",
|
||||
"The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "A conta do provedor está sem crédito. Verifique o faturamento ou escolha outro provedor de IA em \"Configurações\"."
|
||||
"The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "A conta do provedor está sem crédito. Verifique o faturamento ou escolha outro provedor de IA em \"Configurações\".",
|
||||
"SIGHTING": "AVISTAMENTO",
|
||||
"Show {name} sighting": "Mostrar avistamento de {name}",
|
||||
"seen {age} ago": "visto há {age}",
|
||||
"observed {age} ago": "observado há {age}",
|
||||
"Be Whale Wise": "Respeite os cetáceos",
|
||||
"Reported by {name} ({entity})": "Relatado por {name} ({entity})",
|
||||
"Reported by {name}": "Relatado por {name}",
|
||||
"Salish Sea": "Mar de Salish",
|
||||
"Birds": "Aves",
|
||||
"Mammals": "Mamíferos",
|
||||
"Reptiles": "Répteis",
|
||||
"Amphibians": "Anfíbios",
|
||||
"Ray-finned fishes": "Peixes de nadadeiras raiadas",
|
||||
"Insects": "Insetos",
|
||||
"Arachnids": "Aracnídeos",
|
||||
"Plants": "Plantas",
|
||||
"Fungi": "Fungos",
|
||||
"Other animals": "Outros animais",
|
||||
"Other or unknown": "Outros ou desconhecidos",
|
||||
"{label} {detail} — click to retry": "{label} {detail} — clique para tentar novamente",
|
||||
"Wildlife sightings source unavailable": "Fonte de avistamentos indisponível",
|
||||
"Show only community-confirmed (research-grade) sightings": "Mostrar apenas observações confirmadas pela comunidade (grau de pesquisa)",
|
||||
"No recent sightings in view": "Nenhum avistamento recente na vista",
|
||||
"Acartia feed unavailable": "Feed Acartia indisponível",
|
||||
"Show all taxa": "Mostrar todos os grupos taxonômicos",
|
||||
"BIRDS": "AVES",
|
||||
"Show only birds": "Mostrar apenas aves",
|
||||
"MAMMALS": "MAMÍFEROS",
|
||||
"Show only mammals": "Mostrar apenas mamíferos",
|
||||
"WHALES": "CETÁCEOS",
|
||||
"Show only whales and dolphins (7 days)": "Mostrar apenas baleias e golfinhos (7 dias)",
|
||||
"INSECTS": "INSETOS",
|
||||
"Show only insects": "Mostrar apenas insetos",
|
||||
"PLANTS": "PLANTAS",
|
||||
"Show only plants": "Mostrar apenas plantas",
|
||||
"All rights reserved": "Todos os direitos reservados",
|
||||
"Cetacean": "Cetáceo",
|
||||
"Wildlife Sightings": "Avistamentos de vida selvagem",
|
||||
"Exit cockpit to fly to a sighting": "Saia da cabine para voar até um avistamento",
|
||||
"Exit cockpit to fly to a sighting area": "Saia da cabine para voar até uma área de avistamento",
|
||||
"Wildlife": "Vida selvagem",
|
||||
"Zoom in for local sightings": "Amplie para ver avistamentos locais",
|
||||
"Loading photo...": "Carregando foto…",
|
||||
"Photo unavailable": "Foto indisponível",
|
||||
"Location obscured to a 0.2° cell": "Localização ocultada em uma célula de 0,2°",
|
||||
"Photo of {name}": "Foto de {name}",
|
||||
"Acartia": "Acartia",
|
||||
"CC BY 4.0": "CC BY 4.0",
|
||||
"{count} sightings in view": "{count} avistamentos na vista",
|
||||
"Close sighting": "Fechar avistamento",
|
||||
"Wildlife observed within 48 h (whales 7 d). Color and badge glyph show the taxon group. Sensitive species are shown only as 0.2° cells; Salish Sea marine mammal reports via Acartia are rounded to 0.01°. Not a census.": "Fauna observada nas últimas 48 h (cetáceos 7 dias). A cor e o símbolo do emblema indicam o grupo taxonômico. Espécies sensíveis aparecem apenas em células de 0,2°; os relatos de mamíferos marinhos da Acartia no mar de Salish são arredondados para 0,01°. Não é um censo.",
|
||||
"Open {name} on iNaturalist": "Abrir {name} no iNaturalist",
|
||||
"Open {name} on Acartia": "Abrir {name} no Acartia",
|
||||
"iNaturalist": "iNaturalist",
|
||||
"iNaturalist contributors · licences per record": "Colaboradores do iNaturalist · licenças por registro",
|
||||
"iNaturalist contributors · Acartia {count} (Salish Sea) · licences per record": "Colaboradores do iNaturalist · Acartia {count} (mar de Salish) · licenças por registro",
|
||||
"{days}d": "{days}d",
|
||||
"All markers": "Todos os marcadores",
|
||||
"Show markers of every age": "Mostrar marcadores de qualquer idade",
|
||||
"Maximum event age: {days}d; undated markers are kept": "Idade máxima do evento: {days} dias; marcadores sem data mantidos",
|
||||
"event age filter|All": "Todos",
|
||||
"Event age": "Idade do evento",
|
||||
"Event age: {days}d · undated kept": "Idade do evento: {days} dias · sem data mantidos"
|
||||
}
|
||||
+98
-22
@@ -128,7 +128,7 @@
|
||||
"SRC {value}": "ИСТ. {value}",
|
||||
"{value} CONTEXT": "КОНТЕКСТ {value}",
|
||||
"Focus fire detection {title}, {details}": "Фокус на очаге пожара {title}, {details}",
|
||||
"{value} conf": "увер.: {value}",
|
||||
"{value} conf": "дост.: {value}",
|
||||
"{value} ago": "{value} назад",
|
||||
"sensor n/a": "сенсор н/д",
|
||||
"FIRE · {frp} MW": "ПОЖАР · {frp} МВт",
|
||||
@@ -458,7 +458,7 @@
|
||||
"SOURCE STATUS UNAVAILABLE": "СТАТУС ИСТОЧНИКА НЕДОСТУПЕН",
|
||||
"Select flight {title}": "Выбрать рейс {title}",
|
||||
"TRACK ACQUIRED": "ЦЕЛЬ ЗАХВАЧЕНА",
|
||||
"{label} · COURSE {heading}°": "{label} · КУРС {heading}°",
|
||||
"{label} · COURSE {heading}°": "{label} · ПУТЕВОЙ УГОЛ {heading}°",
|
||||
"Restore real aircraft": "Вернуть реальный самолёт",
|
||||
"Reclassify as TR-3B": "Переклассифицировать как TR-3B",
|
||||
"No selected data layers": "Нет выбранных слоёв данных",
|
||||
@@ -722,6 +722,8 @@
|
||||
"Contact Context actions": "Действия контекста контакта",
|
||||
"Available Space Missions": "Доступные космические миссии",
|
||||
"LOADING 30-DAY MISSION INDEX": "ЗАГРУЗКА ИНДЕКСА МИССИЙ ЗА 30 ДНЕЙ",
|
||||
"Space Missions unavailable: the mission index could not load. Try again later.": "Космические миссии недоступны: не удалось загрузить список миссий. Повторите попытку позже.",
|
||||
"30-DAY MISSION INDEX UNAVAILABLE": "ИНДЕКС МИССИЙ ЗА 30 ДНЕЙ НЕДОСТУПЕН",
|
||||
"Radio playback": "Воспроизведение радио",
|
||||
"Radio volume": "Громкость радио",
|
||||
"STATION TAG": "ТЕГ СТАНЦИИ",
|
||||
@@ -1953,6 +1955,9 @@
|
||||
"N/A": "Н/Д",
|
||||
"{label} · {radius} AIR/SEA WINDOW": "{label} · ОКНО ВОЗДУХ/МОРЕ {radius}",
|
||||
"{scope} · INSTALLATIONS {coverage}": "{scope} · ОБЪЕКТЫ: {coverage}",
|
||||
"MILITARY FEED OFF · PARTIAL PICTURE": "ВОЕННЫЙ ПОТОК ВЫКЛ · КАРТИНА НЕПОЛНАЯ",
|
||||
"FLIGHTS FEED OFF · PARTIAL PICTURE": "ПОТОК РЕЙСОВ ВЫКЛ · КАРТИНА НЕПОЛНАЯ",
|
||||
"NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR": "БЛИЖНИЕ ВОЗДУШНЫЕ СУДА: ДАННЫЕ НЕПОЛНЫЕ · ОТСУТСТВИЕ УГРОЗ НЕ ПОДТВЕРЖДЕНО",
|
||||
"{source} · LOCATION QUERY · RECENT": "{source} · ЗАПРОС ПО МЕСТУ · НЕДАВНИЕ",
|
||||
"ft": "фт",
|
||||
"kts": "уз",
|
||||
@@ -2310,6 +2315,9 @@
|
||||
"SIMULATED — {reason}": "СИМУЛЯЦИЯ — {reason}",
|
||||
"TomTom key unavailable": "Ключ TomTom недоступен",
|
||||
"TomTom daily budget reached": "Исчерпан дневной лимит TomTom",
|
||||
"Monthly traffic allowance used": "Месячный лимит данных о трафике исчерпан",
|
||||
"Traffic rate limit reached": "Достигнут лимит запросов данных о трафике",
|
||||
"Traffic service unavailable": "Сервис данных о трафике недоступен",
|
||||
"TomTom upstream unreachable": "Сервер TomTom не отвечает",
|
||||
"TomTom flow unavailable": "Поток TomTom недоступен",
|
||||
"API key rejected — check AISSTREAM_API_KEY": "Ключ API отклонён — проверьте AISSTREAM_API_KEY",
|
||||
@@ -2749,6 +2757,11 @@
|
||||
"Zoom in for vector tile coverage": "Приблизьте карту, чтобы загрузить векторные тайлы",
|
||||
"Camera coverage unavailable": "Покрытие камер недоступно",
|
||||
"Hybrid": "Гибрид",
|
||||
"Lines": "Линии",
|
||||
"Dots": "Точки",
|
||||
"Road lines colored by live flow: green to red": "Линии дорог по потоку онлайн: от зелёного к красному",
|
||||
"Moving dots colored by live flow": "Движущиеся точки, окрашенные по потоку онлайн",
|
||||
"Moving dots": "Движущиеся точки",
|
||||
"TomTom roads only, each with its live flow": "Только дороги TomTom, у каждой — свой поток онлайн",
|
||||
"TomTom roads only, each with its live flow (needs a TomTom key)": "Только дороги TomTom, у каждой — свой поток онлайн (нужен ключ TomTom)",
|
||||
"OpenStreetMap roads, with TomTom flow matched when available": "Дороги OpenStreetMap; поток TomTom сопоставляется, где он есть",
|
||||
@@ -3056,27 +3069,27 @@
|
||||
"fire confidence filter|All": "Все",
|
||||
"fire confidence filter|Nominal+": "Средняя+",
|
||||
"fire confidence filter|High": "Высокая",
|
||||
"Every detection — many of the strongest fires are rated low confidence": "Все детекции — у многих сильнейших пожаров достоверность низкая",
|
||||
"Nominal and high confidence detections": "Детекции средней и высокой достоверности",
|
||||
"High confidence detections only": "Только детекции высокой достоверности",
|
||||
"Every detection — many of the strongest fires are rated low confidence": "Все очаги — многим сильнейшим пожарам присвоена низкая достоверность",
|
||||
"Nominal and high confidence detections": "Очаги средней и высокой достоверности",
|
||||
"High confidence detections only": "Только очаги высокой достоверности",
|
||||
"Fire intensity (FRP)": "Интенсивность (FRP)",
|
||||
"≥ {value} MW": "≥ {value} МВт",
|
||||
"Confidence": "Достоверность",
|
||||
"Minimum fire radiative power, megawatts": "Минимальная мощность излучения пожара, мегаватты",
|
||||
"fire intensity filter|All": "Все",
|
||||
"Detection confidence": "Достоверность детекции",
|
||||
"{shown} of {total} shown": "показано {shown} из {total}",
|
||||
"{lower} to {upper}": "от {lower} до {upper}",
|
||||
"Detection confidence": "Достоверность обнаружения",
|
||||
"{shown} of {total} shown": "Показано {shown} из {total}",
|
||||
"{lower} to {upper}": "От {lower} до {upper}",
|
||||
"Up to {upper}": "До {upper}",
|
||||
"From {lower}": "От {lower}",
|
||||
"Informational only · not for flight planning": "Только для информации · не для планирования полётов",
|
||||
"Unnamed zone": "Зона без названия",
|
||||
"Restricted airspace source unavailable": "Источник данных об ограниченном воздушном пространстве недоступен",
|
||||
"Unnamed zone": "Без названия",
|
||||
"Restricted airspace source unavailable": "Источник данных о зонах ограничения полётов недоступен",
|
||||
"Prohibited (P)": "Запретная зона (P)",
|
||||
"Restricted (R)": "Зона ограничения (R)",
|
||||
"Restricted (R)": "Зона ограничения полётов (R)",
|
||||
"FAA prohibited and restricted special-use airspace only, not the UAS Facility Map or UAS flight restrictions. Informational only — not a substitute for official UAS flight-planning tools (B4UFLY / LAANC).": "Только запретные зоны и зоны ограничения полётов FAA (воздушное пространство особого использования), без UAS Facility Map и ограничений полётов БАС. Только для информации — не заменяет официальные инструменты планирования полётов БАС (B4UFLY / LAANC).",
|
||||
"Prohibited": "Запретная зона",
|
||||
"Restricted": "Зона ограничения",
|
||||
"Restricted": "Зона ограничения полётов",
|
||||
"Prohibited & Restricted Airspace": "Запретные зоны и зоны ограничения",
|
||||
"routing busy — too many routes at once": "сервис маршрутов занят: слишком много маршрутов одновременно",
|
||||
"route proxy error": "ошибка прокси маршрутов",
|
||||
@@ -3110,13 +3123,13 @@
|
||||
"Directions, drawing and your own files": "Маршруты, рисование и ваши файлы",
|
||||
"Voice place search (needs an Overpass server)": "Голосовой поиск мест (нужен сервер Overpass)",
|
||||
"OSM Places": "Места OSM",
|
||||
"state or province": "штат или провинция",
|
||||
"state or province": "штат, провинция или область",
|
||||
"county or district": "округ или район",
|
||||
"neighbourhood": "район города",
|
||||
"natural region": "природный регион",
|
||||
"Which {place}?": "Какой из вариантов «{place}»?",
|
||||
"Which place?": "Какое место?",
|
||||
"Not drawn: {parts}": "Не нарисовано: {parts}",
|
||||
"Not drawn: {parts}": "Контур не нарисован: {parts}",
|
||||
"approximate": "приблизительно",
|
||||
"{pct}% cloud": "Облачность {pct}%",
|
||||
"Over {area}": "Область: {area}",
|
||||
@@ -3137,7 +3150,7 @@
|
||||
"About {count} {kind}": "{kind}: около {count}",
|
||||
"At least {count} {kind}": "{kind}: не менее {count}",
|
||||
"{count} {kind}": "{kind}: {count}",
|
||||
"In {area}": "Территория: {area}",
|
||||
"In {area}": "Область: {area}",
|
||||
"Area around {place}": "Окрестности: {place}",
|
||||
"Area around landmark": "Окрестности ориентира",
|
||||
"Outline {place}": "Контур: {place}",
|
||||
@@ -3153,12 +3166,12 @@
|
||||
"Searching OpenStreetMap": "Поиск в OpenStreetMap",
|
||||
"voice area|Area": "Область",
|
||||
"buffer sized to the place": "буферная зона по размеру места",
|
||||
"enclosing mapped area": "охватывающая область на карте",
|
||||
"enclosing mapped area": "область на карте, включающая ориентир",
|
||||
"the map was cleared while looking it up": "карта была очищена во время поиска",
|
||||
"OpenStreetMap coverage varies by place": "Покрытие OpenStreetMap различается в разных местах",
|
||||
"the catalog covers the last 30 days": "каталог охватывает последние 30 дней",
|
||||
"there are more": "есть ещё",
|
||||
"{radius} m around the landmark": "{radius} м вокруг объекта",
|
||||
"there are more": "есть и другие",
|
||||
"{radius} m around the landmark": "{radius} м вокруг ориентира",
|
||||
"{count} parts": {
|
||||
"few": "Частей: {count}",
|
||||
"many": "Частей: {count}",
|
||||
@@ -3177,11 +3190,11 @@
|
||||
"one": "Объединено дублей: {count}",
|
||||
"other": "Объединено дублей: {count}"
|
||||
},
|
||||
"listed the first {limit}": "показаны первые {limit}",
|
||||
"listed the first {limit}": "показаны первые результаты: {limit}",
|
||||
"counted over the bounding box of {area}, not its outline": "подсчитано по ограничивающему прямоугольнику ({area}), а не по контуру",
|
||||
"Search cancelled": "Поиск отменён",
|
||||
"Sea Marks": "Морские знаки",
|
||||
"Ski Pistes": "Горнолыжные трассы",
|
||||
"Sea Marks": "Навигационные знаки",
|
||||
"Ski Pistes": "Лыжные трассы",
|
||||
"Fly to Tokyo": "Перелети в Токио",
|
||||
"Show live flights": "Покажи рейсы в реальном времени",
|
||||
"Reset to globe view": "Вернись к виду всего глобуса",
|
||||
@@ -3254,5 +3267,68 @@
|
||||
"The provider rejected the API key. Check it in Provider Settings.": "Провайдер отклонил ключ API. Проверьте его в «Настройках провайдеров».",
|
||||
"Too many chat requests. Try again in {seconds} s.": "Слишком много запросов в чате. Повторите попытку через {seconds} с.",
|
||||
"The provider refused access for this key. Check the key's project, region or permissions.": "Провайдер отказал этому ключу в доступе. Проверьте проект, регион или разрешения ключа.",
|
||||
"The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "На вашем счёте у провайдера закончились средства. Проверьте оплату или выберите другого провайдера ИИ в настройках."
|
||||
"The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "На вашем счёте у провайдера закончились средства. Проверьте оплату или выберите другого провайдера ИИ в настройках.",
|
||||
"SIGHTING": "НАБЛЮДЕНИЕ",
|
||||
"Show {name} sighting": "Показать наблюдение: {name}",
|
||||
"seen {age} ago": "замечено {age} назад",
|
||||
"observed {age} ago": "наблюдалось {age} назад",
|
||||
"Be Whale Wise": "Наблюдайте китов бережно",
|
||||
"Reported by {name} ({entity})": "Сообщил: {name} ({entity})",
|
||||
"Reported by {name}": "Сообщил: {name}",
|
||||
"Salish Sea": "Селишское море",
|
||||
"Birds": "Птицы",
|
||||
"Mammals": "Млекопитающие",
|
||||
"Reptiles": "Пресмыкающиеся",
|
||||
"Amphibians": "Земноводные",
|
||||
"Ray-finned fishes": "Лучепёрые рыбы",
|
||||
"Insects": "Насекомые",
|
||||
"Arachnids": "Паукообразные",
|
||||
"Plants": "Растения",
|
||||
"Fungi": "Грибы",
|
||||
"Other animals": "Другие животные",
|
||||
"Other or unknown": "Прочие или неизвестные",
|
||||
"{label} {detail} — click to retry": "{label} {detail} — нажмите, чтобы повторить",
|
||||
"Wildlife sightings source unavailable": "Источник наблюдений живой природы недоступен",
|
||||
"Show only community-confirmed (research-grade) sightings": "Показать только подтверждённые сообществом наблюдения исследовательского уровня",
|
||||
"No recent sightings in view": "Нет недавних наблюдений в области обзора",
|
||||
"Acartia feed unavailable": "Лента Acartia недоступна",
|
||||
"Show all taxa": "Показать все группы организмов",
|
||||
"BIRDS": "ПТИЦЫ",
|
||||
"Show only birds": "Показать только птиц",
|
||||
"MAMMALS": "МЛЕКОПИТАЮЩИЕ",
|
||||
"Show only mammals": "Показать только млекопитающих",
|
||||
"WHALES": "КИТООБРАЗНЫЕ",
|
||||
"Show only whales and dolphins (7 days)": "Показать только китов и дельфинов (7 дней)",
|
||||
"INSECTS": "НАСЕКОМЫЕ",
|
||||
"Show only insects": "Показать только насекомых",
|
||||
"PLANTS": "РАСТЕНИЯ",
|
||||
"Show only plants": "Показать только растения",
|
||||
"All rights reserved": "Все права защищены",
|
||||
"Cetacean": "Китообразное",
|
||||
"Wildlife Sightings": "Наблюдения живой природы",
|
||||
"Exit cockpit to fly to a sighting": "Выйдите из кабины, чтобы перейти к наблюдению",
|
||||
"Exit cockpit to fly to a sighting area": "Выйдите из кабины, чтобы перейти к области наблюдения",
|
||||
"Wildlife": "Живая природа",
|
||||
"Zoom in for local sightings": "Приблизьте для местных наблюдений",
|
||||
"Loading photo...": "Загрузка фото…",
|
||||
"Photo unavailable": "Фото недоступно",
|
||||
"Location obscured to a 0.2° cell": "Местоположение скрыто в ячейке 0,2°",
|
||||
"Photo of {name}": "Фото {name}",
|
||||
"Acartia": "Acartia",
|
||||
"CC BY 4.0": "CC BY 4.0",
|
||||
"{count} sightings in view": "{count} наблюдений в области просмотра",
|
||||
"Close sighting": "Закрыть наблюдение",
|
||||
"Wildlife observed within 48 h (whales 7 d). Color and badge glyph show the taxon group. Sensitive species are shown only as 0.2° cells; Salish Sea marine mammal reports via Acartia are rounded to 0.01°. Not a census.": "Наблюдения дикой природы за 48 ч (китообразные — 7 дней). Цвет и символ значка обозначают таксономическую группу. Уязвимые виды показаны только ячейками 0,2°; сообщения Acartia о морских млекопитающих моря Селиш округлены до 0,01°. Не перепись.",
|
||||
"Open {name} on iNaturalist": "Открыть {name} в iNaturalist",
|
||||
"Open {name} on Acartia": "Открыть {name} в Acartia",
|
||||
"iNaturalist": "iNaturalist",
|
||||
"iNaturalist contributors · licences per record": "Участники iNaturalist · лицензия у каждой записи",
|
||||
"iNaturalist contributors · Acartia {count} (Salish Sea) · licences per record": "Участники iNaturalist · Acartia {count} (море Сэлиш) · лицензия у каждой записи",
|
||||
"{days}d": "{days}д",
|
||||
"All markers": "Все метки",
|
||||
"Show markers of every age": "Показать метки любого возраста",
|
||||
"Maximum event age: {days}d; undated markers are kept": "Максимальный возраст события: {days} дн.; метки без даты сохранены",
|
||||
"event age filter|All": "Все",
|
||||
"Event age": "Возраст события",
|
||||
"Event age: {days}d · undated kept": "Возраст события: {days} дн. · метки без даты сохранены"
|
||||
}
|
||||
+99
-23
@@ -1118,8 +1118,16 @@
|
||||
"TomTom roads first; OpenStreetMap fills the rest, simulated": "Спершу дороги TomTom; решту доповнює OpenStreetMap з імітацією потоку",
|
||||
"TomTom roads first; OpenStreetMap fills the rest, simulated (needs a TomTom key)": "Спершу дороги TomTom; решту доповнює OpenStreetMap з імітацією потоку (потрібен ключ TomTom)",
|
||||
"Hybrid": "Гібрид",
|
||||
"Lines": "Лінії",
|
||||
"Dots": "Точки",
|
||||
"Road lines colored by live flow: green to red": "Лінії доріг за потоком онлайн: від зеленого до червоного",
|
||||
"Moving dots colored by live flow": "Рухомі точки, забарвлені за потоком онлайн",
|
||||
"Moving dots": "Рухомі точки",
|
||||
"TomTom key unavailable": "Ключ TomTom недоступний",
|
||||
"TomTom daily budget reached": "Вичерпано денний ліміт TomTom",
|
||||
"Monthly traffic allowance used": "Місячний ліміт даних про трафік вичерпано",
|
||||
"Traffic rate limit reached": "Досягнуто ліміту запитів даних про трафік",
|
||||
"Traffic service unavailable": "Сервіс даних про трафік недоступний",
|
||||
"TomTom upstream unreachable": "Сервер TomTom не відповідає",
|
||||
"TomTom flow unavailable": "Потік TomTom недоступний",
|
||||
"SIMULATED — {reason}": "ІМІТАЦІЯ — {reason}",
|
||||
@@ -1683,6 +1691,9 @@
|
||||
"REGIONAL NEWS": "РЕГІОНАЛЬНІ НОВИНИ",
|
||||
"{label} · {radius} AIR/SEA WINDOW": "{label} · РЕЙСИ / СУДНА · РАДІУС {radius}",
|
||||
"{scope} · INSTALLATIONS {coverage}": "{scope} · ОБʼЄКТИ: {coverage}",
|
||||
"MILITARY FEED OFF · PARTIAL PICTURE": "ВІЙСЬКОВИЙ ПОТІК ВИМК. · КАРТИНА НЕПОВНА",
|
||||
"FLIGHTS FEED OFF · PARTIAL PICTURE": "ПОТІК РЕЙСІВ ВИМК. · КАРТИНА НЕПОВНА",
|
||||
"NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR": "БЛИЗЬКІ ПОВІТРЯНІ СУДНА: ДАНІ НЕПОВНІ · НЕ ОЗНАЧАЄ «УСЕ ЧИСТО»",
|
||||
"CONTEXT STANDBY": "КОНТЕКСТ: ОЧІКУВАННЯ",
|
||||
"ENABLE GLOBAL CONTEXT FOR PROXIMITY PINGS": "УВІМКНІТЬ ГЛОБАЛЬНИЙ КОНТЕКСТ, ЩОБ БАЧИТИ КОНТАКТИ ПОБЛИЗУ",
|
||||
"CONTACT LOST · LAST KNOWN READOUT · NOT AN ALL-CLEAR": "КОНТАКТ ВТРАЧЕНО · ОСТАННІ ВІДОМІ ПОКАЗНИКИ · НЕ ОЗНАЧАЄ «УСЕ ЧИСТО»",
|
||||
@@ -1761,7 +1772,7 @@
|
||||
"Restore real aircraft": "Повернути справжній літак",
|
||||
"Reclassify as TR-3B": "Перекласифікувати як TR-3B",
|
||||
"TRACK ACQUIRED": "ТРЕК ЗАХОПЛЕНО",
|
||||
"{label} · COURSE {heading}°": "{label} · КУРС {heading}°",
|
||||
"{label} · COURSE {heading}°": "{label} · ШЛЯХОВИЙ КУТ {heading}°",
|
||||
"Context could not restore every layer; try again": "Не вдалося відновити всі шари контексту; спробуйте ще раз",
|
||||
"No selected data layers": "Шари даних не вибрано",
|
||||
"{count} data layers could not be cleared": {
|
||||
@@ -2869,6 +2880,8 @@
|
||||
"AVAILABLE MISSIONS": "ДОСТУПНІ МІСІЇ",
|
||||
"SELECT A MISSION TO INSPECT": "ВИБЕРІТЬ МІСІЮ ДЛЯ ПЕРЕГЛЯДУ",
|
||||
"LOADING 30-DAY MISSION INDEX": "ЗАВАНТАЖЕННЯ ІНДЕКСУ МІСІЙ ЗА 30 ДНІВ",
|
||||
"Space Missions unavailable: the mission index could not load. Try again later.": "Космічні місії недоступні: не вдалося завантажити список місій. Спробуйте пізніше.",
|
||||
"30-DAY MISSION INDEX UNAVAILABLE": "ІНДЕКС МІСІЙ ЗА 30 ДНІВ НЕДОСТУПНИЙ",
|
||||
"TAB PREVIEWS · ENTER / SPACE SELECTS": "TAB — ОГЛЯД · ENTER / SPACE — ВИБІР",
|
||||
"Internet radio and local SDR": "Інтернет-радіо та локальний SDR",
|
||||
"STATION TAG": "ТЕГ СТАНЦІЇ",
|
||||
@@ -3057,19 +3070,19 @@
|
||||
"Minimum fire radiative power, megawatts": "Мінімальна потужність випромінювання пожежі, мегавати",
|
||||
"fire intensity filter|All": "Усі",
|
||||
"Detection confidence": "Достовірність виявлення",
|
||||
"{shown} of {total} shown": "показано {shown} з {total}",
|
||||
"{lower} to {upper}": "від {lower} до {upper}",
|
||||
"{shown} of {total} shown": "Показано {shown} з {total}",
|
||||
"{lower} to {upper}": "Від {lower} до {upper}",
|
||||
"Up to {upper}": "До {upper}",
|
||||
"From {lower}": "Від {lower}",
|
||||
"Informational only · not for flight planning": "Лише для довідки · не для планування польотів",
|
||||
"Unnamed zone": "Зона без назви",
|
||||
"Restricted airspace source unavailable": "Джерело даних про обмежений повітряний простір недоступне",
|
||||
"Unnamed zone": "Без назви",
|
||||
"Restricted airspace source unavailable": "Джерело даних про зони обмеження польотів недоступне",
|
||||
"Prohibited (P)": "Заборонена зона (P)",
|
||||
"Restricted (R)": "Зона обмеження (R)",
|
||||
"FAA prohibited and restricted special-use airspace only, not the UAS Facility Map or UAS flight restrictions. Informational only — not a substitute for official UAS flight-planning tools (B4UFLY / LAANC).": "Лише заборонені зони та зони обмеження FAA (повітряний простір особливого використання), без UAS Facility Map і обмежень польотів БпЛА. Лише для довідки — не замінює офіційні інструменти планування польотів БпЛА (B4UFLY / LAANC).",
|
||||
"FAA prohibited and restricted special-use airspace only, not the UAS Facility Map or UAS flight restrictions. Informational only — not a substitute for official UAS flight-planning tools (B4UFLY / LAANC).": "Лише заборонені зони та зони обмеження польотів FAA (повітряний простір особливого використання), без UAS Facility Map і обмежень польотів БпЛА. Лише для довідки — не замінює офіційні інструменти планування польотів БпЛА (B4UFLY / LAANC).",
|
||||
"Prohibited": "Заборонена зона",
|
||||
"Restricted": "Зона обмеження",
|
||||
"Prohibited & Restricted Airspace": "Заборонені зони та зони обмеження",
|
||||
"Prohibited & Restricted Airspace": "Заборонені зони та зони обмеження польотів",
|
||||
"Malformed cyclone snapshot": "Некоректний знімок циклонів",
|
||||
"Cyclone request timed out": "Минув час очікування запиту циклонів",
|
||||
"scene|PLAY": "СТАРТ",
|
||||
@@ -3110,7 +3123,7 @@
|
||||
"Directions, drawing and your own files": "Маршрути, малювання та ваші файли",
|
||||
"Voice place search (needs an Overpass server)": "Голосовий пошук місць (потрібен сервер Overpass)",
|
||||
"OSM Places": "Місця OSM",
|
||||
"state or province": "штат або провінція",
|
||||
"state or province": "область, штат або провінція",
|
||||
"county or district": "округ або район",
|
||||
"neighbourhood": "район міста",
|
||||
"natural region": "природний регіон",
|
||||
@@ -3141,9 +3154,9 @@
|
||||
"Area around {place}": "Околиці: {place}",
|
||||
"Area around landmark": "Околиці орієнтира",
|
||||
"Outline {place}": "Контур: {place}",
|
||||
"Outline area": "Контур області",
|
||||
"Outline area": "Контур території",
|
||||
"Find {place}": "Пошук: {place}",
|
||||
"Find area": "Знайти область",
|
||||
"Find area": "Знайти територію",
|
||||
"Find recent imagery": "Знайти свіжі знімки",
|
||||
"Find {what}": "Пошук: {what}",
|
||||
"Find places": "Знайти місця",
|
||||
@@ -3151,14 +3164,14 @@
|
||||
"Searching imagery catalog": "Пошук у каталозі знімків",
|
||||
"Loading imagery": "Завантаження знімків",
|
||||
"Searching OpenStreetMap": "Пошук в OpenStreetMap",
|
||||
"voice area|Area": "Область",
|
||||
"voice area|Area": "Територія",
|
||||
"buffer sized to the place": "буферна зона за розміром місця",
|
||||
"enclosing mapped area": "охопна область на карті",
|
||||
"enclosing mapped area": "нанесений контур, що охоплює орієнтир",
|
||||
"the map was cleared while looking it up": "карту було очищено під час пошуку",
|
||||
"OpenStreetMap coverage varies by place": "Покриття OpenStreetMap відрізняється залежно від місця",
|
||||
"OpenStreetMap coverage varies by place": "покриття OpenStreetMap різниться залежно від місця",
|
||||
"the catalog covers the last 30 days": "каталог охоплює останні 30 днів",
|
||||
"there are more": "є ще",
|
||||
"{radius} m around the landmark": "{radius} м навколо об’єкта",
|
||||
"there are more": "є й інші",
|
||||
"{radius} m around the landmark": "{radius} м навколо орієнтира",
|
||||
"{count} parts": {
|
||||
"few": "Частин: {count}",
|
||||
"many": "Частин: {count}",
|
||||
@@ -3172,16 +3185,16 @@
|
||||
"other": "Намальовано частин: {drawn} з {total}"
|
||||
},
|
||||
"{count} duplicate mappings merged": {
|
||||
"few": "Об’єднано дублікатів: {count}",
|
||||
"many": "Об’єднано дублікатів: {count}",
|
||||
"one": "Об’єднано дублікатів: {count}",
|
||||
"other": "Об’єднано дублікатів: {count}"
|
||||
"one": "Обʼєднано дублікатів: {count}",
|
||||
"few": "Обʼєднано дублікатів: {count}",
|
||||
"many": "Обʼєднано дублікатів: {count}",
|
||||
"other": "Обʼєднано дублікатів: {count}"
|
||||
},
|
||||
"listed the first {limit}": "показано перші {limit}",
|
||||
"listed the first {limit}": "показано перші результати: {limit}",
|
||||
"counted over the bounding box of {area}, not its outline": "підраховано за обмежувальним прямокутником ({area}), а не за контуром",
|
||||
"Search cancelled": "Пошук скасовано",
|
||||
"Sea Marks": "Морські знаки",
|
||||
"Ski Pistes": "Гірськолижні траси",
|
||||
"Sea Marks": "Навігаційні знаки",
|
||||
"Ski Pistes": "Лижні траси",
|
||||
"Fly to Tokyo": "Полети до Токіо",
|
||||
"Show live flights": "Покажи рейси онлайн",
|
||||
"Reset to globe view": "Поверни вигляд глобуса",
|
||||
@@ -3254,5 +3267,68 @@
|
||||
"The provider rejected the API key. Check it in Provider Settings.": "Постачальник відхилив ключ API. Перевірте його в «Налаштуваннях постачальників».",
|
||||
"Too many chat requests. Try again in {seconds} s.": "Забагато запитів до чату. Спробуйте ще раз через {seconds} с.",
|
||||
"The provider refused access for this key. Check the key's project, region or permissions.": "Постачальник відмовив цьому ключу в доступі. Перевірте проєкт, регіон або дозволи ключа.",
|
||||
"The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "На вашому рахунку в постачальника закінчилися кошти. Перевірте оплату або виберіть іншого постачальника ШІ в налаштуваннях."
|
||||
"The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "На вашому рахунку в постачальника закінчилися кошти. Перевірте оплату або виберіть іншого постачальника ШІ в налаштуваннях.",
|
||||
"SIGHTING": "СПОСТЕРЕЖЕННЯ",
|
||||
"Show {name} sighting": "Показати спостереження: {name}",
|
||||
"seen {age} ago": "помічено {age} тому",
|
||||
"observed {age} ago": "спостерігалося {age} тому",
|
||||
"Be Whale Wise": "Спостерігайте за китами дбайливо",
|
||||
"Reported by {name} ({entity})": "Повідомив: {name} ({entity})",
|
||||
"Reported by {name}": "Повідомив: {name}",
|
||||
"Salish Sea": "Салішське море",
|
||||
"Birds": "Птахи",
|
||||
"Mammals": "Ссавці",
|
||||
"Reptiles": "Плазуни",
|
||||
"Amphibians": "Земноводні",
|
||||
"Ray-finned fishes": "Променепері риби",
|
||||
"Insects": "Комахи",
|
||||
"Arachnids": "Павукоподібні",
|
||||
"Plants": "Рослини",
|
||||
"Fungi": "Гриби",
|
||||
"Other animals": "Інші тварини",
|
||||
"Other or unknown": "Інші або невідомі",
|
||||
"{label} {detail} — click to retry": "{label} {detail} — натисніть, щоб повторити",
|
||||
"Wildlife sightings source unavailable": "Джерело спостережень живої природи недоступне",
|
||||
"Show only community-confirmed (research-grade) sightings": "Показати лише підтверджені спільнотою спостереження дослідницького рівня",
|
||||
"No recent sightings in view": "Немає недавніх спостережень в області огляду",
|
||||
"Acartia feed unavailable": "Стрічка Acartia недоступна",
|
||||
"Show all taxa": "Показати всі групи організмів",
|
||||
"BIRDS": "ПТАХИ",
|
||||
"Show only birds": "Показати лише птахів",
|
||||
"MAMMALS": "ССАВЦІ",
|
||||
"Show only mammals": "Показати лише ссавців",
|
||||
"WHALES": "КИТОПОДІБНІ",
|
||||
"Show only whales and dolphins (7 days)": "Показати лише китів і дельфінів (7 днів)",
|
||||
"INSECTS": "КОМАХИ",
|
||||
"Show only insects": "Показати лише комах",
|
||||
"PLANTS": "РОСЛИНИ",
|
||||
"Show only plants": "Показати лише рослини",
|
||||
"All rights reserved": "Усі права захищено",
|
||||
"Cetacean": "Китоподібне",
|
||||
"Wildlife Sightings": "Спостереження живої природи",
|
||||
"Exit cockpit to fly to a sighting": "Вийдіть із кабіни, щоб перейти до спостереження",
|
||||
"Exit cockpit to fly to a sighting area": "Вийдіть із кабіни, щоб перейти до області спостереження",
|
||||
"Wildlife": "Жива природа",
|
||||
"Zoom in for local sightings": "Наблизьте для місцевих спостережень",
|
||||
"Loading photo...": "Завантаження фото…",
|
||||
"Photo unavailable": "Фото недоступне",
|
||||
"Location obscured to a 0.2° cell": "Місцеположення приховано в комірці 0,2°",
|
||||
"Photo of {name}": "Фото {name}",
|
||||
"Acartia": "Acartia",
|
||||
"CC BY 4.0": "CC BY 4.0",
|
||||
"{count} sightings in view": "{count} спостережень у полі зору",
|
||||
"Close sighting": "Закрити спостереження",
|
||||
"Wildlife observed within 48 h (whales 7 d). Color and badge glyph show the taxon group. Sensitive species are shown only as 0.2° cells; Salish Sea marine mammal reports via Acartia are rounded to 0.01°. Not a census.": "Спостереження дикої природи за 48 год (китоподібні — 7 днів). Колір і символ значка позначають таксономічну групу. Вразливі види показано лише клітинками 0,2°; повідомлення Acartia про морських ссавців моря Селіш округлено до 0,01°. Не перепис.",
|
||||
"Open {name} on iNaturalist": "Відкрити {name} в iNaturalist",
|
||||
"Open {name} on Acartia": "Відкрити {name} в Acartia",
|
||||
"iNaturalist": "iNaturalist",
|
||||
"iNaturalist contributors · licences per record": "Учасники iNaturalist · ліцензія в кожного запису",
|
||||
"iNaturalist contributors · Acartia {count} (Salish Sea) · licences per record": "Учасники iNaturalist · Acartia {count} (море Селіш) · ліцензія в кожного запису",
|
||||
"{days}d": "{days}д",
|
||||
"All markers": "Усі позначки",
|
||||
"Show markers of every age": "Показати позначки будь-якого віку",
|
||||
"Maximum event age: {days}d; undated markers are kept": "Максимальний вік події: {days} дн.; позначки без дати збережено",
|
||||
"event age filter|All": "Усі",
|
||||
"Event age": "Вік події",
|
||||
"Event age: {days}d · undated kept": "Вік події: {days} дн. · позначки без дати збережено"
|
||||
}
|
||||
+98
-22
@@ -1047,8 +1047,16 @@
|
||||
"TomTom roads first; OpenStreetMap fills the rest, simulated": "优先使用 TomTom 道路及实时路况;缺失路段由 OpenStreetMap 补充,并模拟其路况",
|
||||
"TomTom roads first; OpenStreetMap fills the rest, simulated (needs a TomTom key)": "优先使用 TomTom 道路及实时路况;缺失路段由 OpenStreetMap 补充,并模拟其路况(需要 TomTom 密钥)",
|
||||
"Hybrid": "混合",
|
||||
"Lines": "线条",
|
||||
"Dots": "圆点",
|
||||
"Road lines colored by live flow: green to red": "按实时路况着色的道路线条:从绿到红",
|
||||
"Moving dots colored by live flow": "按实时路况着色的移动圆点",
|
||||
"Moving dots": "移动圆点",
|
||||
"TomTom key unavailable": "TomTom 密钥不可用",
|
||||
"TomTom daily budget reached": "已达 TomTom 每日配额",
|
||||
"Monthly traffic allowance used": "本月交通流量额度已用完",
|
||||
"Traffic rate limit reached": "已达到交通数据请求频率上限",
|
||||
"Traffic service unavailable": "交通服务不可用",
|
||||
"TomTom upstream unreachable": "TomTom 上游服务无法连接",
|
||||
"TomTom flow unavailable": "TomTom 路况不可用",
|
||||
"SIMULATED — {reason}": "模拟——{reason}",
|
||||
@@ -1588,6 +1596,9 @@
|
||||
"REGIONAL NEWS": "地区新闻",
|
||||
"{label} · {radius} AIR/SEA WINDOW": "{label} · {radius} 空/海监视窗口",
|
||||
"{scope} · INSTALLATIONS {coverage}": "{scope} · 设施 {coverage}",
|
||||
"MILITARY FEED OFF · PARTIAL PICTURE": "军用数据源已关闭 · 态势不完整",
|
||||
"FLIGHTS FEED OFF · PARTIAL PICTURE": "航班数据源已关闭 · 态势不完整",
|
||||
"NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR": "附近航空器信息不完整 · 不代表安全无虞",
|
||||
"CONTEXT STANDBY": "态势待机",
|
||||
"ENABLE GLOBAL CONTEXT FOR PROXIMITY PINGS": "启用全局态势以接收邻近目标提醒",
|
||||
"CONTACT LOST · LAST KNOWN READOUT · NOT AN ALL-CLEAR": "目标丢失 · 最后已知读数 · 不代表安全无虞",
|
||||
@@ -1660,7 +1671,7 @@
|
||||
"Restore real aircraft": "恢复真实飞机",
|
||||
"Reclassify as TR-3B": "重新归类为 TR-3B",
|
||||
"TRACK ACQUIRED": "已获取航迹",
|
||||
"{label} · COURSE {heading}°": "{label} · 航向 {heading}°",
|
||||
"{label} · COURSE {heading}°": "{label} · 对地航向 {heading}°",
|
||||
"Context could not restore every layer; try again": "态势未能恢复所有图层,请重试",
|
||||
"No selected data layers": "没有已选数据图层",
|
||||
"{count} data layers could not be cleared": {
|
||||
@@ -2556,6 +2567,8 @@
|
||||
"AVAILABLE MISSIONS": "可用任务",
|
||||
"SELECT A MISSION TO INSPECT": "选择一项任务以查看",
|
||||
"LOADING 30-DAY MISSION INDEX": "正在加载 30 天任务索引",
|
||||
"Space Missions unavailable: the mission index could not load. Try again later.": "太空任务不可用:无法加载任务索引。请稍后重试。",
|
||||
"30-DAY MISSION INDEX UNAVAILABLE": "30 天任务索引不可用",
|
||||
"TAB PREVIEWS · ENTER / SPACE SELECTS": "TAB 预览 · ENTER / 空格键选择",
|
||||
"Internet radio and local SDR": "网络电台与本地 SDR",
|
||||
"STATION TAG": "电台标签",
|
||||
@@ -2844,26 +2857,26 @@
|
||||
},
|
||||
"S2 Underground publishes these KMZ products to GitHub. New uploads are detected within seconds and replace the previous edition automatically. Click a marker for its report.": "S2 Underground 在 GitHub 发布这些 KMZ 产品。新上传的内容会在数秒内被检测到,并自动替换旧版本。点击标记查看报告。",
|
||||
"fire confidence filter|All": "全部",
|
||||
"fire confidence filter|Nominal+": "标称+",
|
||||
"fire confidence filter|Nominal+": "中+",
|
||||
"fire confidence filter|High": "高",
|
||||
"Every detection — many of the strongest fires are rated low confidence": "所有检测——许多最强的火点置信度为低",
|
||||
"Nominal and high confidence detections": "标称和高置信度的检测",
|
||||
"High confidence detections only": "仅高置信度的检测",
|
||||
"Fire intensity (FRP)": "火势强度 (FRP)",
|
||||
"Every detection — many of the strongest fires are rated low confidence": "所有火点探测——许多强度最高的火点被评为低置信度",
|
||||
"Nominal and high confidence detections": "中、高置信度的火点探测",
|
||||
"High confidence detections only": "仅高置信度的火点探测",
|
||||
"Fire intensity (FRP)": "火势强度(FRP)",
|
||||
"≥ {value} MW": "≥ {value} MW",
|
||||
"Confidence": "置信度",
|
||||
"Minimum fire radiative power, megawatts": "最小火辐射功率(兆瓦)",
|
||||
"fire intensity filter|All": "全部",
|
||||
"Detection confidence": "检测置信度",
|
||||
"Detection confidence": "探测置信度",
|
||||
"{shown} of {total} shown": "显示 {shown} / {total}",
|
||||
"{lower} to {upper}": "{lower} 至 {upper}",
|
||||
"Up to {upper}": "最高 {upper}",
|
||||
"From {lower}": "自 {lower} 起",
|
||||
"From {lower}": "最低 {lower}",
|
||||
"Informational only · not for flight planning": "仅供参考 · 不可用于飞行规划",
|
||||
"Unnamed zone": "未命名区域",
|
||||
"Restricted airspace source unavailable": "受限空域数据源不可用",
|
||||
"Prohibited (P)": "禁区 (P)",
|
||||
"Restricted (R)": "限制区 (R)",
|
||||
"Unnamed zone": "未命名空域",
|
||||
"Restricted airspace source unavailable": "禁区与限制区空域数据源不可用",
|
||||
"Prohibited (P)": "禁区(P)",
|
||||
"Restricted (R)": "限制区(R)",
|
||||
"FAA prohibited and restricted special-use airspace only, not the UAS Facility Map or UAS flight restrictions. Informational only — not a substitute for official UAS flight-planning tools (B4UFLY / LAANC).": "仅包含 FAA 禁区和限制区特殊用途空域,不含 UAS Facility Map 和无人机飞行限制。仅供参考,不能替代官方无人机飞行规划工具(B4UFLY / LAANC)。",
|
||||
"Prohibited": "禁区",
|
||||
"Restricted": "限制区",
|
||||
@@ -2909,7 +2922,7 @@
|
||||
"Not drawn: {parts}": "未绘制:{parts}",
|
||||
"approximate": "近似",
|
||||
"{pct}% cloud": "云量 {pct}%",
|
||||
"Over {area}": "{area} 上空",
|
||||
"Over {area}": "覆盖 {area}",
|
||||
"{count} of {total} acquisitions match": {
|
||||
"other": "匹配影像:{count}/{total}"
|
||||
},
|
||||
@@ -2924,36 +2937,36 @@
|
||||
"In {area}": "位于 {area}",
|
||||
"Area around {place}": "{place} 周边区域",
|
||||
"Area around landmark": "地标周边区域",
|
||||
"Outline {place}": "勾勒 {place} 轮廓",
|
||||
"Outline area": "勾勒区域轮廓",
|
||||
"Outline {place}": "绘制 {place} 轮廓",
|
||||
"Outline area": "绘制区域轮廓",
|
||||
"Find {place}": "查找 {place}",
|
||||
"Find area": "查找区域",
|
||||
"Find recent imagery": "查找近期影像",
|
||||
"Find {what}": "查找 {what}",
|
||||
"Find places": "查找地点",
|
||||
"Drawing outline": "正在勾勒轮廓",
|
||||
"Drawing outline": "正在地图上绘制轮廓",
|
||||
"Searching imagery catalog": "正在搜索影像目录",
|
||||
"Loading imagery": "正在加载影像",
|
||||
"Searching OpenStreetMap": "正在搜索 OpenStreetMap",
|
||||
"voice area|Area": "区域",
|
||||
"buffer sized to the place": "按地点大小设置的缓冲区",
|
||||
"enclosing mapped area": "包含该地点的地图区域",
|
||||
"enclosing mapped area": "包含该地标的地图标注区域",
|
||||
"the map was cleared while looking it up": "查找期间地图已清空",
|
||||
"OpenStreetMap coverage varies by place": "OpenStreetMap 的覆盖程度因地而异",
|
||||
"the catalog covers the last 30 days": "目录涵盖最近 30 天",
|
||||
"there are more": "还有更多",
|
||||
"{radius} m around the landmark": "地标周围 {radius} 米",
|
||||
"{radius} m around the landmark": "地标周围 {radius} m",
|
||||
"{count} parts": {
|
||||
"other": "部分数:{count}"
|
||||
"other": "{count} 个部分"
|
||||
},
|
||||
"{drawn} of {total} parts drawn": {
|
||||
"other": "已绘制部分:{drawn}/{total}"
|
||||
"other": "已绘制 {drawn}/{total} 个部分"
|
||||
},
|
||||
"{count} duplicate mappings merged": {
|
||||
"other": "已合并重复项:{count}"
|
||||
},
|
||||
"listed the first {limit}": "已列出前 {limit} 个",
|
||||
"counted over the bounding box of {area}, not its outline": "按外接矩形({area})计数,而非其轮廓",
|
||||
"counted over the bounding box of {area}, not its outline": "按 {area} 的外接矩形计数,而非其轮廓",
|
||||
"Search cancelled": "搜索已取消",
|
||||
"Sea Marks": "航标",
|
||||
"Ski Pistes": "滑雪道",
|
||||
@@ -3029,5 +3042,68 @@
|
||||
"The provider rejected the API key. Check it in Provider Settings.": "服务商拒绝了 API 密钥。请在服务商设置中检查。",
|
||||
"Too many chat requests. Try again in {seconds} s.": "聊天请求过多。请在 {seconds} 秒后重试。",
|
||||
"The provider refused access for this key. Check the key's project, region or permissions.": "服务商拒绝了此密钥的访问请求。请检查该密钥所属的项目、区域或权限。",
|
||||
"The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "服务商账户余额不足。请检查账户的计费信息,或在设置中选择其他 AI 服务商。"
|
||||
"The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "服务商账户余额不足。请检查账户的计费信息,或在设置中选择其他 AI 服务商。",
|
||||
"SIGHTING": "目击",
|
||||
"Show {name} sighting": "显示{name}目击",
|
||||
"seen {age} ago": "{age}前目击",
|
||||
"observed {age} ago": "{age}前观察",
|
||||
"Be Whale Wise": "负责任地观鲸",
|
||||
"Reported by {name} ({entity})": "报告方 {name}({entity})",
|
||||
"Reported by {name}": "报告方 {name}",
|
||||
"Salish Sea": "萨利希海",
|
||||
"Birds": "鸟类",
|
||||
"Mammals": "哺乳类",
|
||||
"Reptiles": "爬行动物",
|
||||
"Amphibians": "两栖动物",
|
||||
"Ray-finned fishes": "辐鳍鱼类",
|
||||
"Insects": "昆虫",
|
||||
"Arachnids": "蛛形动物",
|
||||
"Plants": "植物",
|
||||
"Fungi": "真菌",
|
||||
"Other animals": "其他动物",
|
||||
"Other or unknown": "其他或未知",
|
||||
"{label} {detail} — click to retry": "{label} {detail} — 点击重试",
|
||||
"Wildlife sightings source unavailable": "野生生物目击来源不可用",
|
||||
"Show only community-confirmed (research-grade) sightings": "仅显示社区确认(研究级)的目击",
|
||||
"No recent sightings in view": "视野内无近期目击",
|
||||
"Acartia feed unavailable": "Acartia数据源不可用",
|
||||
"Show all taxa": "显示所有分类群",
|
||||
"BIRDS": "鸟类",
|
||||
"Show only birds": "仅显示鸟类",
|
||||
"MAMMALS": "哺乳类",
|
||||
"Show only mammals": "仅显示哺乳类",
|
||||
"WHALES": "鲸类",
|
||||
"Show only whales and dolphins (7 days)": "仅显示鲸和海豚(7天)",
|
||||
"INSECTS": "昆虫",
|
||||
"Show only insects": "仅显示昆虫",
|
||||
"PLANTS": "植物",
|
||||
"Show only plants": "仅显示植物",
|
||||
"All rights reserved": "保留所有权利",
|
||||
"Cetacean": "鲸类",
|
||||
"Wildlife Sightings": "野生生物目击",
|
||||
"Exit cockpit to fly to a sighting": "退出驾驶舱以飞往目击地点",
|
||||
"Exit cockpit to fly to a sighting area": "退出驾驶舱以飞往目击区域",
|
||||
"Wildlife": "野生生物",
|
||||
"Zoom in for local sightings": "放大查看当地目击",
|
||||
"Loading photo...": "正在加载照片…",
|
||||
"Photo unavailable": "照片不可用",
|
||||
"Location obscured to a 0.2° cell": "位置已模糊至0.2°网格",
|
||||
"Photo of {name}": "{name}的照片",
|
||||
"Acartia": "Acartia",
|
||||
"CC BY 4.0": "CC BY 4.0",
|
||||
"{count} sightings in view": "视野内有 {count} 次目击",
|
||||
"Close sighting": "关闭目击记录",
|
||||
"Wildlife observed within 48 h (whales 7 d). Color and badge glyph show the taxon group. Sensitive species are shown only as 0.2° cells; Salish Sea marine mammal reports via Acartia are rounded to 0.01°. Not a census.": "近48小时内观察到的野生生物(鲸类为7天)。颜色与徽章图标表示分类群。敏感物种仅以0.2°网格显示;Acartia提供的萨利希海海洋哺乳动物目击位置四舍五入到0.01°。并非种群普查。",
|
||||
"Open {name} on iNaturalist": "在 iNaturalist 上打开 {name}",
|
||||
"Open {name} on Acartia": "在 Acartia 上打开 {name}",
|
||||
"iNaturalist": "iNaturalist",
|
||||
"iNaturalist contributors · licences per record": "iNaturalist 贡献者 · 各记录单独授权",
|
||||
"iNaturalist contributors · Acartia {count} (Salish Sea) · licences per record": "iNaturalist 贡献者 · Acartia {count}(萨利希海) · 各记录单独授权",
|
||||
"{days}d": "{days}天",
|
||||
"All markers": "所有标记",
|
||||
"Show markers of every age": "显示所有时间的标记",
|
||||
"Maximum event age: {days}d; undated markers are kept": "事件最长保留时间:{days}天;保留无日期标记",
|
||||
"event age filter|All": "全部",
|
||||
"Event age": "事件时间",
|
||||
"Event age: {days}d · undated kept": "事件时间:{days}天内 · 保留无日期标记"
|
||||
}
|
||||
+102
-26
@@ -1042,8 +1042,16 @@
|
||||
"TomTom roads first; OpenStreetMap fills the rest, simulated": "優先使用 TomTom 道路及實時路況;缺失路段由 OpenStreetMap 補充,並模擬其路況",
|
||||
"TomTom roads first; OpenStreetMap fills the rest, simulated (needs a TomTom key)": "優先使用 TomTom 道路及實時路況;缺失路段由 OpenStreetMap 補充,並模擬其路況(需要 TomTom 密鑰)",
|
||||
"Hybrid": "混合",
|
||||
"Lines": "線條",
|
||||
"Dots": "圓點",
|
||||
"Road lines colored by live flow: green to red": "依即時路況著色的道路線條:由綠到紅",
|
||||
"Moving dots colored by live flow": "依即時路況著色的移動圓點",
|
||||
"Moving dots": "移動圓點",
|
||||
"TomTom key unavailable": "TomTom 密鑰不可用",
|
||||
"TomTom daily budget reached": "已達 TomTom 每日配額",
|
||||
"Monthly traffic allowance used": "本月交通流量額度已用完",
|
||||
"Traffic rate limit reached": "已達交通資料請求頻率上限",
|
||||
"Traffic service unavailable": "交通服務無法使用",
|
||||
"TomTom upstream unreachable": "TomTom 上游服務無法連接",
|
||||
"TomTom flow unavailable": "TomTom 路況不可用",
|
||||
"SIMULATED — {reason}": "模擬——{reason}",
|
||||
@@ -1578,6 +1586,9 @@
|
||||
"REGIONAL NEWS": "地區新聞",
|
||||
"{label} · {radius} AIR/SEA WINDOW": "{label} · {radius} 空/海監視窗口",
|
||||
"{scope} · INSTALLATIONS {coverage}": "{scope} · 設施 {coverage}",
|
||||
"MILITARY FEED OFF · PARTIAL PICTURE": "軍用數據源已關閉 · 態勢不完整",
|
||||
"FLIGHTS FEED OFF · PARTIAL PICTURE": "航班數據源已關閉 · 態勢不完整",
|
||||
"NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR": "附近航空器資訊不完整 · 不代表安全無虞",
|
||||
"CONTEXT STANDBY": "態勢待機",
|
||||
"ENABLE GLOBAL CONTEXT FOR PROXIMITY PINGS": "啟用全局態勢以接收鄰近目標提醒",
|
||||
"CONTACT LOST · LAST KNOWN READOUT · NOT AN ALL-CLEAR": "目標丟失 · 最後已知讀數 · 不代表安全無虞",
|
||||
@@ -1650,7 +1661,7 @@
|
||||
"Restore real aircraft": "恢復真實飛機",
|
||||
"Reclassify as TR-3B": "重新歸類為 TR-3B",
|
||||
"TRACK ACQUIRED": "已獲取航跡",
|
||||
"{label} · COURSE {heading}°": "{label} · 航向 {heading}°",
|
||||
"{label} · COURSE {heading}°": "{label} · 對地航向 {heading}°",
|
||||
"Context could not restore every layer; try again": "態勢未能恢復所有圖層,請重試",
|
||||
"No selected data layers": "沒有已選數據圖層",
|
||||
"{count} data layers could not be cleared": {
|
||||
@@ -2544,6 +2555,8 @@
|
||||
"AVAILABLE MISSIONS": "可用任務",
|
||||
"SELECT A MISSION TO INSPECT": "選擇一項任務以查看",
|
||||
"LOADING 30-DAY MISSION INDEX": "正在加載 30 天任務索引",
|
||||
"Space Missions unavailable: the mission index could not load. Try again later.": "太空任務無法使用:無法載入任務索引。請稍後再試。",
|
||||
"30-DAY MISSION INDEX UNAVAILABLE": "30 天任務索引不可用",
|
||||
"TAB PREVIEWS · ENTER / SPACE SELECTS": "TAB 預覽 · ENTER / 空格鍵選擇",
|
||||
"Internet radio and local SDR": "網絡電臺與本地 SDR",
|
||||
"STATION TAG": "電臺標籤",
|
||||
@@ -2847,16 +2860,16 @@
|
||||
"route proxy error": "路線代理出錯",
|
||||
"{lower} to {upper}": "{lower} 至 {upper}",
|
||||
"Up to {upper}": "最高 {upper}",
|
||||
"From {lower}": "自 {lower} 起",
|
||||
"From {lower}": "最低 {lower}",
|
||||
"Informational only · not for flight planning": "僅供參考 · 不可用於飛行規劃",
|
||||
"Unnamed zone": "未命名區域",
|
||||
"Restricted airspace source unavailable": "限制空域資料來源無法使用",
|
||||
"Prohibited (P)": "禁航區 (P)",
|
||||
"Restricted (R)": "限航區 (R)",
|
||||
"FAA prohibited and restricted special-use airspace only, not the UAS Facility Map or UAS flight restrictions. Informational only — not a substitute for official UAS flight-planning tools (B4UFLY / LAANC).": "僅包含 FAA 禁航區與限航區特殊用途空域,不含 UAS Facility Map 與無人機飛行限制。僅供參考,不能取代官方無人機飛行規劃工具(B4UFLY / LAANC)。",
|
||||
"Prohibited": "禁航區",
|
||||
"Restricted": "限航區",
|
||||
"Prohibited & Restricted Airspace": "禁航區與限航區空域",
|
||||
"Unnamed zone": "未命名空域",
|
||||
"Restricted airspace source unavailable": "禁區與限制區空域數據源不可用",
|
||||
"Prohibited (P)": "禁區(P)",
|
||||
"Restricted (R)": "限制區(R)",
|
||||
"FAA prohibited and restricted special-use airspace only, not the UAS Facility Map or UAS flight restrictions. Informational only — not a substitute for official UAS flight-planning tools (B4UFLY / LAANC).": "僅包含 FAA 禁區和限制區特殊用途空域,不含 UAS Facility Map 和無人機飛行限制。僅供參考,不能替代官方無人機飛行規劃工具(B4UFLY / LAANC)。",
|
||||
"Prohibited": "禁區",
|
||||
"Restricted": "限制區",
|
||||
"Prohibited & Restricted Airspace": "禁區與限制區空域",
|
||||
"AUTHORING": "創作",
|
||||
"Create and edit scenes": "創建和編輯場景",
|
||||
"SCENE": "場景",
|
||||
@@ -2883,17 +2896,17 @@
|
||||
"Left panels": "左側面板",
|
||||
"Right panels": "右側面板",
|
||||
"fire confidence filter|All": "全部",
|
||||
"fire confidence filter|Nominal+": "標稱+",
|
||||
"fire confidence filter|Nominal+": "中+",
|
||||
"fire confidence filter|High": "高",
|
||||
"Every detection — many of the strongest fires are rated low confidence": "所有檢測——許多最強的火點置信度為低",
|
||||
"Nominal and high confidence detections": "標稱和高置信度的檢測",
|
||||
"High confidence detections only": "僅高置信度的檢測",
|
||||
"Fire intensity (FRP)": "火勢強度 (FRP)",
|
||||
"Every detection — many of the strongest fires are rated low confidence": "所有火點探測——許多強度最高的火點被評為低置信度",
|
||||
"Nominal and high confidence detections": "中、高置信度的火點探測",
|
||||
"High confidence detections only": "僅高置信度的火點探測",
|
||||
"Fire intensity (FRP)": "火勢強度(FRP)",
|
||||
"≥ {value} MW": "≥ {value} MW",
|
||||
"Confidence": "置信度",
|
||||
"Minimum fire radiative power, megawatts": "最小火輻射功率(兆瓦)",
|
||||
"fire intensity filter|All": "全部",
|
||||
"Detection confidence": "檢測置信度",
|
||||
"Detection confidence": "探測置信度",
|
||||
"{shown} of {total} shown": "顯示 {shown} / {total}",
|
||||
"UTILITIES": "工具",
|
||||
"Exit cockpit to fly to a drawing": "請先退出座艙,再飛往繪製位置",
|
||||
@@ -2909,7 +2922,7 @@
|
||||
"Not drawn: {parts}": "未繪製:{parts}",
|
||||
"approximate": "近似",
|
||||
"{pct}% cloud": "雲量 {pct}%",
|
||||
"Over {area}": "{area} 上空",
|
||||
"Over {area}": "覆蓋 {area}",
|
||||
"{count} of {total} acquisitions match": {
|
||||
"other": "匹配影像:{count}/{total}"
|
||||
},
|
||||
@@ -2924,36 +2937,36 @@
|
||||
"In {area}": "位於 {area}",
|
||||
"Area around {place}": "{place} 周邊區域",
|
||||
"Area around landmark": "地標周邊區域",
|
||||
"Outline {place}": "勾勒 {place} 輪廓",
|
||||
"Outline area": "勾勒區域輪廓",
|
||||
"Outline {place}": "繪製 {place} 輪廓",
|
||||
"Outline area": "繪製區域輪廓",
|
||||
"Find {place}": "查找 {place}",
|
||||
"Find area": "查找區域",
|
||||
"Find recent imagery": "查找近期影像",
|
||||
"Find {what}": "查找 {what}",
|
||||
"Find places": "查找地點",
|
||||
"Drawing outline": "正在勾勒輪廓",
|
||||
"Drawing outline": "正在地圖上繪製輪廓",
|
||||
"Searching imagery catalog": "正在搜索影像目錄",
|
||||
"Loading imagery": "正在加載影像",
|
||||
"Searching OpenStreetMap": "正在搜索 OpenStreetMap",
|
||||
"voice area|Area": "區域",
|
||||
"buffer sized to the place": "按地點大小設置的緩衝區",
|
||||
"enclosing mapped area": "包含該地點的地圖區域",
|
||||
"enclosing mapped area": "包含該地標的地圖標註區域",
|
||||
"the map was cleared while looking it up": "查找期間地圖已清空",
|
||||
"OpenStreetMap coverage varies by place": "OpenStreetMap 的覆蓋程度因地而異",
|
||||
"the catalog covers the last 30 days": "目錄涵蓋最近 30 天",
|
||||
"there are more": "還有更多",
|
||||
"{radius} m around the landmark": "地標周圍 {radius} 米",
|
||||
"{radius} m around the landmark": "地標周圍 {radius} m",
|
||||
"{count} parts": {
|
||||
"other": "部分數:{count}"
|
||||
"other": "{count} 個部分"
|
||||
},
|
||||
"{drawn} of {total} parts drawn": {
|
||||
"other": "已繪製部分:{drawn}/{total}"
|
||||
"other": "已繪製 {drawn}/{total} 個部分"
|
||||
},
|
||||
"{count} duplicate mappings merged": {
|
||||
"other": "已合併重複項:{count}"
|
||||
},
|
||||
"listed the first {limit}": "已列出前 {limit} 個",
|
||||
"counted over the bounding box of {area}, not its outline": "按外接矩形({area})計數,而非其輪廓",
|
||||
"counted over the bounding box of {area}, not its outline": "按 {area} 的外接矩形計數,而非其輪廓",
|
||||
"Search cancelled": "搜索已取消",
|
||||
"Sea Marks": "航標",
|
||||
"Ski Pistes": "滑雪道",
|
||||
@@ -3029,5 +3042,68 @@
|
||||
"The provider rejected the API key. Check it in Provider Settings.": "服務商拒絕了 API 密鑰。請在服務商設置中檢查。",
|
||||
"Too many chat requests. Try again in {seconds} s.": "聊天請求過多。請在 {seconds} 秒後重試。",
|
||||
"The provider refused access for this key. Check the key's project, region or permissions.": "服務商拒絕了此密鑰的訪問請求。請檢查該密鑰所屬的項目、區域或權限。",
|
||||
"The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "服務商賬戶餘額不足。請檢查賬戶的計費信息,或在設置中選擇其他 AI 服務商。"
|
||||
"The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "服務商賬戶餘額不足。請檢查賬戶的計費信息,或在設置中選擇其他 AI 服務商。",
|
||||
"SIGHTING": "目擊",
|
||||
"Show {name} sighting": "顯示{name}目擊",
|
||||
"seen {age} ago": "{age}前目擊",
|
||||
"observed {age} ago": "{age}前觀察",
|
||||
"Be Whale Wise": "負責任地賞鯨",
|
||||
"Reported by {name} ({entity})": "通報方 {name}({entity})",
|
||||
"Reported by {name}": "通報方 {name}",
|
||||
"Salish Sea": "薩利希海",
|
||||
"Birds": "鳥類",
|
||||
"Mammals": "哺乳類",
|
||||
"Reptiles": "爬行動物",
|
||||
"Amphibians": "兩棲動物",
|
||||
"Ray-finned fishes": "輻鰭魚類",
|
||||
"Insects": "昆蟲",
|
||||
"Arachnids": "蛛形動物",
|
||||
"Plants": "植物",
|
||||
"Fungi": "真菌",
|
||||
"Other animals": "其他動物",
|
||||
"Other or unknown": "其他或未知",
|
||||
"{label} {detail} — click to retry": "{label} {detail} — 點擊重試",
|
||||
"Wildlife sightings source unavailable": "野生生物目擊來源無法使用",
|
||||
"Show only community-confirmed (research-grade) sightings": "僅顯示社群確認(研究級)的目擊",
|
||||
"No recent sightings in view": "視野內無近期目擊",
|
||||
"Acartia feed unavailable": "Acartia資料來源無法使用",
|
||||
"Show all taxa": "顯示所有分類群",
|
||||
"BIRDS": "鳥類",
|
||||
"Show only birds": "僅顯示鳥類",
|
||||
"MAMMALS": "哺乳類",
|
||||
"Show only mammals": "僅顯示哺乳類",
|
||||
"WHALES": "鯨類",
|
||||
"Show only whales and dolphins (7 days)": "僅顯示鯨和海豚(7天)",
|
||||
"INSECTS": "昆蟲",
|
||||
"Show only insects": "僅顯示昆蟲",
|
||||
"PLANTS": "植物",
|
||||
"Show only plants": "僅顯示植物",
|
||||
"All rights reserved": "保留所有權利",
|
||||
"Cetacean": "鯨類",
|
||||
"Wildlife Sightings": "野生生物目擊",
|
||||
"Exit cockpit to fly to a sighting": "退出駕駛艙以飛往目擊地點",
|
||||
"Exit cockpit to fly to a sighting area": "退出駕駛艙以飛往目擊區域",
|
||||
"Wildlife": "野生生物",
|
||||
"Zoom in for local sightings": "放大查看當地目擊",
|
||||
"Loading photo...": "正在載入照片…",
|
||||
"Photo unavailable": "照片無法使用",
|
||||
"Location obscured to a 0.2° cell": "位置已模糊至0.2°網格",
|
||||
"Photo of {name}": "{name}的照片",
|
||||
"Acartia": "Acartia",
|
||||
"CC BY 4.0": "CC BY 4.0",
|
||||
"{count} sightings in view": "視野內有 {count} 次目擊",
|
||||
"Close sighting": "關閉目擊紀錄",
|
||||
"Wildlife observed within 48 h (whales 7 d). Color and badge glyph show the taxon group. Sensitive species are shown only as 0.2° cells; Salish Sea marine mammal reports via Acartia are rounded to 0.01°. Not a census.": "近48小時內觀察到的野生生物(鯨類為7天)。顏色與徽章圖示表示分類群。敏感物種僅以0.2°網格顯示;Acartia提供的薩利希海海洋哺乳動物目擊位置四捨五入到0.01°。並非族群普查。",
|
||||
"Open {name} on iNaturalist": "在 iNaturalist 上打開 {name}",
|
||||
"Open {name} on Acartia": "在 Acartia 上打開 {name}",
|
||||
"iNaturalist": "iNaturalist",
|
||||
"iNaturalist contributors · licences per record": "iNaturalist 貢獻者 · 各紀錄個別授權",
|
||||
"iNaturalist contributors · Acartia {count} (Salish Sea) · licences per record": "iNaturalist 貢獻者 · Acartia {count}(薩利希海) · 各紀錄個別授權",
|
||||
"{days}d": "{days}天",
|
||||
"All markers": "所有標記",
|
||||
"Show markers of every age": "顯示所有時間的標記",
|
||||
"Maximum event age: {days}d; undated markers are kept": "事件最長保留時間:{days}天;保留無日期標記",
|
||||
"event age filter|All": "全部",
|
||||
"Event age": "事件時間",
|
||||
"Event age: {days}d · undated kept": "事件時間:{days}天內 · 保留無日期標記"
|
||||
}
|
||||
@@ -4,10 +4,12 @@
|
||||
"978 MHz UAT",
|
||||
"AIS: --",
|
||||
"AIS: {name}",
|
||||
"Acartia",
|
||||
"BRG {value}°",
|
||||
"BRG —",
|
||||
"CAL {value}",
|
||||
"CAL · {badge}",
|
||||
"CC BY 4.0",
|
||||
"CCTV",
|
||||
"CRS {value}°",
|
||||
"CRT",
|
||||
@@ -28,8 +30,11 @@
|
||||
"SRC {value}",
|
||||
"Sentinel-2",
|
||||
"WHOT/BHOT",
|
||||
"acartia.io ↗",
|
||||
"dB",
|
||||
"hPa",
|
||||
"iNaturalist",
|
||||
"iNaturalist ↗",
|
||||
"{source} HTTP {status}",
|
||||
"▲ {frp} MW"
|
||||
],
|
||||
@@ -40,6 +45,7 @@
|
||||
"AIS: {name}",
|
||||
"ANIME",
|
||||
"AUTO",
|
||||
"Acartia",
|
||||
"Alternative",
|
||||
"Ambient",
|
||||
"Anime",
|
||||
@@ -50,6 +56,7 @@
|
||||
"Bloom",
|
||||
"Blues",
|
||||
"Bus",
|
||||
"CC BY 4.0",
|
||||
"CCTV",
|
||||
"CHAT",
|
||||
"COCKPIT",
|
||||
@@ -178,11 +185,14 @@
|
||||
"WSW",
|
||||
"Washington DC",
|
||||
"Wind",
|
||||
"acartia.io ↗",
|
||||
"dB",
|
||||
"feed status|live",
|
||||
"fire behavior|Minimal",
|
||||
"ft",
|
||||
"hPa",
|
||||
"iNaturalist",
|
||||
"iNaturalist ↗",
|
||||
"km",
|
||||
"km/h",
|
||||
"min",
|
||||
@@ -334,15 +344,24 @@
|
||||
"≥ {value} MW",
|
||||
"ΔE",
|
||||
"ΔN",
|
||||
"▲ {frp} MW"
|
||||
"▲ {frp} MW",
|
||||
"ANIMAL · {name}",
|
||||
"Acartia",
|
||||
"CC BY 4.0",
|
||||
"Reptiles",
|
||||
"acartia.io ↗",
|
||||
"iNaturalist ↗",
|
||||
"iNaturalist"
|
||||
],
|
||||
"fr": [
|
||||
"1090 MHz",
|
||||
"978 MHz UAT",
|
||||
"ALTITUDE",
|
||||
"ALTITUDE · FT",
|
||||
"ANIMAL · {name}",
|
||||
"ANIME",
|
||||
"AUTO",
|
||||
"Acartia",
|
||||
"Action",
|
||||
"Allocation",
|
||||
"Altitude {altitude}",
|
||||
@@ -361,6 +380,7 @@
|
||||
"CALIBRATION",
|
||||
"CARGO",
|
||||
"CAUSE",
|
||||
"CC BY 4.0",
|
||||
"CCTV",
|
||||
"CCTV OFF",
|
||||
"CCTV ON",
|
||||
@@ -437,6 +457,7 @@
|
||||
"OK",
|
||||
"ON",
|
||||
"ON/OFF",
|
||||
"Obs. {licence}",
|
||||
"Oldies",
|
||||
"PAUSE",
|
||||
"POSITION {age}",
|
||||
@@ -452,8 +473,10 @@
|
||||
"RADAR · {units}",
|
||||
"RADIO",
|
||||
"REC",
|
||||
"REPTILE · {name}",
|
||||
"Radio",
|
||||
"Reggae",
|
||||
"Reptiles",
|
||||
"Rock",
|
||||
"S2 GhostMaps",
|
||||
"SAR",
|
||||
@@ -488,10 +511,13 @@
|
||||
"VOLUME",
|
||||
"VS",
|
||||
"WHOT/BHOT",
|
||||
"acartia.io ↗",
|
||||
"audio {state}",
|
||||
"dB",
|
||||
"ft",
|
||||
"hPa",
|
||||
"iNaturalist",
|
||||
"iNaturalist ↗",
|
||||
"image",
|
||||
"installation class|BASE",
|
||||
"installation class|INSTALLATION",
|
||||
@@ -694,17 +720,24 @@
|
||||
"≥ {value} MW",
|
||||
"ΔE",
|
||||
"ΔN",
|
||||
"▲ {frp} MW"
|
||||
"▲ {frp} MW",
|
||||
"Acartia",
|
||||
"CC BY 4.0",
|
||||
"acartia.io ↗",
|
||||
"iNaturalist ↗",
|
||||
"iNaturalist"
|
||||
],
|
||||
"ja": [
|
||||
"1090 MHz",
|
||||
"978 MHz UAT",
|
||||
"AIS: --",
|
||||
"AIS: {name}",
|
||||
"Acartia",
|
||||
"BRG {value}°",
|
||||
"BRG —",
|
||||
"CAL {value}",
|
||||
"CAL · {badge}",
|
||||
"CC BY 4.0",
|
||||
"CCTV",
|
||||
"CH {index} / {total}",
|
||||
"CRS {value}°",
|
||||
@@ -729,9 +762,12 @@
|
||||
"Sentinel-2",
|
||||
"USB {rate} msg/s",
|
||||
"WHOT/BHOT",
|
||||
"acartia.io ↗",
|
||||
"dB",
|
||||
"ft",
|
||||
"hPa",
|
||||
"iNaturalist",
|
||||
"iNaturalist ↗",
|
||||
"km",
|
||||
"km/h",
|
||||
"kt",
|
||||
@@ -877,11 +913,19 @@
|
||||
"{value} KM/S",
|
||||
"ΔN",
|
||||
"▲ {frp} MW",
|
||||
"≥ {value} MW"
|
||||
"≥ {value} MW",
|
||||
"ANIMAL · {name}",
|
||||
"Acartia",
|
||||
"CC BY 4.0",
|
||||
"acartia.io ↗",
|
||||
"iNaturalist ↗",
|
||||
"iNaturalist"
|
||||
],
|
||||
"ru": [
|
||||
"AIS: --",
|
||||
"AIS: {name}",
|
||||
"Acartia",
|
||||
"CC BY 4.0",
|
||||
"CCTV",
|
||||
"CRT",
|
||||
"FLIR",
|
||||
@@ -895,11 +939,16 @@
|
||||
"S2 GhostMaps",
|
||||
"S2 · {name}",
|
||||
"VS",
|
||||
"WHOT/BHOT"
|
||||
"WHOT/BHOT",
|
||||
"acartia.io ↗",
|
||||
"iNaturalist",
|
||||
"iNaturalist ↗"
|
||||
],
|
||||
"uk": [
|
||||
"AIS: --",
|
||||
"AIS: {name}",
|
||||
"Acartia",
|
||||
"CC BY 4.0",
|
||||
"CCTV",
|
||||
"CRT",
|
||||
"FLIR",
|
||||
@@ -908,7 +957,10 @@
|
||||
"NVG",
|
||||
"REC",
|
||||
"S2 GhostMaps",
|
||||
"WHOT/BHOT"
|
||||
"WHOT/BHOT",
|
||||
"acartia.io ↗",
|
||||
"iNaturalist",
|
||||
"iNaturalist ↗"
|
||||
],
|
||||
"zh-CN": [
|
||||
"1090 MHz",
|
||||
@@ -966,7 +1018,12 @@
|
||||
"≥ {value} MW",
|
||||
"ΔE",
|
||||
"ΔN",
|
||||
"▲ {frp} MW"
|
||||
"▲ {frp} MW",
|
||||
"Acartia",
|
||||
"CC BY 4.0",
|
||||
"acartia.io ↗",
|
||||
"iNaturalist ↗",
|
||||
"iNaturalist"
|
||||
],
|
||||
"zh-Hant": [
|
||||
"1090 MHz",
|
||||
@@ -1024,6 +1081,11 @@
|
||||
"≥ {value} MW",
|
||||
"ΔE",
|
||||
"ΔN",
|
||||
"▲ {frp} MW"
|
||||
"▲ {frp} MW",
|
||||
"Acartia",
|
||||
"CC BY 4.0",
|
||||
"acartia.io ↗",
|
||||
"iNaturalist ↗",
|
||||
"iNaturalist"
|
||||
]
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import { foldPlaceText } from './data/placeNames.js';
|
||||
|
||||
/** Photon/OpenStreetMap place-search adapter. */
|
||||
const PHOTON_ENDPOINT = 'https://photon.komoot.io/api/';
|
||||
|
||||
@@ -233,19 +235,15 @@ export function photonSearchUrl(
|
||||
}
|
||||
|
||||
/**
|
||||
* Place name reduced to comparable form: unaccented, lowercased, punctuation
|
||||
* collapsed to single spaces. "Huế" and "Hue" have to compare equal, because a
|
||||
* user typing either means the same city.
|
||||
* Place name reduced to comparable form: lowercased, Latin accents removed,
|
||||
* other scripts preserved, and punctuation collapsed to single spaces. "Huế"
|
||||
* and "Hue" have to compare equal, because a user typing either means the same
|
||||
* city.
|
||||
* @param {string} text
|
||||
* @returns {string}
|
||||
*/
|
||||
export function normalizeToponym(text) {
|
||||
return String(text ?? '')
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, ' ')
|
||||
.trim();
|
||||
return foldPlaceText(text);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -35,6 +35,12 @@ import {
|
||||
import { geocodeNavigationMode } from './locations.js';
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const MOSCOW_RESPONSES = JSON.parse(
|
||||
fs.readFileSync(
|
||||
path.join(ROOT, 'src/data/fixtures/photon-moscow-search.json'),
|
||||
'utf8',
|
||||
),
|
||||
);
|
||||
|
||||
/** Photon's real answer for "Hanoi", trimmed to the fields we consume. */
|
||||
const HANOI = {
|
||||
@@ -249,6 +255,58 @@ test('a biased near-miss is refused, and the unbiased answer is taken instead',
|
||||
assert.equal(fetchImpl.urls.length, 2, 'the refused bias costs exactly one extra request');
|
||||
});
|
||||
|
||||
test('a non-Latin exact match keeps the nearby namesake', async () => {
|
||||
const fetchImpl = photonPair(
|
||||
MOSCOW_RESPONSES.biased.features,
|
||||
MOSCOW_RESPONSES.unbiased.features,
|
||||
);
|
||||
|
||||
const result = await geocodeKeyless('Москва', {
|
||||
bias: '38.90,71.80|39.00,71.90',
|
||||
fetchImpl,
|
||||
});
|
||||
|
||||
assert.equal(result.label, 'Москва, Тоҷикистон');
|
||||
assert.equal(fetchImpl.urls.length, 1);
|
||||
});
|
||||
|
||||
test('toponym folding preserves non-Latin scripts', () => {
|
||||
const cases = [
|
||||
['Huế', 'hue'],
|
||||
['Москва', 'москва'],
|
||||
['Αθήνα', 'αθήνα'],
|
||||
['القاهرة', 'القاهرة'],
|
||||
['東京', '東京'],
|
||||
['São 東京', 'sao 東京'],
|
||||
['Straße', 'strasse'],
|
||||
['Tromsø', 'tromso'],
|
||||
['Łódź', 'lodz'],
|
||||
['The Hague', 'the hague'],
|
||||
];
|
||||
|
||||
for (const [input, expected] of cases) {
|
||||
assert.equal(normalizeToponym(input), expected, input);
|
||||
}
|
||||
});
|
||||
|
||||
test('a leading "the" is part of the name a nearby match must equal', async () => {
|
||||
// "The Hague" over upstate New York means the Dutch city, not Hague, NY.
|
||||
const hagueNy = {
|
||||
geometry: { type: 'Point', coordinates: [-73.5004, 43.6967] },
|
||||
properties: { osm_key: 'place', osm_value: 'town', type: 'city', name: 'Hague', country: 'United States' },
|
||||
};
|
||||
const theHague = {
|
||||
geometry: { type: 'Point', coordinates: [4.3113, 52.0799] },
|
||||
properties: { osm_key: 'place', osm_value: 'city', type: 'city', name: 'The Hague', country: 'Nederland' },
|
||||
};
|
||||
const fetchImpl = photonPair([hagueNy], [theHague]);
|
||||
|
||||
const result = await geocodeKeyless('The Hague', { bias: '43.65,-73.55|43.75,-73.45', fetchImpl });
|
||||
|
||||
assert.equal(Math.round(result.lat), 52, `got ${result.label}`);
|
||||
assert.equal(fetchImpl.urls.length, 2);
|
||||
});
|
||||
|
||||
test('a biased match that leads with the name is taken without a second request', async () => {
|
||||
// The other half of the rule, and the reason bias exists at all: "Sixth
|
||||
// Street" over Austin must stay Austin's.
|
||||
|
||||
Vendored
+1
@@ -23,6 +23,7 @@ export function createControls({ state: layerState, services, parts, source }) {
|
||||
|
||||
attachDataManager(dataManager) {
|
||||
layerState.dataManager = dataManager;
|
||||
parts.dependencies.watchAdoptions(dataManager);
|
||||
},
|
||||
|
||||
setParams(params = {}) {
|
||||
|
||||
@@ -79,6 +79,25 @@ export function createDependencies({
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Give up a dependency the user adopts. A direct selection, or the Cockpit
|
||||
* riding one of its aircraft, promotes a layer Contacts turned on into the
|
||||
* user's own (`DataLayerManager.adoptLayerVisibility`). Releasing it when
|
||||
* Contacts goes off would tear down that track or Cockpit before the
|
||||
* Context restore could put the layer back.
|
||||
*/
|
||||
function relinquishAdoptedDependency(change) {
|
||||
if (change?.type === 'visibility' && change.adopted && change.enabled)
|
||||
layerState.ownedDependencies.delete(change.layerId);
|
||||
}
|
||||
|
||||
/** Follow one manager's adoptions; null stops following. */
|
||||
function watchAdoptions(dataManager) {
|
||||
layerState.unwatchAdoptions?.();
|
||||
layerState.unwatchAdoptions =
|
||||
dataManager?.subscribe?.(relinquishAdoptedDependency) || null;
|
||||
}
|
||||
|
||||
function activateOperationalContext() {
|
||||
const activationId = ++layerState.activationId;
|
||||
layerState.autoFocusRetryPending = false;
|
||||
@@ -113,6 +132,7 @@ export function createDependencies({
|
||||
refreshAfterDeferredDependency,
|
||||
enableDependencies,
|
||||
releaseOwnedDependencies,
|
||||
watchAdoptions,
|
||||
activateOperationalContext,
|
||||
};
|
||||
}
|
||||
@@ -340,6 +340,7 @@ export function createLifecycle({
|
||||
layerState.clearListener = null;
|
||||
layerState.subjectClearListener = null;
|
||||
layerState.viewer = null;
|
||||
parts.dependencies.watchAdoptions(null);
|
||||
layerState.dataManager = null;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -26,6 +26,8 @@ export function createState({ services }) {
|
||||
// layers activate only after Contacts is explicitly selected.
|
||||
passive: true,
|
||||
ownedDependencies: new Set(),
|
||||
/** Stops following the manager's adoptions (see watchAdoptions). */
|
||||
unwatchAdoptions: null,
|
||||
subject: null,
|
||||
results: null,
|
||||
visual: null,
|
||||
|
||||
@@ -112,6 +112,8 @@ export function createCctvLayer({ services, source }) {
|
||||
_setCctvCoverageStateForTest: parts.testing._setCctvCoverageStateForTest,
|
||||
focusCctvRecord: parts.navigation.focusCctvRecord,
|
||||
maybeAutoHop: parts.navigation.maybeAutoHop,
|
||||
openingCameraId: parts.navigation.openingCameraId,
|
||||
reopenNearestCamera: parts.navigation.reopenNearestCamera,
|
||||
cctvCycleIndex: parts.navigation.cctvCycleIndex,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -151,6 +151,7 @@ export function createLifecycle({
|
||||
layerState._enabled = false;
|
||||
layerState._activeCameraId = null;
|
||||
layerState._autoHopSuspended = false;
|
||||
layerState._openingCameraProvisional = false;
|
||||
layerState._lastHopAt = 0;
|
||||
layerState._lastViewContext = '';
|
||||
layerState._calibrationById = parts.calibration.loadCalibrationStore();
|
||||
@@ -299,11 +300,8 @@ export function createLifecycle({
|
||||
}
|
||||
|
||||
layerState._count = layerState._records.length;
|
||||
if (layerState._records.length > 0) {
|
||||
// Projection runtime + first frame fetch are deferred to enable() so
|
||||
// initializing the catalog stays render-cheap.
|
||||
layerState._activeCameraId = layerState._records[0].camera.id;
|
||||
}
|
||||
// No camera is picked here: enable() opens on the one nearest the
|
||||
// viewer, unless restored params selected one first.
|
||||
|
||||
// Task 5: if the prior batch lost init's bounded race, apply it post-hoc
|
||||
// when it lands (pure recomputes — applyLateGroundPriors guards against
|
||||
@@ -361,6 +359,7 @@ export function createLifecycle({
|
||||
layerState._horizonCullListener = () => {
|
||||
layerState._cameraMoving = false;
|
||||
stopViewMotionTimer();
|
||||
parts.navigation.reopenNearestCamera();
|
||||
settleView();
|
||||
};
|
||||
layerState._viewer.camera.moveEnd.addEventListener(
|
||||
@@ -508,8 +507,9 @@ export function createLifecycle({
|
||||
return Boolean(coverage && layerState._recordById.has(coverage[1]));
|
||||
});
|
||||
if (!layerState._activeCameraId && layerState._records.length) {
|
||||
layerState._activeCameraId = layerState._records[0].camera.id;
|
||||
layerState._activeCameraId = parts.navigation.openingCameraId();
|
||||
layerState._autoHopSuspended = false;
|
||||
layerState._openingCameraProvisional = true;
|
||||
}
|
||||
const activeRecord = parts.selection.getActiveRecord();
|
||||
if (activeRecord) {
|
||||
@@ -629,6 +629,7 @@ export function createLifecycle({
|
||||
layerState._enabled = false;
|
||||
layerState._activeCameraId = null;
|
||||
layerState._autoHopSuspended = false;
|
||||
layerState._openingCameraProvisional = false;
|
||||
// Clear existing subscribers rather than replacing the Set —
|
||||
// replacing would silently orphan any unsubscribe() closures
|
||||
layerState._listeners.clear();
|
||||
|
||||
@@ -33,6 +33,36 @@ export function createNavigation({
|
||||
return best?.id || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The camera the layer opens on when none is selected: the one nearest the
|
||||
* viewer, or the first record when the viewer has no position yet.
|
||||
* @returns {string|null} Camera ID, or null when the catalog is empty.
|
||||
*/
|
||||
|
||||
function openingCameraId() {
|
||||
return (
|
||||
nearestCameraIdToViewer() || layerState._records[0]?.camera?.id || null
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* After a settled move, moves the opening camera to the one nearest the
|
||||
* view, until someone chooses a camera. A shared view's flight lands after
|
||||
* enable() picked from the startup view.
|
||||
* @returns {boolean} True when the active camera changed.
|
||||
*/
|
||||
|
||||
function reopenNearestCamera() {
|
||||
if (!layerState._openingCameraProvisional || !layerState._enabled)
|
||||
return false;
|
||||
const nearest = nearestCameraIdToViewer();
|
||||
if (!nearest || nearest === layerState._activeCameraId) return false;
|
||||
parts.selection.setActiveCamera(nearest);
|
||||
// setActiveCamera ends the opening pick; this is still that pick.
|
||||
layerState._openingCameraProvisional = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Flies the Cesium viewer camera to frame the specified CCTV camera,
|
||||
* looking along its heading from above.
|
||||
@@ -149,6 +179,8 @@ export function createNavigation({
|
||||
}
|
||||
return {
|
||||
nearestCameraIdToViewer,
|
||||
openingCameraId,
|
||||
reopenNearestCamera,
|
||||
focusCctvRecord,
|
||||
focusCamera,
|
||||
maybeAutoHop,
|
||||
|
||||
@@ -83,6 +83,7 @@ export function createSelection({
|
||||
}
|
||||
layerState._activeCameraId = cameraId;
|
||||
layerState._autoHopSuspended = false;
|
||||
layerState._openingCameraProvisional = false;
|
||||
// A real activation creates projection work — wake the self-stopping loop.
|
||||
parts.projection.startProjectionLoop();
|
||||
if (previousActiveRecord && previousActiveRecord !== record) {
|
||||
@@ -169,6 +170,7 @@ export function createSelection({
|
||||
if (!record) return false;
|
||||
layerState._activeCameraId = null;
|
||||
layerState._autoHopSuspended = true;
|
||||
layerState._openingCameraProvisional = false;
|
||||
record.activationDone = false;
|
||||
parts.geometry.clearProbeClampOnDeactivation(record, (previous) => {
|
||||
parts.geometry.applyFrustumGeometry(
|
||||
|
||||
@@ -36,6 +36,12 @@ export function createState({ services }) {
|
||||
|
||||
layerState._autoHop = false;
|
||||
|
||||
// True while the active camera is the one enable() opened on and nobody has
|
||||
// chosen a camera since: each settled move then re-picks the one nearest
|
||||
// the view, so a share flight that lands after enable still opens nearby.
|
||||
|
||||
layerState._openingCameraProvisional = false;
|
||||
|
||||
// An explicit empty-space deselect keeps AUTO HOP configured but prevents its
|
||||
// timer from silently choosing a replacement. A later explicit activation or
|
||||
// AUTO HOP toggle-on releases the hold.
|
||||
|
||||
@@ -91,6 +91,7 @@ export function createTesting({ state: layerState, services, parts, source }) {
|
||||
coverageMode = 'on',
|
||||
showProjection = false,
|
||||
healthById = new Map(),
|
||||
openingCameraProvisional = false,
|
||||
} = {}) {
|
||||
layerState._viewer = viewer;
|
||||
layerState._records = Array.isArray(records) ? records : [];
|
||||
@@ -104,6 +105,7 @@ export function createTesting({ state: layerState, services, parts, source }) {
|
||||
layerState._billboards = null;
|
||||
layerState._activeCameraId = activeCameraId;
|
||||
layerState._autoHopSuspended = false;
|
||||
layerState._openingCameraProvisional = !!openingCameraProvisional;
|
||||
layerState._enabled = !!enabled;
|
||||
layerState._coverageMode = parts.model.normalizeCoverageMode(
|
||||
coverageMode,
|
||||
|
||||
@@ -67,6 +67,7 @@ export function createCyclonesLayer({
|
||||
feed,
|
||||
hitTestOverlay = hitTestWorldOverlay,
|
||||
overlayHost,
|
||||
context = null,
|
||||
cesium = Cesium,
|
||||
createRendering = createCycloneRendering,
|
||||
matchMedia = globalThis.matchMedia?.bind(globalThis),
|
||||
@@ -82,6 +83,7 @@ export function createCyclonesLayer({
|
||||
selectedId = null,
|
||||
selectionIntent = 'auto',
|
||||
navigationGeneration = 0,
|
||||
selectedContextStormId = null,
|
||||
clickHandler = null,
|
||||
removeClickCapture = null;
|
||||
let enabled = false,
|
||||
@@ -92,6 +94,62 @@ export function createCyclonesLayer({
|
||||
const notify = () => listener?.();
|
||||
const selected = () =>
|
||||
snapshot?.storms.find((storm) => storm.id === selectedId) || null;
|
||||
function clearSelectedContext({ evicted = false } = {}) {
|
||||
if (selectedContextStormId) {
|
||||
if (!evicted)
|
||||
context.clearSelectedEntityContextForLayer('weather-cyclones');
|
||||
context.removeEntityContextsForLayer('weather-cyclones');
|
||||
}
|
||||
selectedContextStormId = null;
|
||||
}
|
||||
|
||||
// Callers publish only while selectedId names a storm in the snapshot.
|
||||
function publishSelectedContext({ announce = false } = {}) {
|
||||
const storm = selected();
|
||||
if (!context) return;
|
||||
const entity = {};
|
||||
const contextId = `cyclone:${storm.id}`;
|
||||
context.registerEntityContext(entity, {
|
||||
id: contextId,
|
||||
layerId: 'weather-cyclones',
|
||||
layerName: 'Cyclone advisories',
|
||||
source: 'NOAA NHC / CPHC',
|
||||
label: storm.name,
|
||||
latitude: storm.position.latitude,
|
||||
longitude: storm.position.longitude,
|
||||
// At most 12 properties reach the voice summary; keep the list short.
|
||||
properties: {
|
||||
classification: storm.classification,
|
||||
classificationName: Object.hasOwn(
|
||||
CLASSIFICATION_NAMES,
|
||||
storm.classification,
|
||||
)
|
||||
? CLASSIFICATION_NAMES[storm.classification]
|
||||
: undefined,
|
||||
basin: storm.basin,
|
||||
advisoryNumber: storm.advisoryNumber,
|
||||
issuedAt: storm.issuedAt,
|
||||
positionAt: storm.positionAt,
|
||||
windKt: storm.windKt,
|
||||
pressureHpa: storm.pressureHpa,
|
||||
// Flat scalars: the voice context summary drops nested objects.
|
||||
movementDirectionDegrees: storm.movement?.directionDegrees,
|
||||
movementSpeedKt: storm.movement?.speedKt,
|
||||
advisoryUrl: storm.advisoryUrl,
|
||||
// Only while the proxy serves its cache because NHC is unreachable.
|
||||
staleCacheFetchedAt:
|
||||
snapshot.stale && Number.isFinite(snapshot.fetchedAt)
|
||||
? new Date(snapshot.fetchedAt).toISOString()
|
||||
: undefined,
|
||||
},
|
||||
});
|
||||
selectedContextStormId = storm.id;
|
||||
if (announce) context.selectEntityContext(entity);
|
||||
context.removeEntityContextsForLayer('weather-cyclones', {
|
||||
retainIds: new Set([contextId]),
|
||||
});
|
||||
}
|
||||
|
||||
function select(id) {
|
||||
if (selectedId !== id) ++navigationGeneration;
|
||||
selectedId = id;
|
||||
@@ -236,6 +294,7 @@ export function createCyclonesLayer({
|
||||
selectedId = null;
|
||||
selectionIntent = 'auto';
|
||||
++navigationGeneration;
|
||||
clearSelectedContext();
|
||||
rendering?.clear();
|
||||
},
|
||||
async update(_viewer, { signal } = {}) {
|
||||
@@ -259,6 +318,7 @@ export function createCyclonesLayer({
|
||||
snapshot = next;
|
||||
selectedId = null;
|
||||
++navigationGeneration;
|
||||
clearSelectedContext({ evicted: true });
|
||||
error = next.reason || ADVISORIES_UNAVAILABLE;
|
||||
return true;
|
||||
}
|
||||
@@ -281,6 +341,10 @@ export function createCyclonesLayer({
|
||||
: snapshot.storms[0]?.id || null,
|
||||
);
|
||||
else rendering.setSelection(selectedId);
|
||||
if (selectedContextStormId) {
|
||||
if (selectedId === selectedContextStormId) publishSelectedContext();
|
||||
else clearSelectedContext({ evicted: true });
|
||||
}
|
||||
return true;
|
||||
} catch (cause) {
|
||||
if (controller.signal.aborted || request !== controller) return false;
|
||||
@@ -290,6 +354,7 @@ export function createCyclonesLayer({
|
||||
snapshot = null;
|
||||
selectedId = null;
|
||||
++navigationGeneration;
|
||||
clearSelectedContext({ evicted: true });
|
||||
return true;
|
||||
} finally {
|
||||
signal?.removeEventListener('abort', abort);
|
||||
@@ -305,6 +370,7 @@ export function createCyclonesLayer({
|
||||
if (params.clear === true || params.stormId === null) {
|
||||
selectionIntent = 'cleared';
|
||||
select(null);
|
||||
clearSelectedContext();
|
||||
notify();
|
||||
} else if (
|
||||
typeof params.stormId === 'string' &&
|
||||
@@ -312,6 +378,7 @@ export function createCyclonesLayer({
|
||||
) {
|
||||
selectionIntent = 'user';
|
||||
select(params.stormId);
|
||||
publishSelectedContext({ announce: true });
|
||||
notify();
|
||||
}
|
||||
const storm = selected();
|
||||
|
||||
@@ -7,6 +7,9 @@ import {
|
||||
isPointerFree,
|
||||
} from '../../data/inputOwnership.js';
|
||||
import { isOwnedByOtherLayer } from '../../data/pickRegistry.js';
|
||||
import * as contextStore from '../../data/contextStore.js';
|
||||
import { createGevActionRunner } from '../../voice/gevActions.js';
|
||||
import * as Cesium from 'cesium';
|
||||
|
||||
const time = '2026-09-16T03:00:00.000Z';
|
||||
const storm = (id = 'ep152026') => ({
|
||||
@@ -338,7 +341,7 @@ test('a click on a storm card or lead-hour label selects that storm without pick
|
||||
});
|
||||
function harness(
|
||||
feed = { getSnapshot: async () => snapshot() },
|
||||
{ reducedMotion = false } = {},
|
||||
{ reducedMotion = false, context = null } = {},
|
||||
) {
|
||||
const applied = [],
|
||||
navigation = [],
|
||||
@@ -368,6 +371,7 @@ function harness(
|
||||
};
|
||||
const layer = createCyclonesLayer({
|
||||
feed,
|
||||
context,
|
||||
createRendering: () => rendering,
|
||||
matchMedia: () => ({ matches: reducedMotion }),
|
||||
openLink: (url) => opened.push(url),
|
||||
@@ -401,6 +405,256 @@ function harness(
|
||||
},
|
||||
};
|
||||
}
|
||||
// Real shared-context store on a stand-in window; the voice runner reads it.
|
||||
async function withSharedContext(t, body) {
|
||||
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, 'window');
|
||||
const host = new EventTarget();
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: host,
|
||||
});
|
||||
const layers = [];
|
||||
t.after(() => {
|
||||
try {
|
||||
for (const layer of layers) layer.destroy();
|
||||
} finally {
|
||||
if (previousWindow)
|
||||
Object.defineProperty(globalThis, 'window', previousWindow);
|
||||
else delete globalThis.window;
|
||||
}
|
||||
});
|
||||
const events = [];
|
||||
host.addEventListener('gev:entity-selected', ({ detail }) =>
|
||||
events.push(['selected', detail.id]),
|
||||
);
|
||||
host.addEventListener('gev:entity-selection-cleared', ({ detail }) =>
|
||||
events.push(['cleared', detail.layerId, detail.reason]),
|
||||
);
|
||||
// The voice runner bounds its basemap wait with the window timers.
|
||||
Object.assign(host, { setTimeout, clearTimeout });
|
||||
const position = Cesium.Cartesian3.fromDegrees(-125.8, 15.5, 5_000_000);
|
||||
const runner = createGevActionRunner({
|
||||
viewer: {
|
||||
clock: { onTick: { addEventListener: () => () => {} } },
|
||||
scene: { canvas: { addEventListener() {}, removeEventListener() {} } },
|
||||
camera: {
|
||||
moveEnd: { addEventListener() {} },
|
||||
positionWC: position,
|
||||
positionCartographic: Cesium.Cartographic.fromCartesian(position),
|
||||
heading: 0,
|
||||
pitch: 0,
|
||||
},
|
||||
},
|
||||
styleManager: {},
|
||||
dataManager: {
|
||||
layers: new Map(),
|
||||
isEnabled: (id) => id === 'weather-cyclones',
|
||||
getAll: () => [],
|
||||
},
|
||||
});
|
||||
const selectedProperties = async () => {
|
||||
const result = await runner('get_entity_context', { scope: 'selected' });
|
||||
assert.equal(result.scope, 'selected');
|
||||
return result.selected.properties;
|
||||
};
|
||||
const store = contextStore.getContextStore();
|
||||
const open = (feed) => {
|
||||
const h = harness(feed, { context: contextStore });
|
||||
layers.push(h.layer);
|
||||
return h;
|
||||
};
|
||||
await body({ events, selectedProperties, store, open });
|
||||
}
|
||||
|
||||
test('shared cyclone context distinguishes switches, deliberate clears and feed eviction', async (t) => {
|
||||
await withSharedContext(
|
||||
t,
|
||||
async ({ events, selectedProperties, store, open }) => {
|
||||
let next = snapshot([
|
||||
{ ...storm(), movement: { directionDegrees: 315, speedKt: 12 } },
|
||||
{
|
||||
...storm('ep162026'),
|
||||
movement: { directionDegrees: 0, speedKt: 10 },
|
||||
},
|
||||
]);
|
||||
const h = open({ getSnapshot: async () => next });
|
||||
h.layer.enable();
|
||||
await h.layer.update();
|
||||
assert.deepEqual(
|
||||
events,
|
||||
[],
|
||||
'the automatic first-storm row selection must not claim shared context',
|
||||
);
|
||||
assert.equal(store.entities.size, 0);
|
||||
|
||||
h.layer.setParams({ stormId: 'ep152026' });
|
||||
assert.equal(store.entities.get('cyclone:ep152026').label, 'Fifteen-E');
|
||||
let properties = await selectedProperties();
|
||||
assert.equal(properties.classification, 'PTC');
|
||||
assert.equal(properties.classificationName, 'Post-tropical cyclone');
|
||||
assert.equal(properties.advisoryNumber, '10');
|
||||
assert.equal(
|
||||
properties.movementDirectionDegrees,
|
||||
'315',
|
||||
'selected storm movement should survive voice summarization',
|
||||
);
|
||||
assert.equal(properties.movementSpeedKt, '12');
|
||||
assert.equal(
|
||||
properties.advisoryUrl,
|
||||
'https://www.nhc.noaa.gov/text/MIATCMEP5.shtml',
|
||||
);
|
||||
assert.equal(properties.staleCacheFetchedAt, undefined);
|
||||
|
||||
h.layer.setParams({ stormId: 'ep162026' });
|
||||
properties = await selectedProperties();
|
||||
assert.equal(
|
||||
properties.movementDirectionDegrees,
|
||||
'0',
|
||||
'a due-north heading is a value, not a missing one',
|
||||
);
|
||||
assert.equal(properties.movementSpeedKt, '10');
|
||||
assert.deepEqual(events, [
|
||||
['selected', 'cyclone:ep152026'],
|
||||
['selected', 'cyclone:ep162026'],
|
||||
]);
|
||||
assert.deepEqual([...store.entities.keys()], ['cyclone:ep162026']);
|
||||
|
||||
next = snapshot([
|
||||
{ ...storm(), movement: { directionDegrees: 315, speedKt: 12 } },
|
||||
{ ...storm('ep162026'), movement: { directionDegrees: 0, speedKt: 0 } },
|
||||
]);
|
||||
await h.layer.update();
|
||||
assert.equal(events.length, 2, 'refresh must not announce selection');
|
||||
properties = await selectedProperties();
|
||||
assert.equal(
|
||||
properties.movementSpeedKt,
|
||||
'0',
|
||||
'stationary is a value, refreshed into the selected record',
|
||||
);
|
||||
|
||||
h.layer.setParams({ clear: true });
|
||||
assert.deepEqual(events.at(-1), [
|
||||
'cleared',
|
||||
'weather-cyclones',
|
||||
'deliberate',
|
||||
]);
|
||||
assert.equal(store.entities.size, 0);
|
||||
|
||||
h.layer.setParams({ stormId: 'ep162026' });
|
||||
next = snapshot([storm()]);
|
||||
await h.layer.update();
|
||||
assert.deepEqual(
|
||||
events.at(-1),
|
||||
['cleared', 'weather-cyclones', 'evicted'],
|
||||
'a vanished user-selected storm must clear instead of transferring context to the fallback row',
|
||||
);
|
||||
assert.equal(store.entities.size, 0);
|
||||
assert.equal(store.selectedEntityId, null);
|
||||
|
||||
h.layer.setParams({ stormId: 'ep152026' });
|
||||
h.layer.disable();
|
||||
assert.deepEqual(events.at(-1), [
|
||||
'cleared',
|
||||
'weather-cyclones',
|
||||
'deliberate',
|
||||
]);
|
||||
assert.equal(store.entities.size, 0);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('shared cyclone context is evicted when the source fails and marks a stale cache', async (t) => {
|
||||
await withSharedContext(
|
||||
t,
|
||||
async ({ events, selectedProperties, store, open }) => {
|
||||
let next = snapshot([{ ...storm(), classification: 'XX' }]);
|
||||
let fail = false;
|
||||
const h = open({
|
||||
getSnapshot: async () => {
|
||||
if (fail) throw new Error('Network unavailable');
|
||||
return next;
|
||||
},
|
||||
});
|
||||
h.layer.enable();
|
||||
await h.layer.update();
|
||||
h.layer.setParams({ stormId: 'ep152026' });
|
||||
let properties = await selectedProperties();
|
||||
assert.equal(properties.classification, 'XX');
|
||||
assert.equal(
|
||||
properties.classificationName,
|
||||
undefined,
|
||||
'an unknown code is not given a guessed meaning',
|
||||
);
|
||||
|
||||
next = { ...snapshot(), stale: true };
|
||||
await h.layer.update();
|
||||
properties = await selectedProperties();
|
||||
assert.equal(properties.staleCacheFetchedAt, time);
|
||||
assert.equal(events.length, 1, 'refresh must not announce selection');
|
||||
|
||||
next = {
|
||||
...snapshot([]),
|
||||
unavailable: true,
|
||||
reason: 'Cyclone data unavailable',
|
||||
};
|
||||
await h.layer.update();
|
||||
assert.deepEqual(events.at(-1), [
|
||||
'cleared',
|
||||
'weather-cyclones',
|
||||
'evicted',
|
||||
]);
|
||||
assert.equal(store.entities.size, 0);
|
||||
|
||||
next = snapshot();
|
||||
await h.layer.update();
|
||||
h.layer.setParams({ stormId: 'ep152026' });
|
||||
assert.equal(store.selectedEntityId, 'cyclone:ep152026');
|
||||
fail = true;
|
||||
await h.layer.update();
|
||||
assert.deepEqual(events.at(-1), [
|
||||
'cleared',
|
||||
'weather-cyclones',
|
||||
'evicted',
|
||||
]);
|
||||
assert.equal(store.entities.size, 0);
|
||||
assert.equal(store.selectedEntityId, null);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('cyclone clears leave another layer selection and its records alone', async (t) => {
|
||||
await withSharedContext(t, async ({ events, store, open }) => {
|
||||
const h = open({ getSnapshot: async () => snapshot() });
|
||||
h.layer.enable();
|
||||
await h.layer.update();
|
||||
h.layer.setParams({ stormId: 'ep152026' });
|
||||
const foreign = {};
|
||||
contextStore.registerEntityContext(foreign, {
|
||||
id: 'flight:abc123',
|
||||
layerId: 'flights',
|
||||
label: 'Foreign',
|
||||
});
|
||||
contextStore.selectEntityContext(foreign);
|
||||
const before = events.length;
|
||||
|
||||
h.layer.setParams({ clear: true });
|
||||
assert.equal(store.selectedEntityId, 'flight:abc123');
|
||||
assert.ok(store.entities.has('flight:abc123'));
|
||||
assert.equal(store.entities.has('cyclone:ep152026'), false);
|
||||
assert.equal(
|
||||
events.length,
|
||||
before,
|
||||
'no clear is reported for a foreign selection',
|
||||
);
|
||||
|
||||
// A cyclone clear does not report a dangling foreign selection as evicted.
|
||||
store.entities.delete('flight:abc123');
|
||||
h.layer.disable();
|
||||
assert.equal(store.selectedEntityId, 'flight:abc123');
|
||||
assert.equal(events.length, before);
|
||||
});
|
||||
});
|
||||
|
||||
test('refresh preserves selection intent, including explicit clears and missing storms', async () => {
|
||||
let next = snapshot([storm(), storm('ep162026')]);
|
||||
const h = harness({ getSnapshot: async () => next });
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import { createFlightState } from './state.js';
|
||||
import { createRendering } from './rendering.js';
|
||||
import {
|
||||
FLEET_DR_INTERVAL_MS,
|
||||
HIDDEN_CONTACT_DR_INTERVAL_MS,
|
||||
HIDDEN_CONTACT_DR_TICKS,
|
||||
hiddenContactDrPhase,
|
||||
} from './policy.js';
|
||||
|
||||
// Contacts hidden behind the horizon re-reckon on one fleet tick per rotation
|
||||
// instead of every tick. Their positions still feed hidden-inclusive proximity
|
||||
// queries, so the rotation must reach every contact about once a second, and
|
||||
// it must spread them across ticks so no single tick carries the whole fleet.
|
||||
|
||||
const icao = (n) => n.toString(16).padStart(6, '0');
|
||||
|
||||
test('the rotation revisits each hidden contact about once a second', () => {
|
||||
const periodMs = HIDDEN_CONTACT_DR_TICKS * FLEET_DR_INTERVAL_MS;
|
||||
assert.ok(HIDDEN_CONTACT_DR_TICKS > 1);
|
||||
assert.ok(
|
||||
Math.abs(periodMs - HIDDEN_CONTACT_DR_INTERVAL_MS) <= FLEET_DR_INTERVAL_MS,
|
||||
);
|
||||
});
|
||||
|
||||
test('a contact keeps the same slot, inside the rotation', () => {
|
||||
for (let n = 0; n < 500; n++) {
|
||||
const phase = hiddenContactDrPhase(icao(n * 7919));
|
||||
assert.ok(Number.isInteger(phase));
|
||||
assert.ok(phase >= 0 && phase < HIDDEN_CONTACT_DR_TICKS);
|
||||
assert.equal(hiddenContactDrPhase(icao(n * 7919)), phase);
|
||||
}
|
||||
});
|
||||
|
||||
test('a worldwide fleet is spread evenly enough that no tick carries a burst', () => {
|
||||
const fleet = 13000;
|
||||
const perSlot = new Array(HIDDEN_CONTACT_DR_TICKS).fill(0);
|
||||
// Real ICAO24 addresses cluster by national block; sample several blocks.
|
||||
for (let n = 0; n < fleet; n++)
|
||||
perSlot[hiddenContactDrPhase(icao(0x0a0000 + n * 53))]++;
|
||||
const fair = fleet / HIDDEN_CONTACT_DR_TICKS;
|
||||
for (const count of perSlot) assert.ok(count < fair * 1.5, `${count} > 1.5×`);
|
||||
});
|
||||
|
||||
/** Real fleet tick and horizon culling, with motion and presentation injected. */
|
||||
function fleetFixture() {
|
||||
let now = 1000;
|
||||
let reckonings = 0;
|
||||
const services = {
|
||||
groundSnap: { createGroundSnap: () => ({ clear() {} }) },
|
||||
militaryRegistry: { isMilitaryIcao: () => false },
|
||||
aircraftPresentation: { isTr3b: () => false },
|
||||
camera: {},
|
||||
focus: {
|
||||
focusNowMs: () => now,
|
||||
getFocusTarget: () => null,
|
||||
nearFarScalarValueAtDistance: () => 1,
|
||||
advanceProjectedSpriteFocus: () => ({ factor: 1 }),
|
||||
},
|
||||
recession: {
|
||||
applyAircraftBillboardTreatment: () => ({
|
||||
factors: { scale: 1 },
|
||||
alpha: 1,
|
||||
}),
|
||||
},
|
||||
};
|
||||
const flightState = createFlightState({ services });
|
||||
const camera = {
|
||||
positionWC: Cesium.Cartesian3.fromDegrees(0, 0, 100),
|
||||
positionCartographic: { height: 100 },
|
||||
heading: 0,
|
||||
pitch: 0,
|
||||
roll: 0,
|
||||
frustum: {
|
||||
computeCullingVolume: () => ({
|
||||
computeVisibility: () => Cesium.Intersect.INSIDE,
|
||||
}),
|
||||
},
|
||||
};
|
||||
const id = 'abc123';
|
||||
// 111 km away at ground level is beyond a 100 m camera's horizon, but
|
||||
// inside the model keep radius. No WebGL scene is needed for the culler.
|
||||
const billboard = {
|
||||
show: false,
|
||||
position: Cesium.Cartesian3.fromDegrees(1, 0, 0),
|
||||
};
|
||||
flightState._viewer = { camera, scene: {} };
|
||||
flightState._billboardCollection = { show: true };
|
||||
flightState._billboards.set(id, billboard);
|
||||
flightState.records.data.set(id, {});
|
||||
flightState._models3dEnabled = false;
|
||||
// Far cockpit contacts avoid glyph rasterization, keeping the fixture DOM-free.
|
||||
flightState._cockpitContactMode = true;
|
||||
const parts = {
|
||||
tracking: { _refreshCockpitNearContacts() {} },
|
||||
motion: {
|
||||
_deadReckon() {
|
||||
reckonings++;
|
||||
return billboard.position;
|
||||
},
|
||||
_floorGroundedDisplayPosition(id, info, position) {
|
||||
return position;
|
||||
},
|
||||
},
|
||||
};
|
||||
const rendering = createRendering({ flightState, services, parts });
|
||||
return {
|
||||
flightState,
|
||||
camera,
|
||||
billboard,
|
||||
id,
|
||||
reckonings: () => reckonings,
|
||||
tick() {
|
||||
now += FLEET_DR_INTERVAL_MS;
|
||||
rendering._fleetTick();
|
||||
},
|
||||
offTurn() {
|
||||
flightState._fleetTickSeq =
|
||||
(hiddenContactDrPhase(id) + 1) % HIDDEN_CONTACT_DR_TICKS;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('the fleet tick dead-reckons a hidden contact once per complete rotation', () => {
|
||||
const f = fleetFixture();
|
||||
for (let i = 0; i < HIDDEN_CONTACT_DR_TICKS; i++) f.tick();
|
||||
assert.equal(f.reckonings(), 1);
|
||||
assert.equal(f.billboard.show, false);
|
||||
});
|
||||
|
||||
test('a camera reveal bypasses the hidden rotation on the same fleet tick', () => {
|
||||
const f = fleetFixture();
|
||||
f.offTurn();
|
||||
f.tick();
|
||||
assert.equal(f.reckonings(), 0);
|
||||
f.camera.positionWC = Cesium.Cartesian3.fromDegrees(1, 0, 100);
|
||||
f.offTurn();
|
||||
f.tick();
|
||||
assert.equal(f.reckonings(), 1);
|
||||
assert.equal(f.billboard.show, true);
|
||||
});
|
||||
|
||||
test('a hidden contact with a retained fleet model is never rotation-skipped', () => {
|
||||
const f = fleetFixture();
|
||||
f.flightState._models3dEnabled = true;
|
||||
const model = { ready: true, show: true };
|
||||
f.flightState._models.set(f.id, model);
|
||||
f.offTurn();
|
||||
f.tick();
|
||||
assert.equal(f.reckonings(), 1);
|
||||
assert.equal(
|
||||
f.flightState._models.get(f.id),
|
||||
model,
|
||||
'model remains in keep range',
|
||||
);
|
||||
assert.equal(
|
||||
model.show,
|
||||
false,
|
||||
'the full pass hides its model beyond the horizon',
|
||||
);
|
||||
});
|
||||
@@ -198,6 +198,37 @@ export const FLEET_DR_INTERVAL_MS = 80;
|
||||
|
||||
export const ROTATION_REFRESH_MS = 1000;
|
||||
|
||||
/**
|
||||
* @constant {number} Dead-reckoning cadence (ms) for contacts hidden behind the
|
||||
* horizon. The worldwide feed carries ~13k contacts and a zoomed-in view shows
|
||||
* well under 1% of them; the rest still need positions for hidden-inclusive
|
||||
* proximity queries, but ~1 s old (≈250 m for an airliner) is well inside the
|
||||
* 30 s poll's own uncertainty. Each hidden contact is re-reckoned on one tick in
|
||||
* every HIDDEN_CONTACT_DR_TICKS, staggered by id so no tick carries them all.
|
||||
*/
|
||||
|
||||
export const HIDDEN_CONTACT_DR_INTERVAL_MS = 1000;
|
||||
|
||||
export const HIDDEN_CONTACT_DR_TICKS = Math.max(
|
||||
1,
|
||||
Math.round(HIDDEN_CONTACT_DR_INTERVAL_MS / FLEET_DR_INTERVAL_MS),
|
||||
);
|
||||
|
||||
/**
|
||||
* Stable slot in [0, HIDDEN_CONTACT_DR_TICKS) for a contact id, so each hidden
|
||||
* contact re-reckons on the same tick of every rotation.
|
||||
* @param {string} id Contact id (ICAO24).
|
||||
* @returns {number}
|
||||
*/
|
||||
|
||||
export function hiddenContactDrPhase(id) {
|
||||
const text = String(id);
|
||||
let hash = 0;
|
||||
for (let i = 0; i < text.length; i++)
|
||||
hash = (hash * 31 + text.charCodeAt(i)) >>> 0;
|
||||
return hash % HIDDEN_CONTACT_DR_TICKS;
|
||||
}
|
||||
|
||||
/** Max course slew (deg/s) — well above real turns (≤4°/s), hides fix-boundary
|
||||
* steps. Scaled down toward COURSE_MIN_DPS at low speed (courseSlewCapDps). */
|
||||
|
||||
|
||||
@@ -36,6 +36,8 @@ import {
|
||||
COURSE_SLEW_DT_MAX_SEC,
|
||||
ROTATION_REFRESH_MS,
|
||||
COURSE_MAX_DPS,
|
||||
HIDDEN_CONTACT_DR_TICKS,
|
||||
hiddenContactDrPhase,
|
||||
} from './policy.js';
|
||||
import { AIRCRAFT_ENVIRONMENT_MAP_OPTIONS } from '../../data/aircraftModelLighting.js';
|
||||
|
||||
@@ -839,6 +841,12 @@ export function createRendering({
|
||||
}
|
||||
}
|
||||
|
||||
/** The contact's hidden-rotation slot, cached on its billboard. */
|
||||
function _hiddenDrPhase(bb, icao24) {
|
||||
bb._gevHiddenDrPhase ??= hiddenContactDrPhase(icao24);
|
||||
return bb._gevHiddenDrPhase;
|
||||
}
|
||||
|
||||
function _fleetTick() {
|
||||
if (
|
||||
!flightState._viewer ||
|
||||
@@ -965,9 +973,25 @@ export function createRendering({
|
||||
for (const icao of toRelease) _releaseModel(icao);
|
||||
}
|
||||
|
||||
const hiddenDrPhase = flightState._fleetTickSeq++ % HIDDEN_CONTACT_DR_TICKS;
|
||||
|
||||
for (const [icao24, bb] of flightState._billboards) {
|
||||
if (icao24 === flightState._trackedIcao) continue; // tracked entity owns its own motion
|
||||
|
||||
// A contact still behind the horizon at its last position would only be
|
||||
// re-reckoned to be hidden again, so off its turn it keeps that position
|
||||
// (see HIDDEN_CONTACT_DR_TICKS). Anything the stale position puts in view,
|
||||
// and any contact with a fleet model, takes the full pass below at once.
|
||||
if (
|
||||
!bb.show &&
|
||||
_hiddenDrPhase(bb, icao24) !== hiddenDrPhase &&
|
||||
!flightState._models.has(icao24) &&
|
||||
!occluder.isPointVisible(
|
||||
flightState._cullPositions.get(icao24) || bb.position,
|
||||
)
|
||||
)
|
||||
continue;
|
||||
|
||||
const info = flightState.records.data.get(icao24);
|
||||
|
||||
const dr = parts.motion._deadReckon(
|
||||
|
||||
@@ -204,6 +204,10 @@ export function createFlightState({ source, services }) {
|
||||
|
||||
flightState._lastFleetTickMs = 0;
|
||||
|
||||
/** @type {number} Fleet ticks run; selects which hidden contacts re-reckon */
|
||||
|
||||
flightState._fleetTickSeq = 0;
|
||||
|
||||
/** @type {string} Camera pose signature at the last rotation pass */
|
||||
|
||||
flightState._lastCamPoseSig = '';
|
||||
|
||||
+190
-57
@@ -16,7 +16,15 @@ import {
|
||||
} from '../../i18n/index.js';
|
||||
import { createHorizonCull } from '../streetLevel/view.js';
|
||||
import { readGhostMapsKml } from './kml.js';
|
||||
import { fieldLabel, parseDescriptionFields, wrapCardText } from './records.js';
|
||||
import {
|
||||
fieldLabel,
|
||||
GHOSTMAPS_AGE_OPTIONS,
|
||||
GHOSTMAPS_DEFAULT_AGE_DAYS,
|
||||
ghostMapsEventDate,
|
||||
isGhostMapsEventExpired,
|
||||
parseDescriptionFields,
|
||||
wrapCardText,
|
||||
} from './records.js';
|
||||
export * from './catalog.js';
|
||||
export * from './records.js';
|
||||
export { readGhostMapsKml } from './kml.js';
|
||||
@@ -26,6 +34,7 @@ export const GHOSTMAPS_LAYER_ID = 's2-ghostmaps';
|
||||
export const GHOSTMAPS_OVERLAY_SOURCE_ID = 's2-ghostmaps';
|
||||
const PICK_PREFIX = 'ghostmaps:';
|
||||
const STORAGE_KEY = 'gev.ghostmaps.products';
|
||||
const AGE_STORAGE_KEY = 'gev.ghostmaps.ageDays';
|
||||
const ACCENT = '#c8ff00';
|
||||
const MAX_ICON_BYTES = 512 * 1024;
|
||||
const CARD_HOST_OPTIONS = Object.freeze({
|
||||
@@ -82,6 +91,19 @@ function writeStoredSelection(storage, selected) {
|
||||
}
|
||||
}
|
||||
|
||||
function readStoredAge(storage) {
|
||||
try {
|
||||
const raw = storage?.getItem(AGE_STORAGE_KEY);
|
||||
if (raw !== null && raw !== undefined) {
|
||||
const value = JSON.parse(raw);
|
||||
if (GHOSTMAPS_AGE_OPTIONS.includes(value)) return value;
|
||||
}
|
||||
} catch {
|
||||
/* unavailable or invalid storage uses the default */
|
||||
}
|
||||
return GHOSTMAPS_DEFAULT_AGE_DAYS;
|
||||
}
|
||||
|
||||
/** A round marker for placemarks whose icon is missing or remote. */
|
||||
const markerCache = new Map();
|
||||
function markerImage(color) {
|
||||
@@ -137,6 +159,7 @@ export function createGhostMapsLayer({
|
||||
pointer = null,
|
||||
openExternal = null,
|
||||
storage = defaultStorage(),
|
||||
now = () => Date.now(),
|
||||
} = {}) {
|
||||
if (typeof source?.getCatalog !== 'function')
|
||||
throw new TypeError('GhostMaps layer requires a catalog source');
|
||||
@@ -154,7 +177,9 @@ export function createGhostMapsLayer({
|
||||
let _selectedLink = null;
|
||||
let _lifetime = new AbortController();
|
||||
let _selection = readStoredSelection(storage);
|
||||
/** productId → {version, dataSource, urls, count, features} */
|
||||
let _ageDays = readStoredAge(storage);
|
||||
let _expiryTimer = null;
|
||||
/** productId → {version, dataSource, urls, features: Map<index, record>, pins} */
|
||||
const _loaded = new Map();
|
||||
/** productId → in-flight version */
|
||||
const _loading = new Map();
|
||||
@@ -183,17 +208,40 @@ export function createGhostMapsLayer({
|
||||
return (_catalog?.products || []).filter(wanted).map(({ id }) => id);
|
||||
}
|
||||
|
||||
function unload(id) {
|
||||
function unload(id, keepSelection = false) {
|
||||
const item = _loaded.get(id);
|
||||
if (!item) return;
|
||||
_loaded.delete(id);
|
||||
_viewer?.dataSources?.remove(item.dataSource, true);
|
||||
item.urls.forEach((url) => URL.revokeObjectURL(url));
|
||||
if (_selectedKey?.startsWith(`${id}:`)) clearSelection();
|
||||
if (!keepSelection && _selectedKey?.startsWith(`${id}:`)) clearSelection();
|
||||
}
|
||||
|
||||
// Remove expired entities, rather than hiding them: horizon culling must
|
||||
// never make an old pin visible again. Map keys keep surviving picks stable.
|
||||
function expireEvents() {
|
||||
const at = now();
|
||||
let changed = false;
|
||||
for (const [productId, item] of _loaded) {
|
||||
for (const [index, feature] of item.features) {
|
||||
if (!isGhostMapsEventExpired(feature.dateMs, at, _ageDays)) continue;
|
||||
for (const entity of feature.entities)
|
||||
item.dataSource.entities.remove(entity);
|
||||
item.features.delete(index);
|
||||
if (_selectedKey === `${productId}:${index}`) clearSelection();
|
||||
changed = true;
|
||||
}
|
||||
item.pins = item.pins.filter((pin) => item.features.has(pin.index));
|
||||
}
|
||||
if (changed) {
|
||||
notifyRow();
|
||||
render();
|
||||
}
|
||||
}
|
||||
|
||||
/** Build one product's data source off-scene, then swap it in. */
|
||||
async function load(product, signal) {
|
||||
const ageDays = _ageDays;
|
||||
const bytes = await source.getFile(product.id, product.version, { signal });
|
||||
signal.throwIfAborted();
|
||||
const entries = await readZipEntries(bytes);
|
||||
@@ -223,12 +271,16 @@ export function createGhostMapsLayer({
|
||||
const dataSource = new Cesium.CustomDataSource(
|
||||
`${GHOSTMAPS_LAYER_ID}:${product.id}`,
|
||||
);
|
||||
const features = [];
|
||||
const features = new Map();
|
||||
const pins = [];
|
||||
const at = now();
|
||||
dataSource.entities.suspendEvents();
|
||||
parsed.features.forEach((feature, index) => {
|
||||
const dateMs = ghostMapsEventDate(feature.description);
|
||||
if (isGhostMapsEventExpired(dateMs, at, ageDays)) return;
|
||||
const key = `${product.id}:${index}`;
|
||||
const { style } = feature;
|
||||
const entities = [];
|
||||
feature.geometries.forEach((geometry, part) => {
|
||||
const id = `${PICK_PREFIX}${key}:${part}`;
|
||||
if (geometry.type === 'point') {
|
||||
@@ -255,7 +307,9 @@ export function createGhostMapsLayer({
|
||||
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
||||
},
|
||||
});
|
||||
entities.push(entity);
|
||||
pins.push({
|
||||
index,
|
||||
position,
|
||||
get show() {
|
||||
return entity.show;
|
||||
@@ -265,61 +319,76 @@ export function createGhostMapsLayer({
|
||||
},
|
||||
});
|
||||
} else if (geometry.type === 'line') {
|
||||
dataSource.entities.add({
|
||||
id,
|
||||
polyline: {
|
||||
positions: Cesium.Cartesian3.fromDegreesArray(
|
||||
geometry.positions.flat(),
|
||||
),
|
||||
width: Math.min(8, Math.max(1.5, style.lineWidth ?? 2)),
|
||||
material: cesiumColor(style.lineColor, color),
|
||||
clampToGround: true,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
const ring = (points) =>
|
||||
Cesium.Cartesian3.fromDegreesArray(points.flat());
|
||||
dataSource.entities.add({
|
||||
id,
|
||||
polygon: {
|
||||
hierarchy: new Cesium.PolygonHierarchy(
|
||||
ring(geometry.outer),
|
||||
geometry.holes.map(
|
||||
(hole) => new Cesium.PolygonHierarchy(ring(hole)),
|
||||
),
|
||||
),
|
||||
material:
|
||||
style.fill === false
|
||||
? Cesium.Color.TRANSPARENT
|
||||
: cesiumColor(
|
||||
style.polyColor,
|
||||
color,
|
||||
style.polyColor ? undefined : 0.25,
|
||||
),
|
||||
classificationType: Cesium.ClassificationType.BOTH,
|
||||
},
|
||||
});
|
||||
if (style.outline !== false)
|
||||
entities.push(
|
||||
dataSource.entities.add({
|
||||
id: `${id}:outline`,
|
||||
id,
|
||||
polyline: {
|
||||
positions: ring([...geometry.outer, geometry.outer[0]]),
|
||||
width: Math.min(6, Math.max(1.5, style.lineWidth ?? 2)),
|
||||
positions: Cesium.Cartesian3.fromDegreesArray(
|
||||
geometry.positions.flat(),
|
||||
),
|
||||
width: Math.min(8, Math.max(1.5, style.lineWidth ?? 2)),
|
||||
material: cesiumColor(style.lineColor, color),
|
||||
clampToGround: true,
|
||||
},
|
||||
});
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
const ring = (points) =>
|
||||
Cesium.Cartesian3.fromDegreesArray(points.flat());
|
||||
entities.push(
|
||||
dataSource.entities.add({
|
||||
id,
|
||||
polygon: {
|
||||
hierarchy: new Cesium.PolygonHierarchy(
|
||||
ring(geometry.outer),
|
||||
geometry.holes.map(
|
||||
(hole) => new Cesium.PolygonHierarchy(ring(hole)),
|
||||
),
|
||||
),
|
||||
material:
|
||||
style.fill === false
|
||||
? Cesium.Color.TRANSPARENT
|
||||
: cesiumColor(
|
||||
style.polyColor,
|
||||
color,
|
||||
style.polyColor ? undefined : 0.25,
|
||||
),
|
||||
classificationType: Cesium.ClassificationType.BOTH,
|
||||
},
|
||||
}),
|
||||
);
|
||||
if (style.outline !== false)
|
||||
entities.push(
|
||||
dataSource.entities.add({
|
||||
id: `${id}:outline`,
|
||||
polyline: {
|
||||
positions: ring([...geometry.outer, geometry.outer[0]]),
|
||||
width: Math.min(6, Math.max(1.5, style.lineWidth ?? 2)),
|
||||
material: cesiumColor(style.lineColor, color),
|
||||
clampToGround: true,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
features.push({
|
||||
features.set(index, {
|
||||
name: feature.name,
|
||||
folder: feature.folder,
|
||||
description: feature.description,
|
||||
dateMs,
|
||||
entities,
|
||||
anchor: anchorOf(feature.geometries[0]),
|
||||
});
|
||||
});
|
||||
dataSource.entities.resumeEvents();
|
||||
return { version: product.version, dataSource, urls, features, pins };
|
||||
return {
|
||||
version: product.version,
|
||||
ageDays,
|
||||
dataSource,
|
||||
urls,
|
||||
features,
|
||||
pins,
|
||||
};
|
||||
} catch (error) {
|
||||
urls.forEach((url) => URL.revokeObjectURL(url));
|
||||
throw error;
|
||||
@@ -330,6 +399,7 @@ export function createGhostMapsLayer({
|
||||
function sync() {
|
||||
const run = _queue.then(async () => {
|
||||
if (!_enabled || !_viewer || !_catalog) return;
|
||||
expireEvents();
|
||||
const signal = _lifetime.signal;
|
||||
const products = new Map(_catalog.products.map((p) => [p.id, p]));
|
||||
for (const id of [..._loaded.keys()]) {
|
||||
@@ -338,21 +408,33 @@ export function createGhostMapsLayer({
|
||||
}
|
||||
for (const product of _catalog.products) {
|
||||
if (!wanted(product) || signal.aborted) continue;
|
||||
if (_loaded.get(product.id)?.version === product.version) continue;
|
||||
const loaded = _loaded.get(product.id);
|
||||
if (loaded?.version === product.version && loaded.ageDays === _ageDays)
|
||||
continue;
|
||||
_loading.set(product.id, product.version);
|
||||
_failed.delete(product.id);
|
||||
notifyRow();
|
||||
try {
|
||||
const next = await load(product, signal);
|
||||
if (signal.aborted || !_enabled) {
|
||||
if (signal.aborted || !_enabled || next.ageDays !== _ageDays) {
|
||||
next.urls.forEach((url) => URL.revokeObjectURL(url));
|
||||
return;
|
||||
}
|
||||
// Cull before the first frame so far-side pins never flash.
|
||||
_horizon.update(next.pins);
|
||||
await _viewer.dataSources.add(next.dataSource);
|
||||
// The new edition is on the globe before the old one leaves.
|
||||
unload(product.id);
|
||||
// The new edition is on the globe before the old one leaves. The card
|
||||
// stays only if its index still holds the same report: the server
|
||||
// may answer a pinned version with newer bytes.
|
||||
const index = Number(_selectedKey?.split(':')[1]);
|
||||
const before = loaded?.features.get(index);
|
||||
const after = next.features.get(index);
|
||||
unload(
|
||||
product.id,
|
||||
Boolean(before && after) &&
|
||||
before.name === after.name &&
|
||||
before.description === after.description,
|
||||
);
|
||||
_loaded.set(product.id, next);
|
||||
} catch (error) {
|
||||
if (signal.aborted) return;
|
||||
@@ -391,12 +473,38 @@ export function createGhostMapsLayer({
|
||||
void sync();
|
||||
}
|
||||
|
||||
function setAgeDays(ageDays) {
|
||||
if (!GHOSTMAPS_AGE_OPTIONS.includes(ageDays) || ageDays === _ageDays)
|
||||
return;
|
||||
_ageDays = ageDays;
|
||||
try {
|
||||
storage?.setItem(AGE_STORAGE_KEY, JSON.stringify(ageDays));
|
||||
} catch {
|
||||
/* private window or blocked storage: keep the session choice */
|
||||
}
|
||||
expireEvents();
|
||||
// Narrowing only removes markers. A product loaded with a narrower window
|
||||
// keeps its old ageDays, so sync() restores omitted placemarks from the
|
||||
// cached KMZ even though its upstream version has not changed.
|
||||
for (const [id, item] of _loaded)
|
||||
if (
|
||||
item.ageDays === null ||
|
||||
(ageDays !== null && item.ageDays >= ageDays)
|
||||
) {
|
||||
item.ageDays = ageDays;
|
||||
// A failed wider reload no longer matters; a stale edition retries.
|
||||
_failed.delete(id);
|
||||
}
|
||||
notifyRow();
|
||||
return sync();
|
||||
}
|
||||
|
||||
// ---- selection card ----------------------------------------------------
|
||||
function featureFor(key) {
|
||||
const [productId, index] = key.split(':');
|
||||
return {
|
||||
product: _catalog?.products.find((p) => p.id === productId) || null,
|
||||
feature: _loaded.get(productId)?.features[Number(index)] || null,
|
||||
feature: _loaded.get(productId)?.features.get(Number(index)) || null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -490,7 +598,7 @@ export function createGhostMapsLayer({
|
||||
if (typeof pickId !== 'string' || !pickId.startsWith(PICK_PREFIX))
|
||||
return null;
|
||||
const [productId, index] = pickId.slice(PICK_PREFIX.length).split(':');
|
||||
return _loaded.get(productId)?.features[Number(index)]
|
||||
return _loaded.get(productId)?.features.has(Number(index))
|
||||
? `${productId}:${index}`
|
||||
: null;
|
||||
}
|
||||
@@ -542,7 +650,7 @@ export function createGhostMapsLayer({
|
||||
|
||||
function featureCount() {
|
||||
let count = 0;
|
||||
for (const item of _loaded.values()) count += item.features.length;
|
||||
for (const item of _loaded.values()) count += item.features.size;
|
||||
return count;
|
||||
}
|
||||
|
||||
@@ -563,6 +671,8 @@ export function createGhostMapsLayer({
|
||||
enable() {
|
||||
_enabled = true;
|
||||
_lifetime = new AbortController();
|
||||
if (_expiryTimer === null)
|
||||
_expiryTimer = setInterval(expireEvents, 60_000);
|
||||
for (const item of _loaded.values()) item.dataSource.show = true;
|
||||
installClickHandler();
|
||||
if (!_unsubscribe && typeof source.subscribe === 'function')
|
||||
@@ -580,6 +690,8 @@ export function createGhostMapsLayer({
|
||||
|
||||
disable() {
|
||||
_enabled = false;
|
||||
clearInterval(_expiryTimer);
|
||||
_expiryTimer = null;
|
||||
_lifetime.abort();
|
||||
_unsubscribe?.();
|
||||
_unsubscribe = null;
|
||||
@@ -593,6 +705,7 @@ export function createGhostMapsLayer({
|
||||
|
||||
async update(_viewerArg, { signal } = {}) {
|
||||
if (!_enabled || signal?.aborted) return false;
|
||||
expireEvents();
|
||||
try {
|
||||
const catalog = await source.getCatalog({ signal });
|
||||
if (!_enabled) return false;
|
||||
@@ -637,6 +750,25 @@ export function createGhostMapsLayer({
|
||||
})
|
||||
: t('Connecting to the S2 GhostMaps repository…');
|
||||
return {
|
||||
segmented: {
|
||||
id: 'age',
|
||||
label: t('Event age'),
|
||||
options: GHOSTMAPS_AGE_OPTIONS.map((days) => ({
|
||||
id: `age-${days ?? 'all'}`,
|
||||
label:
|
||||
days === null
|
||||
? t('All', { context: 'event age filter' })
|
||||
: t('{days}d', { days: formatNumber(days) }),
|
||||
title:
|
||||
days === null
|
||||
? t('Show markers of every age')
|
||||
: t('Maximum event age: {days}d; undated markers are kept', {
|
||||
days: formatNumber(days),
|
||||
}),
|
||||
active: days === _ageDays,
|
||||
onClick: () => setAgeDays(days),
|
||||
})),
|
||||
},
|
||||
chips: products.map((product) => {
|
||||
const loaded = _loaded.get(product.id);
|
||||
const failed = _failed.get(product.id);
|
||||
@@ -651,11 +783,11 @@ export function createGhostMapsLayer({
|
||||
product.fileTitle,
|
||||
loaded
|
||||
? tn(
|
||||
loaded.features.length,
|
||||
loaded.features.size,
|
||||
'{count} placemark',
|
||||
'{count} placemarks',
|
||||
{
|
||||
count: formatNumber(loaded.features.length),
|
||||
count: formatNumber(loaded.features.size),
|
||||
},
|
||||
)
|
||||
: null,
|
||||
@@ -667,7 +799,7 @@ export function createGhostMapsLayer({
|
||||
};
|
||||
}),
|
||||
legend: [],
|
||||
info,
|
||||
info: `${_ageDays === null ? t('All markers') : t('Event age: {days}d · undated kept', { days: formatNumber(_ageDays) })} · ${info}`,
|
||||
infoTitle: t(
|
||||
'S2 Underground publishes these KMZ products to GitHub. New uploads are detected within seconds and replace the previous edition automatically. Click a marker for its report.',
|
||||
),
|
||||
@@ -677,6 +809,7 @@ export function createGhostMapsLayer({
|
||||
getStats() {
|
||||
return {
|
||||
count: featureCount(),
|
||||
ageDays: _ageDays,
|
||||
products: [..._loaded.keys()],
|
||||
commit: _catalog?.commit || null,
|
||||
lastUpdate: _catalog?.committedAt || _catalog?.changedAt || null,
|
||||
@@ -693,7 +826,7 @@ export function createGhostMapsLayer({
|
||||
if (!_enabled) return [];
|
||||
const out = [];
|
||||
for (const [productId, item] of _loaded) {
|
||||
for (const feature of item.features) {
|
||||
for (const feature of item.features.values()) {
|
||||
if (out.length >= maxCount) return out;
|
||||
out.push({
|
||||
product: productId,
|
||||
|
||||
@@ -2,6 +2,7 @@ import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import * as Cesium from 'cesium';
|
||||
import { createGhostMapsLayer } from './index.js';
|
||||
import { GHOSTMAPS_PRODUCTS, GHOSTMAPS_LATEST_DROP } from './catalog.js';
|
||||
|
||||
function element(localName, children = [], value = '', attributes = {}) {
|
||||
const node = {
|
||||
@@ -22,7 +23,7 @@ function element(localName, children = [], value = '', attributes = {}) {
|
||||
return node;
|
||||
}
|
||||
|
||||
function documentFixture() {
|
||||
function documentFixture(reports = [{ name: 'Test report' }]) {
|
||||
return element('document', [
|
||||
element('kml', [
|
||||
element('Document', [
|
||||
@@ -36,11 +37,14 @@ function documentFixture() {
|
||||
'',
|
||||
{ id: 'pin' },
|
||||
),
|
||||
element('Placemark', [
|
||||
element('name', [], 'Test report'),
|
||||
element('styleUrl', [], '#pin'),
|
||||
element('Point', [element('coordinates', [], '12,34')]),
|
||||
]),
|
||||
...reports.map(({ name, description = '', geometry }) =>
|
||||
element('Placemark', [
|
||||
element('name', [], name),
|
||||
element('description', [], description),
|
||||
element('styleUrl', [], '#pin'),
|
||||
geometry || element('Point', [element('coordinates', [], '12,34')]),
|
||||
]),
|
||||
),
|
||||
]),
|
||||
]),
|
||||
]);
|
||||
@@ -83,11 +87,21 @@ function fixtureKmz() {
|
||||
return new Uint8Array(Buffer.concat([...local, directory, end]));
|
||||
}
|
||||
|
||||
function fixture(t, { beforeFileReturns, onCreateUrl } = {}) {
|
||||
function fixture(
|
||||
t,
|
||||
{
|
||||
beforeFileReturns,
|
||||
onCreateUrl,
|
||||
reports,
|
||||
now,
|
||||
products,
|
||||
controls = {},
|
||||
} = {},
|
||||
) {
|
||||
const previous = Object.getOwnPropertyDescriptor(globalThis, 'DOMParser');
|
||||
globalThis.DOMParser = class {
|
||||
parseFromString() {
|
||||
return documentFixture();
|
||||
return documentFixture(reports);
|
||||
}
|
||||
};
|
||||
t.after(() => {
|
||||
@@ -119,9 +133,13 @@ function fixture(t, { beforeFileReturns, onCreateUrl } = {}) {
|
||||
};
|
||||
const layer = createGhostMapsLayer({
|
||||
storage: null,
|
||||
now,
|
||||
...controls,
|
||||
source: {
|
||||
getCatalog: async () => ({
|
||||
products: [{ id: 'cip', version: 'a'.repeat(40), defaultOn: true }],
|
||||
products: products ?? [
|
||||
{ id: 'cip', version: 'a'.repeat(40), defaultOn: true },
|
||||
],
|
||||
}),
|
||||
getFile: async () => {
|
||||
beforeFileReturns?.(layer);
|
||||
@@ -178,3 +196,416 @@ test('successful load keeps icons until the product is disabled', async (t) => {
|
||||
assert.deepEqual(revoked, created);
|
||||
assert.equal(mounted.size, 0);
|
||||
});
|
||||
|
||||
const dated = (name, value, label = 'Date') => ({
|
||||
name,
|
||||
description: `<table><tr><td>${label}</td><td>${value}</td></tr></table>`,
|
||||
});
|
||||
|
||||
const ageChip = (layer, days) =>
|
||||
layer
|
||||
.getRowControls()
|
||||
.segmented.options.find(({ id }) => id === `age-${days ?? 'all'}`);
|
||||
|
||||
test('age controls default to seven days and widening restores filtered and expired reports', async (t) => {
|
||||
let at = Date.UTC(2026, 10, 3, 12);
|
||||
const { layer, viewer, mounted } = fixture(t, {
|
||||
now: () => at,
|
||||
reports: [
|
||||
dated('Old', '10/1/2026'),
|
||||
dated('Ten days', '10/24/2026'),
|
||||
dated('Recent', '11/2/2026'),
|
||||
{ name: 'Undated' },
|
||||
],
|
||||
});
|
||||
await layer.update(viewer);
|
||||
assert.equal(layer.getStats().ageDays, 7);
|
||||
assert.equal(ageChip(layer, 7).active, true);
|
||||
assert.equal(layer.getStats().count, 2);
|
||||
await ageChip(layer, 14).onClick();
|
||||
assert.equal(layer.getStats().count, 3);
|
||||
await ageChip(layer, null).onClick();
|
||||
assert.equal(layer.getStats().count, 4);
|
||||
at += 100 * 86400_000;
|
||||
await layer.update(viewer);
|
||||
assert.equal(layer.getStats().count, 4, 'all markers never ages out');
|
||||
await ageChip(layer, 1).onClick();
|
||||
assert.equal(layer.getStats().count, 1);
|
||||
assert.equal(layer.getAnalystRecords()[0].name, 'Undated');
|
||||
await ageChip(layer, null).onClick();
|
||||
assert.equal(layer.getStats().count, 4);
|
||||
assert.equal(mounted.size, 1, 'filter changes never duplicate data sources');
|
||||
const controls = layer.getRowControls();
|
||||
assert.equal(
|
||||
controls.segmented.options.filter(({ active }) => active).length,
|
||||
1,
|
||||
);
|
||||
assert.equal(
|
||||
controls.chips.some(({ id }) => id.startsWith('age-')),
|
||||
false,
|
||||
'age options are one switch, not product chips',
|
||||
);
|
||||
assert.equal(ageChip(layer, null).active, true);
|
||||
});
|
||||
|
||||
test('age preferences persist independently of product choices and reject invalid storage', async (t) => {
|
||||
const values = new Map([['gev.ghostmaps.products', '["cip"]']]);
|
||||
const storage = {
|
||||
getItem: (key) => values.get(key) ?? null,
|
||||
setItem: (key, value) => values.set(key, value),
|
||||
};
|
||||
await t.test('save All markers without changing products', async (t) => {
|
||||
const { layer, viewer } = fixture(t, { controls: { storage } });
|
||||
await layer.update(viewer);
|
||||
await ageChip(layer, null).onClick();
|
||||
assert.equal(values.get('gev.ghostmaps.ageDays'), 'null');
|
||||
assert.equal(values.get('gev.ghostmaps.products'), '["cip"]');
|
||||
});
|
||||
await t.test('restore All markers and save thirty days', async (t) => {
|
||||
const { layer } = fixture(t, { controls: { storage } });
|
||||
assert.equal(layer.getStats().ageDays, null);
|
||||
await ageChip(layer, 30).onClick();
|
||||
assert.equal(values.get('gev.ghostmaps.ageDays'), '30');
|
||||
});
|
||||
await t.test('restore thirty days', (t) => {
|
||||
const { layer } = fixture(t, { controls: { storage } });
|
||||
assert.equal(layer.getStats().ageDays, 30);
|
||||
});
|
||||
for (const value of ['garbage', '"7"', '-1', '0', '2', '{}']) {
|
||||
await t.test(`reject ${value}`, (t) => {
|
||||
values.set('gev.ghostmaps.ageDays', value);
|
||||
const { layer } = fixture(t, { controls: { storage } });
|
||||
assert.equal(layer.getStats().ageDays, 7);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('blocked storage still permits changing the age within the session', async (t) => {
|
||||
const storage = {
|
||||
getItem() {
|
||||
throw new Error('blocked');
|
||||
},
|
||||
setItem() {
|
||||
throw new Error('blocked');
|
||||
},
|
||||
};
|
||||
const { layer, viewer } = fixture(t, { controls: { storage } });
|
||||
await layer.update(viewer);
|
||||
assert.equal(layer.getStats().ageDays, 7);
|
||||
await ageChip(layer, null).onClick();
|
||||
assert.equal(layer.getStats().ageDays, null);
|
||||
});
|
||||
|
||||
test('a filter changed during a pending load never installs the superseded filter', async (t) => {
|
||||
let changed = false;
|
||||
const { layer, viewer, mounted, revoked, created } = fixture(t, {
|
||||
now: () => Date.UTC(2026, 10, 3),
|
||||
reports: [dated('Old', '10/1/2026')],
|
||||
beforeFileReturns: (layer) => {
|
||||
if (changed) return;
|
||||
changed = true;
|
||||
ageChip(layer, null).onClick();
|
||||
},
|
||||
});
|
||||
await layer.update(viewer);
|
||||
await layer.update(viewer);
|
||||
assert.equal(layer.getStats().count, 1);
|
||||
assert.equal(layer.getStats().ageDays, null);
|
||||
assert.equal(mounted.size, 1);
|
||||
assert.equal(
|
||||
revoked.length,
|
||||
created.length - 1,
|
||||
'discarded load releases icons',
|
||||
);
|
||||
});
|
||||
|
||||
test('age filtering and All markers apply to every conflict-map product, including optional maps', async (t) => {
|
||||
for (const product of [...GHOSTMAPS_PRODUCTS, GHOSTMAPS_LATEST_DROP]) {
|
||||
await t.test(product.id, async (t) => {
|
||||
const { layer, viewer, mounted } = fixture(t, {
|
||||
now: () => Date.UTC(2026, 10, 3, 12),
|
||||
products: [{ ...product, version: 'a'.repeat(40) }],
|
||||
reports: [
|
||||
dated(
|
||||
'Old event',
|
||||
'10/1/2026',
|
||||
product.id === 'cip' ? 'Date of Event' : 'Date',
|
||||
),
|
||||
dated('Recent event', '11/2/2026'),
|
||||
{ name: 'Undated reference feature' },
|
||||
],
|
||||
});
|
||||
await layer.update(viewer);
|
||||
if (!product.defaultOn) {
|
||||
layer
|
||||
.getRowControls()
|
||||
.chips.find(({ id }) => id === product.id)
|
||||
.onClick();
|
||||
await layer.update(viewer);
|
||||
}
|
||||
assert.deepEqual(layer.getStats().products, [product.id]);
|
||||
assert.equal(layer.getStats().count, 2);
|
||||
assert.deepEqual(
|
||||
layer.getAnalystRecords().map(({ name }) => name),
|
||||
['Recent event', 'Undated reference feature'],
|
||||
);
|
||||
await ageChip(layer, null).onClick();
|
||||
assert.equal(
|
||||
layer.getStats().count,
|
||||
3,
|
||||
"All markers restores this product's old events",
|
||||
);
|
||||
await ageChip(layer, 7).onClick();
|
||||
assert.equal(layer.getStats().count, 2);
|
||||
assert.equal(mounted.size, 1);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('default products filter old event dates while keeping recent, future and undated markers', async (t) => {
|
||||
const { layer, viewer, mounted } = fixture(t, {
|
||||
now: () => Date.UTC(2026, 10, 3, 12),
|
||||
reports: [
|
||||
dated('Old event', '10/20/2026'),
|
||||
dated('Recent event', '11/2/2026'),
|
||||
dated('At cutoff', '10/27/2026 12:00:00 PM'),
|
||||
dated('Before cutoff', '10/27/2026 11:59:59 AM'),
|
||||
dated('Future event', '11/4/2026'),
|
||||
{ name: 'Undated site' },
|
||||
dated('Blank date', '<Null>'),
|
||||
dated('Invalid date', '2/30/2026'),
|
||||
dated('Only a report date', '10/1/2026', 'Date Of Report'),
|
||||
{
|
||||
name: 'Old event reported recently',
|
||||
description:
|
||||
'<table><tr><td>Date of Event</td><td>10/1/2026</td></tr><tr><td>Date Of Report</td><td>11/2/2026</td></tr></table>',
|
||||
},
|
||||
],
|
||||
});
|
||||
await layer.update(viewer);
|
||||
assert.deepEqual(layer.getStats().products, ['cip']);
|
||||
assert.deepEqual(
|
||||
layer.getAnalystRecords().map((r) => r.name),
|
||||
[
|
||||
'Recent event',
|
||||
'At cutoff',
|
||||
'Future event',
|
||||
'Undated site',
|
||||
'Blank date',
|
||||
'Invalid date',
|
||||
'Only a report date',
|
||||
],
|
||||
);
|
||||
assert.equal(layer.getStats().count, 7);
|
||||
assert.equal([...mounted][0].entities.values.length, 7);
|
||||
// Filtering cannot shift pick ids onto a different report.
|
||||
assert.ok([...mounted][0].entities.getById('ghostmaps:cip:1:0'));
|
||||
assert.equal(
|
||||
[...mounted][0].entities.getById('ghostmaps:cip:0:0'),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
test('unchanged cached products age out on the timer, clear selected cards and keep surviving picks', async (t) => {
|
||||
t.mock.timers.enable({ apis: ['setInterval'] });
|
||||
let at = Date.UTC(2026, 10, 3, 12);
|
||||
let click;
|
||||
const cards = [];
|
||||
const { layer, viewer, mounted } = fixture(t, {
|
||||
now: () => at,
|
||||
reports: [
|
||||
dated('Expiring', '10/27/2026 12:00:00 PM'),
|
||||
dated('Surviving', '11/2/2026'),
|
||||
],
|
||||
controls: {
|
||||
overlayHost: { setEntries: (_id, entries) => cards.push(entries) },
|
||||
screenSpaceEventHandlerFactory: () => ({
|
||||
setInputAction: (fn) => {
|
||||
click = fn;
|
||||
},
|
||||
destroy() {},
|
||||
}),
|
||||
picking: { resolvePickId: (picked) => picked },
|
||||
},
|
||||
});
|
||||
await layer.update(viewer);
|
||||
const ds = [...mounted][0];
|
||||
viewer.scene.pick = () => 'ghostmaps:cip:0:0';
|
||||
click({ position: { x: 0, y: 0 } });
|
||||
assert.equal(cards.at(-1).length, 1);
|
||||
at += 60_000;
|
||||
t.mock.timers.tick(60_000);
|
||||
assert.equal(layer.getStats().count, 1);
|
||||
assert.equal(ds.entities.values.length, 1);
|
||||
assert.equal(ds.entities.getById('ghostmaps:cip:0:0'), undefined);
|
||||
assert.ok(ds.entities.getById('ghostmaps:cip:1:0'));
|
||||
assert.deepEqual(cards.at(-1), []);
|
||||
await layer.update(viewer);
|
||||
assert.equal([...mounted][0], ds, 'same-version KMZ is not reloaded');
|
||||
viewer.scene.pick = () => 'ghostmaps:cip:1:0';
|
||||
click({ position: { x: 0, y: 0 } });
|
||||
assert.match(cards.at(-1)[0].title, /Surviving/);
|
||||
layer.disable();
|
||||
at += 14 * 86400_000;
|
||||
t.mock.timers.tick(60_000);
|
||||
assert.equal(mounted.size, 0);
|
||||
layer.enable();
|
||||
await layer.update(viewer);
|
||||
assert.equal(layer.getStats().count, 0);
|
||||
});
|
||||
|
||||
test('expiry removes every geometry and polygon outline belonging to an old report', async (t) => {
|
||||
t.mock.timers.enable({ apis: ['setInterval'] });
|
||||
// 10/27 is a whole UTC day: at 7d it expires when 11/3 ends.
|
||||
let at = Date.UTC(2026, 10, 4) - 30_000;
|
||||
const polygon = element('Polygon', [
|
||||
element('outerBoundaryIs', [
|
||||
element('LinearRing', [
|
||||
element('coordinates', [], '12,34 13,34 13,35 12,34'),
|
||||
]),
|
||||
]),
|
||||
]);
|
||||
const { layer, viewer, mounted } = fixture(t, {
|
||||
now: () => at,
|
||||
reports: [
|
||||
{
|
||||
...dated('Multi-part', '10/27/2026'),
|
||||
geometry: element('MultiGeometry', [
|
||||
element('Point', [element('coordinates', [], '12,34')]),
|
||||
element('LineString', [element('coordinates', [], '12,34 13,35')]),
|
||||
polygon,
|
||||
]),
|
||||
},
|
||||
],
|
||||
});
|
||||
await layer.update(viewer);
|
||||
const ds = [...mounted][0];
|
||||
assert.equal(ds.entities.values.length, 4);
|
||||
at += 60_000;
|
||||
t.mock.timers.tick(60_000);
|
||||
assert.equal(ds.entities.values.length, 0);
|
||||
assert.equal(layer.getStats().count, 0);
|
||||
});
|
||||
|
||||
test('narrowing then returning to the previous window restores its reports without a duplicate source', async (t) => {
|
||||
const { layer, viewer, mounted } = fixture(t, {
|
||||
now: () => Date.UTC(2026, 10, 3, 12),
|
||||
reports: [dated('Today', '11/3/2026'), dated('Three days', '10/31/2026')],
|
||||
});
|
||||
await layer.update(viewer);
|
||||
assert.equal(layer.getStats().count, 2);
|
||||
await ageChip(layer, 1).onClick();
|
||||
assert.equal(layer.getStats().count, 1);
|
||||
await ageChip(layer, 7).onClick();
|
||||
await layer.update(viewer);
|
||||
assert.equal(layer.getStats().count, 2, '7d must show both reports again');
|
||||
assert.equal(mounted.size, 1);
|
||||
});
|
||||
|
||||
test('two quick clicks, 1d then 7d, restore the report', async (t) => {
|
||||
const at = Date.UTC(2026, 10, 3, 12);
|
||||
const { layer, viewer } = fixture(t, {
|
||||
now: () => at,
|
||||
reports: [dated('Today', '11/3/2026'), dated('Three days', '10/31/2026')],
|
||||
});
|
||||
await layer.update(viewer);
|
||||
const a = ageChip(layer, 1).onClick();
|
||||
const b = ageChip(layer, 7).onClick();
|
||||
await a;
|
||||
await b;
|
||||
await layer.update(viewer);
|
||||
assert.equal(layer.getStats().count, 2);
|
||||
});
|
||||
|
||||
test('narrowing the window keeps the open card of a surviving marker', async (t) => {
|
||||
let click;
|
||||
const cards = [];
|
||||
const { layer, viewer } = fixture(t, {
|
||||
now: () => Date.UTC(2026, 10, 3, 12),
|
||||
reports: [dated('Recent', '11/2/2026'), dated('Older', '10/20/2026')],
|
||||
controls: {
|
||||
overlayHost: { setEntries: (_id, entries) => cards.push(entries) },
|
||||
screenSpaceEventHandlerFactory: () => ({
|
||||
setInputAction: (fn) => {
|
||||
click = fn;
|
||||
},
|
||||
destroy() {},
|
||||
}),
|
||||
picking: { resolvePickId: (picked) => picked },
|
||||
},
|
||||
});
|
||||
await layer.update(viewer);
|
||||
await ageChip(layer, 30).onClick();
|
||||
assert.equal(layer.getStats().count, 2);
|
||||
viewer.scene.pick = () => 'ghostmaps:cip:0:0';
|
||||
click({ position: { x: 0, y: 0 } });
|
||||
assert.equal(cards.at(-1).length, 1);
|
||||
await ageChip(layer, 3).onClick();
|
||||
assert.equal(layer.getStats().count, 1);
|
||||
assert.equal(
|
||||
cards.at(-1).length,
|
||||
1,
|
||||
'card of the surviving marker stays open',
|
||||
);
|
||||
});
|
||||
|
||||
test('a report dated yesterday is shown by 1d one hour into today (UTC)', async (t) => {
|
||||
const { layer, viewer } = fixture(t, {
|
||||
now: () => Date.UTC(2026, 10, 6, 1),
|
||||
reports: [dated('Yesterday', '11/5/2026')],
|
||||
});
|
||||
await layer.update(viewer);
|
||||
await ageChip(layer, 1).onClick();
|
||||
assert.equal(layer.getStats().count, 1);
|
||||
});
|
||||
|
||||
test('a widened reload that returns a different report at the selected index closes its card', async (t) => {
|
||||
let click;
|
||||
const cards = [];
|
||||
const reports = [dated('Recent', '11/2/2026'), dated('Older', '10/20/2026')];
|
||||
const { layer, viewer } = fixture(t, {
|
||||
now: () => Date.UTC(2026, 10, 3, 12),
|
||||
reports,
|
||||
controls: {
|
||||
overlayHost: { setEntries: (_id, entries) => cards.push(entries) },
|
||||
screenSpaceEventHandlerFactory: () => ({
|
||||
setInputAction: (fn) => {
|
||||
click = fn;
|
||||
},
|
||||
destroy() {},
|
||||
}),
|
||||
picking: { resolvePickId: (picked) => picked },
|
||||
},
|
||||
});
|
||||
await layer.update(viewer);
|
||||
viewer.scene.pick = () => 'ghostmaps:cip:0:0';
|
||||
click({ position: { x: 0, y: 0 } });
|
||||
assert.equal(cards.at(-1).length, 1);
|
||||
// The server answers the pinned version with a newer edition's bytes.
|
||||
reports[0] = dated('Replacement', '11/2/2026');
|
||||
await ageChip(layer, 30).onClick();
|
||||
assert.equal(layer.getStats().count, 2);
|
||||
assert.equal(
|
||||
cards.at(-1).length,
|
||||
0,
|
||||
'a different report must not keep the old card',
|
||||
);
|
||||
});
|
||||
|
||||
test('narrowing to a window the loaded data covers clears a failed wider reload', async (t) => {
|
||||
let fail = false;
|
||||
const { layer, viewer } = fixture(t, {
|
||||
now: () => Date.UTC(2026, 10, 3, 12),
|
||||
reports: [dated('Recent', '11/2/2026'), dated('Older', '10/20/2026')],
|
||||
beforeFileReturns: () => {
|
||||
if (fail) throw new Error('network down');
|
||||
},
|
||||
});
|
||||
await layer.update(viewer);
|
||||
fail = true;
|
||||
await ageChip(layer, 30).onClick();
|
||||
assert.ok(layer.getStats().error, 'the wider reload failed');
|
||||
await ageChip(layer, 3).onClick();
|
||||
assert.equal(layer.getStats().error, null);
|
||||
assert.equal(layer.getStats().count, 1);
|
||||
});
|
||||
+103
-11
@@ -1,4 +1,4 @@
|
||||
// Pure helpers for GhostMaps placemarks: description tables to plain fields,
|
||||
// Pure helpers for GhostMaps placemarks: report fields and event dates,
|
||||
// KML colors, and card text wrapping. Strings in, data out — description
|
||||
// markup is only ever read as text, never inserted into the page.
|
||||
|
||||
@@ -51,6 +51,106 @@ const MAX_FIELDS = 24;
|
||||
const MAX_VALUE = 600;
|
||||
const MAX_LINKS = 6;
|
||||
|
||||
// Use the same leaf rows for report cards and event dates, without the card's
|
||||
// field-count/value-length limits affecting the age filter.
|
||||
function* descriptionRows(source) {
|
||||
for (const row of source.matchAll(
|
||||
/<tr\b[^>]*>((?:(?!<tr\b)[\s\S])*?)<\/tr\s*>/gi,
|
||||
)) {
|
||||
const cells = [
|
||||
...row[1].matchAll(/<t[dh]\b[^>]*>([\s\S]*?)<\/t[dh]\s*>/gi),
|
||||
].map((cell) => cell[1]);
|
||||
if (cells.length === 2)
|
||||
yield {
|
||||
label: htmlToText(cells[0]),
|
||||
value: htmlToText(cells[1]),
|
||||
markup: cells[1],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const GHOSTMAPS_DEFAULT_AGE_DAYS = 7;
|
||||
export const GHOSTMAPS_AGE_OPTIONS = Object.freeze([1, 3, 7, 14, 30, null]);
|
||||
|
||||
/** ArcGIS US dates (optionally with a time) and ISO dates, interpreted in UTC
|
||||
* when no zone is supplied. A date without a time means the whole UTC day, so
|
||||
* it resolves to the day's last millisecond: "1d" still shows yesterday's
|
||||
* reports. Reject calendar rollovers instead of guessing. */
|
||||
export function parseGhostMapsEventDate(value) {
|
||||
const input = String(value ?? '').trim();
|
||||
const us =
|
||||
/^(\d{1,2})\/(\d{1,2})\/(\d{4})(?:\s+(\d{1,2}):(\d{2})(?::(\d{2}))?\s*(AM|PM))?$/i.exec(
|
||||
input,
|
||||
);
|
||||
const iso =
|
||||
/^(\d{4})-(\d{2})-(\d{2})(?:T(\d{2}):(\d{2})(?::(\d{2})(?:\.\d{1,3})?)?(?:Z|[+-]\d{2}:\d{2})?)?$/.exec(
|
||||
input,
|
||||
);
|
||||
if (!us && !iso) return null;
|
||||
const year = Number(us ? us[3] : iso[1]);
|
||||
const month = Number(us ? us[1] : iso[2]);
|
||||
const day = Number(us ? us[2] : iso[3]);
|
||||
let hour = Number(us?.[4] ?? iso?.[4] ?? 0);
|
||||
const minute = Number(us?.[5] ?? iso?.[5] ?? 0);
|
||||
const second = Number(us?.[6] ?? iso?.[6] ?? 0);
|
||||
if (us?.[7]) {
|
||||
if (hour < 1 || hour > 12) return null;
|
||||
hour = (hour % 12) + (us[7].toUpperCase() === 'PM' ? 12 : 0);
|
||||
}
|
||||
const at = Date.UTC(year, month - 1, day, hour, minute, second);
|
||||
const date = new Date(at);
|
||||
if (
|
||||
year < 1900 ||
|
||||
month < 1 ||
|
||||
month > 12 ||
|
||||
day < 1 ||
|
||||
date.getUTCDate() !== day ||
|
||||
hour > 23 ||
|
||||
minute > 59 ||
|
||||
second > 59
|
||||
)
|
||||
return null;
|
||||
if (iso?.[4]) {
|
||||
const zoned = /(?:Z|[+-]\d{2}:\d{2})$/.test(input) ? input : `${input}Z`;
|
||||
const parsed = Date.parse(zoned);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
return us?.[4] ? at : at + 86400_000 - 1;
|
||||
}
|
||||
|
||||
/** The event's date, never its report/upload date or dates mentioned in prose. */
|
||||
export function ghostMapsEventDate(description) {
|
||||
const rows = [
|
||||
...descriptionRows(
|
||||
String(description ?? '').replace(
|
||||
/<(script|style|head)\b[\s\S]*?<\/\1\s*>/gi,
|
||||
' ',
|
||||
),
|
||||
),
|
||||
];
|
||||
for (const name of ['dateofevent', 'eventdate', 'date']) {
|
||||
for (const { label, value } of rows) {
|
||||
if (label.toLowerCase().replace(/[\s_]+/g, '') !== name) continue;
|
||||
const at = parseGhostMapsEventDate(value);
|
||||
if (at !== null) return at;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Unknown and future dates stay visible; only a confirmed old event expires. */
|
||||
export function isGhostMapsEventExpired(
|
||||
dateMs,
|
||||
now = Date.now(),
|
||||
ageDays = GHOSTMAPS_DEFAULT_AGE_DAYS,
|
||||
) {
|
||||
return (
|
||||
ageDays !== null &&
|
||||
Number.isFinite(dateMs) &&
|
||||
dateMs < now - ageDays * 86400_000
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fields and links from a placemark description. ArcGIS "popup" exports are a
|
||||
* two-column table (label, value); anything else becomes one unlabelled field.
|
||||
@@ -73,18 +173,10 @@ export function parseDescriptionFields(html) {
|
||||
};
|
||||
// Innermost rows only: ArcGIS nests the field table inside an outer row,
|
||||
// and only leaf rows with exactly two cells are label/value pairs.
|
||||
for (const row of source.matchAll(
|
||||
/<tr\b[^>]*>((?:(?!<tr\b)[\s\S])*?)<\/tr\s*>/gi,
|
||||
)) {
|
||||
const cells = [
|
||||
...row[1].matchAll(/<t[dh]\b[^>]*>([\s\S]*?)<\/t[dh]\s*>/gi),
|
||||
].map((cell) => cell[1]);
|
||||
if (cells.length !== 2) continue;
|
||||
const label = htmlToText(cells[0]);
|
||||
const value = htmlToText(cells[1]);
|
||||
for (const { label, value, markup } of descriptionRows(source)) {
|
||||
if (!label || SKIPPED_LABELS.has(label.toLowerCase())) continue;
|
||||
if (EMPTY_VALUES.has(value.toLowerCase())) continue;
|
||||
addLinks(cells[1]);
|
||||
addLinks(markup);
|
||||
// A bare research link is carried by `links`; the card does not need
|
||||
// the URL twice.
|
||||
if (/^https?:\/\/\S+$/i.test(value)) continue;
|
||||
|
||||
@@ -7,8 +7,83 @@ import {
|
||||
kmlColor,
|
||||
parseDescriptionFields,
|
||||
wrapCardText,
|
||||
ghostMapsEventDate,
|
||||
isGhostMapsEventExpired,
|
||||
parseGhostMapsEventDate,
|
||||
} from './records.js';
|
||||
|
||||
test('event dates parse ArcGIS calendar dates and timestamps consistently in UTC', () => {
|
||||
for (const [value, iso] of [
|
||||
['10/28/2026', '2026-10-28T23:59:59.999Z'],
|
||||
['10/28/2026 6:12:00 PM', '2026-10-28T18:12:00Z'],
|
||||
['1/17/2026 9:46:41 AM', '2026-01-17T09:46:41Z'],
|
||||
['10/28/2026 12:00 AM', '2026-10-28T00:00:00Z'],
|
||||
['10/28/2026 12:00 PM', '2026-10-28T12:00:00Z'],
|
||||
['2026-10-28', '2026-10-28T23:59:59.999Z'],
|
||||
['2026-10-28T12:34:56', '2026-10-28T12:34:56Z'],
|
||||
['2026-10-28T12:34:56.123-04:00', '2026-10-28T16:34:56.123Z'],
|
||||
['2/29/2024', '2024-02-29T23:59:59.999Z'],
|
||||
])
|
||||
assert.equal(parseGhostMapsEventDate(value), Date.parse(iso), value);
|
||||
for (const value of [
|
||||
'',
|
||||
'<Null>',
|
||||
'N/A',
|
||||
'unknown',
|
||||
'2/29/2026',
|
||||
'9/31/2026',
|
||||
'2026-02-30',
|
||||
'13/1/2026',
|
||||
'1/0/2026',
|
||||
'10/28/2026 13:00 PM',
|
||||
'2026-10-28T24:00:00Z',
|
||||
'2026-10-28T12:60:00Z',
|
||||
]) {
|
||||
assert.equal(parseGhostMapsEventDate(value), null, value);
|
||||
}
|
||||
});
|
||||
|
||||
test('event date fields take precedence over report dates and prose, even after long report tables', () => {
|
||||
const row = (label, value) => `<tr><td>${label}</td><td>${value}</td></tr>`;
|
||||
const html = `<table><tr><td><table>${
|
||||
row('Date Of Report', '11/2/2026') +
|
||||
row('Description', 'Incident mentioned on 11/1/2026') +
|
||||
Array.from({ length: 30 }, (_, i) => row(`Field${i}`, 'value')).join('') +
|
||||
row('Date', '10/30/2026') +
|
||||
row('Date of Event', '10/28/2026')
|
||||
}</table></td></tr></table>`;
|
||||
assert.equal(ghostMapsEventDate(html), Date.UTC(2026, 9, 29) - 1);
|
||||
assert.equal(
|
||||
ghostMapsEventDate(
|
||||
`<table>${row('Date', '<Null>')}${row('Date Of Report', '11/2/2026')}</table>`,
|
||||
),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
ghostMapsEventDate(`<table>${row('Date_Of_Event', '10/28/2026')}</table>`),
|
||||
Date.UTC(2026, 9, 29) - 1,
|
||||
);
|
||||
assert.equal(ghostMapsEventDate('Occurred 10/28/2026'), null);
|
||||
assert.equal(
|
||||
ghostMapsEventDate(
|
||||
`<script><table>${row('Date', '1/1/2020')}</table></script>`,
|
||||
),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test('seven-day expiry keeps the exact boundary, future dates and unknown dates', () => {
|
||||
const at = Date.UTC(2026, 10, 3, 12);
|
||||
assert.equal(isGhostMapsEventExpired(Date.UTC(2026, 9, 27, 12), at), false);
|
||||
assert.equal(
|
||||
isGhostMapsEventExpired(Date.UTC(2026, 9, 27, 12) - 1, at),
|
||||
true,
|
||||
);
|
||||
assert.equal(isGhostMapsEventExpired(Date.UTC(2026, 10, 4), at), false);
|
||||
for (const value of [null, undefined, NaN])
|
||||
assert.equal(isGhostMapsEventExpired(value, at), false);
|
||||
});
|
||||
|
||||
// The shape ArcGIS Pro writes into every S2 placemark description.
|
||||
const ARCGIS = `<html xmlns:fo="http://www.w3.org/1999/XSL/Format"><head>
|
||||
<META http-equiv="Content-Type" content="text/html"></head>
|
||||
@@ -27,6 +102,14 @@ const ARCGIS = `<html xmlns:fo="http://www.w3.org/1999/XSL/Format"><head>
|
||||
function changeImage(a, b) { document.getElementById('x').innerHTML = b; }
|
||||
</script></html>`;
|
||||
|
||||
test('custom ages change the cutoff and all markers disables expiry', () => {
|
||||
const at = Date.UTC(2026, 10, 3, 12);
|
||||
const event = at - 10 * 86400_000;
|
||||
assert.equal(isGhostMapsEventExpired(event, at, 7), true);
|
||||
assert.equal(isGhostMapsEventExpired(event, at, 14), false);
|
||||
assert.equal(isGhostMapsEventExpired(event, at, null), false);
|
||||
});
|
||||
|
||||
test('ArcGIS popup tables become plain label/value fields and http links', () => {
|
||||
const { fields, links } = parseDescriptionFields(ARCGIS);
|
||||
assert.deepEqual(fields, [
|
||||
|
||||
Vendored
+4
@@ -12,6 +12,9 @@ export function createControls({ state: layerState, services, parts, source }) {
|
||||
|
||||
/** Release only Space Mission camera ownership, preserving layer and selection state. */
|
||||
releaseCameraOwnership() {
|
||||
// Navigation takes the camera; a failed entry must not restore the old view.
|
||||
layerState._entryCameraView = null;
|
||||
layerState._cameraReleased = true;
|
||||
parts.panel.clearMissionRosterHover();
|
||||
parts.replay.stopMissionReplay();
|
||||
parts.selection.stopMissionZoomAnchor();
|
||||
@@ -57,6 +60,7 @@ export function createControls({ state: layerState, services, parts, source }) {
|
||||
orbitMatches: layerState._orbitMatches,
|
||||
lastUpdate: layerState._lastUpdate,
|
||||
error: layerState._lastError,
|
||||
errorAt: layerState._lastErrorAt,
|
||||
stale: layerState._stale,
|
||||
};
|
||||
},
|
||||
|
||||
@@ -0,0 +1,495 @@
|
||||
// The real manager, Context coordinator and launches ingestion run against a
|
||||
// stubbed /api/launches, so a failed first load takes the browser's path.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import { DataLayerManager } from '../../data/manager.js';
|
||||
import { ContextControls } from '../../ui/contextControls.js';
|
||||
import { spaceMissionsIndexFailure } from '../../ui/contextActions.js';
|
||||
import { createLaunchSource } from './source.js';
|
||||
import { createState } from './state.js';
|
||||
import { createModel } from './model.js';
|
||||
import { createPolicyHelpers } from './policyHelpers.js';
|
||||
import { createIngestion } from './ingestion.js';
|
||||
import { createPanel } from './panel.js';
|
||||
import { createControls } from './controls.js';
|
||||
import { createLifecycle } from './lifecycle.js';
|
||||
import { createReplay } from './replay.js';
|
||||
|
||||
const noop = () => {};
|
||||
|
||||
const launchRecord = {
|
||||
id: 'fixture-launch',
|
||||
name: 'Fixture mission',
|
||||
net: new Date(Date.now() - 3600000).toISOString(),
|
||||
status: { name: 'Launch Successful' },
|
||||
pad: { name: 'Fixture pad', latitude: 28.6, longitude: -80.6 },
|
||||
rocket: { payloads: [] },
|
||||
};
|
||||
|
||||
function plainLayer(id, name, params = {}) {
|
||||
let current = { ...params };
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
icon: '',
|
||||
source: 'test',
|
||||
updateInterval: -1,
|
||||
init: noop,
|
||||
enable: noop,
|
||||
disable: noop,
|
||||
update: noop,
|
||||
getParams: () => ({ ...current }),
|
||||
setParams(next) {
|
||||
current = { ...current, ...next };
|
||||
return true;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function fakeCamera() {
|
||||
const camera = {
|
||||
position: new Cesium.Cartesian3(1, 2, 3),
|
||||
heading: 0.1,
|
||||
pitch: -0.5,
|
||||
roll: 0,
|
||||
flights: 0,
|
||||
cancelled: 0,
|
||||
flight: null,
|
||||
get positionWC() {
|
||||
return camera.position;
|
||||
},
|
||||
// Like Cesium, a new flight cancels the one in progress.
|
||||
flyTo({ destination, complete, cancel }) {
|
||||
camera.flight?.cancel?.();
|
||||
camera.flight = { complete, cancel };
|
||||
camera.flights += 1;
|
||||
camera.position = Cesium.Cartesian3.clone(destination);
|
||||
camera.heading = 0;
|
||||
camera.pitch = -Math.PI / 2;
|
||||
},
|
||||
land() {
|
||||
const flight = camera.flight;
|
||||
camera.flight = null;
|
||||
flight?.complete?.();
|
||||
},
|
||||
cancelFlight() {
|
||||
camera.cancelled += 1;
|
||||
const flight = camera.flight;
|
||||
camera.flight = null;
|
||||
flight?.cancel?.();
|
||||
},
|
||||
lookAtTransform: noop,
|
||||
setView({ destination, orientation }) {
|
||||
camera.position = Cesium.Cartesian3.clone(destination);
|
||||
camera.heading = orientation.heading;
|
||||
camera.pitch = orientation.pitch;
|
||||
camera.roll = orientation.roll;
|
||||
},
|
||||
};
|
||||
return camera;
|
||||
}
|
||||
|
||||
async function fixture(t) {
|
||||
const upstream = { status: 429, results: [launchRecord] };
|
||||
const source = createLaunchSource({
|
||||
async fetchImpl(url) {
|
||||
if (url === '/api/celestrak/active') return new Response('');
|
||||
if (upstream.hold) await upstream.hold;
|
||||
return Response.json(
|
||||
upstream.status === 200 ? { results: upstream.results } : {},
|
||||
{ status: upstream.status },
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const services = {
|
||||
overlays: {
|
||||
setOverlayEntries: noop,
|
||||
setOverlaySourceVisible: noop,
|
||||
clearOverlaySource: noop,
|
||||
},
|
||||
render: { holdContinuousRender: noop, releaseContinuousRender: noop },
|
||||
};
|
||||
const state = createState({ services });
|
||||
const camera = fakeCamera();
|
||||
const canvas = Object.assign(new EventTarget(), {
|
||||
disableRootEvents: true,
|
||||
onwheel: null,
|
||||
style: {},
|
||||
clientWidth: 1200,
|
||||
clientHeight: 800,
|
||||
});
|
||||
const viewer = {
|
||||
camera,
|
||||
dataSources: new Cesium.DataSourceCollection(),
|
||||
scene: { canvas, preRender: new Cesium.Event(), drillPick: () => [] },
|
||||
};
|
||||
// Scene presentation is stubbed; ingestion, lifecycle and panel are real.
|
||||
const parts = {
|
||||
overlays: {
|
||||
shortMissionLabel: (name) => name,
|
||||
createReplayVehicleOverlay: noop,
|
||||
clearMissionOverlaySources: noop,
|
||||
hideReplayVehicleOverlay: noop,
|
||||
destroyReplayVehicleOverlay: noop,
|
||||
syncMissionOverlayEntries: noop,
|
||||
},
|
||||
selection: {
|
||||
focusFullGlobe(target, flight) {
|
||||
target.camera.flyTo({
|
||||
destination: new Cesium.Cartesian3(9, 9, 9),
|
||||
...flight,
|
||||
});
|
||||
return true;
|
||||
},
|
||||
setSelectedMission: noop,
|
||||
focusMission: noop,
|
||||
stopMissionZoomAnchor: noop,
|
||||
entityLaunchId: () => null,
|
||||
},
|
||||
orbitRendering: {
|
||||
removeMissionOrbitPrimitives: noop,
|
||||
syncMissionOrbitPrimitiveVisibility: noop,
|
||||
},
|
||||
launchPad: {
|
||||
initLaunchPadZonePrimitive: noop,
|
||||
hideLaunchPadZone: noop,
|
||||
destroyLaunchPadZonePrimitive: noop,
|
||||
},
|
||||
rendering: {
|
||||
updateMissionFrame: noop,
|
||||
addLaunchEntity(record) {
|
||||
state._dataSource.entities.add({
|
||||
id: record.id,
|
||||
position: Cesium.Cartesian3.fromDegrees(record.lon, record.lat),
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
const context = { state, services, parts, source };
|
||||
parts.policyHelpers = createPolicyHelpers(context);
|
||||
parts.model = createModel(context);
|
||||
parts.panel = createPanel(context);
|
||||
parts.replay = createReplay(context);
|
||||
parts.ingestion = createIngestion(context);
|
||||
parts.controls = createControls(context);
|
||||
parts.lifecycle = createLifecycle(context);
|
||||
const missions = Object.assign(
|
||||
{},
|
||||
parts.controls.methods,
|
||||
parts.lifecycle.methods,
|
||||
parts.ingestion.methods,
|
||||
);
|
||||
const rosters = [];
|
||||
parts.panel.subscribePanel((view) => rosters.push(view.roster));
|
||||
|
||||
const manager = new DataLayerManager(viewer);
|
||||
manager.register(plainLayer('earthquakes', 'Earthquakes (24h)'));
|
||||
manager.register(plainLayer('satellites', 'Satellites'));
|
||||
manager.register(plainLayer('wind', 'Wind'));
|
||||
manager.register(missions);
|
||||
missions.attachDataManager(manager);
|
||||
|
||||
const toasts = [];
|
||||
const controls = new ContextControls({
|
||||
elements: {},
|
||||
installations: {},
|
||||
actions: {
|
||||
getCockpit: () => null,
|
||||
claimVisualAuthority: noop,
|
||||
showToast: (message) => toasts.push(message),
|
||||
setClearBusy: noop,
|
||||
syncDetection: noop,
|
||||
scheduleLayout: noop,
|
||||
refreshRadio: noop,
|
||||
setPanelCollapsed: noop,
|
||||
},
|
||||
});
|
||||
controls.connect(manager);
|
||||
|
||||
t.after(async () => {
|
||||
controls.stop();
|
||||
controls.disconnect();
|
||||
for (const id of ['rocket-launches', 'satellites', 'earthquakes', 'wind'])
|
||||
await manager.setEnabled(id, false);
|
||||
await missions.destroy(viewer);
|
||||
});
|
||||
|
||||
const enabledIds = () => [...manager.getEnabledLayerIds()].sort();
|
||||
const view = () => ({
|
||||
x: camera.position.x,
|
||||
y: camera.position.y,
|
||||
z: camera.position.z,
|
||||
heading: camera.heading,
|
||||
pitch: camera.pitch,
|
||||
});
|
||||
const settle = async () => {
|
||||
await controls._waitForContextLayerSettlement();
|
||||
for (const id of ['rocket-launches', 'satellites', 'earthquakes'])
|
||||
await manager.waitForLayerSettled(id);
|
||||
await controls._waitForContextLayerSettlement();
|
||||
};
|
||||
return {
|
||||
upstream,
|
||||
manager,
|
||||
controls,
|
||||
missions,
|
||||
toasts,
|
||||
state,
|
||||
camera,
|
||||
rosters,
|
||||
enabledIds,
|
||||
view,
|
||||
settle,
|
||||
};
|
||||
}
|
||||
|
||||
const entries = {
|
||||
direct: (f) =>
|
||||
f.manager.setEnabled('rocket-launches', true, { origin: 'user' }),
|
||||
'Context-panel': async (f) =>
|
||||
(
|
||||
await f.controls.setContextMode('space-missions', {
|
||||
claimVisualAuthority: false,
|
||||
})
|
||||
).ok,
|
||||
};
|
||||
|
||||
for (const [path, enter] of Object.entries(entries))
|
||||
test(`a ${path} Space Missions entry that gets HTTP 429 rolls back instead of isolating`, async (t) => {
|
||||
const f = await fixture(t);
|
||||
await f.manager.setEnabled('earthquakes', true, { origin: 'user' });
|
||||
const before = f.view();
|
||||
|
||||
const entered = await enter(f);
|
||||
await f.settle();
|
||||
|
||||
assert.equal(entered, false, 'a failed mission index fails the enable');
|
||||
assert.deepEqual(f.enabledIds(), ['earthquakes']);
|
||||
assert.equal(f.controls.getContextModeState().mode, null);
|
||||
assert.equal(f.controls.getContextModeState().active, false);
|
||||
assert.equal(f.missions.getStats().error, 'HTTP 429');
|
||||
assert.deepEqual(f.view(), before, 'the pre-entry view comes back');
|
||||
assert.ok(f.camera.cancelled > 0, 'the full-globe flight is cancelled');
|
||||
|
||||
assert.equal(
|
||||
await f.manager.setEnabled('wind', true, { origin: 'user' }),
|
||||
true,
|
||||
'an unrelated layer is not blocked after the failed entry',
|
||||
);
|
||||
});
|
||||
|
||||
// Holds the /api/launches reply until the returned release is called.
|
||||
function holdReply(f) {
|
||||
let release;
|
||||
f.upstream.hold = new Promise((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
return () => {
|
||||
f.upstream.hold = null;
|
||||
release();
|
||||
};
|
||||
}
|
||||
|
||||
async function untilFlown(f) {
|
||||
for (let i = 0; i < 200 && f.camera.flights === 0; i++)
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
assert.ok(f.camera.flights > 0, 'the entry started its full-globe flight');
|
||||
}
|
||||
|
||||
test('a landed entry flight still gives the view back after a failed first load', async (t) => {
|
||||
const f = await fixture(t);
|
||||
await f.manager.setEnabled('earthquakes', true, { origin: 'user' });
|
||||
const before = f.view();
|
||||
const release = holdReply(f);
|
||||
|
||||
const entering = f.manager.setEnabled('rocket-launches', true, {
|
||||
origin: 'user',
|
||||
});
|
||||
await untilFlown(f);
|
||||
f.camera.land();
|
||||
|
||||
release();
|
||||
assert.equal(await entering, false);
|
||||
await f.settle();
|
||||
assert.deepEqual(f.view(), before);
|
||||
});
|
||||
|
||||
test('a camera move after the entry flight survives a failed first load', async (t) => {
|
||||
const f = await fixture(t);
|
||||
await f.manager.setEnabled('earthquakes', true, { origin: 'user' });
|
||||
const release = holdReply(f);
|
||||
|
||||
const entering = f.manager.setEnabled('rocket-launches', true, {
|
||||
origin: 'user',
|
||||
});
|
||||
await untilFlown(f);
|
||||
f.camera.land();
|
||||
// The user drags the globe while the mission index is still loading.
|
||||
f.camera.setView({
|
||||
destination: new Cesium.Cartesian3(7, 7, 7),
|
||||
orientation: { heading: 0.3, pitch: -0.2, roll: 0 },
|
||||
});
|
||||
const moved = f.view();
|
||||
|
||||
release();
|
||||
assert.equal(await entering, false);
|
||||
await f.settle();
|
||||
assert.deepEqual(f.view(), moved);
|
||||
});
|
||||
|
||||
test('a navigation during a failed first load is not cancelled by the rollback', async (t) => {
|
||||
const f = await fixture(t);
|
||||
await f.manager.setEnabled('earthquakes', true, { origin: 'user' });
|
||||
const release = holdReply(f);
|
||||
|
||||
const entering = f.manager.setEnabled('rocket-launches', true, {
|
||||
origin: 'user',
|
||||
});
|
||||
await untilFlown(f);
|
||||
// Search or Home: navigation releases mission camera ownership, then flies.
|
||||
f.missions.releaseCameraOwnership();
|
||||
let navigationCancelled = false;
|
||||
f.camera.flyTo({
|
||||
destination: new Cesium.Cartesian3(5, 5, 5),
|
||||
cancel: () => {
|
||||
navigationCancelled = true;
|
||||
},
|
||||
});
|
||||
const cancelled = f.camera.cancelled;
|
||||
const navigated = f.view();
|
||||
|
||||
release();
|
||||
assert.equal(await entering, false);
|
||||
await f.settle();
|
||||
assert.equal(
|
||||
navigationCancelled,
|
||||
false,
|
||||
'the rollback does not cancel the Search/Home flight',
|
||||
);
|
||||
assert.equal(f.camera.cancelled, cancelled);
|
||||
assert.deepEqual(f.view(), navigated);
|
||||
});
|
||||
|
||||
test('the roster reports a failed mission index instead of loading forever', async (t) => {
|
||||
const f = await fixture(t);
|
||||
await f.manager.setEnabled('rocket-launches', true, { origin: 'user' });
|
||||
await f.settle();
|
||||
|
||||
const roster = f.rosters.at(-1);
|
||||
assert.ok(roster, 'a failed load publishes a roster view');
|
||||
assert.deepEqual(roster.entries, []);
|
||||
assert.equal(roster.empty, '30-DAY MISSION INDEX UNAVAILABLE');
|
||||
|
||||
// A new attempt starts from the loading state, then shows the index.
|
||||
f.upstream.status = 200;
|
||||
const loadingFrom = f.rosters.length;
|
||||
assert.equal(
|
||||
await f.manager.setEnabled('rocket-launches', true, { origin: 'user' }),
|
||||
true,
|
||||
);
|
||||
await f.settle();
|
||||
assert.equal(f.rosters.slice(loadingFrom).includes(null), true);
|
||||
assert.equal(f.rosters.at(-1).entries.length, 1);
|
||||
assert.equal(f.controls.getContextModeState().mode, 'space-missions');
|
||||
});
|
||||
|
||||
test('a valid empty mission index is a successful entry', async (t) => {
|
||||
const f = await fixture(t);
|
||||
f.upstream.status = 200;
|
||||
f.upstream.results = [];
|
||||
assert.equal(
|
||||
await f.manager.setEnabled('rocket-launches', true, { origin: 'user' }),
|
||||
true,
|
||||
);
|
||||
await f.settle();
|
||||
assert.equal(f.controls.getContextModeState().mode, 'space-missions');
|
||||
assert.equal(
|
||||
f.rosters.at(-1).empty,
|
||||
'NO MISSIONS AVAILABLE IN THE CURRENT 30-DAY WINDOW',
|
||||
);
|
||||
assert.ok(f.camera.flights > 0, 'a successful entry frames the globe');
|
||||
});
|
||||
|
||||
test('a refresh failure after a loaded index keeps the mode and its records', async (t) => {
|
||||
const f = await fixture(t);
|
||||
f.upstream.status = 200;
|
||||
await f.manager.setEnabled('rocket-launches', true, { origin: 'user' });
|
||||
await f.settle();
|
||||
const framed = f.view();
|
||||
|
||||
f.upstream.status = 429;
|
||||
await f.missions.update();
|
||||
await f.settle();
|
||||
|
||||
assert.equal(f.manager.isEnabled('rocket-launches'), true);
|
||||
assert.equal(f.controls.getContextModeState().mode, 'space-missions');
|
||||
assert.equal(f.missions.getStats().count, 1);
|
||||
assert.equal(f.missions.getStats().error, 'HTTP 429');
|
||||
assert.equal(f.rosters.at(-1).entries.length, 1);
|
||||
|
||||
// Leaving a loaded mode does not restore the pre-entry view.
|
||||
await f.manager.setEnabled('rocket-launches', false, { origin: 'user' });
|
||||
await f.settle();
|
||||
assert.deepEqual(f.view(), framed);
|
||||
});
|
||||
test('exiting during a successful entry flight cancels the mission-owned flight', async (t) => {
|
||||
const f = await fixture(t);
|
||||
f.upstream.status = 200;
|
||||
assert.equal(
|
||||
await f.manager.setEnabled('rocket-launches', true, { origin: 'user' }),
|
||||
true,
|
||||
);
|
||||
await f.settle();
|
||||
assert.ok(f.camera.flight, 'the 2.4 s entry flight is still in progress');
|
||||
|
||||
await f.manager.setEnabled('rocket-launches', false, { origin: 'user' });
|
||||
await f.settle();
|
||||
assert.equal(
|
||||
f.camera.flight,
|
||||
null,
|
||||
'leaving Space Missions stops the flight it started (main cancels it)',
|
||||
);
|
||||
});
|
||||
// The rollback hides the roster, so the toast has to name the cause.
|
||||
test('a failed first load tells the user the mission index is unavailable', async (t) => {
|
||||
const f = await fixture(t);
|
||||
await f.manager.setEnabled('earthquakes', true, { origin: 'user' });
|
||||
|
||||
await f.controls.setContextMode('space-missions', {
|
||||
claimVisualAuthority: false,
|
||||
});
|
||||
await f.settle();
|
||||
assert.equal(f.controls.getContextModeState().mode, null, 'rolled back');
|
||||
assert.ok(
|
||||
f.toasts.some((message) => /mission index/i.test(message)),
|
||||
`visible feedback names the failure, got ${JSON.stringify(f.toasts)}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('navigation taking the camera keeps a mission flight it asked to preserve', async (t) => {
|
||||
const f = await fixture(t);
|
||||
f.upstream.status = 200;
|
||||
await f.manager.setEnabled('rocket-launches', true, { origin: 'user' });
|
||||
await f.settle();
|
||||
const flight = f.camera.flight;
|
||||
assert.ok(flight, 'the entry flight is in progress');
|
||||
f.missions.releaseCameraOwnership();
|
||||
assert.equal(f.camera.flight, flight, 'navigation decides about the flight');
|
||||
// After exit an inactive layer never cancels another owner's flight.
|
||||
await f.manager.setEnabled('rocket-launches', false, { origin: 'user' });
|
||||
await f.settle();
|
||||
f.camera.flyTo({ destination: new Cesium.Cartesian3(1, 1, 1) });
|
||||
const other = f.camera.flight;
|
||||
f.missions.releaseCameraOwnership();
|
||||
assert.equal(f.camera.flight, other);
|
||||
});
|
||||
|
||||
test("an earlier attempt's index error is not blamed for a later failure", async (t) => {
|
||||
const f = await fixture(t);
|
||||
await f.manager.setEnabled('rocket-launches', true, { origin: 'user' });
|
||||
await f.settle();
|
||||
assert.match(spaceMissionsIndexFailure(f.manager), /mission index/);
|
||||
assert.equal(spaceMissionsIndexFailure(f.manager, Date.now() + 1), null);
|
||||
});
|
||||
@@ -50,8 +50,9 @@ export function createIngestion({
|
||||
layerState._postTleRetryCount++;
|
||||
layerState._retryTimer = setTimeout(() => {
|
||||
layerState._retryTimer = null;
|
||||
// A failed retry is already recorded in the layer stats.
|
||||
if (layerState._enabled && token === layerState._lifecycleToken)
|
||||
requestMissionUpdate();
|
||||
requestMissionUpdate().catch(() => {});
|
||||
}, POST_TLE_RETRY_DELAY_MS);
|
||||
}
|
||||
|
||||
@@ -201,6 +202,10 @@ export function createIngestion({
|
||||
if (layerState._renderedTleText === layerState._activeTleText)
|
||||
clearPostTleRetry();
|
||||
layerState._count = launches.length;
|
||||
// The roster reads these to tell a loaded empty index from loading.
|
||||
layerState._lastUpdate = Date.now();
|
||||
layerState._lastError = null;
|
||||
layerState._stale = stale === true;
|
||||
parts.panel.renderMissionRoster();
|
||||
if (
|
||||
!layerState._selectedLaunchId ||
|
||||
@@ -219,13 +224,18 @@ export function createIngestion({
|
||||
}
|
||||
layerState._focusAfterActiveLookup = false;
|
||||
parts.panel.renderMissionPanel();
|
||||
layerState._lastUpdate = Date.now();
|
||||
layerState._lastError = null;
|
||||
layerState._stale = stale === true;
|
||||
layerState._entryCameraView = null;
|
||||
} catch (error) {
|
||||
if (layerState._enabled && token === layerState._lifecycleToken) {
|
||||
layerState._lastError = error.message;
|
||||
layerState._lastErrorAt = Date.now();
|
||||
console.warn('[Data:RocketLaunches] Fetch error:', error);
|
||||
// No index yet: fail the enable so Context rolls back.
|
||||
// After a loaded index, a failed refresh keeps its records.
|
||||
if (layerState._lastUpdate === null) {
|
||||
parts.panel.renderMissionRoster();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,6 +111,43 @@ export function createLaunchHover({
|
||||
};
|
||||
}
|
||||
|
||||
/** The camera pose to give back, in world coordinates. */
|
||||
function captureCameraView(viewer) {
|
||||
const camera = viewer?.camera;
|
||||
if (!camera?.positionWC) return null;
|
||||
return {
|
||||
destination: Cesium.Cartesian3.clone(camera.positionWC),
|
||||
orientation: {
|
||||
heading: camera.heading,
|
||||
pitch: camera.pitch,
|
||||
roll: camera.roll,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function sameCameraView(camera, view) {
|
||||
return (
|
||||
Cesium.Cartesian3.equalsEpsilon(
|
||||
camera.positionWC,
|
||||
view.destination,
|
||||
0,
|
||||
1,
|
||||
) &&
|
||||
['heading', 'pitch', 'roll'].every((key) =>
|
||||
Cesium.Math.equalsEpsilon(camera[key], view.orientation[key], 0, 1e-6),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/** Restore the pre-entry view if the entry flight still holds the camera. */
|
||||
function restoreCameraView(viewer, view, landedView) {
|
||||
const camera = viewer?.camera;
|
||||
if (!camera) return;
|
||||
if (!landedView) camera.cancelFlight?.();
|
||||
else if (!sameCameraView(camera, landedView)) return;
|
||||
camera.setView(view);
|
||||
}
|
||||
|
||||
export function createLifecycle({
|
||||
state: layerState,
|
||||
services,
|
||||
@@ -200,7 +237,27 @@ export function createLifecycle({
|
||||
layerState._updateDirty = false;
|
||||
if (layerState._dataSource) layerState._dataSource.show = true;
|
||||
parts.orbitRendering.syncMissionOrbitPrimitiveVisibility();
|
||||
parts.selection.focusFullGlobe(layerState._viewer);
|
||||
if (layerState._lastUpdate === null) {
|
||||
// A new attempt at an index that never loaded starts from loading.
|
||||
layerState._lastError = null;
|
||||
parts.panel.renderMissionRoster();
|
||||
// Kept until the mission index loads, so an entry that never does
|
||||
// gives the view back. A newer flight takes the camera and drops it.
|
||||
layerState._entryCameraView = captureCameraView(layerState._viewer);
|
||||
layerState._entryLandedView = null;
|
||||
}
|
||||
const token = layerState._lifecycleToken;
|
||||
const flying = parts.selection.focusFullGlobe(layerState._viewer, {
|
||||
complete: () => {
|
||||
if (token === layerState._lifecycleToken)
|
||||
layerState._entryLandedView = captureCameraView(layerState._viewer);
|
||||
},
|
||||
cancel: () => {
|
||||
if (token === layerState._lifecycleToken)
|
||||
layerState._entryCameraView = null;
|
||||
},
|
||||
});
|
||||
if (!flying) layerState._entryCameraView = null;
|
||||
if (layerState._selectedLaunchId)
|
||||
parts.selection.setSelectedMission(
|
||||
layerState._selectedLaunchId,
|
||||
@@ -214,12 +271,21 @@ export function createLifecycle({
|
||||
hover?.reset();
|
||||
layerState._sourceController.abort();
|
||||
layerState._enabled = false;
|
||||
const entryView = layerState._entryCameraView;
|
||||
layerState._entryCameraView = null;
|
||||
if (entryView)
|
||||
restoreCameraView(
|
||||
layerState._viewer,
|
||||
entryView,
|
||||
layerState._entryLandedView,
|
||||
);
|
||||
releaseContinuousRender('rocket-launches');
|
||||
layerState._lifecycleToken++;
|
||||
layerState._updateDirty = false;
|
||||
parts.ingestion.clearPostTleRetry();
|
||||
parts.panel.clearMissionRosterHover();
|
||||
parts.replay.stopMissionReplay();
|
||||
layerState._cameraReleased = true;
|
||||
parts.selection.stopMissionZoomAnchor();
|
||||
if (layerState._dataSource) layerState._dataSource.show = false;
|
||||
parts.orbitRendering.syncMissionOrbitPrimitiveVisibility();
|
||||
@@ -244,6 +310,7 @@ export function createLifecycle({
|
||||
await parts.ingestion.restoreSatelliteDependency();
|
||||
parts.panel.clearMissionRosterHover();
|
||||
parts.replay.stopMissionReplay();
|
||||
layerState._cameraReleased = true;
|
||||
parts.selection.stopMissionZoomAnchor();
|
||||
parts.ingestion.clearPostTleRetry();
|
||||
if (layerState._clickHandler) layerState._clickHandler.destroy();
|
||||
|
||||
@@ -263,8 +263,16 @@ export function createPanel({ state: layerState, services, parts, source }) {
|
||||
publishPanel();
|
||||
}
|
||||
|
||||
/** The roster entries as the list shows them. */
|
||||
/** The roster as listed; null until the first index loads or fails. */
|
||||
function presentRoster() {
|
||||
if (layerState._lastUpdate === null && !layerState._launches.length) {
|
||||
if (!layerState._lastError) return null;
|
||||
return {
|
||||
count: '—',
|
||||
entries: [],
|
||||
empty: t('30-DAY MISSION INDEX UNAVAILABLE'),
|
||||
};
|
||||
}
|
||||
const entries = parts.policyHelpers
|
||||
.missionRosterEntries(layerState._launches)
|
||||
.map(({ launch, index }) => {
|
||||
|
||||
@@ -229,6 +229,7 @@ export function createPolicyHelpers({
|
||||
layerState._viewer.camera.positionCartographic?.height,
|
||||
);
|
||||
const position = Cesium.Cartesian3.fromDegrees(launch.lon, launch.lat);
|
||||
layerState._cameraReleased = false;
|
||||
layerState._viewer.camera.flyToBoundingSphere(
|
||||
new Cesium.BoundingSphere(position, 0),
|
||||
{
|
||||
|
||||
@@ -314,9 +314,15 @@ export function createReplay({ state: layerState, services, parts, source }) {
|
||||
layerState._replayCameraToken++;
|
||||
if (layerState._replayCameraRemover) layerState._replayCameraRemover();
|
||||
layerState._replayCameraRemover = null;
|
||||
layerState._viewer?.camera?.cancelFlight();
|
||||
if (layerState._viewer?.camera)
|
||||
// Cancel the layer's own flights, never one navigation started after
|
||||
// taking the camera.
|
||||
if (
|
||||
(stoppedLaunchId || !layerState._cameraReleased) &&
|
||||
layerState._viewer?.camera
|
||||
) {
|
||||
layerState._viewer.camera.cancelFlight();
|
||||
layerState._viewer.camera.lookAtTransform(Cesium.Matrix4.IDENTITY);
|
||||
}
|
||||
layerState._replayCameraLaunchId = null;
|
||||
layerState._replayPaused = false;
|
||||
layerState._replayPausedAtMs = null;
|
||||
|
||||
@@ -9,7 +9,8 @@ export function createSelection({
|
||||
}) {
|
||||
const { getKeyholeGeometry } = services.geometry;
|
||||
|
||||
function focusFullGlobe(viewer, duration = 2.4) {
|
||||
/** @returns {boolean} Whether the flight started. */
|
||||
function focusFullGlobe(viewer, { duration = 2.4, complete, cancel } = {}) {
|
||||
const canvas = viewer?.scene?.canvas;
|
||||
const height = canvas?.clientHeight || canvas?.height;
|
||||
const width = canvas?.clientWidth || canvas?.width;
|
||||
@@ -23,7 +24,7 @@ export function createSelection({
|
||||
fovy <= 0 ||
|
||||
fovy >= Math.PI
|
||||
)
|
||||
return;
|
||||
return false;
|
||||
const earthRadius = Cesium.Ellipsoid.WGS84.maximumRadius;
|
||||
const keyholeRadius = getKeyholeGeometry(width, height).radius;
|
||||
const targetScreenRadius = keyholeRadius * 0.61;
|
||||
@@ -32,6 +33,7 @@ export function createSelection({
|
||||
);
|
||||
const distance = earthRadius / Math.max(Math.sin(angularRadius), 1e-4);
|
||||
const altitude = Math.max(earthRadius * 1.55, distance - earthRadius);
|
||||
layerState._cameraReleased = false;
|
||||
viewer.camera.flyTo({
|
||||
destination: Cesium.Cartesian3.fromRadians(
|
||||
cartographic.longitude,
|
||||
@@ -45,7 +47,10 @@ export function createSelection({
|
||||
},
|
||||
duration,
|
||||
easingFunction: Cesium.EasingFunction.CUBIC_IN_OUT,
|
||||
complete,
|
||||
cancel,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
function entityLaunchId(entity) {
|
||||
@@ -187,6 +192,7 @@ export function createSelection({
|
||||
Cesium.Cartesian3.cross(right, direction, new Cesium.Cartesian3()),
|
||||
new Cesium.Cartesian3(),
|
||||
);
|
||||
layerState._cameraReleased = false;
|
||||
layerState._viewer.camera.flyTo({
|
||||
destination,
|
||||
orientation: { direction, up },
|
||||
@@ -209,6 +215,7 @@ export function createSelection({
|
||||
layerState._viewer.selectedEntity = layerState._dataSource.entities.getById(
|
||||
`rocket-launch:${launch.id}`,
|
||||
);
|
||||
layerState._cameraReleased = false;
|
||||
layerState._viewer.camera.flyToBoundingSphere(
|
||||
new Cesium.BoundingSphere(launchPosition, 0),
|
||||
{
|
||||
|
||||
@@ -19,6 +19,15 @@ export function createState({ services }) {
|
||||
|
||||
state._lastError = null;
|
||||
|
||||
state._lastErrorAt = null;
|
||||
|
||||
state._entryCameraView = null;
|
||||
|
||||
state._entryLandedView = null;
|
||||
|
||||
/** Navigation took the camera; set until the layer flies again. */
|
||||
state._cameraReleased = false;
|
||||
|
||||
state._stale = false;
|
||||
|
||||
state._orbitMatches = 0;
|
||||
|
||||
@@ -126,8 +126,17 @@ export function createStreetLevelLayer({
|
||||
});
|
||||
}
|
||||
|
||||
// One source at a time: the first provider that defaults on.
|
||||
const startsOn = defs.find((def) => def.defaultOn !== false);
|
||||
/** `def` if its key is ready, else the first keyless provider not refused. */
|
||||
function keylessInstead(def, refused = () => false) {
|
||||
if (def.keyReady?.() !== false) return def;
|
||||
return defs.find((d) => !d.requiresKeyId && !refused(d.id)) || def;
|
||||
}
|
||||
|
||||
// One source at a time: the first provider that defaults on. Without its
|
||||
// key, the first keyless source instead, so a fresh keyless install opens
|
||||
// on coverage rather than KEY REQUIRED.
|
||||
const preferred = defs.find((def) => def.defaultOn !== false);
|
||||
const startsOn = preferred && keylessInstead(preferred);
|
||||
for (const def of defs) {
|
||||
const entry = {
|
||||
def,
|
||||
@@ -585,7 +594,17 @@ export function createStreetLevelLayer({
|
||||
providerIds: state.providers.keys(),
|
||||
filter: state.filter,
|
||||
});
|
||||
const lit = litByParams(decoded.providers);
|
||||
// A restored "Mapillary on" (the codec default) would light a source a
|
||||
// keyless install cannot draw. Only redirect a provider the params switch
|
||||
// on: a filter-only update keeps whatever the user chose.
|
||||
const asked = litByParams(decoded.providers);
|
||||
const lit =
|
||||
asked && decoded.providers.get(asked) === true
|
||||
? keylessInstead(
|
||||
state.providers.get(asked).def,
|
||||
(id) => decoded.providers.get(id) === false,
|
||||
).id
|
||||
: asked;
|
||||
const before = litIds();
|
||||
for (const [id, entry] of state.providers)
|
||||
if (id !== lit) switchProvider(entry, false);
|
||||
|
||||
@@ -1127,3 +1127,59 @@ test('the panel says why a lit Street View draws nothing off Google 3D', async (
|
||||
const view = presentStreetLevelPanel(layer.getUIState());
|
||||
assert.match(view.meta, /Google Street View needs the Google 3D map/);
|
||||
});
|
||||
|
||||
/** Mapillary (keyless unless `mapillary` says otherwise) plus KartaView. */
|
||||
function keylessLayer(t, mapillary = { hasToken: () => false }) {
|
||||
const layer = createStreetLevelLayer({
|
||||
providers: [
|
||||
createMapillaryProvider({
|
||||
source: fakeMapillarySource(mapillary),
|
||||
photoViewer: fakePhotoViewer(),
|
||||
}),
|
||||
createKartaViewProvider(),
|
||||
],
|
||||
});
|
||||
t.after(() => layer.destroy());
|
||||
return layer;
|
||||
}
|
||||
const litProviders = (layer) =>
|
||||
layer
|
||||
.getUIState()
|
||||
.providers.filter((provider) => provider.on)
|
||||
.map((provider) => provider.id);
|
||||
|
||||
test('without a Mapillary key the first keyless source is lit instead', (t) => {
|
||||
assert.deepEqual(litProviders(keylessLayer(t)), ['kartaview']);
|
||||
assert.deepEqual(litProviders(keylessLayer(t, {})), ['mapillary']);
|
||||
// A token-adding Graph proxy counts as a key.
|
||||
const proxied = keylessLayer(t, {
|
||||
hasToken: () => false,
|
||||
graphEndpoint: '/api/mapillary/graph',
|
||||
});
|
||||
assert.deepEqual(litProviders(proxied), ['mapillary']);
|
||||
});
|
||||
|
||||
test('restoring the default "Mapillary on" without its key keeps a keyless source', (t) => {
|
||||
const layer = keylessLayer(t);
|
||||
layer.setParams({ mapillary: true, providerSwitches: '' });
|
||||
assert.deepEqual(litProviders(layer), ['kartaview']);
|
||||
// A link that switches KartaView off keeps its word.
|
||||
layer.setParams({ mapillary: true, providerSwitches: 'kartaview-0' });
|
||||
assert.deepEqual(litProviders(layer), ['mapillary']);
|
||||
});
|
||||
|
||||
test('filter-only updates keep the lit source, even a chosen keyless Mapillary', (t) => {
|
||||
const layer = keylessLayer(t);
|
||||
layer.setParams({ pano: 'pano' });
|
||||
assert.deepEqual(litProviders(layer), ['kartaview']);
|
||||
// What the MAPILLARY chip sends, and what stored state restores.
|
||||
layer.setParams({ mapillary: true, providerSwitches: 'kartaview-0' });
|
||||
for (const params of [{ pano: 'pano' }, { sinceDays: 365 }, {}]) {
|
||||
layer.setParams(params);
|
||||
assert.deepEqual(
|
||||
litProviders(layer),
|
||||
['mapillary'],
|
||||
JSON.stringify(params),
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -74,6 +74,9 @@ export function createMapillaryProvider({ source, photoViewer = null }) {
|
||||
name: MAPILLARY_NAME,
|
||||
label: MAPILLARY_LABEL,
|
||||
requiresKeyId: MAPILLARY_KEY_ID,
|
||||
/** A browser token, or a Graph proxy that adds one server-side. */
|
||||
keyReady: () =>
|
||||
source.hasToken?.() !== false || Boolean(source.graphEndpoint),
|
||||
pickPrefix: PICK_PREFIX.root,
|
||||
colors: COLORS,
|
||||
credit: Object.freeze({ html: MAPILLARY_CREDIT_HTML }),
|
||||
|
||||
@@ -20,6 +20,8 @@ export function filtersOf(provider) {
|
||||
* @property {boolean} [groundClick] No coverage: a ground click at street zoom opens its nearest image.
|
||||
* @property {string} [requiresMapStack] Usable only on this map stack.
|
||||
* @property {boolean} [defaultOn] Default true; the first that defaults on is lit.
|
||||
* @property {() => boolean} [keyReady] False when its key is missing now; the
|
||||
* first keyless provider is lit instead of a default that cannot draw.
|
||||
* @property {{kind: 'view-cone', color: string}} [marker] Default: GEV's pin.
|
||||
* @property {Array<'pano'|'since'>} [filters] Filters it honours (default both).
|
||||
* @property {{noun: string, action: string}} [coverageLabel] How the panel counts its lines and says what a click does (default: sequences, "click a line for its photos").
|
||||
|
||||
@@ -21,9 +21,20 @@ export function createAnimation({
|
||||
source,
|
||||
}) {
|
||||
function syncRenderDemand() {
|
||||
// Hidden dots (`dots` off) need no frames. Lines need them only while
|
||||
// their asynchronous geometry builds (or old lines wait to be retired),
|
||||
// and the heat-line prototype's jam batch while it pulses.
|
||||
const lines = [
|
||||
layerState._heatJamPrim,
|
||||
layerState._heatSlowPrim,
|
||||
layerState._heatFreePrim,
|
||||
].filter(Boolean);
|
||||
const needed = Boolean(
|
||||
layerState._enabled &&
|
||||
(layerState._dots.length || layerState._heatJamPrim),
|
||||
((layerState._showDots !== false && layerState._dots.length) ||
|
||||
(layerState._heatJamPrim && !layerState._showLines) ||
|
||||
layerState._heatRetired?.length ||
|
||||
lines.some((primitive) => !primitive.ready)),
|
||||
);
|
||||
// Height easing only runs in a held frame. Settle it before releasing, so
|
||||
// a population emptied mid-correction cannot report loading forever.
|
||||
@@ -224,6 +235,7 @@ export function createAnimation({
|
||||
*/
|
||||
|
||||
function animate() {
|
||||
parts.rendering.retireHeatLines?.();
|
||||
syncRenderDemand();
|
||||
if (!layerState._renderHeld) return;
|
||||
const now = Date.now();
|
||||
@@ -317,8 +329,9 @@ export function createAnimation({
|
||||
}
|
||||
|
||||
// Jam heat-lines throb (~1.6 s period) — one shared material uniform for
|
||||
// the whole jam batch, no geometry rebuild. Null outside live heatline mode.
|
||||
if (layerState._heatJamPrim?.appearance) {
|
||||
// the whole jam batch, no geometry rebuild. Null outside live heatline
|
||||
// mode; the steady flow lines (`lines` toggle) never pulse.
|
||||
if (layerState._heatJamPrim?.appearance && !layerState._showLines) {
|
||||
layerState._heatJamPrim.appearance.material.uniforms.color.alpha =
|
||||
HEAT_JAM_BASE_ALPHA + HEAT_JAM_PULSE_ALPHA * Math.sin(now / 260);
|
||||
}
|
||||
|
||||
Vendored
+95
-20
@@ -1,7 +1,7 @@
|
||||
import {
|
||||
normalizeRoadMode,
|
||||
resolveRoadMode,
|
||||
TRAFFIC_ROAD_MODES,
|
||||
roadModesSimulate,
|
||||
} from './roadModes.js';
|
||||
import {
|
||||
trafficBucketTier,
|
||||
@@ -19,19 +19,25 @@ export function createControls({ state: layerState, services, parts, source }) {
|
||||
const notes = [feed.loadingLabel];
|
||||
if (!layerState._liveMode || feed.error)
|
||||
notes.push(t('Roads: {source}', { source }));
|
||||
// An explicit TomTom or Hybrid choice without a key draws OpenStreetMap.
|
||||
// An explicit TomTom or Hybrid choice without a key draws OpenStreetMap;
|
||||
// a host without OpenStreetMap draws nothing.
|
||||
const requested = roadModesSimulate(layerState._roadModes)
|
||||
? layerState._roadMode
|
||||
: resolveRoadMode(layerState._roadMode, true, layerState._roadModes);
|
||||
// A host without OpenStreetMap waits for the probe before naming a key.
|
||||
if (
|
||||
!layerState._liveMode &&
|
||||
['tomtom', 'hybrid'].includes(layerState._roadMode)
|
||||
['tomtom', 'hybrid'].includes(requested) &&
|
||||
(roadModesSimulate(layerState._roadModes) || layerState._flowStatusKnown)
|
||||
)
|
||||
notes.push(
|
||||
layerState._flowStatusUnavailable
|
||||
? layerState._roadMode === 'tomtom'
|
||||
? requested === 'tomtom'
|
||||
? t(
|
||||
'TomTom roads unavailable while the traffic service is unreachable',
|
||||
)
|
||||
: t('Hybrid unavailable while the traffic service is unreachable')
|
||||
: layerState._roadMode === 'tomtom'
|
||||
: requested === 'tomtom'
|
||||
? t('TomTom roads need a TomTom key')
|
||||
: t('Hybrid needs a TomTom key'),
|
||||
);
|
||||
@@ -62,10 +68,13 @@ export function createControls({ state: layerState, services, parts, source }) {
|
||||
updateInterval: 0,
|
||||
|
||||
/**
|
||||
* Update user-adjustable parameters (road source, density and speed scaling).
|
||||
* Update user-adjustable parameters (road source, row toggles, density and
|
||||
* speed scaling).
|
||||
*
|
||||
* @param {Object} [params]
|
||||
* @param {'tomtom'|'osm'|'hybrid'|null} [params.roadMode] - Road source; null = default.
|
||||
* @param {'tomtom'|'osm'|'hybrid'|null} [params.roadMode] - Road source; null = default; outside the host's modes = ignored.
|
||||
* @param {boolean} [params.lines] - Draw flow-colored road lines (live mode).
|
||||
* @param {boolean} [params.dots] - Draw the moving dots.
|
||||
* @param {number} [params.densityScale] - Dot density multiplier (clamped 0.2–2.5).
|
||||
* @param {number} [params.speedScale] - Dot speed multiplier (clamped 0.3–3.0).
|
||||
* @param {{origin?:string}} [options] - Intent origin; restores never beat `?trafficRoads=`.
|
||||
@@ -75,7 +84,8 @@ export function createControls({ state: layerState, services, parts, source }) {
|
||||
const previousSpeed = layerState._speedScale;
|
||||
if (
|
||||
Object.hasOwn(params, 'roadMode') &&
|
||||
(params.roadMode === null || normalizeRoadMode(params.roadMode))
|
||||
(params.roadMode === null ||
|
||||
normalizeRoadMode(params.roadMode, layerState._roadModes))
|
||||
) {
|
||||
const explicit = ['user', 'voice', 'tool'].includes(origin);
|
||||
const mode =
|
||||
@@ -98,6 +108,31 @@ export function createControls({ state: layerState, services, parts, source }) {
|
||||
parts.viewport.onCameraChanged({ immediate: true });
|
||||
}
|
||||
}
|
||||
if (
|
||||
typeof params.lines === 'boolean' &&
|
||||
params.lines !== layerState._showLines
|
||||
) {
|
||||
layerState._showLines = params.lines;
|
||||
// Rebuild in place from the roads on screen: no refetch, no respawn.
|
||||
if (layerState._enabled)
|
||||
parts.rendering.rebuildHeatLines(
|
||||
parts.rendering.visibleRoadsForAltitude(
|
||||
layerState._roads,
|
||||
layerState._lastRenderAltitude,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (
|
||||
typeof params.dots === 'boolean' &&
|
||||
params.dots !== layerState._showDots
|
||||
) {
|
||||
layerState._showDots = params.dots;
|
||||
// Hidden dots are kept, so showing them again is instant.
|
||||
if (layerState._enabled && layerState._pointCollection)
|
||||
layerState._pointCollection.show = params.dots;
|
||||
parts.animation.syncRenderDemand();
|
||||
layerState._viewer?.scene?.requestRender?.();
|
||||
}
|
||||
if (typeof params.densityScale === 'number') {
|
||||
layerState._densityScale = Math.max(
|
||||
0.2,
|
||||
@@ -154,11 +189,14 @@ export function createControls({ state: layerState, services, parts, source }) {
|
||||
|
||||
/**
|
||||
* Return the current user-adjustable parameters.
|
||||
* @returns {{densityScale:number, speedScale:number}}
|
||||
* @returns {{roadMode:string|null, lines:boolean, dots:boolean,
|
||||
* densityScale:number, speedScale:number}}
|
||||
*/
|
||||
getParams() {
|
||||
return {
|
||||
roadMode: layerState._roadMode,
|
||||
lines: layerState._showLines,
|
||||
dots: layerState._showDots,
|
||||
densityScale: layerState._densityScale,
|
||||
speedScale: layerState._speedScale,
|
||||
uncoveredRoads: layerState._uncoveredMode,
|
||||
@@ -167,10 +205,15 @@ export function createControls({ state: layerState, services, parts, source }) {
|
||||
};
|
||||
},
|
||||
|
||||
/** Road-source chips on the Street Traffic row (TomTom / OSM / Hybrid). */
|
||||
/**
|
||||
* Street Traffic row chips: a road-source chip per offered mode (none for
|
||||
* a single mode), then Lines and Dots toggles while live flow colors them.
|
||||
*/
|
||||
getRowControls() {
|
||||
const modes = layerState._roadModes;
|
||||
const selected =
|
||||
layerState._roadMode || resolveRoadMode(null, layerState._liveMode);
|
||||
layerState._roadMode ||
|
||||
resolveRoadMode(null, layerState._liveMode, modes);
|
||||
const live = layerState._liveMode;
|
||||
const titles = {
|
||||
tomtom: live
|
||||
@@ -187,14 +230,36 @@ export function createControls({ state: layerState, services, parts, source }) {
|
||||
};
|
||||
// TomTom and OSM are provider names and stay as they are.
|
||||
const labels = { tomtom: 'TomTom', osm: 'OSM', hybrid: t('Hybrid') };
|
||||
const lines = {
|
||||
id: 'lines',
|
||||
label: t('Lines'),
|
||||
title: t('Road lines colored by live flow: green to red'),
|
||||
active: layerState._showLines,
|
||||
params: { lines: !layerState._showLines },
|
||||
};
|
||||
const dots = {
|
||||
id: 'dots',
|
||||
label: t('Dots'),
|
||||
title: live ? t('Moving dots colored by live flow') : t('Moving dots'),
|
||||
active: layerState._showDots,
|
||||
params: { dots: !layerState._showDots },
|
||||
};
|
||||
// Lines need live flow. Dots, once hidden (a live choice, a restore or
|
||||
// a share link), stay offered so they can always be shown again.
|
||||
const toggles = live ? [lines, dots] : layerState._showDots ? [] : [dots];
|
||||
return {
|
||||
chips: TRAFFIC_ROAD_MODES.map((mode) => ({
|
||||
id: `roads-${mode}`,
|
||||
label: labels[mode],
|
||||
title: titles[mode],
|
||||
active: mode === selected,
|
||||
params: { roadMode: mode },
|
||||
})),
|
||||
chips: [
|
||||
...(modes.length > 1
|
||||
? modes.map((mode) => ({
|
||||
id: `roads-${mode}`,
|
||||
label: labels[mode],
|
||||
title: titles[mode],
|
||||
active: mode === selected,
|
||||
params: { roadMode: mode },
|
||||
}))
|
||||
: []),
|
||||
...toggles,
|
||||
],
|
||||
};
|
||||
},
|
||||
|
||||
@@ -211,7 +276,13 @@ export function createControls({ state: layerState, services, parts, source }) {
|
||||
* @returns {Array<{position:Cesium.Cartesian3, id:string, type:string}>}
|
||||
*/
|
||||
getDetectableObjects(options = {}) {
|
||||
if (!layerState._enabled || layerState._dots.length === 0) return [];
|
||||
// Hidden dots are not on screen to detect.
|
||||
if (
|
||||
!layerState._enabled ||
|
||||
!layerState._showDots ||
|
||||
layerState._dots.length === 0
|
||||
)
|
||||
return [];
|
||||
const maxCount = Number.isFinite(options.maxCount)
|
||||
? Math.max(1, Math.floor(options.maxCount))
|
||||
: layerState._dots.length;
|
||||
@@ -382,7 +453,11 @@ export function createControls({ state: layerState, services, parts, source }) {
|
||||
// in one had better be the honest one. This is also where LIVE vs
|
||||
// SIMULATED mode is surfaced, and it must never imply a live feed the
|
||||
// layer does not have.
|
||||
roadMode: resolveRoadMode(layerState._roadMode, layerState._liveMode),
|
||||
roadMode: resolveRoadMode(
|
||||
layerState._roadMode,
|
||||
layerState._liveMode,
|
||||
layerState._roadModes,
|
||||
),
|
||||
roadModeRequested: layerState._roadMode,
|
||||
roadSource: layerState._roadSource,
|
||||
source: layerState._roadSource,
|
||||
|
||||
@@ -38,7 +38,15 @@ export function createFlow({ state: layerState, services, parts, source }) {
|
||||
const status = Number.isFinite(error.status)
|
||||
? error.status
|
||||
: Number(message.match(/HTTP (\d{3})/)?.[1]);
|
||||
const code = typeof error.code === 'string' ? error.code : null;
|
||||
// English in state; trafficFeedPresentation translates it for the panel.
|
||||
// A hosted deployment limits each account (code from the server); the
|
||||
// local proxy has one daily budget (`budget`) and answers `no_key`.
|
||||
if (code === 'USAGE_LIMIT_REACHED')
|
||||
return msg('Monthly traffic allowance used');
|
||||
if (code === 'RATE_LIMITED') return msg('Traffic rate limit reached');
|
||||
if (status === 503 && code && code !== 'no_key')
|
||||
return msg('Traffic service unavailable');
|
||||
if (status === 503) return msg('TomTom key unavailable');
|
||||
if (status === 429) return msg('TomTom daily budget reached');
|
||||
if (status === 502 || status === 504)
|
||||
|
||||
@@ -11,6 +11,26 @@ const FLOW_TILE_PATH = (z, x, y) => `/api/tomtom/flow/${z}/${x}/${y}.pbf`;
|
||||
* (STALE-BUDGET once the daily budget is spent, STALE-ERROR when TomTom
|
||||
* fails), and when the proxy fetched it.
|
||||
*/
|
||||
/**
|
||||
* The machine-readable reason a failed tile carries: `code` (hosted usage,
|
||||
* e.g. USAGE_LIMIT_REACHED or RATE_LIMITED) or the local proxy's `error`
|
||||
* (`no_key`, `budget`). Only short identifiers are kept; prose is ignored.
|
||||
*/
|
||||
async function failureCode(response) {
|
||||
if (!/json/i.test(response.headers?.get?.('content-type') || '')) return null;
|
||||
try {
|
||||
const text = await response.text();
|
||||
if (text.length > 4096) return null;
|
||||
const body = JSON.parse(text);
|
||||
const code = body?.code ?? body?.error;
|
||||
return typeof code === 'string' && /^[A-Za-z_]{1,40}$/.test(code)
|
||||
? code
|
||||
: null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function tileFreshness(response) {
|
||||
const status = response.headers?.get?.('x-tomtom-cache') || '';
|
||||
const fetchedAt = Number(response.headers?.get?.('x-tomtom-fetched-at'));
|
||||
@@ -60,7 +80,16 @@ export function createFlowTileSource({
|
||||
},
|
||||
fetchImpl: async (url, init) => {
|
||||
const response = await fetchImpl(url, init);
|
||||
if (response.ok && !init.signal.aborted) {
|
||||
if (!response.ok) {
|
||||
const code = await failureCode(response);
|
||||
await response.body?.cancel().catch(() => {});
|
||||
throw Object.assign(
|
||||
// Classified for display by deriveTrafficFlowError.
|
||||
new Error(`TomTom flow unavailable (HTTP ${response.status})`),
|
||||
{ status: response.status, code },
|
||||
);
|
||||
}
|
||||
if (!init.signal.aborted) {
|
||||
const path = String(url);
|
||||
const entry = {
|
||||
mark: tileFreshness(response),
|
||||
|
||||
@@ -0,0 +1,326 @@
|
||||
// A host's road-source offer (createTrafficSource `roadModes`) and the
|
||||
// Street Traffic Lines / Dots row toggles.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
normalizeRoadModes,
|
||||
resolveRoadMode,
|
||||
roadModesSimulate,
|
||||
TRAFFIC_ROAD_MODES,
|
||||
} from './roadModes.js';
|
||||
import { createTrafficSource } from './source.js';
|
||||
import { createTrafficLayer } from './index.js';
|
||||
import {
|
||||
createDefaultLayerState,
|
||||
decodeLayerStateParams,
|
||||
encodeLayerStateParams,
|
||||
parseStoredLayerState,
|
||||
serializeStoredLayerState,
|
||||
} from '../../data/layerState.js';
|
||||
|
||||
const box = { west: -97.75, east: -97.74, south: 30.26, north: 30.27 };
|
||||
const flow = {
|
||||
coords: [
|
||||
[-97.744, 30.267],
|
||||
[-97.742, 30.267],
|
||||
],
|
||||
trafficLevel: 0.3,
|
||||
closure: false,
|
||||
roadType: 'Major road',
|
||||
};
|
||||
|
||||
function tomtomOnlySource() {
|
||||
let requests = 0;
|
||||
const source = createTrafficSource({
|
||||
roadModes: ['tomtom'],
|
||||
mapTiles: {
|
||||
async fetchBounds() {
|
||||
requests++;
|
||||
return { tiles: [], partial: false };
|
||||
},
|
||||
async getMetadata() {
|
||||
requests++;
|
||||
},
|
||||
clear() {},
|
||||
},
|
||||
});
|
||||
return { source, requests: () => requests };
|
||||
}
|
||||
|
||||
test('host road modes are a non-empty subset in canonical order', () => {
|
||||
assert.equal(normalizeRoadModes(undefined), TRAFFIC_ROAD_MODES);
|
||||
assert.deepEqual(normalizeRoadModes(['hybrid', 'tomtom']), [
|
||||
'tomtom',
|
||||
'hybrid',
|
||||
]);
|
||||
assert.ok(Object.isFrozen(normalizeRoadModes(['tomtom'])));
|
||||
for (const bad of [[], ['bogus'], ['tomtom', 'tomtom'], 'tomtom', null])
|
||||
assert.throws(() => normalizeRoadModes(bad), TypeError, String(bad));
|
||||
assert.throws(() => createTrafficSource({ roadModes: [] }), TypeError);
|
||||
assert.equal(roadModesSimulate(), true);
|
||||
assert.equal(roadModesSimulate(['tomtom', 'hybrid']), false);
|
||||
});
|
||||
|
||||
test('a TomTom-only host resolves every request to TomTom, keyed or not', () => {
|
||||
for (const value of [null, 'osm', 'hybrid', 'tomtom'])
|
||||
for (const hasKey of [true, false])
|
||||
assert.equal(resolveRoadMode(value, hasKey, ['tomtom']), 'tomtom');
|
||||
// Without Hybrid the default is the first offered mode.
|
||||
assert.equal(resolveRoadMode(null, true, ['tomtom', 'osm']), 'tomtom');
|
||||
assert.equal(resolveRoadMode(null, false, ['tomtom', 'osm']), 'osm');
|
||||
});
|
||||
|
||||
test('a TomTom-only source draws TomTom roads and never requests OpenStreetMap', async () => {
|
||||
const f = tomtomOnlySource();
|
||||
assert.deepEqual(f.source.roadModes, ['tomtom']);
|
||||
for (const roadMode of [null, 'osm', 'hybrid']) {
|
||||
const data = await (
|
||||
await f.source.requestRoads(box, {
|
||||
roadMode,
|
||||
flowSnapshot: Promise.resolve({ hasKey: true, segments: [flow] }),
|
||||
})
|
||||
).json();
|
||||
assert.equal(data.roadSource, 'TomTom', String(roadMode));
|
||||
assert.equal(data.roads.length, 1);
|
||||
assert.equal(data.roads[0].directFlow, true);
|
||||
}
|
||||
assert.equal(f.requests(), 0);
|
||||
await assert.rejects(f.source.requestRoads(box, {}), TypeError);
|
||||
});
|
||||
|
||||
test('a TomTom-only source never simulates: keyless or unreachable loads fail with the reason', async () => {
|
||||
const f = tomtomOnlySource();
|
||||
const failure = (snapshot) =>
|
||||
f.source
|
||||
.requestRoads(box, { flowSnapshot: Promise.resolve(snapshot) })
|
||||
.then(
|
||||
() => assert.fail('a keyless load drew roads'),
|
||||
(error) => error,
|
||||
);
|
||||
const keyless = await failure({ hasKey: false, segments: [] });
|
||||
assert.equal(keyless.message, 'TomTom roads need a TomTom key');
|
||||
assert.equal(keyless.retryable, false);
|
||||
const unreachable = await failure({
|
||||
hasKey: false,
|
||||
segments: [],
|
||||
statusUnavailable: true,
|
||||
});
|
||||
assert.equal(
|
||||
unreachable.message,
|
||||
'TomTom roads unavailable while the traffic service is unreachable',
|
||||
);
|
||||
assert.equal(unreachable.retryable, true);
|
||||
const late = await failure({
|
||||
hasKey: false,
|
||||
segments: [],
|
||||
error: 'TomTom flow timed out',
|
||||
});
|
||||
assert.equal(late.message, 'TomTom flow timed out');
|
||||
assert.equal(f.requests(), 0);
|
||||
});
|
||||
|
||||
test('a TomTom-only layer offers no road chips and ignores other road choices', () => {
|
||||
const oldLocation = globalThis.location;
|
||||
globalThis.location = { search: '?trafficRoads=osm' };
|
||||
try {
|
||||
const { source } = tomtomOnlySource();
|
||||
const layer = createTrafficLayer({
|
||||
services: { credits: {}, render: {} },
|
||||
source,
|
||||
});
|
||||
assert.equal(layer.getParams().roadMode, null);
|
||||
for (const origin of ['user', 'local-restore', 'share-restore'])
|
||||
layer.setParams({ roadMode: 'osm' }, { origin });
|
||||
assert.equal(layer.getParams().roadMode, null);
|
||||
assert.deepEqual(layer.getRowControls().chips, []);
|
||||
const stats = layer.getStats();
|
||||
assert.equal(stats.roadMode, 'tomtom');
|
||||
// No key verdict before the status probe settles.
|
||||
assert.doesNotMatch(stats.loadingLabel, /need a TomTom key/);
|
||||
} finally {
|
||||
if (oldLocation === undefined) delete globalThis.location;
|
||||
else globalThis.location = oldLocation;
|
||||
}
|
||||
});
|
||||
|
||||
test('Lines and Dots are boolean params that only appear as chips with live flow', () => {
|
||||
const layer = createTrafficLayer({
|
||||
services: { credits: {}, render: {} },
|
||||
source: tomtomOnlySource().source,
|
||||
});
|
||||
assert.equal(layer.getParams().lines, false);
|
||||
assert.equal(layer.getParams().dots, true);
|
||||
// Keyless (the status probe has not reported a key): nothing to color.
|
||||
assert.deepEqual(layer.getRowControls().chips, []);
|
||||
layer.setParams({ lines: true });
|
||||
assert.equal(layer.getParams().lines, true);
|
||||
layer.setParams({ lines: 'no', dots: 0 });
|
||||
assert.equal(layer.getParams().lines, true, 'non-boolean ignored');
|
||||
assert.equal(layer.getParams().dots, true, 'non-boolean ignored');
|
||||
layer.setParams({ lines: false, dots: false });
|
||||
assert.deepEqual(
|
||||
[layer.getParams().lines, layer.getParams().dots],
|
||||
[false, false],
|
||||
);
|
||||
assert.deepEqual(layer.getDetectableObjects(), []);
|
||||
// Hidden dots stay recoverable without live flow (restore, share link).
|
||||
assert.deepEqual(
|
||||
layer.getRowControls().chips.map((chip) => [chip.id, chip.title]),
|
||||
[['dots', 'Moving dots']],
|
||||
);
|
||||
layer.setParams(layer.getRowControls().chips[0].params, { origin: 'user' });
|
||||
assert.equal(layer.getParams().dots, true);
|
||||
assert.deepEqual(layer.getRowControls().chips, []);
|
||||
});
|
||||
|
||||
test('live chips toggle each control without touching the other', async () => {
|
||||
const { createState } = await import('./state.js');
|
||||
const { createControls } = await import('./controls.js');
|
||||
const state = createState({
|
||||
services: {},
|
||||
source: { roadModes: ['tomtom'] },
|
||||
});
|
||||
state._liveMode = true;
|
||||
const controls = createControls({
|
||||
state,
|
||||
services: {},
|
||||
source: { getFlowSessionStats: () => ({}) },
|
||||
parts: {
|
||||
animation: { syncRenderDemand() {} },
|
||||
rendering: {},
|
||||
},
|
||||
}).methods;
|
||||
const chip = (id) =>
|
||||
controls.getRowControls().chips.find((entry) => entry.id === id);
|
||||
assert.deepEqual(
|
||||
controls.getRowControls().chips.map((entry) => entry.id),
|
||||
['lines', 'dots'],
|
||||
);
|
||||
assert.equal(chip('lines').active, false);
|
||||
assert.equal(chip('dots').active, true);
|
||||
controls.setParams(chip('lines').params, { origin: 'user' });
|
||||
assert.equal(chip('lines').active, true);
|
||||
assert.equal(chip('dots').active, true);
|
||||
controls.setParams(chip('dots').params, { origin: 'user' });
|
||||
assert.equal(chip('dots').active, false);
|
||||
assert.equal(chip('lines').active, true);
|
||||
// Every mode keeps its road chips beside the toggles.
|
||||
const all = createState({ services: {}, source: {} });
|
||||
all._liveMode = true;
|
||||
assert.deepEqual(
|
||||
createControls({
|
||||
state: all,
|
||||
services: {},
|
||||
source: { getFlowSessionStats: () => ({}) },
|
||||
parts: {},
|
||||
})
|
||||
.methods.getRowControls()
|
||||
.chips.map((entry) => entry.id),
|
||||
['roads-tomtom', 'roads-osm', 'roads-hybrid', 'lines', 'dots'],
|
||||
);
|
||||
});
|
||||
|
||||
test('hiding dots hides the point collection of an enabled layer', async () => {
|
||||
const { createState } = await import('./state.js');
|
||||
const { createControls } = await import('./controls.js');
|
||||
const state = createState({ services: {}, source: {} });
|
||||
let demand = 0;
|
||||
state._enabled = true;
|
||||
state._pointCollection = { show: true };
|
||||
const controls = createControls({
|
||||
state,
|
||||
services: {},
|
||||
source: { getFlowSessionStats: () => ({}) },
|
||||
parts: { animation: { syncRenderDemand: () => demand++ } },
|
||||
}).methods;
|
||||
controls.setParams({ dots: false });
|
||||
assert.equal(state._pointCollection.show, false);
|
||||
controls.setParams({ dots: true });
|
||||
assert.equal(state._pointCollection.show, true);
|
||||
assert.equal(demand, 2);
|
||||
});
|
||||
|
||||
test('Lines and Dots persist in layer state and local storage', () => {
|
||||
const state = createDefaultLayerState();
|
||||
assert.equal(state.options.traffic.lines, false);
|
||||
assert.equal(state.options.traffic.dots, true);
|
||||
state.enabledLayerIds = ['traffic'];
|
||||
state.options.traffic.lines = true;
|
||||
state.options.traffic.dots = false;
|
||||
const params = new URLSearchParams('v=2');
|
||||
encodeLayerStateParams(params, state);
|
||||
const decoded = decodeLayerStateParams(params).options.traffic;
|
||||
assert.equal(decoded.lines, true);
|
||||
assert.equal(decoded.dots, false);
|
||||
const stored = parseStoredLayerState(serializeStoredLayerState(state)).options
|
||||
.traffic;
|
||||
assert.equal(stored.lines, true);
|
||||
assert.equal(stored.dots, false);
|
||||
});
|
||||
|
||||
test('rebuilt lines keep the old ones drawn until the new geometry is ready', async () => {
|
||||
const { createRendering } = await import('./rendering.js');
|
||||
const removed = [];
|
||||
const state = {
|
||||
_viewer: {
|
||||
scene: { groundPrimitives: { remove: (p) => removed.push(p) } },
|
||||
},
|
||||
_liveMode: false,
|
||||
_heatFreePrim: { ready: true, id: 'old' },
|
||||
_heatRetired: [],
|
||||
};
|
||||
const parts = {
|
||||
style: { heatlineOn: () => true },
|
||||
animation: { syncRenderDemand() {} },
|
||||
};
|
||||
const rendering = createRendering({ state, services: {}, parts, source: {} });
|
||||
// Nothing new to draw (not live): the old line goes at once.
|
||||
rendering.rebuildHeatLines([]);
|
||||
assert.deepEqual(
|
||||
removed.map((p) => p.id),
|
||||
['old'],
|
||||
);
|
||||
// A pending successor holds the retired line until it is ready.
|
||||
const next = { ready: false, id: 'new' };
|
||||
state._heatRetired = [{ id: 'older' }];
|
||||
state._heatJamPrim = next;
|
||||
rendering.retireHeatLines();
|
||||
assert.equal(state._heatRetired.length, 1);
|
||||
next.ready = true;
|
||||
rendering.retireHeatLines();
|
||||
assert.deepEqual(state._heatRetired, []);
|
||||
assert.deepEqual(
|
||||
removed.map((p) => p.id),
|
||||
['old', 'older'],
|
||||
);
|
||||
});
|
||||
|
||||
test('lines hold frames only while building; hidden dots hold none', async () => {
|
||||
const { createAnimation } = await import('./animation.js');
|
||||
const calls = [];
|
||||
const state = {
|
||||
_enabled: true,
|
||||
_dots: [{}],
|
||||
_showDots: false,
|
||||
_showLines: true,
|
||||
_motion: { rebuilds: 0 },
|
||||
};
|
||||
const animation = createAnimation({
|
||||
state,
|
||||
services: {
|
||||
render: {
|
||||
holdContinuousRender: () => calls.push('hold'),
|
||||
releaseContinuousRender: () => calls.push('release'),
|
||||
},
|
||||
},
|
||||
parts: {},
|
||||
});
|
||||
animation.syncRenderDemand();
|
||||
assert.deepEqual(calls, []);
|
||||
state._heatFreePrim = { ready: false };
|
||||
animation.syncRenderDemand();
|
||||
assert.deepEqual(calls, ['hold']);
|
||||
state._heatFreePrim.ready = true;
|
||||
animation.syncRenderDemand();
|
||||
assert.deepEqual(calls, ['hold', 'release']);
|
||||
});
|
||||
@@ -24,7 +24,7 @@ export function createTrafficLayer({ services, source }) {
|
||||
].every((key) => typeof source?.[key] === 'function')
|
||||
)
|
||||
throw new TypeError('A traffic source is required');
|
||||
const state = createState({ services });
|
||||
const state = createState({ services, source });
|
||||
const parts = {};
|
||||
const context = { state, services, parts, source };
|
||||
parts.style = createStyle(context);
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { requestWithDeadline } from '../../sources/requestDeadline.js';
|
||||
import { resolveRoadMode, ROAD_SOURCE_LABELS } from './roadModes.js';
|
||||
import {
|
||||
normalizeRoadMode,
|
||||
resolveRoadMode,
|
||||
roadModesSimulate,
|
||||
ROAD_SOURCE_LABELS,
|
||||
} from './roadModes.js';
|
||||
import { phaseTiming } from '../../sources/phaseTiming.js';
|
||||
import { RoadRequestError, roadRequestError } from './source.js';
|
||||
import {
|
||||
@@ -294,10 +299,14 @@ export function createIngestion({
|
||||
// settled; before that (a session's first load) the cache is skipped
|
||||
// rather than waiting on the probe. Writes are decided by the snapshot.
|
||||
const cacheMode =
|
||||
layerState._roadMode === 'osm'
|
||||
normalizeRoadMode(layerState._roadMode, layerState._roadModes) === 'osm'
|
||||
? 'osm'
|
||||
: layerState._flowStatusKnown
|
||||
? resolveRoadMode(layerState._roadMode, layerState._liveMode)
|
||||
? resolveRoadMode(
|
||||
layerState._roadMode,
|
||||
layerState._liveMode,
|
||||
layerState._roadModes,
|
||||
)
|
||||
: null;
|
||||
// Cache key: fixed-precision bounding-box string for deterministic lookups
|
||||
const cacheKey =
|
||||
@@ -378,6 +387,7 @@ export function createIngestion({
|
||||
(segments) => ({
|
||||
segments,
|
||||
hasKey: layerState._liveMode,
|
||||
statusUnavailable: layerState._flowStatusUnavailable,
|
||||
partial: source.getFlowSessionStats?.().partial,
|
||||
}),
|
||||
(error) => {
|
||||
@@ -388,6 +398,7 @@ export function createIngestion({
|
||||
return {
|
||||
segments: [],
|
||||
hasKey: layerState._liveMode,
|
||||
statusUnavailable: layerState._flowStatusUnavailable,
|
||||
error: reason,
|
||||
};
|
||||
},
|
||||
@@ -397,7 +408,9 @@ export function createIngestion({
|
||||
// This uses the same shared XYZ cache and cap, with no extra ring.
|
||||
if (
|
||||
altitude <= FAST_FETCH_ALTITUDE &&
|
||||
layerState._roadMode !== 'tomtom'
|
||||
resolveRoadMode(layerState._roadMode, true, layerState._roadModes) !==
|
||||
'tomtom' &&
|
||||
(roadModesSimulate(layerState._roadModes) || layerState._liveMode)
|
||||
) {
|
||||
source
|
||||
.requestOsmRoads?.(clamped, {
|
||||
@@ -411,8 +424,19 @@ export function createIngestion({
|
||||
.catch(() => {});
|
||||
}
|
||||
requestSignal.throwIfAborted();
|
||||
// Until the probe settles only OpenStreetMap roads can be drawn.
|
||||
layerState._roadSource = ROAD_SOURCE_LABELS[cacheMode || 'osm'];
|
||||
// Until the probe settles only OpenStreetMap roads can be drawn (or,
|
||||
// for a host without them, the TomTom roads still to come).
|
||||
layerState._roadSource =
|
||||
ROAD_SOURCE_LABELS[
|
||||
cacheMode ||
|
||||
(roadModesSimulate(layerState._roadModes)
|
||||
? 'osm'
|
||||
: resolveRoadMode(
|
||||
layerState._roadMode,
|
||||
true,
|
||||
layerState._roadModes,
|
||||
))
|
||||
];
|
||||
let cache =
|
||||
cacheMode === 'osm' ? layerState._tileCache.get(cacheKey) : null;
|
||||
if (cache) {
|
||||
@@ -581,7 +605,11 @@ export function createIngestion({
|
||||
if (generation === layerState._loadGeneration && !renderedSomething)
|
||||
layerState._roadSource =
|
||||
ROAD_SOURCE_LABELS[
|
||||
resolveRoadMode(layerState._roadMode, layerState._liveMode)
|
||||
resolveRoadMode(
|
||||
layerState._roadMode,
|
||||
layerState._liveMode,
|
||||
layerState._roadModes,
|
||||
)
|
||||
];
|
||||
retryable = !isUnavailableCapability(e);
|
||||
layerState._roadRetryStopped = !retryable;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { observeTrafficSurface } from './surface.js';
|
||||
import * as Cesium from 'cesium';
|
||||
import { TRAFFIC_TIMING_ENABLED } from './policy.js';
|
||||
import { resolveRoadMode } from './roadModes.js';
|
||||
import {
|
||||
claimCameraSensitivity,
|
||||
releaseCameraSensitivity,
|
||||
@@ -81,10 +82,15 @@ export function createLifecycle({
|
||||
if (layerState._enabled) return;
|
||||
layerState._enabled = true;
|
||||
layerState._surfaceFrameRemover = observeTrafficSurface(viewer.scene);
|
||||
if (layerState._roadMode !== 'tomtom') source.prefetch?.();
|
||||
// OpenFreeMap metadata, unless only TomTom roads can be drawn.
|
||||
if (
|
||||
resolveRoadMode(layerState._roadMode, true, layerState._roadModes) !==
|
||||
'tomtom'
|
||||
)
|
||||
source.prefetch?.();
|
||||
parts.animation.syncRenderDemand();
|
||||
layerState._lastAnimTime = 0;
|
||||
layerState._pointCollection.show = true;
|
||||
layerState._pointCollection.show = layerState._showDots;
|
||||
|
||||
layerState._preRenderRemover = viewer.scene.preRender.addEventListener(
|
||||
parts.animation.animate,
|
||||
|
||||
@@ -117,6 +117,23 @@ export const FLOW_BUCKET_COLORS = {
|
||||
jam: Cesium.Color.fromCssColorString('#e05252').withAlpha(0.9),
|
||||
};
|
||||
|
||||
// ─── Flow road lines (`lines` row toggle, live mode only) ────────────────
|
||||
/**
|
||||
* @const {number} Max flow-colored road lines per render. Jam first, then
|
||||
* slow, then free-flow, longest first within a bucket, so a dense city keeps
|
||||
* its congestion when the cap trims the free roads.
|
||||
*/
|
||||
|
||||
export const FLOW_LINE_CAP = 1500;
|
||||
|
||||
/** @const {number} Px — flow road-line width (every bucket). */
|
||||
|
||||
export const FLOW_LINE_WIDTH = 4;
|
||||
|
||||
/** @const {number} Flow road-line opacity: the bucket colors, steady. */
|
||||
|
||||
export const FLOW_LINE_ALPHA = 0.8;
|
||||
|
||||
// ─── Jam-viz prototype (live mode only — see 2026-07-21 design doc) ────────
|
||||
/** @const {number} Max congestion heat-line polylines per render (jam first). */
|
||||
|
||||
|
||||
+131
-60
@@ -2,6 +2,10 @@ import * as Cesium from 'cesium';
|
||||
import { flowBucket } from '../../data/trafficFlowStyle.js';
|
||||
import { trafficStyleProfile } from '../../data/trafficPresetStyle.js';
|
||||
import {
|
||||
FLOW_BUCKET_COLORS,
|
||||
FLOW_LINE_ALPHA,
|
||||
FLOW_LINE_CAP,
|
||||
FLOW_LINE_WIDTH,
|
||||
HEAT_LINE_CAP,
|
||||
HEAT_JAM_COLOR,
|
||||
HEAT_SLOW_COLOR,
|
||||
@@ -34,45 +38,81 @@ export function createRendering({
|
||||
: roads;
|
||||
}
|
||||
|
||||
/** Remove both heat-line ground primitives from the scene. */
|
||||
const LINE_KEYS = ['_heatJamPrim', '_heatSlowPrim', '_heatFreePrim'];
|
||||
|
||||
function removeHeatLines() {
|
||||
if (layerState._heatJamPrim) {
|
||||
layerState._viewer?.scene.groundPrimitives.remove(
|
||||
layerState._heatJamPrim,
|
||||
);
|
||||
layerState._heatJamPrim = null;
|
||||
}
|
||||
if (layerState._heatSlowPrim) {
|
||||
layerState._viewer?.scene.groundPrimitives.remove(
|
||||
layerState._heatSlowPrim,
|
||||
);
|
||||
layerState._heatSlowPrim = null;
|
||||
/** Detach the current line primitives, leaving them in the scene. */
|
||||
function takeHeatLines() {
|
||||
const taken = [];
|
||||
for (const key of LINE_KEYS) {
|
||||
if (layerState[key]) taken.push(layerState[key]);
|
||||
layerState[key] = null;
|
||||
}
|
||||
layerState._heatLineCount = 0;
|
||||
return taken;
|
||||
}
|
||||
|
||||
function removePrimitives(primitives) {
|
||||
for (const primitive of primitives)
|
||||
layerState._viewer?.scene.groundPrimitives.remove(primitive);
|
||||
}
|
||||
|
||||
/** Remove the heat-line / flow-line ground primitives from the scene. */
|
||||
|
||||
function removeHeatLines() {
|
||||
removePrimitives(takeHeatLines());
|
||||
removePrimitives(layerState._heatRetired || []);
|
||||
layerState._heatRetired = [];
|
||||
parts.animation.syncRenderDemand?.();
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuild the congestion heat-line underlay (jam-viz heatline prototype):
|
||||
* slow/jam roads drape a corridor line onto the rendered 3D tiles — glowing
|
||||
* pulsing red for jam, faint flat amber for slow — with the dots animating
|
||||
* on top. Two batched GroundPolylinePrimitives (one per bucket) so the jam
|
||||
* batch pulses through one shared material. Capped at HEAT_LINE_CAP (jam
|
||||
* first, longest first); overflow is logged. No-op in sim mode, when the
|
||||
* heatline mode is off, or without ground-primitive support.
|
||||
* Replaced lines stay drawn until their successors finish building
|
||||
* (GroundPolylinePrimitive geometry is asynchronous), so a rebuild never
|
||||
* blinks. Called every held frame.
|
||||
*/
|
||||
|
||||
function retireHeatLines() {
|
||||
if (!layerState._heatRetired?.length) return;
|
||||
if (LINE_KEYS.some((key) => layerState[key] && !layerState[key].ready))
|
||||
return;
|
||||
removePrimitives(layerState._heatRetired);
|
||||
layerState._heatRetired = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuild the road-line underlay drawn from live flow, in one of two looks:
|
||||
*
|
||||
* - Flow lines (the `lines` row toggle): every road with flow drapes a
|
||||
* steady line in its bucket color — green free-flow, amber slow, red jam.
|
||||
* Capped at FLOW_LINE_CAP.
|
||||
* - Heat-lines (jam-viz `heatline` prototype): only slow/jam roads, a
|
||||
* glowing pulsing red for jam and a faint flat amber for slow. Capped at
|
||||
* HEAT_LINE_CAP.
|
||||
*
|
||||
* One batched GroundPolylinePrimitive per bucket, so the heat-line jam batch
|
||||
* pulses through one shared material. Kept roads are jam first, then slow,
|
||||
* then free, longest first; overflow is logged. Lines drape on 3D tiles when
|
||||
* the globe is hidden (photoreal) and on terrain or tiles otherwise. No-op in
|
||||
* sim mode, when both looks are off, or without ground-primitive support.
|
||||
*
|
||||
* @param {Array} roads - Road objects visible in the current render.
|
||||
*/
|
||||
|
||||
function rebuildHeatLines(roads) {
|
||||
removeHeatLines();
|
||||
const previous = takeHeatLines();
|
||||
const finish = () => {
|
||||
// Nothing new to wait for: the old lines go now.
|
||||
if (LINE_KEYS.some((key) => layerState[key]))
|
||||
(layerState._heatRetired ||= []).push(...previous);
|
||||
else removePrimitives(previous);
|
||||
parts.animation.syncRenderDemand?.();
|
||||
};
|
||||
if (
|
||||
!layerState._viewer ||
|
||||
!layerState._liveMode ||
|
||||
!parts.style.heatlineOn()
|
||||
)
|
||||
return;
|
||||
return finish();
|
||||
if (layerState._heatSupported === null) {
|
||||
layerState._heatSupported = Cesium.GroundPolylinePrimitive.isSupported(
|
||||
layerState._viewer.scene,
|
||||
@@ -82,22 +122,25 @@ export function createRendering({
|
||||
'[Data:Traffic] GroundPolylinePrimitive unsupported — heat-lines disabled',
|
||||
);
|
||||
}
|
||||
if (!layerState._heatSupported) return;
|
||||
if (!layerState._heatSupported) return finish();
|
||||
|
||||
const flowLines = layerState._showLines;
|
||||
const cap = flowLines ? FLOW_LINE_CAP : HEAT_LINE_CAP;
|
||||
const rank = { jam: 0, slow: 1, free: 2 };
|
||||
const candidates = [];
|
||||
for (const road of roads) {
|
||||
const flow = road.flow;
|
||||
if (!flow || flow.closure) continue;
|
||||
const bucket = flowBucket(flow.level);
|
||||
if (bucket === 'free') continue;
|
||||
if (bucket === 'free' && !flowLines) continue;
|
||||
let len = 0;
|
||||
for (const d of road.segmentDist) len += d;
|
||||
candidates.push({ road, bucket, len });
|
||||
}
|
||||
candidates.sort((a, b) =>
|
||||
a.bucket === b.bucket ? b.len - a.len : a.bucket === 'jam' ? -1 : 1,
|
||||
a.bucket === b.bucket ? b.len - a.len : rank[a.bucket] - rank[b.bucket],
|
||||
);
|
||||
const kept = candidates.slice(0, HEAT_LINE_CAP);
|
||||
const kept = candidates.slice(0, cap);
|
||||
|
||||
const instancesFor = (bucket, width) =>
|
||||
kept
|
||||
@@ -112,51 +155,72 @@ export function createRendering({
|
||||
}),
|
||||
);
|
||||
|
||||
// Mono presets (NVG/FLIR/noir) discard hue — heat-lines re-encode in
|
||||
// luminance like the dots: jam = white glow, slow = faint gray.
|
||||
const monoHeat =
|
||||
// Mono presets (NVG/FLIR/noir) discard hue — lines re-encode in
|
||||
// luminance like the dots: jam = white, slow = gray, free = dim gray.
|
||||
const mono =
|
||||
layerState._presetDots === 'on' &&
|
||||
trafficStyleProfile(layerState._stylePreset) === 'mono';
|
||||
const jamLineColor = monoHeat ? Cesium.Color.WHITE : HEAT_JAM_COLOR;
|
||||
const slowLineColor = monoHeat
|
||||
? new Cesium.Color(0.7, 0.7, 0.7, HEAT_SLOW_COLOR.alpha)
|
||||
: HEAT_SLOW_COLOR;
|
||||
|
||||
const jamInstances = instancesFor('jam', HEAT_LINE_JAM_WIDTH);
|
||||
if (jamInstances.length) {
|
||||
layerState._heatJamPrim = layerState._viewer.scene.groundPrimitives.add(
|
||||
new Cesium.GroundPolylinePrimitive({
|
||||
geometryInstances: jamInstances,
|
||||
classificationType: Cesium.ClassificationType.CESIUM_3D_TILE,
|
||||
appearance: new Cesium.PolylineMaterialAppearance({
|
||||
material: Cesium.Material.fromType('PolylineGlow', {
|
||||
color: jamLineColor.withAlpha(HEAT_JAM_BASE_ALPHA),
|
||||
glowPower: 0.25,
|
||||
const gray = (level, alpha) => new Cesium.Color(level, level, level, alpha);
|
||||
const classificationType =
|
||||
layerState._viewer.scene.globe?.show === false
|
||||
? Cesium.ClassificationType.CESIUM_3D_TILE
|
||||
: flowLines
|
||||
? Cesium.ClassificationType.BOTH
|
||||
: Cesium.ClassificationType.CESIUM_3D_TILE;
|
||||
const add = (instances, material) =>
|
||||
instances.length
|
||||
? layerState._viewer.scene.groundPrimitives.add(
|
||||
new Cesium.GroundPolylinePrimitive({
|
||||
geometryInstances: instances,
|
||||
classificationType,
|
||||
appearance: new Cesium.PolylineMaterialAppearance({ material }),
|
||||
}),
|
||||
)
|
||||
: null;
|
||||
|
||||
// Lines take the dots' active (preset-aware) colors.
|
||||
const dotColor = (bucket) =>
|
||||
layerState._activeBucketColors?.[bucket] || FLOW_BUCKET_COLORS[bucket];
|
||||
if (flowLines) {
|
||||
const lineColor = {
|
||||
jam: mono ? gray(1, FLOW_LINE_ALPHA) : dotColor('jam'),
|
||||
slow: mono ? gray(0.7, FLOW_LINE_ALPHA) : dotColor('slow'),
|
||||
free: mono ? gray(0.45, FLOW_LINE_ALPHA) : dotColor('free'),
|
||||
};
|
||||
for (const [bucket, key] of [
|
||||
['free', '_heatFreePrim'],
|
||||
['slow', '_heatSlowPrim'],
|
||||
['jam', '_heatJamPrim'],
|
||||
])
|
||||
layerState[key] = add(
|
||||
instancesFor(bucket, FLOW_LINE_WIDTH),
|
||||
Cesium.Material.fromType('Color', {
|
||||
color: lineColor[bucket].withAlpha(FLOW_LINE_ALPHA),
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
const jamLineColor = mono ? Cesium.Color.WHITE : HEAT_JAM_COLOR;
|
||||
const slowLineColor = mono
|
||||
? gray(0.7, HEAT_SLOW_COLOR.alpha)
|
||||
: HEAT_SLOW_COLOR;
|
||||
layerState._heatJamPrim = add(
|
||||
instancesFor('jam', HEAT_LINE_JAM_WIDTH),
|
||||
Cesium.Material.fromType('PolylineGlow', {
|
||||
color: jamLineColor.withAlpha(HEAT_JAM_BASE_ALPHA),
|
||||
glowPower: 0.25,
|
||||
}),
|
||||
);
|
||||
}
|
||||
const slowInstances = instancesFor('slow', HEAT_LINE_SLOW_WIDTH);
|
||||
if (slowInstances.length) {
|
||||
layerState._heatSlowPrim = layerState._viewer.scene.groundPrimitives.add(
|
||||
new Cesium.GroundPolylinePrimitive({
|
||||
geometryInstances: slowInstances,
|
||||
classificationType: Cesium.ClassificationType.CESIUM_3D_TILE,
|
||||
appearance: new Cesium.PolylineMaterialAppearance({
|
||||
material: Cesium.Material.fromType('Color', {
|
||||
color: slowLineColor,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
layerState._heatSlowPrim = add(
|
||||
instancesFor('slow', HEAT_LINE_SLOW_WIDTH),
|
||||
Cesium.Material.fromType('Color', { color: slowLineColor }),
|
||||
);
|
||||
}
|
||||
|
||||
layerState._heatLineCount = kept.length;
|
||||
parts.animation.syncRenderDemand?.();
|
||||
finish();
|
||||
if (candidates.length > kept.length) {
|
||||
console.log(
|
||||
`[Data:Traffic] Heat-lines capped at ${HEAT_LINE_CAP} (${candidates.length} congested roads in view)`,
|
||||
`[Data:Traffic] ${flowLines ? 'Flow lines' : 'Heat-lines'} capped at ${cap} (${candidates.length} roads in view)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -270,7 +334,13 @@ export function createRendering({
|
||||
renderMetrics,
|
||||
)
|
||||
: null;
|
||||
if (!label.includes('tile')) rebuildHeatLines(filteredRoads);
|
||||
// The flow recolor above already rebuilt the lines from every retained
|
||||
// road; without dots to recolor, rebuild them here.
|
||||
if (
|
||||
!label.includes('tile') &&
|
||||
!(layerState._liveMode && layerState._dots.length)
|
||||
)
|
||||
rebuildHeatLines(visibleRoadsForAltitude(layerState._roads, altitude));
|
||||
if (state) {
|
||||
const heatEnd = parts.timing.trafficTimingMark(
|
||||
state,
|
||||
@@ -314,6 +384,7 @@ export function createRendering({
|
||||
return {
|
||||
visibleRoadsForAltitude,
|
||||
removeHeatLines,
|
||||
retireHeatLines,
|
||||
rebuildHeatLines,
|
||||
renderRoadsForAltitude,
|
||||
};
|
||||
|
||||
@@ -46,24 +46,59 @@ const PAIRED_SIDE_SAMPLES = [0.25, 0.5, 0.75];
|
||||
const CONTROLLED_ACCESS_FLOW = new Set(['Motorway', 'International road']);
|
||||
const CONTROLLED_ACCESS_OSM = new Set(['motorway', 'trunk']);
|
||||
|
||||
/**
|
||||
* Validate a host's road-source list (`createTrafficSource({ roadModes })`):
|
||||
* a non-empty subset of TRAFFIC_ROAD_MODES, kept in canonical order.
|
||||
* @param {*} value - Candidate list; undefined means every mode.
|
||||
* @returns {ReadonlyArray<'tomtom'|'osm'|'hybrid'>}
|
||||
*/
|
||||
export function normalizeRoadModes(value) {
|
||||
if (value === undefined) return TRAFFIC_ROAD_MODES;
|
||||
const modes = Array.isArray(value)
|
||||
? TRAFFIC_ROAD_MODES.filter((mode) => value.includes(mode))
|
||||
: [];
|
||||
if (!modes.length || modes.length !== value.length)
|
||||
throw new TypeError(
|
||||
`Traffic road modes must be a non-empty subset of ${TRAFFIC_ROAD_MODES.join(', ')}`,
|
||||
);
|
||||
return Object.freeze(modes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a host's modes allow simulated roads. Without OpenStreetMap the
|
||||
* layer never simulates: keyless, it draws no roads and says why.
|
||||
* @param {ReadonlyArray<string>} [modes]
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function roadModesSimulate(modes = TRAFFIC_ROAD_MODES) {
|
||||
return modes.includes('osm');
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate an explicit choice.
|
||||
* @param {*} value - Candidate mode.
|
||||
* @param {ReadonlyArray<string>} [modes] - The host's offered modes.
|
||||
* @returns {'tomtom'|'osm'|'hybrid'|null} Null means "use the default".
|
||||
*/
|
||||
export function normalizeRoadMode(value) {
|
||||
return TRAFFIC_ROAD_MODES.includes(value) ? value : null;
|
||||
export function normalizeRoadMode(value, modes = TRAFFIC_ROAD_MODES) {
|
||||
return modes.includes(value) ? value : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the mode actually drawn. Without a TomTom key every choice draws
|
||||
* OpenStreetMap roads; with one, an explicit choice wins over Hybrid.
|
||||
* OpenStreetMap roads when the host offers them; with one, an explicit
|
||||
* choice wins over Hybrid. A host that offers no OpenStreetMap mode keeps
|
||||
* its TomTom choice keyless (nothing is drawn; the status names the reason),
|
||||
* and a host without Hybrid defaults to its first offered mode.
|
||||
* @param {*} value - Requested mode (null for the default).
|
||||
* @param {boolean} hasKey - The server reported a TomTom key.
|
||||
* @param {ReadonlyArray<string>} [modes] - The host's offered modes.
|
||||
* @returns {'tomtom'|'osm'|'hybrid'}
|
||||
*/
|
||||
export function resolveRoadMode(value, hasKey) {
|
||||
return hasKey ? normalizeRoadMode(value) || 'hybrid' : 'osm';
|
||||
export function resolveRoadMode(value, hasKey, modes = TRAFFIC_ROAD_MODES) {
|
||||
const chosen = normalizeRoadMode(value, modes);
|
||||
if (!hasKey && roadModesSimulate(modes)) return 'osm';
|
||||
return chosen || (modes.includes('hybrid') ? 'hybrid' : modes[0]);
|
||||
}
|
||||
|
||||
const FLOW_ROAD_TYPES = Object.freeze({
|
||||
|
||||
@@ -74,7 +74,23 @@ function roadChips(liveMode) {
|
||||
source: { getFlowSessionStats: () => ({}) },
|
||||
parts: {},
|
||||
});
|
||||
return controls.methods.getRowControls().chips;
|
||||
return controls.methods
|
||||
.getRowControls()
|
||||
.chips.filter((chip) => chip.id.startsWith('roads-'));
|
||||
}
|
||||
|
||||
function toggleChips() {
|
||||
const state = createState({ services: {} });
|
||||
state._liveMode = true;
|
||||
const controls = createControls({
|
||||
state,
|
||||
services: {},
|
||||
source: { getFlowSessionStats: () => ({}) },
|
||||
parts: {},
|
||||
});
|
||||
return controls.methods
|
||||
.getRowControls()
|
||||
.chips.filter((chip) => !chip.id.startsWith('roads-'));
|
||||
}
|
||||
|
||||
test('en: road-source chips keep their English labels and titles', () => {
|
||||
@@ -124,3 +140,28 @@ for (const [locale, hybrid] of [
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test('en: the Lines and Dots toggles keep their English labels and titles', () => {
|
||||
setLocale('en');
|
||||
assert.deepEqual(
|
||||
toggleChips().map((chip) => [chip.id, chip.label, chip.title]),
|
||||
[
|
||||
['lines', 'Lines', 'Road lines colored by live flow: green to red'],
|
||||
['dots', 'Dots', 'Moving dots colored by live flow'],
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
for (const locale of ['ru', 'es', 'ja']) {
|
||||
test(`${locale}: the Lines and Dots toggles are translated`, () => {
|
||||
setLocale(locale);
|
||||
try {
|
||||
for (const chip of toggleChips()) {
|
||||
assert.doesNotMatch(chip.label, /^(Lines|Dots)$/, locale);
|
||||
assert.doesNotMatch(chip.title, /colored by live flow/, locale);
|
||||
}
|
||||
} finally {
|
||||
setLocale('en');
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
import {
|
||||
createHybridFill,
|
||||
flowSegmentsToRoads,
|
||||
normalizeRoadMode,
|
||||
normalizeRoadModes,
|
||||
resolveRoadMode,
|
||||
roadModesSimulate,
|
||||
ROAD_SOURCE_LABELS,
|
||||
} from './roadModes.js';
|
||||
import { phaseTiming } from '../../sources/phaseTiming.js';
|
||||
@@ -80,7 +83,15 @@ export function roadRequestError(status, cause) {
|
||||
});
|
||||
}
|
||||
|
||||
/** Supply tile-derived road geometry and flow availability without Overpass queries. */
|
||||
/**
|
||||
* Supply tile-derived road geometry and flow availability without Overpass queries.
|
||||
*
|
||||
* `roadModes` is the host's offer of road sources (default: all three). The
|
||||
* layer shows a chip per offered mode (none for a single mode) and never
|
||||
* draws one outside it. Without `osm` nothing is simulated: keyless, or with
|
||||
* the traffic service unreachable, requests fail with the reason instead of
|
||||
* drawing white OpenStreetMap dots.
|
||||
*/
|
||||
export function createTrafficSource({
|
||||
fetchImpl = (...args) => globalThis.fetch(...args),
|
||||
tileFetchImpl = (...args) => globalThis.fetch(...args),
|
||||
@@ -88,10 +99,13 @@ export function createTrafficSource({
|
||||
fetchImpl: tileFetchImpl,
|
||||
selectDecoded: ({ roads }) => ({ roads }),
|
||||
}),
|
||||
roadModes,
|
||||
} = {}) {
|
||||
const modes = normalizeRoadModes(roadModes);
|
||||
const flow = createFlowTileSource({ fetchImpl });
|
||||
const api = {
|
||||
...flow,
|
||||
roadModes: modes,
|
||||
prefetch: () => mapTiles.getMetadata().catch(() => {}),
|
||||
resetFlowTileCache() {
|
||||
flow.resetFlowTileCache();
|
||||
@@ -247,8 +261,8 @@ export function createTrafficSource({
|
||||
*
|
||||
* @param {{south:number, west:number, north:number, east:number}} box
|
||||
* @param {Object} [options]
|
||||
* @param {'tomtom'|'osm'|'hybrid'|null} [options.roadMode] - Requested mode (null = default).
|
||||
* @param {Promise<{segments:Array, hasKey:boolean, error?:string, partial?:boolean}>} [options.flowSnapshot]
|
||||
* @param {'tomtom'|'osm'|'hybrid'|null} [options.roadMode] - Requested mode (null = default; outside `roadModes` = default).
|
||||
* @param {Promise<{segments:Array, hasKey:boolean, error?:string, partial?:boolean, statusUnavailable?:boolean}>} [options.flowSnapshot]
|
||||
* @param {() => boolean} [options.liveModeHint] - Key state if the snapshot misses the pass deadline.
|
||||
* @param {number} [options.timeoutSec=20] - Pass deadline, also bounding the snapshot wait.
|
||||
* @param {(data:Object) => void} [options.onTile] - Incremental snapshots; `replace` resets.
|
||||
@@ -269,7 +283,16 @@ export function createTrafficSource({
|
||||
box.east - box.west > 10
|
||||
)
|
||||
throw new TypeError('A bounded road viewport is required');
|
||||
if (roadMode === 'osm' || !flowSnapshot)
|
||||
roadMode = normalizeRoadMode(roadMode, modes);
|
||||
if (!roadModesSimulate(modes) && !flowSnapshot)
|
||||
throw new TypeError(
|
||||
'Road selection requires a flow availability snapshot',
|
||||
);
|
||||
if (
|
||||
roadMode === 'osm' ||
|
||||
!flowSnapshot ||
|
||||
(modes.length === 1 && modes[0] === 'osm')
|
||||
)
|
||||
return api.requestOsmRoads(box, { ...options, onTile });
|
||||
const signal = options.signal;
|
||||
const osm = [];
|
||||
@@ -332,7 +355,13 @@ export function createTrafficSource({
|
||||
osmError = error;
|
||||
}
|
||||
};
|
||||
const osmJob = roadMode === 'tomtom' ? null : loadOsm();
|
||||
// A host without OpenStreetMap waits for the key before fetching fill.
|
||||
const osmJob =
|
||||
roadMode === 'tomtom' ||
|
||||
modes.every((entry) => entry === 'tomtom') ||
|
||||
!roadModesSimulate(modes)
|
||||
? null
|
||||
: loadOsm();
|
||||
osmJob?.catch(() => {});
|
||||
// The snapshot waits on the status probe and flow tiles; never let it
|
||||
// hold the road pass past its own deadline. A late snapshot counts as a
|
||||
@@ -357,7 +386,21 @@ export function createTrafficSource({
|
||||
throw new TypeError(
|
||||
'Road selection requires a flow availability snapshot',
|
||||
);
|
||||
mode = resolveRoadMode(roadMode, live.hasKey);
|
||||
mode = resolveRoadMode(roadMode, live.hasKey, modes);
|
||||
// A host without OpenStreetMap never simulates: name why no roads draw.
|
||||
if (!live.hasKey && !roadModesSimulate(modes))
|
||||
throw Object.assign(
|
||||
new RoadRequestError(
|
||||
live.error ||
|
||||
(live.statusUnavailable
|
||||
? msg(
|
||||
'TomTom roads unavailable while the traffic service is unreachable',
|
||||
)
|
||||
: msg('TomTom roads need a TomTom key')),
|
||||
),
|
||||
// A known missing key cannot recover this session; the rest can.
|
||||
{ retryable: Boolean(live.error || live.statusUnavailable) },
|
||||
);
|
||||
if (mode === 'tomtom' && live.error) throw new RoadRequestError(live.error);
|
||||
if (mode !== 'osm') {
|
||||
tomtom = flowSegmentsToRoads(live.segments);
|
||||
@@ -365,7 +408,7 @@ export function createTrafficSource({
|
||||
publish(composed(), true);
|
||||
}
|
||||
if (osmJob) await osmJob;
|
||||
else if (mode === 'osm') await loadOsm();
|
||||
else if (mode !== 'tomtom') await loadOsm();
|
||||
signal?.throwIfAborted();
|
||||
if (osmError && (mode === 'osm' || !tomtom.length)) throw osmError;
|
||||
const data = snapshot(composed(), true);
|
||||
|
||||
Loaded 100 of 263 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user