Merge branch 'main' into upstream/nominatim-cache-mode-windows

This commit is contained in:
quanticsoul4772 authored and GitHub committed 2026-11-07 09:38:35 -08:00
commit 6150e21ebb
263 files changed
+32869 -1090

No files matched your search

+663 -1
View File
@@ -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');
});
+1
View File
@@ -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',
+2
View File
@@ -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([], [])),
+7 -2
View File
@@ -1,7 +1,7 @@
import { resolveCatalogSources } from './sourceComposition.js';
import { createWeatherClock } from '../layers/weather/clock.js';
import { createWeatherLayer } from '../layers/weather/index.js';
import { createCyclonesLayer } from '../layers/cyclones/index.js';
import { createApplicationCyclones } from './layers/cyclones.js';
import { createWindLayer } from '../layers/wind/index.js';
import { createLayerCatalog } from './catalog.js';
import { LAYER_STATE_REGISTRY } from '../data/layerState.js';
@@ -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 }),
+2 -2
View File
@@ -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,
+10
View File
@@ -0,0 +1,10 @@
import { createCyclonesLayer } from '../../layers/cyclones/index.js';
import * as context from '../../data/contextStore.js';
/** Wire cyclone selection to the application shared context. */
export function createApplicationCyclones(options) {
return createCyclonesLayer({
context: typeof window !== 'undefined' ? context : null,
...options,
});
}
+30
View File
@@ -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
View File
@@ -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)));
+1
View File
@@ -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']),
});
+1 -1
View File
@@ -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
View File
@@ -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,
+230
View File
@@ -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 {
+669
View File
@@ -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');
});
+10 -1
View File
@@ -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,
+773
View File
@@ -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
View File
@@ -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))}°.`,
};
}
/**
+19 -6
View File
@@ -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'/);
});
+34
View File
@@ -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
View File
@@ -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
);
}
+18 -23
View File
@@ -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 () => {
+2
View File
@@ -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,
+116
View File
@@ -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({
+429
View File
@@ -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',
);
});
+21
View File
@@ -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 &amp; 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:
+5
View File
@@ -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
+110
View File
@@ -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": "Тоҷикистон"
}
}
]
}
}
+1 -1
View File
@@ -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(
+26
View File
@@ -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',
+34 -7
View File
@@ -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, [
+25 -15
View File
@@ -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"]
]
+10 -1
View File
@@ -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}}
+50
View File
@@ -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
View File
@@ -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 /, '');
}
/**
+1 -1
View File
@@ -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);
});
+1
View File
@@ -2,6 +2,7 @@
export const SPRITE_LAYER_ORDER = Object.freeze([
'cctv',
'firms',
'wildlife-sightings',
'bikeshare',
'transit',
'transit-motion',
+22
View File
@@ -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');
+11 -2
View File
@@ -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(
+179
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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}天內 · 保留無日期標記"
}
+69 -7
View File
@@ -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"
]
}
+7 -9
View File
@@ -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);
}
/**
+58
View File
@@ -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.
+1
View File
@@ -23,6 +23,7 @@ export function createControls({ state: layerState, services, parts, source }) {
attachDataManager(dataManager) {
layerState.dataManager = dataManager;
parts.dependencies.watchAdoptions(dataManager);
},
setParams(params = {}) {
+20
View File
@@ -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,
};
}
+1
View File
@@ -340,6 +340,7 @@ export function createLifecycle({
layerState.clearListener = null;
layerState.subjectClearListener = null;
layerState.viewer = null;
parts.dependencies.watchAdoptions(null);
layerState.dataManager = null;
},
};
+2
View File
@@ -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,
+2
View File
@@ -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,
},
);
+7 -6
View File
@@ -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();
+32
View File
@@ -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,
+2
View File
@@ -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(
+6
View File
@@ -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.
+2
View File
@@ -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
View File
@@ -67,6 +67,7 @@ export function createCyclonesLayer({
feed,
hitTestOverlay = hitTestWorldOverlay,
overlayHost,
context = null,
cesium = Cesium,
createRendering = createCycloneRendering,
matchMedia = globalThis.matchMedia?.bind(globalThis),
@@ -82,6 +83,7 @@ export function createCyclonesLayer({
selectedId = null,
selectionIntent = 'auto',
navigationGeneration = 0,
selectedContextStormId = null,
clickHandler = null,
removeClickCapture = null;
let enabled = false,
@@ -92,6 +94,62 @@ export function createCyclonesLayer({
const notify = () => listener?.();
const selected = () =>
snapshot?.storms.find((storm) => storm.id === selectedId) || null;
function clearSelectedContext({ evicted = false } = {}) {
if (selectedContextStormId) {
if (!evicted)
context.clearSelectedEntityContextForLayer('weather-cyclones');
context.removeEntityContextsForLayer('weather-cyclones');
}
selectedContextStormId = null;
}
// Callers publish only while selectedId names a storm in the snapshot.
function publishSelectedContext({ announce = false } = {}) {
const storm = selected();
if (!context) return;
const entity = {};
const contextId = `cyclone:${storm.id}`;
context.registerEntityContext(entity, {
id: contextId,
layerId: 'weather-cyclones',
layerName: 'Cyclone advisories',
source: 'NOAA NHC / CPHC',
label: storm.name,
latitude: storm.position.latitude,
longitude: storm.position.longitude,
// At most 12 properties reach the voice summary; keep the list short.
properties: {
classification: storm.classification,
classificationName: Object.hasOwn(
CLASSIFICATION_NAMES,
storm.classification,
)
? CLASSIFICATION_NAMES[storm.classification]
: undefined,
basin: storm.basin,
advisoryNumber: storm.advisoryNumber,
issuedAt: storm.issuedAt,
positionAt: storm.positionAt,
windKt: storm.windKt,
pressureHpa: storm.pressureHpa,
// Flat scalars: the voice context summary drops nested objects.
movementDirectionDegrees: storm.movement?.directionDegrees,
movementSpeedKt: storm.movement?.speedKt,
advisoryUrl: storm.advisoryUrl,
// Only while the proxy serves its cache because NHC is unreachable.
staleCacheFetchedAt:
snapshot.stale && Number.isFinite(snapshot.fetchedAt)
? new Date(snapshot.fetchedAt).toISOString()
: undefined,
},
});
selectedContextStormId = storm.id;
if (announce) context.selectEntityContext(entity);
context.removeEntityContextsForLayer('weather-cyclones', {
retainIds: new Set([contextId]),
});
}
function select(id) {
if (selectedId !== id) ++navigationGeneration;
selectedId = id;
@@ -236,6 +294,7 @@ export function createCyclonesLayer({
selectedId = null;
selectionIntent = 'auto';
++navigationGeneration;
clearSelectedContext();
rendering?.clear();
},
async update(_viewer, { signal } = {}) {
@@ -259,6 +318,7 @@ export function createCyclonesLayer({
snapshot = next;
selectedId = null;
++navigationGeneration;
clearSelectedContext({ evicted: true });
error = next.reason || ADVISORIES_UNAVAILABLE;
return true;
}
@@ -281,6 +341,10 @@ export function createCyclonesLayer({
: snapshot.storms[0]?.id || null,
);
else rendering.setSelection(selectedId);
if (selectedContextStormId) {
if (selectedId === selectedContextStormId) publishSelectedContext();
else clearSelectedContext({ evicted: true });
}
return true;
} catch (cause) {
if (controller.signal.aborted || request !== controller) return false;
@@ -290,6 +354,7 @@ export function createCyclonesLayer({
snapshot = null;
selectedId = null;
++navigationGeneration;
clearSelectedContext({ evicted: true });
return true;
} finally {
signal?.removeEventListener('abort', abort);
@@ -305,6 +370,7 @@ export function createCyclonesLayer({
if (params.clear === true || params.stormId === null) {
selectionIntent = 'cleared';
select(null);
clearSelectedContext();
notify();
} else if (
typeof params.stormId === 'string' &&
@@ -312,6 +378,7 @@ export function createCyclonesLayer({
) {
selectionIntent = 'user';
select(params.stormId);
publishSelectedContext({ announce: true });
notify();
}
const storm = selected();
+255 -1
View File
@@ -7,6 +7,9 @@ import {
isPointerFree,
} from '../../data/inputOwnership.js';
import { isOwnedByOtherLayer } from '../../data/pickRegistry.js';
import * as contextStore from '../../data/contextStore.js';
import { createGevActionRunner } from '../../voice/gevActions.js';
import * as Cesium from 'cesium';
const time = '2026-09-16T03:00:00.000Z';
const storm = (id = 'ep152026') => ({
@@ -338,7 +341,7 @@ test('a click on a storm card or lead-hour label selects that storm without pick
});
function harness(
feed = { getSnapshot: async () => snapshot() },
{ reducedMotion = false } = {},
{ reducedMotion = false, context = null } = {},
) {
const applied = [],
navigation = [],
@@ -368,6 +371,7 @@ function harness(
};
const layer = createCyclonesLayer({
feed,
context,
createRendering: () => rendering,
matchMedia: () => ({ matches: reducedMotion }),
openLink: (url) => opened.push(url),
@@ -401,6 +405,256 @@ function harness(
},
};
}
// Real shared-context store on a stand-in window; the voice runner reads it.
async function withSharedContext(t, body) {
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, 'window');
const host = new EventTarget();
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: host,
});
const layers = [];
t.after(() => {
try {
for (const layer of layers) layer.destroy();
} finally {
if (previousWindow)
Object.defineProperty(globalThis, 'window', previousWindow);
else delete globalThis.window;
}
});
const events = [];
host.addEventListener('gev:entity-selected', ({ detail }) =>
events.push(['selected', detail.id]),
);
host.addEventListener('gev:entity-selection-cleared', ({ detail }) =>
events.push(['cleared', detail.layerId, detail.reason]),
);
// The voice runner bounds its basemap wait with the window timers.
Object.assign(host, { setTimeout, clearTimeout });
const position = Cesium.Cartesian3.fromDegrees(-125.8, 15.5, 5_000_000);
const runner = createGevActionRunner({
viewer: {
clock: { onTick: { addEventListener: () => () => {} } },
scene: { canvas: { addEventListener() {}, removeEventListener() {} } },
camera: {
moveEnd: { addEventListener() {} },
positionWC: position,
positionCartographic: Cesium.Cartographic.fromCartesian(position),
heading: 0,
pitch: 0,
},
},
styleManager: {},
dataManager: {
layers: new Map(),
isEnabled: (id) => id === 'weather-cyclones',
getAll: () => [],
},
});
const selectedProperties = async () => {
const result = await runner('get_entity_context', { scope: 'selected' });
assert.equal(result.scope, 'selected');
return result.selected.properties;
};
const store = contextStore.getContextStore();
const open = (feed) => {
const h = harness(feed, { context: contextStore });
layers.push(h.layer);
return h;
};
await body({ events, selectedProperties, store, open });
}
test('shared cyclone context distinguishes switches, deliberate clears and feed eviction', async (t) => {
await withSharedContext(
t,
async ({ events, selectedProperties, store, open }) => {
let next = snapshot([
{ ...storm(), movement: { directionDegrees: 315, speedKt: 12 } },
{
...storm('ep162026'),
movement: { directionDegrees: 0, speedKt: 10 },
},
]);
const h = open({ getSnapshot: async () => next });
h.layer.enable();
await h.layer.update();
assert.deepEqual(
events,
[],
'the automatic first-storm row selection must not claim shared context',
);
assert.equal(store.entities.size, 0);
h.layer.setParams({ stormId: 'ep152026' });
assert.equal(store.entities.get('cyclone:ep152026').label, 'Fifteen-E');
let properties = await selectedProperties();
assert.equal(properties.classification, 'PTC');
assert.equal(properties.classificationName, 'Post-tropical cyclone');
assert.equal(properties.advisoryNumber, '10');
assert.equal(
properties.movementDirectionDegrees,
'315',
'selected storm movement should survive voice summarization',
);
assert.equal(properties.movementSpeedKt, '12');
assert.equal(
properties.advisoryUrl,
'https://www.nhc.noaa.gov/text/MIATCMEP5.shtml',
);
assert.equal(properties.staleCacheFetchedAt, undefined);
h.layer.setParams({ stormId: 'ep162026' });
properties = await selectedProperties();
assert.equal(
properties.movementDirectionDegrees,
'0',
'a due-north heading is a value, not a missing one',
);
assert.equal(properties.movementSpeedKt, '10');
assert.deepEqual(events, [
['selected', 'cyclone:ep152026'],
['selected', 'cyclone:ep162026'],
]);
assert.deepEqual([...store.entities.keys()], ['cyclone:ep162026']);
next = snapshot([
{ ...storm(), movement: { directionDegrees: 315, speedKt: 12 } },
{ ...storm('ep162026'), movement: { directionDegrees: 0, speedKt: 0 } },
]);
await h.layer.update();
assert.equal(events.length, 2, 'refresh must not announce selection');
properties = await selectedProperties();
assert.equal(
properties.movementSpeedKt,
'0',
'stationary is a value, refreshed into the selected record',
);
h.layer.setParams({ clear: true });
assert.deepEqual(events.at(-1), [
'cleared',
'weather-cyclones',
'deliberate',
]);
assert.equal(store.entities.size, 0);
h.layer.setParams({ stormId: 'ep162026' });
next = snapshot([storm()]);
await h.layer.update();
assert.deepEqual(
events.at(-1),
['cleared', 'weather-cyclones', 'evicted'],
'a vanished user-selected storm must clear instead of transferring context to the fallback row',
);
assert.equal(store.entities.size, 0);
assert.equal(store.selectedEntityId, null);
h.layer.setParams({ stormId: 'ep152026' });
h.layer.disable();
assert.deepEqual(events.at(-1), [
'cleared',
'weather-cyclones',
'deliberate',
]);
assert.equal(store.entities.size, 0);
},
);
});
test('shared cyclone context is evicted when the source fails and marks a stale cache', async (t) => {
await withSharedContext(
t,
async ({ events, selectedProperties, store, open }) => {
let next = snapshot([{ ...storm(), classification: 'XX' }]);
let fail = false;
const h = open({
getSnapshot: async () => {
if (fail) throw new Error('Network unavailable');
return next;
},
});
h.layer.enable();
await h.layer.update();
h.layer.setParams({ stormId: 'ep152026' });
let properties = await selectedProperties();
assert.equal(properties.classification, 'XX');
assert.equal(
properties.classificationName,
undefined,
'an unknown code is not given a guessed meaning',
);
next = { ...snapshot(), stale: true };
await h.layer.update();
properties = await selectedProperties();
assert.equal(properties.staleCacheFetchedAt, time);
assert.equal(events.length, 1, 'refresh must not announce selection');
next = {
...snapshot([]),
unavailable: true,
reason: 'Cyclone data unavailable',
};
await h.layer.update();
assert.deepEqual(events.at(-1), [
'cleared',
'weather-cyclones',
'evicted',
]);
assert.equal(store.entities.size, 0);
next = snapshot();
await h.layer.update();
h.layer.setParams({ stormId: 'ep152026' });
assert.equal(store.selectedEntityId, 'cyclone:ep152026');
fail = true;
await h.layer.update();
assert.deepEqual(events.at(-1), [
'cleared',
'weather-cyclones',
'evicted',
]);
assert.equal(store.entities.size, 0);
assert.equal(store.selectedEntityId, null);
},
);
});
test('cyclone clears leave another layer selection and its records alone', async (t) => {
await withSharedContext(t, async ({ events, store, open }) => {
const h = open({ getSnapshot: async () => snapshot() });
h.layer.enable();
await h.layer.update();
h.layer.setParams({ stormId: 'ep152026' });
const foreign = {};
contextStore.registerEntityContext(foreign, {
id: 'flight:abc123',
layerId: 'flights',
label: 'Foreign',
});
contextStore.selectEntityContext(foreign);
const before = events.length;
h.layer.setParams({ clear: true });
assert.equal(store.selectedEntityId, 'flight:abc123');
assert.ok(store.entities.has('flight:abc123'));
assert.equal(store.entities.has('cyclone:ep152026'), false);
assert.equal(
events.length,
before,
'no clear is reported for a foreign selection',
);
// A cyclone clear does not report a dangling foreign selection as evicted.
store.entities.delete('flight:abc123');
h.layer.disable();
assert.equal(store.selectedEntityId, 'flight:abc123');
assert.equal(events.length, before);
});
});
test('refresh preserves selection intent, including explicit clears and missing storms', async () => {
let next = snapshot([storm(), storm('ep162026')]);
const h = harness({ getSnapshot: async () => next });
@@ -0,0 +1,163 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { createFlightState } from './state.js';
import { createRendering } from './rendering.js';
import {
FLEET_DR_INTERVAL_MS,
HIDDEN_CONTACT_DR_INTERVAL_MS,
HIDDEN_CONTACT_DR_TICKS,
hiddenContactDrPhase,
} from './policy.js';
// Contacts hidden behind the horizon re-reckon on one fleet tick per rotation
// instead of every tick. Their positions still feed hidden-inclusive proximity
// queries, so the rotation must reach every contact about once a second, and
// it must spread them across ticks so no single tick carries the whole fleet.
const icao = (n) => n.toString(16).padStart(6, '0');
test('the rotation revisits each hidden contact about once a second', () => {
const periodMs = HIDDEN_CONTACT_DR_TICKS * FLEET_DR_INTERVAL_MS;
assert.ok(HIDDEN_CONTACT_DR_TICKS > 1);
assert.ok(
Math.abs(periodMs - HIDDEN_CONTACT_DR_INTERVAL_MS) <= FLEET_DR_INTERVAL_MS,
);
});
test('a contact keeps the same slot, inside the rotation', () => {
for (let n = 0; n < 500; n++) {
const phase = hiddenContactDrPhase(icao(n * 7919));
assert.ok(Number.isInteger(phase));
assert.ok(phase >= 0 && phase < HIDDEN_CONTACT_DR_TICKS);
assert.equal(hiddenContactDrPhase(icao(n * 7919)), phase);
}
});
test('a worldwide fleet is spread evenly enough that no tick carries a burst', () => {
const fleet = 13000;
const perSlot = new Array(HIDDEN_CONTACT_DR_TICKS).fill(0);
// Real ICAO24 addresses cluster by national block; sample several blocks.
for (let n = 0; n < fleet; n++)
perSlot[hiddenContactDrPhase(icao(0x0a0000 + n * 53))]++;
const fair = fleet / HIDDEN_CONTACT_DR_TICKS;
for (const count of perSlot) assert.ok(count < fair * 1.5, `${count} > 1.5×`);
});
/** Real fleet tick and horizon culling, with motion and presentation injected. */
function fleetFixture() {
let now = 1000;
let reckonings = 0;
const services = {
groundSnap: { createGroundSnap: () => ({ clear() {} }) },
militaryRegistry: { isMilitaryIcao: () => false },
aircraftPresentation: { isTr3b: () => false },
camera: {},
focus: {
focusNowMs: () => now,
getFocusTarget: () => null,
nearFarScalarValueAtDistance: () => 1,
advanceProjectedSpriteFocus: () => ({ factor: 1 }),
},
recession: {
applyAircraftBillboardTreatment: () => ({
factors: { scale: 1 },
alpha: 1,
}),
},
};
const flightState = createFlightState({ services });
const camera = {
positionWC: Cesium.Cartesian3.fromDegrees(0, 0, 100),
positionCartographic: { height: 100 },
heading: 0,
pitch: 0,
roll: 0,
frustum: {
computeCullingVolume: () => ({
computeVisibility: () => Cesium.Intersect.INSIDE,
}),
},
};
const id = 'abc123';
// 111 km away at ground level is beyond a 100 m camera's horizon, but
// inside the model keep radius. No WebGL scene is needed for the culler.
const billboard = {
show: false,
position: Cesium.Cartesian3.fromDegrees(1, 0, 0),
};
flightState._viewer = { camera, scene: {} };
flightState._billboardCollection = { show: true };
flightState._billboards.set(id, billboard);
flightState.records.data.set(id, {});
flightState._models3dEnabled = false;
// Far cockpit contacts avoid glyph rasterization, keeping the fixture DOM-free.
flightState._cockpitContactMode = true;
const parts = {
tracking: { _refreshCockpitNearContacts() {} },
motion: {
_deadReckon() {
reckonings++;
return billboard.position;
},
_floorGroundedDisplayPosition(id, info, position) {
return position;
},
},
};
const rendering = createRendering({ flightState, services, parts });
return {
flightState,
camera,
billboard,
id,
reckonings: () => reckonings,
tick() {
now += FLEET_DR_INTERVAL_MS;
rendering._fleetTick();
},
offTurn() {
flightState._fleetTickSeq =
(hiddenContactDrPhase(id) + 1) % HIDDEN_CONTACT_DR_TICKS;
},
};
}
test('the fleet tick dead-reckons a hidden contact once per complete rotation', () => {
const f = fleetFixture();
for (let i = 0; i < HIDDEN_CONTACT_DR_TICKS; i++) f.tick();
assert.equal(f.reckonings(), 1);
assert.equal(f.billboard.show, false);
});
test('a camera reveal bypasses the hidden rotation on the same fleet tick', () => {
const f = fleetFixture();
f.offTurn();
f.tick();
assert.equal(f.reckonings(), 0);
f.camera.positionWC = Cesium.Cartesian3.fromDegrees(1, 0, 100);
f.offTurn();
f.tick();
assert.equal(f.reckonings(), 1);
assert.equal(f.billboard.show, true);
});
test('a hidden contact with a retained fleet model is never rotation-skipped', () => {
const f = fleetFixture();
f.flightState._models3dEnabled = true;
const model = { ready: true, show: true };
f.flightState._models.set(f.id, model);
f.offTurn();
f.tick();
assert.equal(f.reckonings(), 1);
assert.equal(
f.flightState._models.get(f.id),
model,
'model remains in keep range',
);
assert.equal(
model.show,
false,
'the full pass hides its model beyond the horizon',
);
});
+31
View File
@@ -198,6 +198,37 @@ export const FLEET_DR_INTERVAL_MS = 80;
export const ROTATION_REFRESH_MS = 1000;
/**
* @constant {number} Dead-reckoning cadence (ms) for contacts hidden behind the
* horizon. The worldwide feed carries ~13k contacts and a zoomed-in view shows
* well under 1% of them; the rest still need positions for hidden-inclusive
* proximity queries, but ~1 s old (≈250 m for an airliner) is well inside the
* 30 s poll's own uncertainty. Each hidden contact is re-reckoned on one tick in
* every HIDDEN_CONTACT_DR_TICKS, staggered by id so no tick carries them all.
*/
export const HIDDEN_CONTACT_DR_INTERVAL_MS = 1000;
export const HIDDEN_CONTACT_DR_TICKS = Math.max(
1,
Math.round(HIDDEN_CONTACT_DR_INTERVAL_MS / FLEET_DR_INTERVAL_MS),
);
/**
* Stable slot in [0, HIDDEN_CONTACT_DR_TICKS) for a contact id, so each hidden
* contact re-reckons on the same tick of every rotation.
* @param {string} id Contact id (ICAO24).
* @returns {number}
*/
export function hiddenContactDrPhase(id) {
const text = String(id);
let hash = 0;
for (let i = 0; i < text.length; i++)
hash = (hash * 31 + text.charCodeAt(i)) >>> 0;
return hash % HIDDEN_CONTACT_DR_TICKS;
}
/** Max course slew (deg/s) — well above real turns (≤4°/s), hides fix-boundary
* steps. Scaled down toward COURSE_MIN_DPS at low speed (courseSlewCapDps). */
+24
View File
@@ -36,6 +36,8 @@ import {
COURSE_SLEW_DT_MAX_SEC,
ROTATION_REFRESH_MS,
COURSE_MAX_DPS,
HIDDEN_CONTACT_DR_TICKS,
hiddenContactDrPhase,
} from './policy.js';
import { AIRCRAFT_ENVIRONMENT_MAP_OPTIONS } from '../../data/aircraftModelLighting.js';
@@ -839,6 +841,12 @@ export function createRendering({
}
}
/** The contact's hidden-rotation slot, cached on its billboard. */
function _hiddenDrPhase(bb, icao24) {
bb._gevHiddenDrPhase ??= hiddenContactDrPhase(icao24);
return bb._gevHiddenDrPhase;
}
function _fleetTick() {
if (
!flightState._viewer ||
@@ -965,9 +973,25 @@ export function createRendering({
for (const icao of toRelease) _releaseModel(icao);
}
const hiddenDrPhase = flightState._fleetTickSeq++ % HIDDEN_CONTACT_DR_TICKS;
for (const [icao24, bb] of flightState._billboards) {
if (icao24 === flightState._trackedIcao) continue; // tracked entity owns its own motion
// A contact still behind the horizon at its last position would only be
// re-reckoned to be hidden again, so off its turn it keeps that position
// (see HIDDEN_CONTACT_DR_TICKS). Anything the stale position puts in view,
// and any contact with a fleet model, takes the full pass below at once.
if (
!bb.show &&
_hiddenDrPhase(bb, icao24) !== hiddenDrPhase &&
!flightState._models.has(icao24) &&
!occluder.isPointVisible(
flightState._cullPositions.get(icao24) || bb.position,
)
)
continue;
const info = flightState.records.data.get(icao24);
const dr = parts.motion._deadReckon(
+4
View File
@@ -204,6 +204,10 @@ export function createFlightState({ source, services }) {
flightState._lastFleetTickMs = 0;
/** @type {number} Fleet ticks run; selects which hidden contacts re-reckon */
flightState._fleetTickSeq = 0;
/** @type {string} Camera pose signature at the last rotation pass */
flightState._lastCamPoseSig = '';
+190 -57
View File
@@ -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,
+440 -9
View File
@@ -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', '&lt;Null&gt;'),
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
View File
@@ -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;
+83
View File
@@ -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', '&lt;Null&gt;')}${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, [
+4
View File
@@ -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,
};
},
+495
View File
@@ -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);
});
+14 -4
View File
@@ -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;
}
}
}
}
+68 -1
View File
@@ -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();
+9 -1
View File
@@ -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 }) => {
+1
View File
@@ -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),
{
+8 -2
View File
@@ -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 -2
View File
@@ -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),
{
+9
View File
@@ -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;
+22 -3
View File
@@ -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);
+56
View File
@@ -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 }),
+2
View File
@@ -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").
+16 -3
View File
@@ -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);
}
+95 -20
View File
@@ -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,
+8
View File
@@ -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)
+30 -1
View File
@@ -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),
+326
View File
@@ -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']);
});
+1 -1
View File
@@ -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);
+35 -7
View File
@@ -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;
+8 -2
View File
@@ -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,
+17
View File
@@ -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
View File
@@ -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,
};
+40 -5
View File
@@ -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({
+42 -1
View File
@@ -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');
}
});
}
+50 -7
View File
@@ -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